티스토리 뷰

728x90

TL;DR — 선착순 쿠폰의 동시성은 "락으로 막는" 문제라고 생각했는데, Kafka로 요청을 한 줄로 세우면 경쟁 자체를 없앨 수 있었다. 다만 큐로 직렬화해도 그걸로 끝이 아니었다. 직렬화는 "전역 수량(100장)"만 지키고, "1인 1장"은 다른 장치(UK)가 필요했다. 결국 직렬화 + UK + 원자적 수량 감소라는 세 방어선을 조합했고, 그 대가로 발급은 비동기가 되었다.

순진하게 짜보면

선착순 100장 쿠폰. 제일 먼저 떠오른 코드는 이랬다.

1. 남은 수량 조회       (remaining = 100)
2. if remaining > 0 → 발급
3. remaining -= 1

 

혼자 요청하면 잘 된다.

문제는 1만 명이 동시에 1번을 실행할 때다.

다 같이 remaining = 100을 읽는다.

다 같이 "발급 가능"이라고 판단한다.

 

그리고 100장이 아니라 수천 장이 나가버린다. 흔히 말하는 race condition이고, "읽고 → 판단하고 → 쓰는" 사이에 다른 요청이 끼어들 수 있다는 게 핵심이다.

전통적 해법: 락, 그리고 그 비용

이 문제의 교과서적 답은 락이다.

  • 비관적 락 (SELECT ... FOR UPDATE): 읽는 순간 행을 잠근다.
  • 낙관적 락 (version + 재시도): 충돌하면 다시 시도한다.
  • 원자적 감소 (UPDATE remaining = remaining - 1 WHERE remaining > 0): 조건부 UPDATE 한 방으로 읽기-쓰기를 붙인다.

다 동작한다.

그런데 곰곰이 보면, 1만 명이 같은 한 행을 놓고 락을 다툰다.

락 대기 행렬이 생기고, 뒤에 온 요청은 앞이 끝날 때까지 기다린다.

순간 트래픽이 몰릴수록 지연이 튀고 DB가 힘들어진다.

"막는" 방식은 본질적으로 경쟁을 전제로 하고, 경쟁이 심할수록 비싸진다.

발상의 전환: 경쟁을 "없앤다"

여기서 관점을 바꿨다. 1만 명을 싸우게 하지 말고, 한 줄로 세우면 어떨까.

1만 요청 → coupon-issue-requests 토픽 (key = couponId)
           → 같은 couponId = 같은 파티션
           → 한 컨슈머가 한 번에 하나씩 순서대로 처리
           → 앞에서 100개만 발급, 나머지 거절

 

Kafka에서 같은 key는 항상 같은 파티션으로 가고, 한 파티션은 컨슈머 그룹 안에서 오직 한 컨슈머만 소비한다.

그래서 같은 쿠폰의 발급 요청은 완전히 순차적으로 처리된다.

 

순차적이면 동시성이 없다. 동시성이 없으면 지킬 락도 없다.

"읽고 → 판단하고 → 쓰는" 사이에 끼어들 다른 요청이 애초에 존재하지 않기 때문이다.

락으로 막는 대신, 파티션 직렬화로 경쟁을 없앤 셈이다.

 

덤도 있다. API는 요청을 Kafka에 넣기만 하면 되니 응답이 빨라지고, 순간 폭주는 Kafka가 버퍼로 흡수한다.

반전: 직렬화만으론 부족했다

"순차 처리하면 다 되는 거 아냐?"

 

 

나도 그렇게 생각했다. 그런데 한 가지 구멍이 있었다.

 

한 사용자가 발급 버튼을 두 번 누르면 어떻게 될까.

요청이 2개 만들어진다(요청 ID는 다르고, userId·couponId는 같다).

둘 다 같은 파티션에서 순차 처리되고, 각각 "남은 수량 > 0"을 통과한다. 그리고 이 사람이 2장을 받는다.

 

여기서 깨달았다.

 

직렬화는 "총 몇 장 나갔나(전역 수량)"를 지켜주지만, "이 사람이 몇 장 받았나(사용자별 유일성)"는 안 지켜준다.

순서를 아무리 잘 지켜도 같은 사람의 두 요청은 둘 다 정상 요청처럼 보이기 때문이다.

순서의 문제가 아니라 중복의 문제였다.

 

그래서 방어선을 나눴다.

  • 전역 수량(100장) → 파티션 직렬화 + 원자적 수량 감소
  • 사용자별 유일성(1인 1장)(user_id, coupon_id) 유니크 제약(UK)
  • 중복 요청 자체 → 발급 요청의 상태(PENDING/SUCCESS/FAILED)를 멱등 판단 근거로

처리 로직은 이렇게 정리됐다.

