선착순 강의 신청 기능을 구현하면서 가장 중요했던 문제는 이것이었다.
정원이 100명일 때, 동시에 여러 명이 마지막 자리를 신청하면 어떻게 될까?
이 문제를 해결하기 위해 나는 비관적 락(PESSIMISTIC_WRITE) 을 사용했다.
1️⃣ findByIdForUpdate는 무엇을 하는가?
Lecture lecture = lectureRepository
.findByIdForUpdate(lectureId)
.orElseThrow(() -> new IllegalArgumentException("Lecture not found"));
일반적인 findById()는 단순 조회다.
하지만 findByIdForUpdate()는 다르다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select l from Lecture l where l.id = :id")
Optional<Lecture> findByIdForUpdate(@Param("id") Long id);
이 코드는 실제 SQL로 변환되면:
SELECT * FROM lecture WHERE id = ? FOR UPDATE;
즉,
조회하면서 동시에 해당 row에 쓰기 락을 건다.
2️⃣ 왜 이런 “자물쇠”가 필요한가? (Race Condition 방지)
정원이 100명이고, 현재 99명이 등록되어 있다고 가정해보자.
마지막 1자리를 두 명이 동시에 신청하면?
락이 없다면 이런 일이 벌어진다:
- A 유저 → “남은 자리 있나?” → 있음 (99명)
- B 유저 → “남은 자리 있나?” → 있음 (99명)
- A 유저 → 신청 완료 → 100명
- B 유저 → 신청 완료 → 101명 (정원 초과)
이것을 Race Condition(경쟁 상태) 라고 한다.
🔒 비관적 락이 있으면 어떻게 되나?
- A 유저 → SELECT ... FOR UPDATE 실행 → row 잠금
- B 유저 → 같은 row 접근 시도 → 대기
- A 유저 → 정원 차감 후 트랜잭션 종료 → 락 해제
- B 유저 → 이제 조회 가능 → 이미 100명 → 신청 실패
즉,
두 번째 사용자는 “조회 단계”에서부터 기다리게 된다.
이게 핵심이다.
3️⃣ 비관적 락(PESSIMISTIC_WRITE)이란 무엇인가?
@Lock(LockModeType.PESSIMISTIC_WRITE)
✅ PESSIMISTIC = 비관적
“동시에 여러 명이 들어올 거라고 가정하고, 처음부터 잠가버리자.”
✅ WRITE = 쓰기 락
“이 row는 내가 끝날 때까지 아무도 수정하지 못하게 하자.”
쉽게 말하면:
내가 이 강의의 정원 처리를 끝낼 때까지,
다른 트랜잭션은 기다려라.
4️⃣ 중요한 오해 하나
“select인데 왜 락이 걸리나요?”
맞다.
일반 select는 락이 걸리지 않는다.
하지만 SELECT ... FOR UPDATE는 다르다.
조회하면서 동시에 해당 row를 쓰기용 잠금 상태로 만든다.
5️⃣ 락은 언제 잡히는가?
락은 이 줄이 실행되는 순간 잡힌다.
lectureRepository.findByIdForUpdate(lectureId);
DB 입장에서 보면:
- 해당 row에 exclusive lock 획득
- 트랜잭션 종료까지 유지
그래서 반드시 필요하다:
@Transactional
트랜잭션이 끝나는 시점까지 락이 유지되기 때문이다.
만약 트랜잭션이 없다면?
→ 조회 후 즉시 커밋
→ 락도 즉시 해제
→ 의미 없음
6️⃣ 왜 낙관적 락이 아니라 비관적 락을 선택했는가?
이 상황의 특징은:
- 선착순
- 짧은 시간에 동시 요청 집중
- 정원 초과는 절대 허용 불가
낙관적 락은:
- 충돌이 적다고 가정
- 버전 충돌 시 예외 후 재시도
하지만 선착순 상황에서는:
충돌이 “자주” 발생할 가능성이 높다.
따라서:
- 재시도 로직 복잡
- 사용자 경험 저하
- 설계 난이도 증가
그래서 나는:
“충돌이 발생할 가능성이 높다고 가정”하고 비관적 락을 선택했다.
7️⃣ 이 설계의 장점
✅ 정원 초과 100% 방지
✅ 동시성 안전
✅ 구현 직관적
✅ 로직 단순
8️⃣ 이 설계의 단점
- 락으로 인해 대기 시간 발생 가능
- 트래픽이 매우 크면 병목 가능
- DB 부하 증가 가능성
하지만 내 서비스 구조는:
- Redis로 1차 대기열 처리
- DB는 “입장권을 가진 소수”만 접근
이기 때문에
실제로 DB에 몰리는 트래픽은 제한적이다.
따라서 비관적 락이 충분히 합리적인 선택이었다.
'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
| ⏰💡 JMeter 구성 요소 해석해보기 (1) | 2026.02.15 |
|---|---|
| ⏰💡 폴링 API 부하테스트 진행 방법 (JMeter) (0) | 2026.02.15 |
| ⏰ 선착순 강의 대규모 트래픽 처리 – Redis 2차 설계 (0) | 2026.02.15 |
| ⏰🚨 신청 버튼을 누르지도 않았는데 자동으로 신청되는 문제 (Redis 대기열 + localStorage 이슈) (0) | 2026.02.14 |
| ⏰ 선착순 강의 대규모 트래픽 처리 – Redis 1차 설계 (0) | 2026.02.14 |