퀵딜프로젝트

대기열 시스템 설계하기 with Kafka

프로젝트 탄생


처음으로 기획-설계-구현을 진행한 개인 프로젝트입니다.
백엔드 엔지니어로서 다뤄볼 만한 주제를 고민하던 중, 문득 쿠팡의 아이폰 사전 출시가 떠올랐습니다.
쿠팡의 사전 출시 / 명절 코레일 예약 / 선착순 쿠폰 발급처럼 순간적으로 트래픽이 폭증하는 상황은 일상에서 자주 볼 수 있습니다.
이런 상황을 다뤄보는 것이 실무와도 잘 맞겠다는 생각이 들어 주제로 삼게되었습니다.

그렇게 quick-deal-logo 프로젝트와 대기열 시스템이 탄생했습니다.

프로젝트 소개


퀵딜은 ‘할인 상품 온라인 판매 사이트’ 입니다.
특정 시간에 오픈하는 할인하는 상품을 구매하기 위해 유저들의 요청들이 몰린다고 가정하고,
요청을 순서대로 안정적으로 처리하며, 서버가 다운되지 않도록 요청량을 조절하는 것이 핵심입니다.
요청량 조절 방식으로는 결제페이지에 접근할 수 있는 유저 수를 사전에 제한해
트랜잭션 처리와 부하 관리가 중요한 결제 단계에서 병목을 방지하도록 설계했습니다.
퀵딜에서는 그 기준을 1,000명으로 선정해 쓰로틀링을 구현했습니다.

프로젝트의 목표는 다음과 같습니다.

  1. 부하테스트를 통해 대규모 주문 요청이 안정적으로 처리되는지 검증
  2. 메시지 큐 시스템을 기반으로 순차적인 트래픽 처리 및 데이터 파이프라인 구축

관련 아키텍처 리서치와 설계 수정을 거듭하여 최종적으로 아래와 같은 흐름도를 완성하게 되었습니다.

유저가 주문을 시도하면 발생하는 흐름은 다음과 같습니다.

1. 대기표(JWT토큰) 발급
유저가 주문을 시도하면 서버는 대기표를 발급합니다.
이 토큰에는 대기번호, 요청자 정보, 발급시간, 만료시간이 포함되어 있으며, 유저에게 주문 요청의 결과값으로 응답됩니다.
유저는 이제 대기표를 가지고 폴링을 통해 대기 가능 여부를 확인합니다.

2. 대기 내용 카프카 발행
해당 대기표는 상품 번호에 해당하는 Kafka 토픽으로 비동기 발행됩니다.
이때 Java의 CompletableFuture<SendResult<…»를 활용해 대기표 데이터를 논블로킹 방식으로 처리합니다.


대기표 처리 로직


대기표를 발급한 뒤에는 컨슈머가 이를 적절히 처리(consume) 해줘야 합니다.

Kafka 컨슈머는 발행된 메시지를 수신하여 아래의 순서대로 처리합니다:

1. 유효한 토큰인지 검증
만료되었거나 위변조된 토큰은 consume하지 않고 바로 제외합니다.

2. 해당 상품의 재고 확인
재고가 없는 경우 역시 consume하지 않고 보류합니다.

3. 결제 페이지 접근 가능 여부 확인
Redis를 통해 현재 결제 페이지에 접근 중인 유저 수를 조회하고,
최대 접근 인원이 1000명 미만이라면 해당 유저에게 접근 권한을 부여합니다.

✅ 컨슘이 가능한 경우

  • 해당 유저를 결제 페이지 접근 가능 목록에 등록합니다.
  • 마지막으로 처리된 대기 번호를 갱신하여, 유저에게 현재 자신의 대기 순번 정보를 제공할 수 있게 됩니다.

❌ 아직 컨슘하지 않는 경우

  • 조건을 만족하지 않는 대기표는 소비되지 않고 유지되며, 다음 컨슘 시점에 다시 확인됩니다. -> 이 구조를 통해 순서를 지키면서도 유연한 대기 흐름을 유지할 수 있습니다.


(클라이언트) 대기 종료 여부 확인


클라이언트는 대기창에서 발급받은 대기표(JWT 토큰)를 가지고 주기적으로 폴링을 수행합니다.
웹소켓도 고려했지만, 대기열 처리 로직 자체에 더 집중하기 위해 구현이 단순한 폴링 방식을 선택했습니다.

상품별 재고는 서버 측 ConcurrentHashMap에 캐싱되어 있으며,
폴링 요청 시 해당 상품의 재고가 0이라면 즉시 ITEM_SOLD_OUT 응답을 반환합니다.


