Skip to content

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

Merged
SuHyun-git merged 6 commits into
loopers-labs:SuHyun-gitfrom
SuHyun-git:volume-8
Jul 15, 2026
Merged

[volume-8] Redis 기반 주문 대기열 구현 #364
SuHyun-git merged 6 commits into
loopers-labs:SuHyun-gitfrom
SuHyun-git:volume-8

Conversation

@SuHyun-git

Copy link
Copy Markdown

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 평소 초당 100건이던 주문 요청이 블랙 프라이데이 같은 행사 시 초당 10,000건까지 폭증할 수 있다. DB 커넥션 풀(40)과 PG는 스케일이 제한적이라, 요청을 그대로 흘려보내면 커넥션
    풀 고갈 → 응답 지연 → 타임아웃 → 재시도 폭풍으로 이어져 전체 서비스가 멈춘다.
  • 문제(또는 리스크): 초과 요청을 거부(Rate Limiting)하면 유저는 새로고침으로 재시도하며 트래픽을 더 키운다. "거부"가 아니라 "순서대로 기다리게" 하면서, 시스템은 처리 가능한 속도로만 요청을
    흘려보내야 한다(Back-pressure).
  • 성공 기준(완료 정의): 주문 API 앞단에 Redis Sorted Set 기반 대기열을 두고 (1) 중복 진입 없는 순번 부여, (2) 스케줄러가 산정된 배치 크기만큼 주기적으로 입장 토큰 발급, (3) 토큰 있는 유저만
    주문 API 진입 가능(성공 시 토큰 삭제), (4) Polling으로 순번/예상 대기시간 조회 — 이 4가지 Must-Have가 모두 동작하고 테스트로 증명되는 것.

선택지와 결정

1) 입장 토큰 검증 위치

  • 고려한 대안:
    • A: HandlerInterceptor — 기존 LoginInterceptor/AdminInterceptor와 동일한 패턴으로 WebMvcConfig에 등록
    • B: Servlet Filter — DispatcherServlet 이전 단계에서 더 이른 시점에 차단
  • 최종 결정: A (Interceptor)
  • 트레이드오프: Filter가 이론상 더 이른 시점에 차단 가능하지만, 이 프로젝트엔 Filter 컨벤션이 없어 새로운 패턴을 도입해야 하고 HttpServletRequest에서 메서드/경로를 직접 파싱해야 해서 코드
    가 덜 명확해짐. 기존 컨벤션과의 일관성을 우선했다.
  • 추후 개선 여지: 없음 (Interceptor로 충분)

2) 스케줄러 배치 크기 산정

  • 고려한 대안:
    • A: 발제문 예시값(DB 풀 50, 평균 처리 200ms) 그대로 사용
    • B: 프로젝트 실측 DB 커넥션 풀(HikariCP maximum-pool-size: 40) 기준으로 재계산
  • 최종 결정: B — 이론 최대 TPS = 40 / 0.2 = 200, 안전 마진 70% 적용 → 140 TPS, 스케줄러 100ms마다 14명씩 토큰 발급 (BATCH_SIZE = SAFE_TPS / (1000 / SCHEDULER_INTERVAL_MS))
  • 트레이드오프: 평균 처리 시간 200ms는 실측이 아니라 발제문 예시값을 그대로 가져온 추정치라, 실제 운영 환경에서 재측정이 필요할 수 있다.
  • 추후 개선 여지: 실제 주문 처리 시간을 APM 등으로 측정해 SAFE_TPS를 주기적으로 재산정

3) Redis 읽기 경로

  • 고려한 대안:
    • A: 기존 ProductCacheService처럼 Master/Replica 분리해 읽기를 Replica로
    • B: 읽기·쓰기 모두 Master(redisTemplateMaster) 사용
  • 최종 결정: B
  • 트레이드오프: 상품 캐시는 약간의 staleness가 허용되지만, 대기열 순번은 스케줄러가 100ms마다 실시간으로 바꾸므로 Replica 지연 시 "이미 입장 처리된 사람"이 오래된 순번을 볼 위험이 있어 Maste
    r 전용을 선택했다. Master의 읽기 부하가 늘어나는 건 감수.
  • 추후 개선 여지: 대기 인원이 매우 많아져 Master 부하가 문제가 되면, 순번 조회만 캐싱 계층을 추가하는 방안 검토

