Skip to content

[volume-8] Redis 기반 주문 대기열 구현 - #355

Merged
minbo2002 merged 3 commits into
loopers-labs:minbo2002from
minbo2002:feat/volume-8
Jul 16, 2026
Merged

[volume-8] Redis 기반 주문 대기열 구현#355
minbo2002 merged 3 commits into
loopers-labs:minbo2002from
minbo2002:feat/volume-8

Conversation

@minbo2002

@minbo2002 minbo2002 commented Jul 9, 2026

Copy link
Copy Markdown

🧭 Context & Decision

1. 문제 정의

  • 현재 동작/제약 — 주문 API(POST /api/v1/orders)는 진입 제한 없이 요청을 그대로 받는다. 주문 1건은 DB 커넥션을 약 200ms 점유하고 커넥션 풀은 50개다. 평상시 초당 100건은 문제없지만, 선착순 쿠폰 발급 순간에는 초당 수천~수만 건이 몰리면 커넥션·스레드 풀이 한계를 넘어 주문뿐 아니라 전체 API가 함께 느려지거나 멈춘다.
  • 문제(리스크) — 서버 증설로는 풀기 어렵다. DB·PG는 스케일 한계가 있고 피크가 짧아 오토스케일이 반응하기 전에 포화된다. 초과분을 거부(429)하면 유저는 새로고침을 시도해 재시도를 계속해서 만든다. 결국 "감당 못 할 요청을 어떻게 버리느냐"가 아니라 "시스템이 감당하는 속도로 어떻게 흘려보내느냐"(back-pressure)가 핵심이다.
  • 성공 기준(완료 정의)
    • 진입 순서를 보장하고 중복 진입을 방지하는 대기열(Redis Sorted Set).
    • 스케줄러가 감당 가능한 속도로만 앞에서 N명을 꺼내 입장 토큰(TTL)을 발급하고, 토큰 보유자만 주문 API에 진입.
    • 유저가 자기 순번·예상 대기 시간을 실시간(Polling)으로 확인하고, 입장 시점엔 응답으로 토큰을 받아 곧장 주문으로 이어짐.
    • 배치 크기·주기를 처리량 근거로 산정하고 기록.

2. 선택지와 결정

[결정 1] 재진입 시 순번 정책 — 최초 순번 보존

  • 고려한 대안
    • A. 재진입하면 뒤로 밀림 (일반 ZADD, score 갱신). 전통 예매 대기열 방식.
    • B. 최초 순번 유지 (ZADD NX / addIfAbsent).
  • 최종 결정: B. 대기열 공정성의 축은 "먼저 온 사람이 먼저"다. 새로고침·다중 탭·네트워크 재시도·순번 폴링은 유저의 의도가 아니라 클라이언트 동작인데, A는 이걸 전부 페널티로 만든다. 특히 순번을 폴링으로 확인하게 하므로 A는 "확인할수록 뒤로 밀리는" 자기모순이 된다. 순번을 로그인 신원인 loginId에 묶어 새로고침에도 안전하게(refresh-safe) 유지한다.
  • 트레이드오프 — 줄만 서고 돌아오지 않는 유령 대기자가 큐에 남아 대기 인원·예상 시간을 잠깐 부풀린다. 이 회수는 큐가 아니라 입장 토큰 TTL(5분)이 담당한다(입장 후 미사용 토큰이 만료되며 자리가 정리됨).

[결정 2] 스케줄러 배치 크기·주기 — 100ms마다 18명

  • 처리량 상한 = DB pool 50 × (커넥션당 초당 5건, 주문 200ms 기준) = 250 TPS, 여기에 안전마진 70%를 적용해 175 TPS. 이 값이 back-pressure의 상한 수치다.
  • 고려한 대안 — 같은 175 TPS를 어떻게 쪼개느냐의 문제다.
    • A. 큰 배치 + 긴 주기(1초에 175명 한꺼번에). Redis 호출은 최소지만 매 초 175명이 동시에 주문 API로 쏟아진다
      → Thundering Herd, 순간 스파이크.
    • B. 작은 배치 + 짧은 주기(10ms에 2명). 유입은 매끄럽지만 스케줄러·Redis 왕복이 초당 100회로 오버헤드가 커지고 fixedDelay 드리프트 위험.
  • 최종 결정: 가운데(100ms × 18명). 100ms 간격이면 체감상 거의 실시간으로 매끄럽게 입장하고, 한 번에 18명이라 주문 API로 몰리는 순간 부하가 작다. Redis 호출 초당 10회는 오버헤드가 무시할 수준이다. 이 값들은 전부 @ConfigurationProperties로 외부화해 재배포 없이 조정 가능하게 했다.
  • 트레이드오프 — 처리량이 배치÷주기로 고정되므로 실측 없이 정한 산정값이다. 부하 실측 후 주문이 200ms보다 빠르면 배치를 키우고, DB가 더 빡빡하면 주기를 늘리는 방향으로 조정해야 한다.