재고는 있지만 아직 결제 페이지에 입장 권한이 부여되지 않은 상태라면,
Redis 기반 접근 가능 유저 목록에서 해당 유저가 없음을 확인하고 ACCESS_DENIED 응답을 반환합니다.


입장이 허용된 유저라면, 서버는 재고를 미리 확보하고 ACCESS_GRANTED 응답을 반환합니다. 이때 유저는 결제 페이지로 진입할 수 있습니다.


결제


결제 페이지에 입장한 유저가 실제 결제를 완료하면 다음 작업이 수행됩니다:

  1. DB 재고 수량 감소 – 실제 상품 재고를 반영합니다.
  2. 주문 데이터 업데이트 – 주문 내역이 기록됩니다.
  3. 결제페이지 접근자 목록에서 제거 – Redis에서 해당 유저를 제거하여 새로운 유저가 진입할 수 있도록 합니다.

결제 페이지는 많은 유저가 대기 중이기 때문에, 입장한 유저에게는 20분의 제한 시간을 부여했습니다.
제한 시간이 초과되거나 결제가 취소되면 해당 유저는 Redis 목록에서 제거되고, 확보된 재고는 다시 가용 상태로 전환됩니다.


기타 로직


재고 감지

대기열 처리나 폴링 응답 시, 유저에게 정확한 상태를 알려주기 위해 재고 여부를 계속 확인해야 했습니다.
하지만 매번 DB를 직접 조회하면 락이나 동시성 이슈가 발생할 수 있어, 재고 정보를 ConcurrentHashMap에 캐싱해두는 방식으로 구현했습니다.

굳이 Redis를 사용하지 않은 이유는, 간단한 정보이고 변경 주기도 짧지 않아서 메모리 캐싱으로 충분하다고 판단했습니다.
이 재고 캐싱은 스케줄러가 주기적으로 DB의 실제 재고를 확인하여 상품별로 업데이트해주는 구조입니다.


패키지 구조

실시간 처리나 고동시성 트래픽에는 Spring WebFlux가 더 적합하지만, 이번 프로젝트에서는 Spring MVC를 좀 더 깊게 다뤄보고 싶었고, ‘대기열 처리’라는 핵심 기능에 집중하고자 했습니다.

WebFlux는 비동기·논블로킹 특성을 갖고 있어 단일 프로젝트 내에서도 유연한 구조 설계가 가능한 반면에 Spring MVC는 동기-블로킹 기반이라 사실 MSA처럼 각 서비스를 유연하게 구동하거나 확장하는 데에는 사실 제약이 있습니다.
이런 점을 고려하여 이번 프로젝트는 Spring MVC에 더 적합한 Modular Monolithic Architecture로 구성했습니다.

현재는 core 모듈을 중심으로 전체 애플리케이션을 빌드하고 있지만, 모듈 간 결합도를 낮춰두었기 때문에, 추후 각 모듈을 독립적인 서비스로 분리하여 MSA로 전환하는 것도 무리 없이 가능하도록 설계해두었습니다.


검증


요청 큐에 메시지가 쌓인 상황에서, 자리가 있을 때만 정상적으로 컨슘되는지 확인하는 검증을 진행했습니다.

처음에는 조건이 맞지 않아 메시지 컨슘이 계속 실패하며 반복됩니다.

하지만 조건이 만족되면, 다음 컨슘 타이밍에 바로 성공 로그가 출력됩니다.

그 후 오프셋 112는 다시 조건이 맞지 않아(결제 페이지 접근 가능 인원이 가득 찬 상태) 컨슘에 실패하며,
조건이 만족될 때까지 계속 대기하게 됩니다.

자리가 연달아 생기자 오프셋 112와 113은 연속으로 컨슘에 성공했고,
오프셋 114에서 다시 조건이 막히면서 컨슘이 중단되었습니다.


이후 이어지는 부하 테스트 결과에서도, 로직이 예상한 순서대로 안정적으로 작동함을 확인할 수 있었습니다.
부하 테스트와 개선에 대한 내용은 다음 포스팅에서 다룰 예정입니다.


추가 고려 사항


이번 프로젝트에서는 모든 주문 요청에 대기열 시스템이 작동되도록 설정했지만
향후에는 트래픽 병목이 예상되는 특정 상품 코드에만 대기열을 적용하도록 개선한다면 더 유연한 흐름 제어가 가능하고, 시스템 자원도 효율적으로 활용할 수 있을 것이라 생각합니다.


퀵딜 시리즈 …


  1. 대기열 시스템 설계하기 with Kafka (현재글)
  2. 부하테스트 그리고 하드웨어 분리 (작성예정)
  3. 옵저벌리티 향상을 위한 모니터링 시스템 구현 (작성예정)
  4. Redis + Lua로 완성한 트랜잭션 처리 (작성예정)