4) 입장 토큰 TTL

  • 고려한 대안:
    • A: 3분 (알림 확인 + 클릭 정도의 짧은 흐름 기준)
    • B: 5분 (발제문 예시값, 향후 결제 폼 입력 단계가 추가될 가능성 고려)
  • 최종 결정: B
  • 트레이드오프: 5분은 현재 흐름(장바구니 선택 후 클릭 한 번으로 주문) 기준으로는 다소 넉넉해 토큰 미사용 슬롯 낭비 위험이 있지만, 추후 결제 정보 입력 단계가 추가될 걸 감안해 여유를 뒀다.
  • 추후 개선 여지: Token Conversion Rate/Expiry Rate 지표를 운영하면서 TTL 재조정

🤔 고민한 점 / 막혔던 부분

  • 테스트 중 백그라운드 스케줄러가 테스트를 방해하는 문제: @Scheduled(fixedRate=100ms)로 실제 앱 컨텍스트가 뜨는 통합 테스트에서, 19명을 순차로 입력하는 setup 자체가 100ms보다 오래 걸려
    서 실제 스케줄러가 테스트 중간에 여러 번 자동으로 돌며 대기열을 예상보다 더 비워버렸다. 처음엔 스케줄러 주기를 프로퍼티화해서 test 프로파일에서 값을 크게 늘리는 방식으로 고쳤는데, 이후 전체
    리뷰에서 그러면 BATCH_SIZE(컴파일타임 상수로 산정된 값)와 실제 주기가 서로 다른 소스에서 나오게 되어 나중에 누가 주기 값만 바꾸면 배치 크기 산정 근거가 조용히 어긋날 수 있다는 걸 발견했다.
    최종적으로는 주기는 다시 상수로 고정하고, "자동 실행 on/off"만 별도 플래그로 분리해 두 문제를 동시에 해결했다.
  • 의도치 않은 회귀: 계획에는 OrderApiE2ETest만 수정 대상으로 적어뒀는데, 실제로는 StockConcurrencyE2ETest도 주문 API를 직접 호출하고 있어서 토큰 검증 게이트를 전역으로 걸자마자 조용
    히 깨질 뻔했다. 전체 테스트 스위트를 한 번씩 돌려보는 습관 덕분에 계획 단계에서 놓친 부분을 구현 단계에서 잡아낼 수 있었다.
  • 사람이 개입하는 지점에서 생긴 오타: 커밋 직전에 코드를 들여다보다가 한글 IME 조합 중 SAFE_TPS라는 식별자 중간에 "을"이 끼어 들어가 컴파일이 안 되는 상태로 커밋된 적이 있었다. 다음 작
    업에서 컴파일을 시도하다가 우연히 발견해서 고쳤는데, 이후로는 커밋 전에 한 번 더 컴파일을 확인하는 습관이 필요하다는 걸 느꼈다.

🙋 기타

이번 라운드는 발제문의 예시 수치(DB 풀 50, TTL 5분 등)를 그대로 쓰지 않고, 실제 프로젝트의 설정값(HikariCP 풀 40)과 실제 주문 흐름(결제 폼 없이 단일 클릭)을 근거로 다시 계산/판단한 부분이 많
았다. "발제문이 이렇게 예시를 들었으니까"가 아니라 "우리 프로젝트는 이런데, 그래도 맞는 값인가"를 한 번 더 물어보는 게 이번 설계 과정에서 가장 크게 배웠다.

- QueueDomainService.estimateWaitSeconds(rank): 순번 / SAFE_TPS로 예상 대기시간(초) 계산
- QueueInfo: 응용 계층 결과 DTO
- QueueService: enter/position/validateToken/deleteToken 유스케이스, Fake Repository로 TDD 8개 테스트
- QueueFacade: Controller/Interceptor가 호출할 진입점 (단순 위임)
- QueueRepositoryImpl/EntryTokenRepositoryImpl: redisTemplateMaster 기반 Sorted Set/String+TTL 구현
- QueueServiceIntegrationTest: 실제 Redis 통합 테스트, 동시 진입 시 순번 유일성 검증
- QueueDomainService: @component 누락 수정 (Spring 빈 등록 안 되어 컨텍스트 로딩 실패하던 버그)
- QueueController: POST /api/v1/queue/enter, GET /api/v1/queue/position API + E2E 테스트 4개
- QueueEntryScheduler: 100ms마다 ZPOPMIN으로 배치 크기(14명)만큼 토큰 발급
- 스케줄러 주기를 프로퍼티화(queue.scheduler.interval-ms)하고 test 프로파일에서만 비활성화
  → 실제 백그라운드 스케줄러가 테스트 도중 대기열을 미리 비워버리던 문제 해결