🤔 고민한 점 / 막혔던 부분

  • 예상 대기 0초의 함정.
    예상 대기를 순번/처리량으로 단순 정수 나눗셈하면 순번이 처리량보다 작은 대기자가 0초로 표시된다. 아직 줄 서 있는 유저에게 0초라고 안내하면 오해를 주므로, 올림 처리로 최소 1초를 보장했다. 이 공식은 Redis 없이 도는 순수 단위 테스트로 분리해 빠르게 고정했다.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Redis 기반 대기열 진입 토큰 관문 기능이 추가됐다. WaitingQueue/EntryTokenStore/QueuePosition 도메인 계약, QueueService 핵심 로직, RedisWaitingQueue/RedisEntryTokenStore 구현체, QueueAdmissionScheduler, QueueFacade/QueueV1Controller API, 설정(QueueProperties, application.yml)이 추가됐다. OrderFacade.placeOrder는 entryToken 검증/소비 흐름을 포함하도록 시그니처가 변경됐고, OrderV1Controller는 X-Entry-Token 헤더를 전달한다. 관련 통합/E2E 테스트가 확장됐다.

Changes

대기열 입장 토큰 관문

Layer / File(s) Summary
대기열 도메인 계약 및 값 객체
domain/queue/WaitingQueue.java, domain/queue/EntryTokenStore.java, domain/queue/QueuePosition.java, test/.../QueuePositionTest.java
대기열 진입/순번/폴링 계약, 토큰 발급/조회/삭제 계약, 대기/입장 상태를 나타내는 QueuePosition 레코드와 그 단위 테스트가 정의됐다.
QueueService 핵심 로직
domain/queue/QueueService.java, support/error/ErrorType.java
대기열 진입/순번조회/전체인원조회/상태조회(admitNext), 토큰 검증(FORBIDDEN)/소비 메서드가 구현되고, FORBIDDEN 에러 메시지가 갱신됐다.
Redis 인프라 구현체 및 스케줄러
infrastructure/queue/RedisWaitingQueue.java, infrastructure/queue/RedisEntryTokenStore.java, infrastructure/queue/QueueAdmissionScheduler.java, support/config/QueueProperties.java, application.yml
ZSET 기반 대기열, TTL 기반 토큰 저장소, 주기적 admitNext 스케줄러, 관련 설정값이 추가됐다.
QueueFacade 및 대기열 API
application/queue/QueueFacade.java, application/queue/QueuePositionInfo.java, interfaces/api/queue/QueueV1Controller.java, interfaces/api/queue/QueueV1Dto.java
QueueService 결과를 QueuePositionInfo로 변환하는 파사드와, 진입/위치조회 REST 엔드포인트가 추가됐다.
주문 API 토큰 관문 연동
application/order/OrderFacade.java, interfaces/api/order/OrderV1Controller.java
placeOrder가 entryToken 검증/소비를 수행하도록 시그니처가 바뀌고, 컨트롤러가 X-Entry-Token 헤더를 전달한다.
대기열 도메인 통합 테스트
test/.../EntryTokenStoreIntegrationTest.java, QueueAdmissionIntegrationTest.java, QueueServiceIntegrationTest.java
토큰 TTL/발급/삭제, admitNext 배치 처리, 진입/재진입/동시진입/폴링/상태조회 시나리오가 검증됐다.
주문 E2E 테스트에 토큰 시나리오 추가
test/.../OrderV1ApiE2ETest.java
주문 각 케이스에 토큰 발급/헤더 포함이 반영되고, 토큰 없음/불일치(403)/소진 검증 케이스가 추가됐다.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant OrderV1Controller
  participant OrderFacade
  participant QueueService
  participant EntryTokenStore
  Client->>OrderV1Controller: POST /orders (X-Entry-Token)
  OrderV1Controller->>OrderFacade: placeOrder(loginId, entryToken, command)
  OrderFacade->>QueueService: validateToken(loginId, entryToken)
  QueueService->>EntryTokenStore: find(loginId)
  EntryTokenStore-->>QueueService: 저장된 토큰
  alt 토큰 불일치/없음
    QueueService-->>OrderFacade: FORBIDDEN 예외
    OrderFacade-->>Client: 403 응답
  else 토큰 유효
    OrderFacade->>OrderFacade: 주문 생성(재고/쿠폰 차감)
    OrderFacade->>QueueService: consumeToken(loginId)
    QueueService->>EntryTokenStore: delete(loginId)
    OrderFacade-->>Client: 주문 결과
  end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed 제목이 Redis 기반 주문 대기열 구현이라는 변경의 핵심을 간결하고 명확하게 요약한다.