@Transactional
fun process(message: CouponIssueMessage) {
    val request = requestRepository.findByRequestId(message.requestId) ?: return
    // 1) 멱등: 이미 처리된 요청이면 재수신되어도 다시 처리하지 않는다
    if (request.status != PENDING) return
    // 2) 중복 요청: 이미 보유 중이면 수량을 소모하지 않고 성공 처리
    if (userCouponRepository.existsByUserIdAndCouponId(message.userId, message.couponId)) {
        request.markSuccess(); return
    }
    // 3) 전역 수량: 원자적 감소. 소진 시 선착순 마감
    if (!couponService.tryIssue(message.couponId)) {
        request.markFailed("SOLD_OUT"); return
    }
    userCouponRepository.save(UserCouponModel(message.userId, message.couponId))
    request.markSuccess()
}

 

 

tryIssue는 단일 컨슈머라 사실 락이 필요 없지만, 재시작이나 파티션 구성이 바뀌는 상황까지 대비해 원자적 조건부 UPDATE를 백스톱으로 남겼다.

UPDATE coupons
SET issued_quantity = issued_quantity + 1
WHERE id = :id
  AND (issuable_quantity IS NULL OR issued_quantity < issuable_quantity)
-- 영향받은 행 1 = 발급 성공, 0 = 소진

 

정리하면 직렬화 + UK + 원자적 감소가 각자 다른 제약을 담당한다.

하나로 다 하려 하지 않고, 지켜야 할 것마다 담당 장치를 따로 둔 게 이번 설계의 핵심이었다.

공짜는 아니었다: 비동기라는 대가

발급을 Kafka 뒤로 넘기니 API는 "발급됐다/안 됐다"를 즉시 답할 수 없게 됐다. 대신 접수증(requestId)을 즉시 주고, 사용자는 결과를 따로 조회한다.

POST /coupons/{couponId}/issue-requests   → { "requestId": "..." }   (즉시)
GET  /coupons/issue-requests/{requestId}  → PENDING → SUCCESS / FAILED

 

빠른 응답을 얻은 대신 결과의 즉시성을 내줬다.

사용자는 잠깐 PENDING을 보고 폴링해야 한다.

 

그런데 이게 오히려 UX를 정직하게 만들기도 했다.

"쿠폰함에 없음"만으로는 아직 처리 중인지 마감돼서 실패인지 구분할 수 없는데, 명시적 상태를 두니 "잠시만 기다려주세요"와 "아쉽게 마감됐어요"를 다르게 보여줄 수 있었다.

검증: 정말 100장만 나가는가

말로는 안심이 안 돼서 테스트로 확인했다.

선착순 100장에 300명이 동시에 발급 처리를 하도록 했다.

val coupon = couponService.register(..., issuableQuantity = 100)
repeat(300) { i -> requestService.create("req-$i", (i + 1).toLong(), coupon.id) }

// 300명 동시 처리
val executor = Executors.newFixedThreadPool(32)
repeat(300) { i ->
    executor.submit { processor.process(CouponIssueMessage("req-$i", (i + 1).toLong(), coupon.id)) }
}
// ...

assertThat(success).isEqualTo(100)   // 정확히 100장
assertThat(failed).isEqualTo(200)    // 나머지는 SOLD_OUT
assertThat(reloaded.issuedQuantity).isEqualTo(100L)

 

정확히 100장. 초과 발급 없음. 원자적 조건부 UPDATE의 행 잠금이 동시 감소를 직렬화해준 덕이다.

삽질 한 스푼

처음엔 이 테스트가 통과하지 않았다. 발급 수량은 100으로 맞는데, 요청 상태가 전부 PENDING으로 남아 있었다.

원인은 수량 감소용 벌크 쿼리에 붙인 @Modifying(clearAutomatically = true)였다.

이 옵션이 영속성 컨텍스트를 통째로 비우면서, 앞서 로드해둔 요청 엔티티를 detach시켜버린 것이다.

 

그래서 markSuccess()/markFailed()가 아무 데도 반영되지 않았다.

clearAutomatically를 떼어내니 해결됐다. JPA의 벌크 연산과 영속성 컨텍스트의 관계를 몸으로 배운 순간이었다.

마무리

선착순을 처음엔 "얼마나 튼튼한 락을 걸까"의 문제로 봤는데, 결국 "어떻게 경쟁을 없앨까"의 문제였다.

그리고 큐로 직렬화한 뒤에도 "전역 수량"과 "사용자별 유일성"은 다른 층위라서 서로 다른 장치가 필요했다.

 

같은 Kafka라도 이번엔 순서가 생명이었다.

(좋아요 수 집계처럼 증감만 하는 경우엔 순서가 뒤바뀌어도 결과가 같아 순서가 중요하지 않았는데, 선착순은 "누가 먼저 왔나"가 본질이라 정반대였다.)

도구가 같아도 무엇을 담느냐에 따라 요구가 이렇게 달라진다는 걸, 이번에 확실히 체감했다.

728x90