- QueueTokenInterceptor: POST /api/v1/orders에 X-Entry-Token 검증, 성공 시(2xx) 토큰 삭제
- WebMvcConfig: LoginInterceptor 뒤에 QueueTokenInterceptor 등록
- OrderApiE2ETest: 기존 주문 생성 테스트에 유효 토큰 발급 추가, 토큰 없음/무효/성공 후 삭제 테스트 3개 신규
- StockConcurrencyE2ETest: 동일하게 주문 API를 직접 호출하므로 토큰 발급 로직 추가 (회귀 방지)
- 최종 리뷰에서 발견: fixedRateString 프로퍼티화로 인해 BATCH_SIZE(컴파일타임 상수 기반)와
  실제 스케줄러 주기가 서로 다른 소스에서 나오게 되어, 운영에서 interval-ms를 바꾸면
  안전마진 계산이 조용히 어긋날 수 있는 상태였음
- @scheduled를 다시 fixedRate=SCHEDULER_INTERVAL_MS(상수)로 원복하여 BATCH_SIZE와의
  결합 복구
- 테스트 중 자동 실행만 끄기 위한 별도 queue.scheduler.enabled 플래그 추가 (기본 true,
  test 프로파일에서 false) — 테스트의 issueTokens() 직접 호출은 영향 없음
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough
  • 변경 목적: 주문 요청 급증 시 Redis Sorted Set 대기열로 순서를 제어해 DB 커넥션 고갈과 재시도 폭풍을 완화합니다.
  • 핵심 변경점: 대기 순번·예상 대기시간 API, Redis 입장 토큰(TTL 300초), 100ms 주기·14명 배치 발급 스케줄러, 주문 API 토큰 검증 인터셉터를 추가했습니다.
  • 리스크/주의사항: Redis 장애·토큰 만료·인터셉터 적용 범위(/api/v1/orders)를 확인해야 하며, 동시 진입 및 토큰 발급의 원자성이 운영 환경에서도 보장되는지 확인이 필요합니다.
  • 테스트/검증: 큐 서비스·도메인 단위 테스트, Redis 통합 테스트, 큐/주문/재고 동시성 E2E 테스트와 테스트 환경 스케줄러 비활성화 설정을 추가했습니다.

Walkthrough

Redis 기반 대기열과 입장 토큰 발급·검증·삭제 기능을 추가하고, 큐 조회 API와 주문 인터셉터, 배치 스케줄러 및 관련 테스트를 구성했다.

Changes

대기열 도메인과 Redis 저장소