Description check ✅ Passed 템플릿의 Context & Decision, 문제 정의, 선택지, 고민한 점을 포함해 설계 의사결정과 트레이드오프를 충분히 설명한다.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (7)
apps/commerce-api/src/test/java/com/loopers/domain/queue/QueuePositionTest.java (1)

8-44: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

throughputPerSecond 경계값(0, 음수) 테스트가 누락되어 있다.

경계값/실패 케이스가 부족하다. throughputPerSecond = 0인 경우 Long.MAX_VALUE가 반환되는 비정상 동작과, 음수 입력 시 음수 대기 시간이 계산되는 케이스가 커버되지 않는다.

추가 테스트 권장:

`@DisplayName`("throughputPerSecond가 0 이하면 예외가 발생한다.")
`@Test`
void waiting_throwsWhenThroughputNonPositive() {
    assertThatThrownBy(() -> QueuePosition.waiting(1L, 0L))
        .isInstanceOf(IllegalArgumentException.class);
    assertThatThrownBy(() -> QueuePosition.waiting(1L, -1L))
        .isInstanceOf(IllegalArgumentException.class);
}

As per coding guidelines, **/*Test*.java 경로 명령에 따라 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함해야 한다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/queue/QueuePositionTest.java`
around lines 8 - 44, QueuePositionTest is missing boundary/failure coverage for
QueuePosition.waiting when throughputPerSecond is non-positive. Add tests around
the existing waiting() behavior to assert that passing 0 or a negative
throughput throws IllegalArgumentException, using the QueuePosition.waiting and
estimatedWaitSeconds paths as the target for the validation. Keep the existing
happy-path tests, and extend the test class with explicit exception assertions
for these invalid inputs.

Source: Path instructions

apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java (1)

14-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

FORBIDDEN 기본 메시지가 대기열 기능에 특화되어 있다.

"입장 권한이 없습니다."는 대기열 토큰 관문에 특화된 메시지이나, ErrorType은 범용 HTTP 에러 카테고리 enum이다. 다른 기능에서 FORBIDDEN을 커스텀 메시지 없이 사용하면 대기열과 무관한 맥락에서도 "입장 권한" 메시지가 노출된다.

운영 관점: 향후 권한 제어 기능 추가 시 사용자 혼란 발생 가능.

수정안:

-    FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "입장 권한이 없습니다."),
+    FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."),

QueueService.validateToken()에서 이미 커스텀 메시지를 제공하므로 기본 메시지는 범용으로 유지하는 것이 적절하다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java` at
line 14, The FORBIDDEN default message in ErrorType is too queue-specific and
should be made generic for reuse across features. Update the FORBIDDEN enum
entry in ErrorType to use a neutral default message, and keep the queue-specific
wording only in QueueService.validateToken where the custom message is already
supplied. Ensure other callers of ErrorType.FORBIDDEN without custom text no
longer expose the queue-related phrase.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java (2)

217-230: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

실패 케이스에서 토큰 잔존 검증 누락

OrderFacade.placeOrder Javadoc에 "실패하면 토큰은 남아 TTL 내 재시도할 수 있다"라고 명시됐으나, 실패 케이스(404, 409, 400) 테스트에서 토큰이 소비되지 않고 남아있는지 검증하지 않는다. 이 계약이 깨지면 사용자가 실패 후 재시도할 수 없게 되어 운영 장애로 이어질 수 있다.

수정안: 각 실패 케이스의 then 블록에 토큰 잔존 검증을 추가한다.

💚 제안: 404 케이스 예시
 // then
-assertThat(mvcResult.getResponse().getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
+assertAll(
+        () -> assertThat(mvcResult.getResponse().getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()),
+        () -> assertThat(entryTokenStore.find(LOGIN_ID)).isPresent()
+);

추가 테스트: 상기 검증 자체가 실패 시 토큰 잔존 계약에 대한 추가 테스트에 해당한다.

As per coding guidelines, **/*Test*.java 파일은 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 토큰 잔존 여부는 실패 시 예외 흐름의 핵심 검증점이다.

