1️⃣ 왜 이걸 만들었는가? (문제 정의)
내가 만든 “선착순 강의 신청” 기능은 단순 CRUD가 아니라, 본질적으로 이런 문제였다.
- 짧은 시간에 다수 사용자가 동시에 “신청” 버튼 클릭
- 요청 순서를 공정하게 보장해야 함
- 동시에 처리하면 DB에 락/경합 발생 가능
- 사용자는 “내가 신청되었는지”를 계속 확인함 (새로고침/폴링)
즉,
신청 로직을 DB 트랜잭션에 그대로 몰아넣으면 병목이 생길 수 있다.
그래서 해결 방향을 이렇게 잡았다.
- DB는 최종 확정만 담당
- “줄서기”는 Redis가 담당
- 빠른 인메모리 자료구조로 병목을 분리
2️⃣ 설계에서 가장 많이 고민한 부분
(1) 순서 보장을 어떻게 할 것인가?
선착순의 핵심은:
“누가 먼저 왔는지”를 정렬된 상태로 유지하는 것
그래서 Redis의 **ZSET(Sorted Set)**을 사용했다.
key: queue:lecture:{lectureId}
score: 요청 시간(ms)
member: userKey
- score = 현재 시간(ms)
- 먼저 요청한 사람이 score가 작음
- 자동으로 정렬 유지
👉 별도의 정렬 로직 없이 Redis가 순서를 보장
(2) 사용자의 “내 상태 조회”는 어떻게 처리할까?
사용자는:
- 새로고침
- 탭 이동
- 중복 요청
- 반복 폴링
이걸 매번 DB에서 계산하면 비용이 커진다.
그래서 Redis에서 바로 순번 조회.
ZRANK
- ZSET에서 내 현재 rank 반환
- O(logN) 성능
- 매우 빠름
(3) “입장 성공”은 어떻게 확정할 것인가? (핵심 설계 포인트)
여기서 가장 큰 고민이 있었다.
스케줄러가 대기열에서 사용자를 pop하면
그 사용자는 ZSET에서 사라진다.
그런데:
ZSET에서 사라졌다고 무조건 성공은 아니다.
가능한 상황:
- 네트워크 단절
- 잘못된 userKey
- 처리 중 예외
- TTL 만료
그래서 결론은:
“대기열”과 “성공 확정”을 분리하자.
최종 설계
- queue:lecture:{id} → ZSET (대기 중)
- admitted:lecture:{id} → SET (입장 확정자)
이렇게 2단계로 분리했다.
(4) Redis 메모리 누적 문제
admitted를 영원히 저장하면 메모리가 누적된다.
해결:
TTL 10분
- 입장 성공 기록은 일정 시간 후 자동 삭제
- 메모리 안전성 확보
3️⃣ 전체 아키텍처 흐름

[대기열 ZSET] ──(Scheduler)──▶ [입장권 SET] ──▶ 결제 가능
구성 요소
✅ DB (JPA)
- Lecture: 강의 정보
- 정원, 오픈 시간 등 저장
✅ Redis
- ZSET: 대기열
- SET: 입장 성공자 기록
✅ Scheduler
- 3초마다 대기열 앞에서 N명 pop
- admitted에 기록
✅ Polling API
- 프론트에서 주기적으로 상태 조회
4️⃣ 요청 시나리오 흐름
- 사용자가 “대기열 참가”
- 서버는 UUID로 userKey 생성
- Redis ZSET에 추가
- 프론트는 userKey 저장 후 폴링 시작
- 스케줄러가 주기적으로 N명 pop
- pop된 user는 admitted SET에 기록
- 폴링 시:
- ZSET에 있으면 → QUEUED
- ZSET엔 없고 admitted에 있으면 → SUCCESS
- 둘 다 없으면 → NOT_IN_QUEUE
5️⃣ 구현 디테일
5 - 1 대기열 등록
public Long enqueue(Long lectureId, String userKey) {
long score = Instant.now().toEpochMilli();
redisTemplate.opsForZSet().add(key(lectureId), userKey, score);
Long rank = redisTemplate.opsForZSet().rank(key(lectureId), userKey);
return rank != null ? rank + 1 : null;
}
핵심 포인트
- score = 현재 시간
- 자동 정렬
- rank 즉시 반환
- 0-based → +1 처리
5 - 2 폴링 조회
Long position = queueService.getPosition(lectureId, userKey);
if (position != null) {
status = QUEUED;
} else if (queueService.isAdmitted(lectureId, userKey)) {
status = SUCCESS;
} else {
status = NOT_IN_QUEUE;
}
설계 장점
- 상태 판별이 명확
- 새로고침에도 성공 여부 유지
- 로직이 단순하고 예측 가능
5 - 3 스케줄러 입장 처리
@Scheduled(fixedDelay = 3000)
public void processQueue() {
Set<String> admitted = queueService.popFront(lectureId, batchSize);
if (!admitted.isEmpty()) {
queueService.markAdmitted(lectureId, admitted);
}
}
public Set<String> popFront(Long lectureId, int n) {
return redisTemplate.opsForZSet()
.popMin(key(lectureId), n)
.stream()
.map(tuple -> tuple.getValue())
.collect(toSet());
}
중요한 점
- ZPOPMIN은 조회 + 삭제가 원자적
- 중복 입장 방지
- “조회 후 삭제”보다 안전
5 - 4 입장 확정 기록
public void markAdmitted(Long lectureId, Set<String> userKeys) {
redisTemplate.opsForSet()
.add(admittedKey(lectureId), userKeys.toArray(new String[0]));
redisTemplate.expire(admittedKey(lectureId), Duration.ofMinutes(10));
}
왜 SET인가?
- 순서 필요 없음
- 존재 여부만 빠르게 확인하면 됨
- O(1) 조회
6️⃣ 이 설계의 핵심 가치
① DB 병목 분리
- 줄서기는 Redis
- 최종 정원 체크는 DB
트래픽이 몰려도 DB 락을 줄일 수 있다.
② 순서 공정성 보장
- timestamp 기반 정렬
- Redis가 자동 관리
③ 상태 판별 안정성
- queue와 admitted 분리
- 폴링 시 상태 흔들림 없음
④ 원자적 pop
- 중복 입장 위험 감소
- 동시성에 강함
7️⃣ 이 설계의 한계 (솔직한 정리)
이 구조는 1차 구조다.
아직:
- 강의 정원과 Redis pop 수를 완전히 동기화하진 않음
- Redis 장애 시 대안 구조 필요
- 분산 환경에서 다중 인스턴스 스케줄러 처리 고려 필요
하지만 1차 목표였던:
대규모 동시 요청에서 DB 병목을 줄이고
공정한 대기열을 만드는 것
은 성공적으로 달성했다.
'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
| ⏰ 선착순 강의 대규모 트래픽 처리 – Redis 2차 설계 (0) | 2026.02.15 |
|---|---|
| ⏰🚨 신청 버튼을 누르지도 않았는데 자동으로 신청되는 문제 (Redis 대기열 + localStorage 이슈) (0) | 2026.02.14 |
| 👥🚨 좋아요 상태 변경 후 본인 게시글 삭제 오류 해결 (FK 제약 & DB CASCADE) (0) | 2026.02.14 |
| 👥💡 좋아요(Like) 기능 설계: 상태, 집계, 동시성까지 고려한 구현 정리 (0) | 2026.02.14 |
| 👥🚨 같은 유저가 좋아요를 동시에 연타할 때 동시성 테스트가 깨지는 이유 (0) | 2026.02.14 |