Layer / File(s) Summary
대기열 계약·상태·서비스 구현
apps/commerce-api/src/main/java/com/loopers/domain/queue/*, apps/commerce-api/src/main/java/com/loopers/application/queue/*
대기열 순위, 예상 대기 시간, 토큰 상태를 정의하고 Redis Sorted Set과 토큰 저장소를 통해 진입·조회·검증·삭제를 처리한다.

큐 API와 주문 토큰 보호

Layer / File(s) Summary
큐 조회 API와 주문 인터셉터
apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/*, apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java
/api/v1/queue의 진입·위치 조회 API를 추가하고, 주문 POST 요청에 X-Entry-Token 검증을 적용하며 성공한 요청 후 토큰을 삭제한다.

토큰 발급 스케줄러

Layer / File(s) Summary
배치 토큰 발급과 테스트 설정
apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/queue/QueueEntryScheduler.java, apps/commerce-api/src/main/resources/application.yml
스케줄러가 대기열에서 배치 크기만큼 사용자를 꺼내 입장 토큰을 발급하며, 테스트 프로파일에서 관련 설정을 구성한다.

대기열과 주문 통합 검증

Layer / File(s) Summary
단위·통합·E2E 검증
apps/commerce-api/src/test/java/com/loopers/application/queue/*, apps/commerce-api/src/test/java/com/loopers/domain/queue/*, apps/commerce-api/src/test/java/com/loopers/interfaces/api/*, apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/queue/*
순차·동시 진입, 순위 조회, 토큰 검증, 주문 요청 인증, 성공 후 토큰 삭제, 배치 발급을 검증한다.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant QueueController
  participant QueueFacade
  participant QueueService
  participant Redis
  Client->>QueueController: POST /api/v1/queue/enter
  QueueController->>QueueFacade: enter(userId)
  QueueFacade->>QueueService: enter(userId)
  QueueService->>Redis: 대기열 사용자 등록 및 순위 조회
  Redis-->>QueueService: 순위 반환
  QueueService-->>QueueController: QueueInfo
  QueueController-->>Client: PositionResponse
Loading
sequenceDiagram
  participant Client
  participant QueueTokenInterceptor
  participant QueueFacade
  participant Redis
  participant OrderController
  Client->>QueueTokenInterceptor: 주문 POST + X-Entry-Token
  QueueTokenInterceptor->>QueueFacade: validateToken(userId, token)
  QueueFacade->>Redis: 입장 토큰 조회
  Redis-->>QueueFacade: 토큰 반환
  QueueTokenInterceptor->>OrderController: 검증 통과
  OrderController-->>QueueTokenInterceptor: 2xx 응답
  QueueTokenInterceptor->>QueueFacade: completeOrder(userId)
  QueueFacade->>Redis: 입장 토큰 삭제
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed 제목이 Redis 기반 주문 대기열 구현이라는 핵심 변경을 정확히 담고 있어 변경 목적을 바로 파악할 수 있다.
Description check ✅ Passed 문제 정의, 선택지와 결정, 고민한 점, 기타가 모두 있어 템플릿의 핵심 섹션을 충족한다.
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.

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: 4

🧹 Nitpick comments (1)
apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueDomainService.java (1)

5-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

도메인 계층의 @Component 어노테이션을 분리 검토하라.

QueueDomainService는 도메인 패키지(domain/queue)에 위치하지만 @Component로 인해 Spring 프레임워크 의존성이 도메인 계층에 침투한다. 도메인 규칙과 인프라 관심사가 혼재되므로, 인터페이스를 도메인에 두고 구현체(또는 어댑터)를 인프라 계층에 배치하는 방식을 권장한다.

As per coding guidelines, **/domain/**/*.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/main/java/com/loopers/domain/queue/QueueDomainService.java`
around lines 5 - 10, QueueDomainService의 도메인 계층에 Spring 의존성이 침투해 있으므로
`@Component를` 제거하고, 도메인에는 순수한 인터페이스 또는 규칙 객체만 유지하세요. Spring 빈 등록과 의존성 주입이 필요한 구현체
또는 어댑터는 인프라 계층으로 이동한 뒤 해당 구현체에 `@Component를` 적용하고, 사용하는 코드가 이를 주입받도록 수정하세요.

Source: Path instructions

🤖 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/queue/QueueService.java`:
- Around line 26-35: QueueService.position에서 토큰과 대기열 순위가 모두 없는 경우 NOT_FOUND 예외를
던지지 말고 재진입이 필요한 상태를 반환하도록 대체 흐름을 구현하세요. QueueInfo에 만료/재진입 필요 상태를 추가하고 해당 상태를
반환하며, 토큰 만료 후 position 조회 시나리오를 검증하는 테스트도 추가하세요.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueRepositoryImpl.java`:
- Around line 27-32: enter()의 addIfAbsent와 rank() 사이 TOCTOU 경쟁 조건을 제거하세요.
QueueRepositoryImpl의 enter 메서드에서 ZADD NX와 ZRANK를 하나의 Redis Lua script로 원자적으로
실행하고, 스크립트 결과로 순위를 반환하도록 변경해 popMin이 개입해 null이 발생하지 않게 하세요. 반환값 변환과 실패 시
CoreException 처리도 명확히 유지하고, 동시 popMin 상황에서 500 오류가 발생하지 않는 테스트를 추가하세요.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/queue/QueueEntryScheduler.java`:
- Around line 32-40: issueTokens()에서 popMin() 후 entryTokenRepository.issue()가
실패하면 사용자가 유실되지 않도록 보상 처리를 추가하세요. 각 userId의 발급을 개별 try-catch로 감싸고 실패한 사용자는
queueRepository를 통해 재큐잉하거나, 큐 제거와 토큰 발급을 원자화하는 방식으로 변경하세요. 또한 issue() 중간 실패 시
나머지 사용자와 실패 사용자가 모두 보존되는 테스트를 추가하세요.