Also applies to: 241-257, 356-366, 377-390

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java`
around lines 217 - 230, The failure-path order tests in OrderV1ApiE2ETest are
missing the contract check that entry tokens remain usable after a failed
OrderFacade.placeOrder call. In each 404, 409, and 400 case, extend the
then/assertion block to verify the token was not consumed and still exists so
retry is possible, using the same entry-token storage/check helper already used
around issueEntryToken and the relevant failure-response assertions.

Source: Path instructions


305-330: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

토큰 소진 후 재사용 시도 검증 누락

deletesToken_afterSuccessfulOrder는 토큰 삭제를 검증하지만, 삭제된 토큰으로 두 번째 주문 시도 시 403이 반환되는지는 검증하지 않는다. 이 검증이 있어야 토큰 단일 사용 보장이 end-to-end로 완전히 입증된다.

수정안: 동일 토큰으로 두 번째 주문 요청 후 403 응답 검증을 추가한다.

💚 제안: 재사용 검증 추가
 // then — 주문 성공 후 토큰이 소비되어 사라진다
 assertAll(
         () -> assertThat(mvcResult.getResponse().getStatus()).isEqualTo(HttpStatus.OK.value()),
         () -> assertThat(entryTokenStore.find(LOGIN_ID)).isEmpty()
 );
+
+// when — 동일 토큰으로 재시도하면 403이 반환된다
+MvcResult retryResult = mockMvc.perform(post(ENDPOINT)
+                .header(AuthenticatedUserArgumentResolver.HEADER_LOGIN_ID, LOGIN_ID)
+                .header(AuthenticatedUserArgumentResolver.HEADER_LOGIN_PW, PASSWORD)
+                .header(HEADER_ENTRY_TOKEN, entryToken)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(request)))
+        .andReturn();
+
+// then
+assertThat(retryResult.getResponse().getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value());

추가 테스트: 상기 검증 자체가 토큰 단일 사용 보장에 대한 추가 테스트에 해당한다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java`
around lines 305 - 330, `deletesToken_afterSuccessfulOrder` only checks that the
entry token is removed after a successful order, but it does not verify
single-use behavior end-to-end. Update this test in `OrderV1ApiE2ETest` to
perform a second `mockMvc.perform(post(ENDPOINT)... )` using the same
`entryToken` after the first success, and assert that the response status is
403. Keep the existing assertions that the first request succeeds and
`entryTokenStore.find(LOGIN_ID)` becomes empty, and use the same request setup
symbols (`issueEntryToken`, `HEADER_ENTRY_TOKEN`, `ENDPOINT`) to clearly
exercise token reuse.
apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueAdmissionScheduler.java (1)

23-26: 🩺 Stability & Availability | 🔵 Trivial

다중 인스턴스 환경에서 스케줄러가 중복 실행되어 입장 처리량이 의도치 않게 증가한다.

@Scheduled는 인스턴스마다 독립적으로 실행된다. popMin이 원자적이어서 중복 발급은 없지만, 인스턴스 수만큼 입장 처리량이 증가한다. 3개 인스턴스 기준 540/s로 의도(175/s)를 3배 초과하여 다운스트림 주문 처리에 과부하가 발생할 수 있다.

수정안: ShedLock 등 분산 락으로 단일 인스턴스만 스케줄러를 실행하도록 제어하라.

추가 테스트: 다중 스케줄러 동시 실행 시 처리량이 의도 범위 내로 제한되는지 검증하는 테스트가 필요하다.

