
선착순 판매를 개발자 시선으로 뜯어보기
왜 나는 항상 선착순에 실패할까?
나의 취미는 키보드 조립이다.
이런 취미를 가진 내가 디스코드 공지로 심장이 두근두근 하는 날이 있다.

19:59:59.
손가락은 이미 F5 + 구매 버튼 위 대기.
오픈하자마자 눌렀는데 결과는 "재고가 부족합니다."
"아니 오픈하자마자 눌렀는데 실패라고?"
"이거 성공한 사람 있긴 해?"
"서버 오류 아니야?"
예전엔 그냥 내 똥손을 탓하고 끝냈다.
근데 이번 과제로 주문 서버를 직접 만들고 나니까, 이 억울함(?)이 왜 생기는지 서버 관점에서 설명이 되었다.
이 글은 내 프로젝트의 실제 주문 로직과 동시성 처리를 기준으로 선착순 실패를 해부해보는 글이다.
상황: 20시 정각, 재고 2개, 요청 수백 건
20:00:00 정각에 키보드 한정판 재고 딱 2개가 풀렸다고 가정해 보자.
- 성공 가능한 주문은 최대 2건
- 나머지는 반드시 실패해야 정상
선착순 시스템의 목표는 "모두 성공시키는 것"이 아니다.
핵심은 정상 탈락과 비정상 실패를 명확히 분리해서, 시스템이 억울한 사람 없이 일관되게 처리하는 것이다.
내 프로젝트 주문 흐름
동시에 쏟아지는 요청은 서버에서 이런 경로를 타게 짜뒀다.
OrderV1Controller.placeOrder (요청 진입)
➡️ OrderFacade.placeOrder (유저 및 입력 검증 + @Retryable 재시도)
➡️ OrderPlacementTxService.placeOrder (실제 쓰기 트랜잭션)
➡️ Product.decreaseStock (재고 차감)
➡️ 주문/상품 저장 (+ 쿠폰 주문이면 쿠폰 사용 상태 저장)
여기서 동시성 충돌을 막기 위해 준비한 전략은 2가지다.
Product.@Version 기반 낙관적 락(Optimistic Lock)
OrderFacade.@Retryable 재시도 로직
여러 요청이 한꺼번에 몰려 충돌이 나는 걸 "충돌 감지 → 잠깐 대기 후 재시도 → 그래도 안 되면 명확한 실패 응답" 으로 설계했다.
왜 체감상 불공정하게 느껴질까?
이유는 간단하다.
사용자 입장에선 "정상 탈락"이랑 "서버 문제"가 똑같이 보이기 때문이다.
재고가 2개면 3번째 요청은 떨어지는 게 정상이다.
근데 실패 이유가 명확히 안 보이면 그저 버그처럼 느껴진다.
심지어 동시성 처리가 미흡하면 초과 판매(오버부킹), 중복 주문, 랜덤 에러들이 터진다.
그리고 트랜잭션이 길면 DB 락을 쥐고 있는 충돌 구간도 길어진다.
그래서 나는 진짜 데이터 쓰기 구간만 OrderPlacementTxService로 분리해서 경합 시간을 최대한 줄였다.
경합 상황을 어떻게 제어할까?
그렇다면 동시에 몰리는 이 요청들을 코드로 어떻게 방어했을까?
1) 재고 부족은 "정상 탈락"으로 명확히
재고 부족 시 ProductInsufficientStockException을 던지고,
이를 PRODUCT_INSUFFICIENT_STOCK 에러로 변환해서 HTTP 400을 반환한다.
이건 서버 장애가 아니라, 비즈니스 룰에 의한 명확한 탈락이다. (손이 느린 건 어쩔 수 없다...)
2) 낙관적 락 충돌은 재시도로 흡수
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class, OptimisticLockException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2.0)
)
여기서 살펴볼 핵심은 다음과 같다.
- 총 시도: 3회 (초기 1회 + 재시도 2회)
- 백오프 대기: 100ms, 200ms (순수 대기 합 300ms)
간발의 차이로 DB 버전을 놓쳐서 실패한 요청에 "한 번 더" 기회를 주는 셈이다. 이때 실패한 요청들이 같은 시간에 동시에 재시도하면 또다시 충돌할 확률이 높으므로, 대기 시간을 점진적으로 늘려 재시도 시점을 분산시켰다.
3) 재시도 소진 시 503 + Retry-After: 1
그래도 실패한다면 ApiControllerAdvice에서 예외를 캐치하여 이렇게 응답한다.
HTTP Status: 503 Service Unavailable
Header: Retry-After: 1
Error Code: CONCURRENT_REQUEST_CONFLICT
단순히 500(Internal Server Error) 에러를 반환하는 대신,
"현재 서버의 경합이 극심하므로 즉각적인 새로고침을 멈추고 1초 뒤에 다시 시도하라" 는 명확한 신호를 클라이언트에게 전달하는 것이다.
20시 정각, 실제 타임라인 예시

재고 2개를 두고 A, B, C가 동시에 진입한 상황을 살펴보자.
- A의 요청: 가장 먼저 진입해 성공 (남은 재고 1)
- B의 요청: 락 충돌로 튕김 → 100ms 뒤 재시도 → 막차 탑승 성공 (남은 재고 0)
- C의 요청: 재시도 시점에 이미 재고가 0인 것을 확인 → 400 PRODUCT_INSUFFICIENT_STOCK (정상 탈락)
만약 경합이 너무 심해 재시도 기회를 다 날렸다면, 일부는 503 CONCURRENT_REQUEST_CONFLICT를 받을 수도 있다.
여기서 중요한 건 서버가 다운된 것이 아니라, 시스템이 의도한 설계대로 명확하고 일관되게 실패 응답을 내려주고 있다는 점이다.
마무리
"왜 나는 항상 선착순에 실패할까?"
이 질문은 결국 시스템이 동시성을 얼마나 정교하게 다루고 있는지를 물어보는 말 같다.
한정된 자원을 두고 경쟁하는 상황에서 누군가 실패하는 것은 기술적으로 당연한 결과이다. 다만, 그 실패가 예기치 못한 버그가 아니라 정의된 비즈니스 규칙에 따른 일관된 결과일 때 시스템의 안정성이 증명된다. 결국 사용자가 체감하는 공정성은 외적인 요소가 아닌, 보이지 않는 백엔드의 견고한 설계에서 결정된다고 생각한다.
효율적인 트랜잭션 범위 설정
상황에 맞는 락(Lock) 전략 선택
부하를 고려한 재시도(Retry) 정책
일관성 있는 예외 응답 설계
다음에 또 선착순 구매에 실패하더라도...
이제는 서버 뒤편에서 어떤 제어 로직이 치열하게 동작하고 있을지 이해할 수 있는 엔지니어의 시야를 얻게 되었다.
서버 탓 하지말고 내 손가락을 탓하자..!
'루퍼스 > 라이팅' 카테고리의 다른 글
| [Spring/Redis] 이커머스 실시간 인기순 랭킹 시스템 구현기 (feat. ZSET) (0) | 2026.04.09 |
|---|---|
| [Spring/Kafka] 트랜잭션을 분리했더니 시스템이 더 어려워졌다 (0) | 2026.03.27 |
| [성능 최적화] 인덱스와 캐시, 각각 얼마나 빠를까 (0) | 2026.03.13 |
| [성장기] 요구사항만 구현하던 SI 주니어가, 처음 설계를 해보며 바뀐 생각들 (0) | 2026.02.13 |
| [삽질기] Colima 까지 갔다가 설정 한 줄로 돌아온 건에 대하여 (feat. Testcontainers) (1) | 2026.02.05 |