In
`@apps/commerce-api/src/test/java/com/loopers/application/queue/QueueServiceIntegrationTest.java`:
- Around line 45-70: Queue concurrency test can hang indefinitely when a worker
fails and does not release the latch. In
enter_guaranteesUniquePositions_underConcurrency, move latch.countDown() into a
finally block, use a bounded timeout with latch.await() and assert it completes,
then call executor.shutdown() followed by awaitTermination() with a timeout and
handle interruption appropriately; preserve worker failures so the test does not
silently pass.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueDomainService.java`:
- Around line 5-10: QueueDomainService의 도메인 계층에 Spring 의존성이 침투해 있으므로 `@Component를`
제거하고, 도메인에는 순수한 인터페이스 또는 규칙 객체만 유지하세요. Spring 빈 등록과 의존성 주입이 필요한 구현체 또는 어댑터는 인프라
계층으로 이동한 뒤 해당 구현체에 `@Component를` 적용하고, 사용하는 코드가 이를 주입받도록 수정하세요.
🪄 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: 63ea99dc-d2e4-4c2f-b507-cee0983e5cd5

📥 Commits

Reviewing files that changed from the base of the PR and between 96179c8 and 75f6d6f.

📒 Files selected for processing (22)
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueService.java
  • apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/EntryTokenRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueDomainService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueThroughputPolicy.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueTokenInterceptor.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/queue/QueueEntryScheduler.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/queue/QueueServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/queue/QueueServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/queue/QueueDomainServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/QueueApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/StockConcurrencyE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/queue/QueueEntrySchedulerTest.java

Comment on lines +26 to +35
public QueueInfo position(Long userId) {
Optional<String> token = entryTokenRepository.find(userId);
if (token.isPresent()) {
return QueueInfo.ready(token.get());
}

long rank = queueRepository.rank(userId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "대기열에 진입한 기록이 없습니다."));
return QueueInfo.waiting(rank, queueDomainService.estimateWaitSeconds(rank));
}

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 | ⚡ Quick win

토큰 만료 후 대기열에서 제거된 사용자의 position 조회 시 NOT_FOUND 예외가 발생한다.

문제: 스케줄러가 popMin으로 사용자를 대기열에서 제거하고 토큰을 발급하지만, 토큰 TTL(300초) 만료 후 사용자가 position을 조회하면 entryTokenRepository.find(userId)가 empty를 반환하고, 이어 queueRepository.rank(userId)도 empty를 반환하여 CoreException(NOT_FOUND)가 발생한다. 사용자는 대기열에서도 제거되었고 토큰도 만료된 상태로, position 조회와 주문 모두 불가능한 상태가 된다.

운영 관점 문제: 인기 상품 오픈 시나리오에서 사용자가 5분 내에 주문을 완료하지 못하는 경우(결제 수단 선택 지연, 앱 일시 정지 등) 발생 가능하다. 현재 에러 메시지("대기열에 진입한 기록이 없습니다")는 사용자에게 재진입을 유도하지 않아 혼란을 야기한다.

수정안: rank가 empty인 경우 NOT_FOUND를 던지는 대신, 사용자에게 재진입이 필요함을 알리는 응답을 반환하거나 자동으로 대기열에 재진입시키는 처리를 추가한다.

추가 테스트: "토큰 만료 후 position 조회 시, 재진입 필요 상태를 반환한다" 케이스 추가.

As per path instructions, **/*Service*.java 리뷰 기준에서 "실패 시 대체 흐름을 제안한다"를 적용했다.

🔧 수정안: 에러 메시지 개선
     long rank = queueRepository.rank(userId)
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "대기열에 진입한 기록이 없습니다."));
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "대기열 정보가 만료되었거나 존재하지 않습니다. 대기열에 진입해주세요."));
🔧 대안: 재진입 유도 응답 반환 (QueueInfo에 expired 상태 추가 필요)
 public QueueInfo position(Long userId) {
     Optional<String> token = entryTokenRepository.find(userId);
     if (token.isPresent()) {
         return QueueInfo.ready(token.get());
     }

-    long rank = queueRepository.rank(userId)
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "대기열에 진입한 기록이 없습니다."));
-    return QueueInfo.waiting(rank, queueDomainService.estimateWaitSeconds(rank));
+    Optional<Long> rank = queueRepository.rank(userId);
+    if (rank.isEmpty()) {
+        return QueueInfo.expired();
+    }
+    return QueueInfo.waiting(rank.get(), queueDomainService.estimateWaitSeconds(rank.get()));
 }
🤖 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/queue/QueueService.java`
around lines 26 - 35, QueueService.position에서 토큰과 대기열 순위가 모두 없는 경우 NOT_FOUND 예외를
던지지 말고 재진입이 필요한 상태를 반환하도록 대체 흐름을 구현하세요. QueueInfo에 만료/재진입 필요 상태를 추가하고 해당 상태를
반환하며, 토큰 만료 후 position 조회 시나리오를 검증하는 테스트도 추가하세요.

Source: Path instructions

Comment on lines +27 to +32
@Override
public long enter(Long userId, long timestampMillis) {
redisTemplate.opsForZSet().addIfAbsent(QUEUE_KEY, String.valueOf(userId), timestampMillis);
return rank(userId)
.orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, "대기열 진입에 실패했습니다."));
}

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 | 🔴 Critical | ⚡ Quick win