다중 인스턴스 배포 여부를 확인하라.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueAdmissionScheduler.java`
around lines 23 - 26, The QueueAdmissionScheduler.admit scheduled job is running
independently on every app instance, which multiplies admission throughput in
multi-instance deployments. Update the admit flow to use a distributed lock such
as ShedLock so only one instance executes the scheduler at a time, and wire the
lock around QueueAdmissionScheduler.admit while keeping queueService.admitNext
unchanged. Add a test around the scheduler behavior to verify concurrent
executions across multiple scheduler instances stay within the intended batch
rate.
apps/commerce-api/src/test/java/com/loopers/domain/queue/EntryTokenStoreIntegrationTest.java (1)

54-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Thread.sleep 기반 TTL 만료 검증은 CI 환경에서 flaky 할 수 있다.

고정 대기(1.2s)는 CI 부하 시 Redis 지연으로 인해 TTL이 만료되지 않았을 때 fail이 발생할 수 있다. 운영 관점에서 flaky 테스트는 CI 신뢰성을 저하시킨다.

수정안: Awaitility로 폴링하여 안정적으로 만료를 검증하라.

추가 테스트: TTL 경계값(만료 직전/직후) 상태를 검증하는 케이스를 추가하라.

♻️ Awaitility 적용 제안
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.test.context.TestPropertySource;

+import java.time.Duration;
 import java.util.Optional;

+import static org.awaitility.Awaitility.await;
 import static org.assertj.core.api.Assertions.assertThat;
         // act — TTL 초과까지 대기
-        Thread.sleep(1_200L);
+        await().atMost(Duration.ofSeconds(3))
+                .pollInterval(Duration.ofMillis(200))
+                .until(() -> entryTokenStore.find("alice").isEmpty());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/queue/EntryTokenStoreIntegrationTest.java`
around lines 54 - 67, The TTL expiration test in EntryTokenStoreIntegrationTest
is flaky because token_expiresAfterTtl uses Thread.sleep with a fixed delay;
replace that wait in token_expiresAfterTtl with Awaitility-based polling against
entryTokenStore.find so the test waits until the token is actually expired. Also
add a boundary-focused test around issue/find behavior for EntryTokenStore to
cover just-before-expiry and just-after-expiry states, using the existing
entryTokenStore methods as the main identifiers to locate the code.
apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueue.java (1)

28-31: 🎯 Functional Correctness | 🔵 Trivial

다중 인스턴스 환경에서 System.currentTimeMillis() score는 클럭 스큐로 대기 순서 공정성이 깨질 수 있다.

인스턴스 간 시간 차이가 발생하면 나중에 진입한 유저가 더 빠른 score를 받아 먼저 입장할 수 있다. NTP 동기화를 하더라도 ms 단위 스큐는 피하기 어려우며, 초당 175명 처리 기준에서 순번 뒤바뀜은 사용자 불만으로 이어진다.

수정안: Redis TIME 명령이나 INCR 카운터를 score로 사용하면 단일 시계 기반으로 공정성을 보장할 수 있다.

추가 테스트: 다중 인스턴스 시나리오에서 동시 진입 시 순서 보장을 검증하는 통합 테스트가 필요하다.

다중 인스턴스 배포 여부를 확인하라.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueue.java`
around lines 28 - 31, `RedisWaitingQueue.enter` uses
`System.currentTimeMillis()` as the ZSET score, which can break queue fairness
across multiple instances due to clock skew. Update the score source to a single
Redis-backed time/sequence, such as Redis `TIME` or an `INCR`-based counter, so
ordering is consistent regardless of app instance. Also add or update an
integration test around `RedisWaitingQueue` to verify concurrent `enter` calls
from multiple instances preserve the intended order, and confirm this deployment
is multi-instance before applying the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`:
- Around line 31-39: The token consumption step in OrderFacade.placeOrder is not
atomic with order creation, so a failed queueService.consumeToken after
orderService.createPendingOrder can leave a reusable token and allow duplicate
orders. Update the flow so the entry token is used as an idempotency guard for
createPendingOrder, or at minimum make consumeToken failures non-fatal by
logging them and still returning a normal response; use the existing
OrderFacade.placeOrder, queueService.consumeToken, and
orderService.createPendingOrder call sites to keep the retry behavior explicit.
Also add tests that cover consumeToken failure and repeated use of the same
token.

In `@apps/commerce-api/src/main/java/com/loopers/domain/queue/QueuePosition.java`:
- Around line 15-16: QueuePosition.waiting currently computes estimated wait
time without validating throughputPerSecond, so zero or negative values can
produce invalid results. Add an explicit guard in QueuePosition.waiting before
the division to reject throughputPerSecond <= 0 with a clear exception, and keep
the normal ceil-based calculation only for positive values. Update or add tests
around QueuePosition.waiting to verify that 0 and negative throughputPerSecond
inputs fail fast with an exception.

