Skip to content

Commit 640693f

Browse files
committed
feat: 대기열 완성도 보강 - 전체 대기 인원 노출, 통합/컨슈머 테스트, 처리량 정책 일원화
8주차 체크리스트 보완: - /queue/enter, /queue/position 응답에 totalWaiting(전체 대기 인원) 추가 - 대기열 Redis 통합 테스트 신설: 동시 진입 50명 무유실/고유 순번, 동시 중복 진입 차단, ZPOPMIN 동시 dequeue 무중복, 토큰 TTL 실만료, 배치 크기 초과 시 처리량 제어 - 배치 크기 산정 근거 문서화 (docs/design/06-waiting-queue.md) 9주차 검증 보완: - commerce-streamer 첫 테스트: 컨슈머 단위 11개(가중치 라우팅, 멱등 스킵, 부분 실패 ack) + Redis 통합 8개(가중치 계산, TTL 2일, carry-over 10%) - 수제 Avro 스키마 대신 정본 avsc(AvroSchemaProvider) 기반 레코드 사용 엄격 리뷰 반영 (10건): - 처리량 산정 입력값을 발제 예시(풀 50)에서 실측 설정(jpa.yml 풀 40)으로 정정. 175 TPS/18명이 140 TPS/14명이 되고, QueueThroughput 정책 객체 하나에서 BATCH_SIZE와 예상 대기시간을 파생시켜 3중 하드코딩 제거 - 예상 대기시간 내림을 올림으로 변경 (141~279위가 전부 1초로 과소 안내되던 문제) - fixedDelay를 fixedRate로 변경 (설계 산식의 고정 주기 전제와 일치) - position/totalWaiting 비원자 스냅샷 모순을 읽기 순서 + 클램프로 차단 - 복제 지연 시 rank null 폴백을 1번째가 아닌 맨 뒤 추정으로 수정 - 테스트 프로파일 전역에서 스케줄러 비활성 (공유 Redis 컨테이너를 캐시된 다른 컨텍스트의 스케줄러가 드레인하는 flaky 차단) - TTL 테스트 고정 sleep을 폴링 대기로 교체, carry-over에 날짜 파라미터 오버로드 추가 (자정 경계 제거) - FakeWaitingQueueRepository를 ZSET 의미론(동점 score 허용, member 사전순 tie-break)에 맞게 재작성 (같은 밀리초 진입 유저가 덮어써지던 잠복 결함) 참고: ProductApiE2ETest '상품 목록을 조회할 수 있다' 실패는 본 변경 이전 HEAD에서도 재현되는 기존 문제로 이 커밋 범위가 아님.
1 parent e39cd97 commit 640693f

20 files changed

Lines changed: 919 additions & 58 deletions

File tree

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.loopers.application.queue
22

33
import com.loopers.domain.queue.EntryTokenRepository
4+
import com.loopers.domain.queue.QueueThroughput
45
import com.loopers.domain.queue.WaitingQueueRepository
56
import org.springframework.stereotype.Component
67