enter()의 TOCTOU 경쟁 조건 — addIfAbsent와 rank() 사이에 popMin이 끼어들면 500 에러 발생

enter()addIfAbsent(ZADD NX)와 rank()(ZRANK)를 두 개의 별도 Redis 명령으로 실행한다. 스케줄러가 100ms마다 popMin()(ZPOPMIN)으로 대기열에서 사용자를 제거하므로, 두 명령 사이에 사용자가 pop되면 rank()null을 반환하고 INTERNAL_ERROR(500)가 발생한다.

운영 관점 문제: 사용자는 500 에러를 수신하지만 실제로는 토큰이 발급되어 있다. 사용자가 재시도하면 대기열에 재진입하며 중복 항목이 발생한다. PR 목적인 요청 급증 상황에서 경쟁 확률이 증가한다.

수정안: Lua script로 ZADD+ZRANK를 원자화한다.

🔒 TOCTOU 경쟁 조건 수정안 (Lua script 원자화)
 import com.loopers.domain.queue.QueueRepository;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
 import org.springframework.stereotype.Component;

-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;

 `@Component`
 public class QueueRepositoryImpl implements QueueRepository {

     private static final String QUEUE_KEY = "waiting-queue";

+    private static final DefaultRedisScript<Long> ENTER_SCRIPT;
+
+    static {
+        ENTER_SCRIPT = new DefaultRedisScript<>();
+        ENTER_SCRIPT.setScriptText(
+            "redis.call('ZADD', KEYS[1], 'NX', ARGV[2], ARGV[1]) " +
+            "local rank = redis.call('ZRANK', KEYS[1], ARGV[1]) " +
+            "if rank == false then return -1 end " +
+            "return rank"
+        );
+        ENTER_SCRIPT.setResultType(Long.class);
+    }
+
     private final RedisTemplate<String, String> redisTemplate;

     public QueueRepositoryImpl(`@Qualifier`("redisTemplateMaster") RedisTemplate<String, String> redisTemplate) {
         this.redisTemplate = redisTemplate;
     }

     `@Override`
     public long enter(Long userId, long timestampMillis) {
-        redisTemplate.opsForZSet().addIfAbsent(QUEUE_KEY, String.valueOf(userId), timestampMillis);
-        return rank(userId)
-            .orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, "대기열 진입에 실패했습니다."));
+        Long rank = redisTemplate.execute(
+            ENTER_SCRIPT,
+            Collections.singletonList(QUEUE_KEY),
+            String.valueOf(userId),
+            String.valueOf(timestampMillis)
+        );
+        if (rank == null || rank < 0) {
+            throw new CoreException(ErrorType.INTERNAL_ERROR, "대기열 진입에 실패했습니다.");
+        }
+        return rank;
     }

추가 테스트 제안:

  • 스케줄러 popMin과 enter()를 동시 실행하여 경쟁 조건 재현 → 500 에러가 발생하지 않음을 검증
  • Lua script 적용 후 enter()의 원자성 보장 테스트

As per coding guidelines, null 처리 및 예외 흐름의 명확성을 점검한다. enter()에서 rank()null을 반환하는 경쟁 상황이 예외 흐름의 불명확성을 초래한다.