In `@apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueService.java`:
- Around line 20-24: QueueService.enter has a TOCTOU race because it calls
waitingQueue.enter() and then getPosition() as two separate Redis operations,
allowing admitNext() to remove the user in between and cause a false NOT_FOUND.
Fix this by making the queue insertion/position retrieval atomic: change
WaitingQueue.enter() to return the assigned rank/position directly, or otherwise
fetch position in the same Redis operation rather than calling getPosition()
afterward. Update QueueService.enter() to rely on that single atomic result, and
add a test that simulates admitNext() interleaving during enter().

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueAdmissionScheduler.java`:
- Around line 23-26: The QueueAdmissionScheduler.admit scheduled job is running
independently on every app instance, which multiplies admission throughput in
multi-instance deployments. Update the admit flow to use a distributed lock such
as ShedLock so only one instance executes the scheduler at a time, and wire the
lock around QueueAdmissionScheduler.admit while keeping queueService.admitNext
unchanged. Add a test around the scheduler behavior to verify concurrent
executions across multiple scheduler instances stay within the intended batch
rate.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueue.java`:
- Around line 28-31: `RedisWaitingQueue.enter` uses `System.currentTimeMillis()`
as the ZSET score, which can break queue fairness across multiple instances due
to clock skew. Update the score source to a single Redis-backed time/sequence,
such as Redis `TIME` or an `INCR`-based counter, so ordering is consistent
regardless of app instance. Also add or update an integration test around
`RedisWaitingQueue` to verify concurrent `enter` calls from multiple instances
preserve the intended order, and confirm this deployment is multi-instance
before applying the change.

