1차 설계에서는 Redis로 대기열을 구현했다.
- ZSET으로 선착순 보장
- Scheduler로 순차 입장 처리
- admitted SET + TTL로 “입장권” 발급
하지만 여기서 끝이 아니다.
“입장 성공”은 Redis에서의 성공일 뿐,
진짜 신청 확정은 DB에 저장되어야 한다.
그래서 2차 설계의 목표는 이것이었다.
1️⃣ 2차 설계 목표
- 대기열 우회 방지 (입장권 검증)
- 정원 초과 방지 (동시성 제어)
- 중복 신청 방지
- 입장권 1회성 사용 보장
- 실제 신청 기록 영구 저장
2️⃣ 전체 구조 흐름
Redis 대기열 성공 (admitted)
↓
EnrollService.enroll()
↓
DB Row Lock (PESSIMISTIC_WRITE)
↓
정원 체크 + 차감
↓
Enrollment 저장
↓
입장권 소비
Redis는 “줄서기” 담당,
DB는 “진짜 확정” 담당.
역할을 명확히 분리했다.
3️⃣ EnrollService – 핵심 로직
@Transactional
public EnrollResult enroll(Long lectureId, String userKey){}
이 메서드가 최종 확정의 핵심이다.
(1) 입장권(admitted) 검증
if(!queueService.isAdmitted(lectureId,userKey)){
throw new IllegalStateException("입장 권한이 없습니다.");
}
왜 필요한가?
- 대기열을 우회해 바로 enroll 호출하는 것 방지
- Redis에서 성공 판정을 받은 사용자만 결제 가능
👉 Redis → DB로 넘어오는 2차 검증
(2) 강의 Row Lock (비관적 락)
@Lock(LockModeType.PESSIMISTIC_WRITE)
Lecture lecture = lectureRepository.findByIdForUpdate(lectureId);
왜 비관적 락을 사용했는가?
정원의 마지막 자리를 여러 명이 동시에 신청하면?
- A가 seat 확인 → 남음
- B도 seat 확인 → 남음
- 둘 다 증가 → 초과 발생
이를 방지하기 위해:
DB Row에 PESSIMISTIC_WRITE 락
- 한 트랜잭션이 끝날 때까지 다른 트랜잭션 대기
- 정원 초과 동시성 완전 차단
(3) 중복 신청 방지
if(enrollmentRepository.existsByLectureIdAndUserKey(...)){
return ALREADY_ENROLLED;
}
그리고 DB 레벨에서도:
@UniqueConstraint(
name="uk_enrollment_lecture_user",
columnNames={"lecture_id","user_key"}
)
👉 애플리케이션 + DB 이중 방어
(4) 정원 체크 + 차감
if (!lecture.hasSeat()) {
throw new IllegalStateException("정원이 마감되었습니다.");
}
lecture.increaseEnrolled();
public boolean hasSeat(){
return enrolledCount < capacity;
}
- capacity 초과 방지
- 락이 걸린 상태에서만 증가
👉 정원 정합성 보장
(5) 신청 기록 저장
enrollmentRepository.save(enrollment);
이게 진짜 성공이다.
Redis 성공이 아니라,
DB에 Enrollment가 저장된 순간이 진짜 확정.
(6) 입장권 소모
queueService.consumeAdmitted(lectureId, userKey);
왜 필요할까?
- 입장권은 1회성
- 재사용 불가
- 중복 결제 방지
👉 “입장 성공 후 결제 시간 제한” 설계 완성
4️⃣ Enrollment 엔티티 설계
@Table(
uniqueConstraints = @UniqueConstraint(
name="uk_enrollment_lecture_user",
columnNames={"lecture_id","user_key"}
)
)
👉 강의 + userKey 유니크
👉 DB가 중복 신청 차단
@PrePersist
void prePersist(){
this.createdAt = LocalDateTime.now();
}
👉 신청 시간 기록
👉 receipt 페이지에서 사용
5️⃣ Lecture 엔티티 – 정원 제어
@Column(nullable = false)
private int capacity;
@Column(nullable = false)
private int enrolledCount;
설계 포인트
- 매번 count(*) 하지 않음
- enrolledCount 캐싱
- 락 걸린 상태에서만 증가
👉 성능 + 정합성 균형
6️⃣ 프론트 흐름까지 완성
detail.html
- 신청 클릭 → /queue
- userKey localStorage 저장
- 폴링 /queue/me
- SUCCESS면 /apply 이동
apply.html
- 결제 버튼 → /enroll
- 성공 시 enrolledUserKey 저장
- receipt 이동
receipt.html
- /enroll/detail 호출
- 신청 번호 + 신청 시간 표시
👉 실제 서비스 흐름과 유사한 UX 완성
7️⃣ 1차 설계 vs 2차 설계 차이
| 구분 | 1차 | 2차 |
| 줄서기 | Redis ZSET | 동일 |
| 입장 처리 | Scheduler | 동일 |
| 결제 보호 | admitted SET | 동일 |
| 정원 보호 | ❌ | DB Row Lock |
| 최종 확정 | ❌ | Enrollment 저장 |
| 중복 방지 | 일부 | 유니크 제약 + 로직 |
8️⃣ 내가 이 설계를 통해 고민한 것
✔ 대규모 트래픽에서 DB 보호
- 줄서기는 Redis
- 확정만 DB
✔ 동시성 문제 해결
- Row Lock
- 정원 초과 방지
✔ 대기열 우회 방지
- 입장권 검증
✔ 결제 시간 제한
- admitted TTL
✔ 중복 확정 방지
- 유니크 제약 + consume
9️⃣ 한 줄 요약
Redis는 “공정한 줄서기”를 담당하고,
DB는 “진짜 확정과 정합성”을 담당한다.
이 구조를 통해:
- 대규모 트래픽 제어
- 정원 동시성 문제 해결
- 서비스 흐름 완성
을 구현했다.
'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
| ⏰💡 폴링 API 부하테스트 진행 방법 (JMeter) (0) | 2026.02.15 |
|---|---|
| ⏰💡 선착순 강의에서 비관적 락(PESSIMISTIC LOCK)을 사용한 이유? (0) | 2026.02.15 |
| ⏰🚨 신청 버튼을 누르지도 않았는데 자동으로 신청되는 문제 (Redis 대기열 + localStorage 이슈) (0) | 2026.02.14 |
| ⏰ 선착순 강의 대규모 트래픽 처리 – Redis 1차 설계 (0) | 2026.02.14 |
| 👥🚨 좋아요 상태 변경 후 본인 게시글 삭제 오류 해결 (FK 제약 & DB CASCADE) (0) | 2026.02.14 |