📝 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
@Override
public long enter(Long userId, long timestampMillis) {
redisTemplate.opsForZSet().addIfAbsent(QUEUE_KEY, String.valueOf(userId), timestampMillis);
return rank(userId)
.orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, "대기열 진입에 실패했습니다."));
}
private static final DefaultRedisScript<Long> ENTER_SCRIPT;
static {
ENTER_SCRIPT = new DefaultRedisScript<>();
ENTER_SCRIPT.setScriptText(
"redis.call('ZADD', KEYS[1], 'NX', ARGV[2], ARGV[1]) " +
"local rank = redis.call('ZRANK', KEYS[1], ARGV[1]) " +
"if rank == false then return -1 end " +
"return rank"
);
ENTER_SCRIPT.setResultType(Long.class);
}
`@Override`
public long enter(Long userId, long timestampMillis) {
Long rank = redisTemplate.execute(
ENTER_SCRIPT,
Collections.singletonList(QUEUE_KEY),
String.valueOf(userId),
String.valueOf(timestampMillis)
);
if (rank == null || rank < 0) {
throw new CoreException(ErrorType.INTERNAL_ERROR, "대기열 진입에 실패했습니다.");
}
return rank;
}
🤖 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/QueueRepositoryImpl.java`
around lines 27 - 32, enter()의 addIfAbsent와 rank() 사이 TOCTOU 경쟁 조건을 제거하세요.
QueueRepositoryImpl의 enter 메서드에서 ZADD NX와 ZRANK를 하나의 Redis Lua script로 원자적으로
실행하고, 스크립트 결과로 순위를 반환하도록 변경해 popMin이 개입해 null이 발생하지 않게 하세요. 반환값 변환과 실패 시
CoreException 처리도 명확히 유지하고, 동시 popMin 상황에서 500 오류가 발생하지 않는 테스트를 추가하세요.

Comment on lines +32 to +40
public void issueTokens() {
List<Long> userIds = queueRepository.popMin(QueueThroughputPolicy.BATCH_SIZE);
for (Long userId : userIds) {
entryTokenRepository.issue(userId);
}
if (!userIds.isEmpty()) {
log.info("[QueueEntryScheduler] 토큰 발급 완료: count={}", userIds.size());
}
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files
git ls-files | rg 'QueueEntryScheduler\.java|QueueRepository\.java|EntryTokenRepository\.java|Queue.*Repository\.java|Queue.*Scheduler\.java'

# Show outlines if available
for f in \
  apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/queue/QueueEntryScheduler.java \
  apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueRepository.java \
  apps/commerce-api/src/main/java/com/loopers/domain/entrytoken/EntryTokenRepository.java
do
  if [ -f "$f" ]; then
    echo "### OUTLINE $f"
    ast-grep outline "$f" --view expanded || true
  fi
done

# Read target file if present
if [ -f apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/queue/QueueEntryScheduler.java ]; then
  echo "### QueueEntryScheduler"
  cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/queue/QueueEntryScheduler.java
fi

# Search for methods/signatures
rg -n "popMin\(|issue\(|enter\(" apps/commerce-api/src/main/java -g'*.java'

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 5174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Broaden search if package paths differ
git ls-files | rg 'QueueEntryScheduler\.java|QueueRepository\.java|EntryTokenRepository\.java|Queue.*Repository\.java|Queue.*Scheduler\.java|entry.*Token.*Repository\.java'

# Search the whole repo for queue-related methods
rg -n "interface .*QueueRepository|class .*QueueRepository|interface .*EntryTokenRepository|class .*EntryTokenRepository|popMin\(|issue\(|enter\(" .

# Read the most relevant files if found
for f in \
  apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/queue/QueueEntryScheduler.java \
  apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueRepository.java \
  apps/commerce-api/src/main/java/com/loopers/domain/entrytoken/EntryTokenRepository.java
do
  if [ -f "$f" ]; then
    echo "### $f"
    wc -l "$f"
    cat -n "$f"
  fi
done

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 9866


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If the earlier paths are different, locate scheduler and repository implementations.
fd -i 'QueueEntryScheduler.java' apps .
fd -i 'QueueRepository.java' apps .
fd -i 'EntryTokenRepository.java' apps .

# Find tests or implementations mentioning 재큐잉 / ZPOPMIN / ZADD NX
rg -n "ZPOPMIN|ZADD NX|재큐|requeue|issueTokens|QueueEntryScheduler" apps .

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 3064


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in \
  apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueRepositoryImpl.java \
  apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenRepositoryImpl.java \
  apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/queue/QueueEntrySchedulerTest.java
do
  echo "### FILE: $f"
  wc -l "$f"
  cat -n "$f"
  echo
done

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 6893


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on scheduler tests to see if failure/exception paths are covered.
cat -n apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/queue/QueueEntrySchedulerTest.java

# Also inspect implementation details for popMin and issue.
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueRepositoryImpl.java
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenRepositoryImpl.java

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 6281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueRepositoryImpl.java
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenRepositoryImpl.java
cat -n apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/queue/QueueEntrySchedulerTest.java

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 6281


issueTokens()의 부분 실패를 보상해야 한다다.

  • popMin()으로 큐에서 먼저 제거한 뒤 issue()가 예외를 던지면, 해당 배치의 남은 사용자는 토큰 없이 처리에서 빠진다다.
  • issue()를 사용자 단위로 try-catch 처리하고 실패 시 재큐잉하거나, Redis Lua로 큐 제거와 토큰 발급을 원자화해야 한다다.
  • issue()가 중간에 실패해도 나머지 사용자가 유실되지 않는 테스트를 추가해야 한다다.
🤖 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/interfaces/scheduler/queue/QueueEntryScheduler.java`
around lines 32 - 40, issueTokens()에서 popMin() 후 entryTokenRepository.issue()가
실패하면 사용자가 유실되지 않도록 보상 처리를 추가하세요. 각 userId의 발급을 개별 try-catch로 감싸고 실패한 사용자는
queueRepository를 통해 재큐잉하거나, 큐 제거와 토큰 발급을 원자화하는 방식으로 변경하세요. 또한 issue() 중간 실패 시
나머지 사용자와 실패 사용자가 모두 보존되는 테스트를 추가하세요.