In `@apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java`:
- Line 14: The FORBIDDEN default message in ErrorType is too queue-specific and
should be made generic for reuse across features. Update the FORBIDDEN enum
entry in ErrorType to use a neutral default message, and keep the queue-specific
wording only in QueueService.validateToken where the custom message is already
supplied. Ensure other callers of ErrorType.FORBIDDEN without custom text no
longer expose the queue-related phrase.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/queue/EntryTokenStoreIntegrationTest.java`:
- Around line 54-67: The TTL expiration test in EntryTokenStoreIntegrationTest
is flaky because token_expiresAfterTtl uses Thread.sleep with a fixed delay;
replace that wait in token_expiresAfterTtl with Awaitility-based polling against
entryTokenStore.find so the test waits until the token is actually expired. Also
add a boundary-focused test around issue/find behavior for EntryTokenStore to
cover just-before-expiry and just-after-expiry states, using the existing
entryTokenStore methods as the main identifiers to locate the code.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/queue/QueuePositionTest.java`:
- Around line 8-44: QueuePositionTest is missing boundary/failure coverage for
QueuePosition.waiting when throughputPerSecond is non-positive. Add tests around
the existing waiting() behavior to assert that passing 0 or a negative
throughput throws IllegalArgumentException, using the QueuePosition.waiting and
estimatedWaitSeconds paths as the target for the validation. Keep the existing
happy-path tests, and extend the test class with explicit exception assertions
for these invalid inputs.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java`:
- Around line 217-230: The failure-path order tests in OrderV1ApiE2ETest are
missing the contract check that entry tokens remain usable after a failed
OrderFacade.placeOrder call. In each 404, 409, and 400 case, extend the
then/assertion block to verify the token was not consumed and still exists so
retry is possible, using the same entry-token storage/check helper already used
around issueEntryToken and the relevant failure-response assertions.
- Around line 305-330: `deletesToken_afterSuccessfulOrder` only checks that the
entry token is removed after a successful order, but it does not verify
single-use behavior end-to-end. Update this test in `OrderV1ApiE2ETest` to
perform a second `mockMvc.perform(post(ENDPOINT)... )` using the same
`entryToken` after the first success, and assert that the response status is
403. Keep the existing assertions that the first request succeeds and
`entryTokenStore.find(LOGIN_ID)` becomes empty, and use the same request setup
symbols (`issueEntryToken`, `HEADER_ENTRY_TOKEN`, `ENDPOINT`) to clearly
exercise token reuse.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c273138-1768-44c9-a515-bc04b4a6efb9

📥 Commits

Reviewing files that changed from the base of the PR and between f4a2412 and 8e98e36.

📒 Files selected for processing (21)
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueuePositionInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/EntryTokenStore.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/QueuePosition.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/WaitingQueue.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueAdmissionScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisEntryTokenStore.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueue.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/support/config/QueueProperties.java
  • apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/domain/queue/EntryTokenStoreIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/queue/QueueAdmissionIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/queue/QueuePositionTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/queue/QueueServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java

Comment on lines +31 to +39
public OrderInfo placeOrder(String loginId, String entryToken, OrderCommand command) {
queueService.validateToken(loginId, entryToken);

UserModel user = userService.getMyInfo(loginId);
List<OrderLine> lines = normalize(command.items());

OrderModel pendingOrderModel = orderService.createPendingOrder(user.getId(), lines, command.couponId());

queueService.consumeToken(loginId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

토큰 소비 실패 시 중복 주문 발생 가능 (원자성 부재)

createPendingOrder 커밋 후 consumeToken(Redis DELETE)이 실패하면(예: Redis 일시적 장애), 주문은 생성됐지만 토큰이 남아 TTL 만료 전까지 재사용 가능하다. 사용자가 동일 토큰으로 재시도하면 validateToken을 통과하여 중복 주문이 발생하고, 재고가 이중 차감되어 데이터 정합성이 훼손된다. 이는 비멱등 쓰기에 대한 안전장치 없는 재시도에 해당한다.

수정안:

  • (권장) 주문 생성 시 엔트리 토큰을 멱등성 키로 저장하여, 동일 토큰으로 두 번째 주문 생성을 차단한다.
  • (최소 완화) consumeToken 실패 시 예외를 전파하지 않고 로그만 남겨 사용자에게 정상 응답을 반환한다. 토큰 TTL을 짧게 설정하여 중복 주문 창을 최소화한다.
🛡️ 최소 완화안: consumeToken 실패 시 로깅 only
 public OrderInfo placeOrder(String loginId, String entryToken, OrderCommand command) {
     queueService.validateToken(loginId, entryToken);

     UserModel user = userService.getMyInfo(loginId);
     List<OrderLine> lines = normalize(command.items());

     OrderModel pendingOrderModel = orderService.createPendingOrder(user.getId(), lines, command.couponId());

-    queueService.consumeToken(loginId);
+    try {
+        queueService.consumeToken(loginId);
+    } catch (Exception e) {
+        log.warn("토큰 소비 실패 [loginId={}]: {}", loginId, e.getMessage());
+    }

     return OrderInfo.from(pendingOrderModel);
 }

추가 테스트:

  • consumeToken 실패 시나리오 통합 테스트 (Redis 장애 상황 모킹)
  • 동일 토큰으로 두 번째 주문 시도 시 403 또는 기존 주문 반환 검증

As per coding guidelines, **/*.java 파일은 예외 흐름이 명확한지 점검한다. 현재 consumeToken 실패 시 주문은 생성됐으나 사용자에게 에러가 반환되는 예외 흐름이 불명확하다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public OrderInfo placeOrder(String loginId, String entryToken, OrderCommand command) {
queueService.validateToken(loginId, entryToken);
UserModel user = userService.getMyInfo(loginId);
List<OrderLine> lines = normalize(command.items());
OrderModel pendingOrderModel = orderService.createPendingOrder(user.getId(), lines, command.couponId());
queueService.consumeToken(loginId);
public OrderInfo placeOrder(String loginId, String entryToken, OrderCommand command) {
queueService.validateToken(loginId, entryToken);
UserModel user = userService.getMyInfo(loginId);
List<OrderLine> lines = normalize(command.items());
OrderModel pendingOrderModel = orderService.createPendingOrder(user.getId(), lines, command.couponId());
try {
queueService.consumeToken(loginId);
} catch (Exception e) {
log.warn("토큰 소비 실패 [loginId={}]: {}", loginId, e.getMessage());
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 31 - 39, The token consumption step in OrderFacade.placeOrder is
not atomic with order creation, so a failed queueService.consumeToken after
orderService.createPendingOrder can leave a reusable token and allow duplicate
orders. Update the flow so the entry token is used as an idempotency guard for
createPendingOrder, or at minimum make consumeToken failures non-fatal by
logging them and still returning a normal response; use the existing
OrderFacade.placeOrder, queueService.consumeToken, and
orderService.createPendingOrder call sites to keep the retry behavior explicit.
Also add tests that cover consumeToken failure and repeated use of the same
token.

Source: Path instructions

Comment on lines +15 to +16
public static QueuePosition waiting(long position, long throughputPerSecond) {
long estimatedWaitSeconds = (long) Math.ceil((double) position / throughputPerSecond);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

throughputPerSecond 0 또는 음수에 대한 검증이 필요하다.

throughputPerSecond가 0이면 (double) position / 0 = Infinity(long) Math.ceil(Infinity) = Long.MAX_VALUE가 반환되어 예상 대기 시간으로 비정상 값이 노출된다. 음수면 음수 대기 시간이 계산된다. QueueProperties 설정 오류 시 즉시 사용자 응답에 잘못된 값이 전파된다.

운영 관점: 설정 변경 한 번으로 전체 대기열 응답이 비정상 동작하며, 원인 파악이 어렵다.

수정안:

 public static QueuePosition waiting(long position, long throughputPerSecond) {
+    if (throughputPerSecond <= 0) {
+        throw new IllegalArgumentException("throughputPerSecond must be positive");
+    }
     long estimatedWaitSeconds = (long) Math.ceil((double) position / throughputPerSecond);
     return new QueuePosition(position, false, null, estimatedWaitSeconds);
 }

추가 테스트: throughputPerSecond = 0 및 음수 입력 시 예외 발생 검증.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static QueuePosition waiting(long position, long throughputPerSecond) {
long estimatedWaitSeconds = (long) Math.ceil((double) position / throughputPerSecond);
public static QueuePosition waiting(long position, long throughputPerSecond) {
if (throughputPerSecond <= 0) {
throw new IllegalArgumentException("throughputPerSecond must be positive");
}
long estimatedWaitSeconds = (long) Math.ceil((double) position / throughputPerSecond);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/commerce-api/src/main/java/com/loopers/domain/queue/QueuePosition.java`
around lines 15 - 16, QueuePosition.waiting currently computes estimated wait
time without validating throughputPerSecond, so zero or negative values can
produce invalid results. Add an explicit guard in QueuePosition.waiting before
the division to reject throughputPerSecond <= 0 with a clear exception, and keep
the normal ceil-based calculation only for positive values. Update or add tests
around QueuePosition.waiting to verify that 0 and negative throughputPerSecond
inputs fail fast with an exception.