@@ -9,15 +10,6 @@ class EnterQueueUseCase(
910
private val waitingQueueRepository: WaitingQueueRepository,
1011
private val entryTokenRepository: EntryTokenRepository,
1112
) {
12-
companion object {
13-
/**
14-
* 유저당 예상 처리 시간(초).
15-
* DB 커넥션 풀 50, 평균 처리 200ms → 175 TPS → 유저당 약 1/175초 ≈ 0.006초
16-
* 대기열 앞 유저 1명당 약 0.006초 대기로 산정하되, 스케줄러 100ms 주기 고려하여 반올림.
17-
*/
18-
private const val SECONDS_PER_USER = 0.006
19-
}
20-
2113
fun enter(userId: Long): QueueEntryResult {
2214
val existingToken = entryTokenRepository.findToken(userId)
2315
if (existingToken != null) {
@@ -27,9 +19,16 @@ class EnterQueueUseCase(
2719
val score = System.currentTimeMillis().toDouble()
2820
waitingQueueRepository.enqueue(userId, score)
2921

30-
val position = (waitingQueueRepository.getPosition(userId) ?: 0L) + 1
31-
val estimatedWaitSeconds = (position * SECONDS_PER_USER).toLong().coerceAtLeast(1)
22+
// 복제 지연으로 rank가 아직 안 보이면 방금 진입한 유저이므로 맨 뒤로 추정한다.
23+
// (rank 폴백 0은 "1번째"라는 거짓 안내가 된다)
24+
val queueSize = waitingQueueRepository.getQueueSize()
25+
val rank = waitingQueueRepository.getPosition(userId)
26+
val position = (rank ?: (queueSize - 1).coerceAtLeast(0)) + 1
27+
28+
// size와 rank는 별도 읽기라 그 사이에 스케줄러가 dequeue하면 모순될 수 있다.
29+
// position > totalWaiting인 응답만은 클램프로 막는다.
30+
val totalWaiting = queueSize.coerceAtLeast(position)
3231

33-
return QueueEntryResult.queued(position, estimatedWaitSeconds)
32+
return QueueEntryResult.queued(position, QueueThroughput.estimateWaitSeconds(position), totalWaiting)
3433
}
3534
}
Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.loopers.application.queue
22

33
import com.loopers.domain.queue.EntryTokenRepository
4+
import com.loopers.domain.queue.QueueThroughput
45
import com.loopers.domain.queue.WaitingQueueRepository
56
import org.springframework.stereotype.Component
67

@@ -9,22 +10,22 @@ class GetQueuePositionUseCase(
910
private val waitingQueueRepository: WaitingQueueRepository,
1011
private val entryTokenRepository: EntryTokenRepository,
1112
) {
12-
companion object {
13-
private const val SECONDS_PER_USER = 0.006
14-
}
15-
1613
fun getPosition(userId: Long): QueuePositionResult {
1714
val token = entryTokenRepository.findToken(userId)
1815
if (token != null) {
1916
return QueuePositionResult.authorized(token)
2017
}
2118

19+
// size를 rank보다 먼저 읽는다: 두 읽기 사이에 스케줄러가 dequeue해도
20+
// 먼저 읽은 size가 더 크므로 position > totalWaiting 모순이 생기지 않는다.
21+
// 반대 방향(사이에 enqueue 증가)은 클램프로 막는다.
22+
val queueSize = waitingQueueRepository.getQueueSize()
2223
val rank = waitingQueueRepository.getPosition(userId)
23-
?: return QueuePositionResult.notInQueue()
24+
?: return QueuePositionResult.notInQueue(queueSize)
2425

2526
val position = rank + 1
26-
val estimatedWaitSeconds = (position * SECONDS_PER_USER).toLong().coerceAtLeast(1)
27+
val totalWaiting = queueSize.coerceAtLeast(position)
2728

28-
return QueuePositionResult.waiting(position, estimatedWaitSeconds)
29+
return QueuePositionResult.waiting(position, QueueThroughput.estimateWaitSeconds(position), totalWaiting)
2930
}
3031
}

apps/commerce-api/src/main/kotlin/com/loopers/application/queue/QueueEntryResult.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ data class QueueEntryResult(
55
val position: Long?,
66
val estimatedWaitSeconds: Long?,
77
val token: String?,
8+
val totalWaiting: Long?,
89
) {
910
enum class QueueStatus { QUEUED, ALREADY_AUTHORIZED }
1011

1112
companion object {
12-
fun queued(position: Long, estimatedWaitSeconds: Long) =
13-
QueueEntryResult(QueueStatus.QUEUED, position, estimatedWaitSeconds, null)
13+
fun queued(position: Long, estimatedWaitSeconds: Long, totalWaiting: Long) =
14+
QueueEntryResult(QueueStatus.QUEUED, position, estimatedWaitSeconds, null, totalWaiting)
1415

1516
fun alreadyAuthorized(token: String) =
16-
QueueEntryResult(QueueStatus.ALREADY_AUTHORIZED, null, null, token)
17+
QueueEntryResult(QueueStatus.ALREADY_AUTHORIZED, null, null, token, null)
1718
}
1819
}

apps/commerce-api/src/main/kotlin/com/loopers/application/queue/QueueEntryScheduler.kt

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package com.loopers.application.queue
22

33
import com.loopers.domain.queue.EntryTokenRepository
4+
import com.loopers.domain.queue.QueueThroughput
45
import com.loopers.domain.queue.WaitingQueueRepository
56
import org.slf4j.LoggerFactory
7+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
68
import org.springframework.scheduling.annotation.Scheduled
79
import org.springframework.stereotype.Component
810
import java.util.UUID
911

12+
// 테스트 프로파일에서는 백그라운드 폴링이 공유 Redis의 대기열을 오염시키므로 끈다.
13+
// (apps/commerce-api/src/test/resources/application-test.yml)
14+
@ConditionalOnProperty(name = ["queue.scheduler.enabled"], havingValue = "true", matchIfMissing = true)
1015
@Component
1116
class QueueEntryScheduler(
1217
private val waitingQueueRepository: WaitingQueueRepository,
@@ -15,22 +20,15 @@ class QueueEntryScheduler(
1520
private val log = LoggerFactory.getLogger(javaClass)
1621

1722
companion object {
18-
/**
19-
* 배치 크기 산정 근거:
20-
* - DB 커넥션 풀: 50
21-
* - 주문 1건 평균 처리 시간: 200ms
22-
* - 이론적 최대 TPS: 50 / 0.2 = 250 TPS
23-
* - 안전 마진 70%: 175 TPS
24-
* - 스케줄러 주기: 100ms
25-
* - 배치 크기: 175 * 0.1 ≈ 18명
26-
*/
27-
const val BATCH_SIZE = 18L
2823
const val TOKEN_TTL_SECONDS = 300L
2924
}
3025

31-
@Scheduled(fixedDelay = 100)
26+
// fixedDelay가 아니라 fixedRate인 이유: 설계 처리량 140 TPS는 100ms "주기"를 전제로
27+
// 산정했다 (QueueThroughput). fixedDelay는 실행 시간이 주기에 가산되어 처리량이
28+
// 항상 설계값 밑으로 떨어진다.
29+
@Scheduled(fixedRate = QueueThroughput.SCHEDULER_INTERVAL_MS)
3230
fun processQueue() {
33-
val userIds = waitingQueueRepository.dequeueTopN(BATCH_SIZE)
31+
val userIds = waitingQueueRepository.dequeueTopN(QueueThroughput.BATCH_SIZE)
3432
if (userIds.isEmpty()) return
3533

3634
for (userId in userIds) {

apps/commerce-api/src/main/kotlin/com/loopers/application/queue/QueuePositionResult.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@ data class QueuePositionResult(
55
val position: Long?,
66
val estimatedWaitSeconds: Long?,
77
val token: String?,
8+
val totalWaiting: Long?,
89
) {
910
enum class PositionStatus { WAITING, AUTHORIZED, NOT_IN_QUEUE }
1011

1112
companion object {
12-
fun waiting(position: Long, estimatedWaitSeconds: Long) =
13-
QueuePositionResult(PositionStatus.WAITING, position, estimatedWaitSeconds, null)
13+
fun waiting(position: Long, estimatedWaitSeconds: Long, totalWaiting: Long) =
14+
QueuePositionResult(PositionStatus.WAITING, position, estimatedWaitSeconds, null, totalWaiting)
1415

1516
fun authorized(token: String) =
16-
QueuePositionResult(PositionStatus.AUTHORIZED, 0, 0, token)
17+
QueuePositionResult(PositionStatus.AUTHORIZED, 0, 0, token, null)
1718

18-
fun notInQueue() =
19-
QueuePositionResult(PositionStatus.NOT_IN_QUEUE, null, null, null)
19+
fun notInQueue(totalWaiting: Long) =
20+
QueuePositionResult(PositionStatus.NOT_IN_QUEUE, null, null, null, totalWaiting)
2021
}
2122
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.loopers.domain.queue
2+
3+
import kotlin.math.ceil
4+
5+
/**
6+
* 대기열 처리량 정책. 산정 근거: docs/design/06-waiting-queue.md
7+
*
8+
* - DB 커넥션 풀 40 (modules/jpa/jpa.yml maximum-pool-size)
9+
* - 주문 1건 평균 처리 시간 200ms
10+
* - 이론적 최대 TPS: 40 / 0.2 = 200
11+
* - 안전 마진 70%: 설계 처리량 140 TPS
12+
*
13+
* 스케줄러 배치 크기와 예상 대기 시간이 모두 이 값에서 파생된다.
14+
* 커넥션 풀이나 처리 시간이 바뀌면 이 파일만 수정한다.
15+
*/
16+
object QueueThroughput {
17+
const val DESIGN_TPS = 140.0
18+
const val SCHEDULER_INTERVAL_MS = 100L
19+
20+
val BATCH_SIZE: Long = (DESIGN_TPS * SCHEDULER_INTERVAL_MS / 1000).toLong()
21+
22+
/**
23+
* 순번 기준 예상 대기 시간(초). 스케줄러가 100ms 단위로 이산 처리하므로
24+
* 내림이 아니라 올림으로 계산해 과소 안내를 막는다.
25+
*/
26+
fun estimateWaitSeconds(position: Long): Long {
27+
return ceil(position / DESIGN_TPS).toLong().coerceAtLeast(1)
28+
}
29+
}

apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/queue/QueueEnterResponse.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ data class QueueEnterResponse(
77
val position: Long?,
88
val estimatedWaitSeconds: Long?,
99
val token: String?,
10+
val totalWaiting: Long?,
1011
) {
1112
companion object {
1213
fun from(result: QueueEntryResult) = QueueEnterResponse(
1314
status = result.status.name,
1415
position = result.position,
1516
estimatedWaitSeconds = result.estimatedWaitSeconds,
1617
token = result.token,
18+
totalWaiting = result.totalWaiting,
1719
)
1820
}
1921
}

apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/queue/QueuePositionResponse.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ data class QueuePositionResponse(
77
val position: Long?,
88
val estimatedWaitSeconds: Long?,
99
val token: String?,
10+
val totalWaiting: Long?,
1011
) {
1112
companion object {
1213
fun from(result: QueuePositionResult) = QueuePositionResponse(
1314
status = result.status.name,
1415
position = result.position,
1516
estimatedWaitSeconds = result.estimatedWaitSeconds,
1617
token = result.token,
18+
totalWaiting = result.totalWaiting,
1719
)
1820
}
1921
}

apps/commerce-api/src/test/kotlin/com/loopers/application/queue/EnterQueueUseCaseTest.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class EnterQueueUseCaseTest {
2626
assertThat(result.status).isEqualTo(QueueEntryResult.QueueStatus.QUEUED)
2727
assertThat(result.position).isEqualTo(1)
2828
assertThat(result.estimatedWaitSeconds).isNotNull()
29+
assertThat(result.totalWaiting).isEqualTo(1)
2930
}
3031

3132
@Test
@@ -55,6 +56,15 @@ class EnterQueueUseCaseTest {
5556
assertThat(result.position).isEqualTo(2)
5657
}
5758

59+
@Test
60+
fun `진입 결과에 전체 대기 인원이 포함되어야 한다`() {
61+
enterQueueUseCase.enter(1L)
62+
enterQueueUseCase.enter(2L)
63+
val result = enterQueueUseCase.enter(3L)
64+
65+
assertThat(result.totalWaiting).isEqualTo(3)
66+
}
67+
5868
companion object {
5969
private const val USER_ID = 1L
6070
}

apps/commerce-api/src/test/kotlin/com/loopers/application/queue/GetQueuePositionUseCaseTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class GetQueuePositionUseCaseTest {
2727

2828
assertThat(result.status).isEqualTo(QueuePositionResult.PositionStatus.WAITING)
2929
assertThat(result.position).isEqualTo(1)
30+
assertThat(result.totalWaiting).isEqualTo(1)
3031
}
3132

3233
@Test
@@ -46,6 +47,28 @@ class GetQueuePositionUseCaseTest {
4647
assertThat(result.status).isEqualTo(QueuePositionResult.PositionStatus.NOT_IN_QUEUE)
4748
}
4849

50+
@Test
51+
fun `대기 중 조회 시 전체 대기 인원이 포함되어야 한다`() {
52+
waitingQueueRepository.enqueue(1L, 1.0)
53+
waitingQueueRepository.enqueue(2L, 2.0)
54+
waitingQueueRepository.enqueue(3L, 3.0)
55+
56+
val result = getQueuePositionUseCase.getPosition(2L)
57+
58+
assertThat(result.totalWaiting).isEqualTo(3)
59+
}
60+
61+
@Test
62+
fun `대기열에 없는 유저도 전체 대기 인원은 확인할 수 있어야 한다`() {
63+
waitingQueueRepository.enqueue(1L, 1.0)
64+
waitingQueueRepository.enqueue(2L, 2.0)
65+
66+
val result = getQueuePositionUseCase.getPosition(999L)
67+
68+
assertThat(result.status).isEqualTo(QueuePositionResult.PositionStatus.NOT_IN_QUEUE)
69+
assertThat(result.totalWaiting).isEqualTo(2)
70+
}
71+
4972
companion object {
5073
private const val USER_ID = 1L
5174
}

0 commit comments

Comments
 (0)