Comment on lines +45 to +70
@DisplayName("동시에 여러 유저가 진입해도,")
@Test
void enter_guaranteesUniquePositions_underConcurrency() throws InterruptedException {
// arrange
int userCount = 50;
ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(userCount);

// act
for (long userId = 1; userId <= userCount; userId++) {
long id = userId;
executor.submit(() -> {
queueService.enter(id);
latch.countDown();
});
}
latch.await();
executor.shutdown();

// assert
Set<Long> positions = new HashSet<>();
for (long userId = 1; userId <= userCount; userId++) {
positions.add(queueService.position(userId).position());
}
assertThat(positions).hasSize(userCount);
}

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 | 🟡 Minor | ⚡ Quick win

latch.await() 타임아웃 누락 — 태스크 예외 시 테스트 무한 대기

queueService.enter(id)가 예외를 throw하면 latch.countDown()이 호출되지 않아 latch.await()가 영구 대기한다. executor.shutdown()awaitTermination() 없이 즉시 반환하므로 후속 테스트에 영향을 줄 수 있다.

운영 관점 문제: CI 파이프라인이 무한 대기하며 빌드가 hang된다.

수정안: countDown()finally 블록으로 이동하고, latch.await()에 타임아웃을 추가하며, awaitTermination()을 호출한다.

🛡️ 테스트 hang 방지 수정안
         for (long userId = 1; userId <= userCount; userId++) {
             long id = userId;
             executor.submit(() -> {
-                queueService.enter(id);
-                latch.countDown();
+                try {
+                    queueService.enter(id);
+                } finally {
+                    latch.countDown();
+                }
             });
         }
-        latch.await();
+        assertThat(latch.await(10, java.util.concurrent.TimeUnit.SECONDS))
+            .as("모든 태스크가 타임아웃 내에 완료되어야 한다")
+            .isTrue();
         executor.shutdown();
+        executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);

추가 테스트 제안:

  • enter() 실패 시 타임아웃으로 테스트가 종료되는지 검증

As per coding guidelines, 통합 테스트의 플래키 가능성을 점검한다. latch.await() 무타임아웃은 플래키 위험 요소이다.

📝 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
@DisplayName("동시에 여러 유저가 진입해도,")
@Test
void enter_guaranteesUniquePositions_underConcurrency() throws InterruptedException {
// arrange
int userCount = 50;
ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(userCount);
// act
for (long userId = 1; userId <= userCount; userId++) {
long id = userId;
executor.submit(() -> {
queueService.enter(id);
latch.countDown();
});
}
latch.await();
executor.shutdown();
// assert
Set<Long> positions = new HashSet<>();
for (long userId = 1; userId <= userCount; userId++) {
positions.add(queueService.position(userId).position());
}
assertThat(positions).hasSize(userCount);
}
`@DisplayName`("동시에 여러 유저가 진입해도,")
`@Test`
void enter_guaranteesUniquePositions_underConcurrency() throws InterruptedException {
// arrange
int userCount = 50;
ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(userCount);
// act
for (long userId = 1; userId <= userCount; userId++) {
long id = userId;
executor.submit(() -> {
try {
queueService.enter(id);
} finally {
latch.countDown();
}
});
}
assertThat(latch.await(10, java.util.concurrent.TimeUnit.SECONDS))
.as("모든 태스크가 타임아웃 내에 완료되어야 한다")
.isTrue();
executor.shutdown();
executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);
// assert
Set<Long> positions = new HashSet<>();
for (long userId = 1; userId <= userCount; userId++) {
positions.add(queueService.position(userId).position());
}
assertThat(positions).hasSize(userCount);
}
🤖 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/application/queue/QueueServiceIntegrationTest.java`
around lines 45 - 70, Queue concurrency test can hang indefinitely when a worker
fails and does not release the latch. In
enter_guaranteesUniquePositions_underConcurrency, move latch.countDown() into a
finally block, use a bounded timeout with latch.await() and assert it completes,
then call executor.shutdown() followed by awaitTermination() with a timeout and
handle interruption appropriately; preserve worker failures so the test does not
silently pass.

@SuHyun-git
SuHyun-git merged commit 9fa89da into loopers-labs:SuHyun-git Jul 15, 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