Comment on lines +20 to +24
public long enter(String loginId) {
waitingQueue.enter(loginId);

return getPosition(loginId);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

enter()에서 waitingQueue.enter()getPosition() 사이에 TOCTOU 경쟁 조건이 존재한다.

enter()는 두 개의 분리된 Redis 호출을 순차 실행한다. 사이에 스케줄러의 admitNext()가 개입하면:

  1. waitingQueue.enter(loginId) — ZSET에 사용자 추가
  2. 스케줄러 admitNext(n) — ZPOPMIN으로 해당 사용자 제거 + 토큰 발급
  3. getPosition(loginId) — ZRANK가 nil → NOT_FOUND 예외 발생

사용자는 방금 진입했는데 404를 받게 되며, 에러 메시지("대기열에 진입한 이력이 없습니다")도 실제 상황과 불일치한다. 재시도 시 ZADD NX가 다시 성공하여 사용자가 대기열에 재진입하면서 토큰도 보유한 상태 inconsistency가 발생한다.

운영 관점: 고트래픽 환경에서 스케줄러 주기와 겹치면 간헐적 404 발생 → 사용자 불만 및 상태 불일치 가능.

수정안 — enter()에서 rank 조회 후 비어 있으면 토큰 존재 여부 확인:

 public long enter(String loginId) {
     waitingQueue.enter(loginId);
-    return getPosition(loginId);
+    return waitingQueue.rank(loginId)
+            .map(rank -> rank + 1)
+            .orElseGet(() -> {
+                // admitNext 경쟁으로 이미 토큰이 발급된 경우
+                if (entryTokenStore.find(loginId).isPresent()) {
+                    return 0L;
+                }
+                throw new CoreException(ErrorType.NOT_FOUND,
+                    "[loginId = " + loginId + "] 대기열에 진입한 이력이 없습니다.");
+            });
 }

근본 해결은 WaitingQueue.enter()가 순번을 반환하도록 인터페이스를 변경하여 단일 Redis 명령으로 원자적 처리하는 것이다.

추가 테스트: enter() 호출 중 admitNext()가 개입하는 경쟁 시나리오 검증.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public long enter(String loginId) {
waitingQueue.enter(loginId);
return getPosition(loginId);
}
public long enter(String loginId) {
waitingQueue.enter(loginId);
return waitingQueue.rank(loginId)
.map(rank -> rank + 1)
.orElseGet(() -> {
// admitNext 경쟁으로 이미 토큰이 발급된 경우
if (entryTokenStore.find(loginId).isPresent()) {
return 0L;
}
throw new CoreException(ErrorType.NOT_FOUND,
"[loginId = " + loginId + "] 대기열에 진입한 이력이 없습니다.");
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueService.java`
around lines 20 - 24, QueueService.enter has a TOCTOU race because it calls
waitingQueue.enter() and then getPosition() as two separate Redis operations,
allowing admitNext() to remove the user in between and cause a false NOT_FOUND.
Fix this by making the queue insertion/position retrieval atomic: change
WaitingQueue.enter() to return the assigned rank/position directly, or otherwise
fetch position in the same Redis operation rather than calling getPosition()
afterward. Update QueueService.enter() to rely on that single atomic result, and
add a test that simulates admitNext() interleaving during enter().

@minbo2002
minbo2002 merged commit 69ff03e into loopers-labs:minbo2002 Jul 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant