카테고리 없음

선착순 쿠폰 서비스 (1)- 트래픽을 견디기 전에, 무너지지 않을 도메인부터

경딩 2026. 8. 24. 22:15

대규모 쿠폰 발급 시스템을 단계별로 만들어보는 시리즈의 첫 글이다. 이번 글에는 Redis도, 큐도, 락도 나오지 않는다. 단일 애플리케이션과 MySQL 한 대뿐이다. 대신 어떤 인프라를 붙여도 변하지 않아야 할 것이 무엇인지를 정한다.

스택: Kotlin · Spring Boot · Spring Data JPA · MySQL 8 · Docker Compose

 

참고 자료: 이 글은 아래 강의를 들으며 정리하고, 제 나름의 해석을 덧붙인 내용입니다.
https://www.inflearn.com/course/designing-a-server-s

 

트래픽 급증을 견디는 서버 시스템 설계 - Coupon 발급 서비스| 한조각 - 인프런 강의

현재 평점 4.9점 수강생 460명인 강의를 만나보세요. 선착순 쿠폰 시스템 구현을 통해 동시성 이슈, 데이터 정합성, 피크 트래픽 처리 등 실전 백엔드 핵심 문제를 해결합니다. 직접 코드를 작성하

www.inflearn.com

 

이 글에서 얻어갈 것들

  • 요구사항을 불변식으로 번역하는 법, 캐시,큐,샤딩을 아무리 붙여도 검증 기준은 네 줄이 전부다.
  • 정책(Coupon)과 사실(Issuance)을 분리하는 데이터 모델링. 그리고 issued_quantity라는 비정규화 컬럼이 왜 다음 편 전체의 주인공이 되는지
  • 중복 발급을 막는 두 겹의 방어. 애플리케이션 검증은 UX용이고, DB 유니크 제약은 정합성용이다. 둘은 대체제가 아니다.
  • 시간은 이벤트를 만들지 않는다. - 만료를 배치가 아닌, 읽기 시점에 판정하는 설계와 그 함정
  • 계층을 나누는 이유는 지금 편해서가 아니라 다음 편에 갈아엎지 않기 위해서다. 서비스가 얆아질수록 엔티티가 두꺼워져야 하는 이유

0. 문제의 성격부터 정의한다.

선착순 쿠폰은 평소에는 조용하다가 오픈 순간 초당 수만 건이 꽂히고, 수십 초 만에 끝난다. 평균 QPS 로 용량을 산정하면  100% 장애가 난다.

 

여기서 중요한 인식이 하나 있다. 이건 쿠폰 문제가 아니다. 유한한 자원을 경쟁적으로 분배하는 문제다.

티켓팅, 재고 차감, 좌석 예약이 전부 같은 형태의 문제다. 그래서 이 시리즈에서 다루는 해법은 다른 도메인에 옮겨 붙는다.

 

그리고 이런 문제에는 특유의 비대칭성이 있다.

  • 느린 건 사용자가 참는다. 3초 기다리는 건 불평으로 끝난다.
  • 틀린 건 회사가 배상한다. 5,000장짜리 쿠폰이 5,013장 나가면 그건 사고 보고서다.

그래서 순서는 성능이 아니라 정확성이 먼저다. 이번 편이 도메인 설계로만 채워지는 이유다.


1. 변하지 않아야 할 네 가지 - 요구사항을 불변식으로 번역하기

요구사항은 4가지이다.

  • 고정 수량 5,000장
  • 같은 쿠폰은 1인 1매
  • 발급된 쿠폰은 1회만 사용
  • 발급 후 7일간 유효

이걸 할 일 목록으로 읽으면 기능이 나오고, 참이어야 하는 명제로 번역하면 검증 기준이 나온다.

 

요구사항 불변식
고정 수량 5,000장 issued_quantity ≤ total_quantity (항상)
1인 1매 (user_id, coupon_id)는 유일
1회 사용 상태 전이는 ISSUED → USED 단방향, 되돌아오지 않음
7일 유효 expires_at = issued_at + validity_days

이렇게 써 두면 얻는게 있다. 2편에서  Redis를 앞에 세우든, 카프카로 발급을 비동기로 돌리든, DB를 샤딩하든 검증 기준은 이 네 줄에서 한글자도 안 바뀐다. 아키텍처가 바뀌어도 이게 맞게 동작하는가를 판단하는 자는 그대로다.

 

부하 테스트 시나리오도 여기서 바로 나온다. 3만명이 동시에 붙었을 때 확인할 건 응답 시간이 아니라

select count(*) from issuance where coupon_id=1이 정확히 5,000 인지다.


2. 데이터 모델 - 정책과 사실을 분리한다.

 

두 개의 테이블로 시작한다. coupon 1:N issuance

 

coupon은 정책이다. 관리자가 미리 정해주든 규칙 - 이름 , 총 수량 , 유효기간 일수, 발급 시작 시각 여기에 하나가 더 붙는다.

issued_quantity, 지금까지 몇 장 나갔는지.

 

issuance는 사실이다. 누가, 어떤 쿠폰을, 언제 받았고, 지금 어떤 상태이며, 언제 썼고, 언제 만료되었는지. 이미 일어난 일의 기록이라 원칙적으로 수정보다 추가가 자연스럽다.

 

이 분리가 왜 중요한가? 정책은 바뀔 수 있고, 사실은 바뀌면 안된다. 관리자가 수량을 5,000 -> 8,000으로 늘려도 이미 발급된 issuance는 그대로여야 한다. 두 개를 한 테이블에 섞어 놓으면 이 경계가 사라진다.

 

issued_quantity는 왜 있는가? - 그리고 이게 다음 편의 진앙지다.

issuance 행을 세면 발급 수량이 나오는데, 왜 컬럼을 따로 두는가? 

-- 이 쿼리를 매 발급 요청마다 돌린다면?
SELECT COUNT(*) FROM issuance WHERE coupon_id = 1;

 

초당 수만 건이 들어오는 상황에서 매번 COUNT를 돌리면 그 자체가 병목이다. 그래서 카운터를 비정규화해서 들고 있다. 정확성을 코드 책임으로 옮기는 대신 읽기 비용을 상수로 만드는 고전적인 트레이드오프다.

 

그리고 바로 이 컬럼이 2편 전체의 주제가 된다. 발급 로직은 결국 이 한 줄로 귀결되는데,

coupon.issuedQuantity++

한 줄처럼 보이지만 실제로는 세 단계다. 읽고(read) → 더하고(modify) → 쓴다(write).

이 사이에 다른 트랜잭션이 끼어들 수 있다 — 이것이 1편이 끝날 때까지 이 애플리케이션에 남아 있게 될 가장 치명적인 구멍이다.

 

엔티티

package com.apiece.coupon.domain

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime

@Entity
@Table(name = "coupon")
class Coupon(
    @Column(nullable = false, length = 80)
    var name: String,

    @Column(name = "total_quantity", nullable = false)
    var totalQuantity: Int,

    @Column(name = "validity_days", nullable = false)
    var validityDays: Int = 7,

    @Column(name = "starts_at")
    var startsAt: LocalDateTime? = null,

    @Column(name = "issued_quantity", nullable = false)
    var issuedQuantity: Int = 0,

    @Column(name = "created_at", nullable = false, updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now(),

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) {
    fun isBookingOpen(now: LocalDateTime): Boolean =
        startsAt?.let { !now.isBefore(it) } ?: true
}

Issuance는 지면상 일부 어노테이션을 생략하고 옮긴다. 중요한 건 테이블 제약과 도메인 메서드다.

 
kotlin
enum class IssuanceStatus { ISSUED, USED, EXPIRED }

@Entity
@Table(
    name = "issuance",
    uniqueConstraints = [
        UniqueConstraint(
            name = "uk_issuance_user_coupon",
            columnNames = ["user_id", "coupon_id"],
        ),
    ],
    indexes = [
        Index(name = "idx_issuance_coupon", columnList = "coupon_id"),
    ],
)
class Issuance(
    // 아래 필드는 usedAt을 제외하고 모두 @Column(nullable = false)
    var userId: Long,
    var couponId: Long,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    var status: IssuanceStatus = IssuanceStatus.ISSUED,

    var issuedAt: LocalDateTime,
    var expiresAt: LocalDateTime,
    var usedAt: LocalDateTime? = null,
    // ... createdAt, id 생략
) {
    fun isExpired(now: LocalDateTime): Boolean = now.isAfter(expiresAt)

    fun markUsed(now: LocalDateTime) {
        status = IssuanceStatus.USED
        usedAt = now
    }
}

두 엔티티 모두 데이터만 들고 있지 않다. isSoldOut(), isExpired(), markUsed() — 규칙에 이름이 붙어 있다.

 

서비스가 if (coupon.issuedQuantity >= coupon.totalQuantity) 라고 직접 쓰는 대신 if (coupon.isSoldOut())을 부르게 하기 위해서다.  전자는 비교식이 호출하는 쪽마다 복사되고, 후자는 정의가 한 곳에만 있다. "매진" 의 정의가 바뀌는 날 고칠 곳이 한 군데인지 열 군데인지가 여기서 갈린다.

 

인덱스는 유니크 키의 컬럼 순서까지 보고 정한다.

 

유니크 키 (user_id, coupon_id)는 선행 컬럼이 user_id다. 그래서 "내 쿠폰 목록 조회"(WHERE user_id = ?)는 이 유니크 인덱스가 그대로 커버해 준다. 하지만 "이 쿠폰의 발급 내역"(WHERE coupon_id = ?)은 선행 컬럼이 아니라 인덱스를 못 탄다. idx_issuance_coupon을 따로 만든 이유다.

복합 인덱스는 왼쪽부터 순서대로만 탄다. 유니크 제약을 걸 때 어떤 컬럼을 앞에 둘지는 정합성 결정이면서 동시에 성능 결정이다




3. 중복 발급 - 두 겹으로 막는다.

애플리케이션 검증만으로 안되는 이유

 

서비스 코드에는 이런 검증이 있다.

if (issuanceRepository.existsByUserIdAndCouponId(userId, couponId)) {
    throw AlreadyIssuedException()
}

 

이걸로 충분할 것 같지만 아니다. 확인한 시점과 쓰는 시점 사이에 틈이 있다.

스레드 A                        스레드 B
─────────────────────────────────────────────
exists(42, 1) → false
                                exists(42, 1) → false
INSERT (42, 1)
                                INSERT (42, 1)   ← 둘 다 통과

 

사용자가 버튼을 두 번 빠르게 눌렀거나, 로드밸런서 뒤 두 인스턴스에 동시에 도착했다면 그냥 일어나는 일이다.

 

DB 유니크 제약이 마지막 안전망이다.

그래서 2절의 Issuance 엔티티에 걸어 둔 uk_issuance_user_coupon이 필요하다. DB는 인덱스 레벨에서 유일성을 보장하므로, 위 시나리오에서는 B이 INSERT는 무조건 실패한다. 애플리케이션 로직은 우회될 수 있어도, 제약 조건은 우회할 수 없다.

 

역할을 이렇게 나뉜다.

  • 애플리케이션 검증: 사용자 경험용. 대부분의 경우 예외를 미리 잡아 친절한 메시지를 준다.
  • DB 유니크 제약: 정합성용. 드물게 뚫린 경쟁 상태를 최종적으로 막는다.

유니크 위반은 장애가 아니라 정상 흐름이다.

여기가 실무에서 자주 놓치는 지점이다. B의 INSERT가 실패하면 스프링은 DataIntegrityViolationException을 던지고, 이걸 그냥 두면 사용자에게 500 Internal Server Error가 나간다.

하지만 이건 서버 잘못이 아니다. 이미 받은 쿠폰을 또 받으려 한 것이고, 우리가 의도적으로 막은 것이다. 그러니 409로 번역해야 한다.

 
return try {
    issuanceRepository.saveAndFlush(
        Issuance(
            userId = userId,
            couponId = couponId,
            issuedAt = now,
            expiresAt = now.plusDays(coupon.validityDays.toLong()),
        )
    )
} catch (e: DataIntegrityViolationException) {
    // 유니크 제약 위반 = 경쟁에서 진 요청. 서버 오류가 아니다.
    throw AlreadyIssuedException()
}

save가 아니라 saveAndFlush인 이유는, JPA가 기본적으로 트랜잭션 커밋 시점까지 INSERT를 미루기 때문이다. flush하지 않으면 예외가 이 try 블록 밖에서 터져서 잡을 수 없다.

 


4. 상태   머신과 만료 - 시간은 이벤트를 만들지 않는다.

issuance의 상태 전이는 단순하다.

                 [사용자가 사용 버튼]
   ISSUED ─────────────────────────────► USED
      │                                  (used_at 기록)
      │
      │  [7일 경과]
      └────────────────────────────────► EXPIRED

ISSUED → USED와 ISSUED → EXPIRED는 성격이 전혀 다르다.

  • USED는 사용자가 만든다. API 호출이라는 명확한 트리거가 있다.
  • EXPIRED는 아무도 만들지 않는다. 그냥 시간이 지날 뿐이다.

이 차이가 설계 결정을 만든다.

시간은 이벤트를 발생시키지 않는다. 누군가 관측할 때 비로소 만료가 확정된다.

 

 

방법은 두 가지다.

 

스케줄러가 주기적으로 UPDATE ... SET status='EXPIRED'를 돌리거나, 읽는 시점에 시각을 비교해서 판정하거나.

이 프로젝트는 학습용 단일 인스턴스라 스케줄러를 붙일 이유가 없어서 지연 만료(lazy expiration) 를 택했다.

배치가 필요해지는 건 만료 건수가 많아지거나 만료 시점에 알림 발송 같은 후속 작업이 붙을 때다.

지연 만료의 함정

판정을 2절의 isExpired(now)로만 하면, status 컬럼은 만료됐는데도 ISSUED로 남아 있다. 그래서 조회 API가 컬럼을 그대로 믿으면 안 된다.

// 위험: status 컬럼만 보면 만료된 쿠폰이 사용 가능한 것처럼 보인다
fun findUsable(userId: Long) =
    issuanceRepository.findByUserIdAndStatus(userId, IssuanceStatus.ISSUED)

응답을 만들 때 실효 상태(effective status)를 계산해서 내려준다.

 
// Issuance 엔티티에 추가
fun effectiveStatus(now: LocalDateTime): IssuanceStatus =
    if (status == IssuanceStatus.ISSUED && isExpired(now)) IssuanceStatus.EXPIRED
    else status

컬럼에 저장된 상태와 실제 상태가 다를 수 있다는 것 — 이게 지연 만료의 비용이다. 성능 비용이 아니라 인지 비용이다. 문서화하지 않으면 6개월 뒤 다른 사람이 WHERE status = 'ISSUED'를 짜고 버그를 만든다.

 

 

 


5. 실패까지 설계하는  API

  • 엔드포인트
기능 메서드 경로
쿠폰 생성 POST /api/coupons
쿠폰 발급 POST /api/coupons/{couponId}/issue
쿠폰 사용 POST /api/issuances/{issuanceId}/use
내 쿠폰 조회 GET /api/users/me/issuances

사용자 식별은 X-User-Id 헤더로만 한다. 이건 인증이 아니다. 헤더는 클라이언트가 마음대로 조작할 수 있으니, 실제 서비스라면 게이트웨이가 토큰을 검증한 뒤 주입한 값이어야 한다.

이번 시리즈의 주제가 아니라 생략했을 뿐, 생략했다는 사실은 명시해 둔다.

실패 응답은 스펙의 절반이다

발급 API 하나에 실패 경로가 넷이다.

상황  HTTP  도메인 코드
쿠폰이 없음 404 COUPON_NOT_FOUND
발급 시작 전 409 NOT_STARTED
수량 소진 409 SOLD_OUT
이미 발급받음 409 ALREADY_ISSUED

사용 API도 마찬가지다. (ISSUANCE_NOT_FOUND 404 / NOT_OWNER 403 / ALREADY_USED 409 / EXPIRED 409)

 

그런데 대부분의 API문서는 성공 응답만 적고 끝난다. 그러면 클라이언트는 알 수 없는 오류가 발생했습니다 밖에 띄울 게 없다. 

여기서 눈여겨볼 건 SOLD_OUT, NOT_STARTED, ALREADY_ISSUED가 전부 409인데 도메인 코드는 다르다는 점이다. HTTP 상태 코드로는 구분되지 않는 세 상황을 클라이언트는 각각 다른 화면으로 보여줘야 한다.

 


6. 패키지 구조 - 의존성은 한 방향으로 흐른다.

 

com.apiece.coupon
├── api            Controller, Request/Response DTO
├── application    Service (유스케이스 조립)
├── domain         Entity, Repository, Enum
└── support        Exception, GlobalExceptionHandler

의존 방향은 이렇다.

api ──► application ──► domain
                          ▲
                     (support는 횡단)

핵심은 화살표가 거꾸로 가지 않는 것이다. domain은 api를 모른다.

Entity가 @JsonProperty를 달고 있거나, Service가 HttpServletRequest를 받고 있다면 이미 무너진 것이다.

 

서비스가 얇아질수록 엔티티는 두꺼워져야 한다

2절에서 엔티티에 isSoldOut() 같은 메서드를 둔 결과가 여기서 드러난다. 판단이 엔티티 안에 있으면 서비스에는 순서만 남는다.

 
@Transactional
fun issue(couponId: Long, userId: Long): Issuance {
    val coupon = couponRepository.findById(couponId)
        .orElseThrow { CouponNotFoundException() }

    val now = LocalDateTime.now()

    if (!coupon.isBookingOpen(now)) throw NotStartedException()
    if (coupon.isSoldOut()) throw SoldOutException()
    if (issuanceRepository.existsByUserIdAndCouponId(userId, couponId)) {
        throw AlreadyIssuedException()
    }

    coupon.issuedQuantity++   // ← 여기가 2편의 주제다

    return issuanceRepository.saveAndFlush(
        Issuance(
            userId = userId,
            couponId = couponId,
            issuedAt = now,
            expiresAt = now.plusDays(coupon.validityDays.toLong()),
        )
    )   // 유니크 제약 위반을 AlreadyIssued로 번역하는 부분은 3절 참고
}

쿠폰을 찾고 → 발급 시작을 확인하고 → 매진을 확인하고 → 중복을 확인하고 → 수량을 늘리고 → 발급한다.

읽으면 요구사항이 그래도 보인다. 

코드가 요구사항 문서처럼 읽히면 그 구조는 대체로 맞다.

반대로 판단이 서비스로 새어 나오면 coupon.issuedQuantity >= coupon.totalQuantity 같은 비교식이 여기저기 흩어지고,

"관리자용 쿠폰은 수량 제한 없음" 같은 요구사항이 붙는 순간 그 비교식을 전부 찾아다녀야 한다.

 

지금의 분리가 2편을 위한 투자다

다음 편에서 발급 경로 앞에 redis가 들어온다. 그때 바뀌는 건 application 계층 뿐이고, domain 의 엔티티와 불변식은 그대로다.

지금 계층을 나눠두는 이유는 지금 편해서가 아니라, 다음 편에서 갈아엎지 않기 위해서다.

 


7. 이 구조는 정확히 여기서 무너진다.

여기까지가 1편이다. 기능 요구사항 네 개를 모둔 만족한다. 테스트도 통과한다.

 

그리고 오픈하는 순간 터진다. 범인은 이 한줄이다.

coupon.issuedQuantity++

issued_quantity  가 4,999 인 시점에 두 요청이 동시에 도착하면, 둘 다 4,999를 읽고, 둘 다 매진이 아니라고 판단하고, 둘 다 5,000 을 쓴다. 갱신 손실 (lost udpate) 이다. 카운터는 5,000인데 실제 발급은 5,001 건이 된다. 동시 요청이 수천 건이면 초과분은 두세 자릿수가 된다.

 

여기서 반드시 짚어야 할 두가지.

 

  • 유니크 제약은 이걸 못 막는다. (user_id, coupon_id) 유니크는 "한 사람이 두 장"을 막지, "5,000명이 5,001장"을 막지 않는다. 제약 조건은 걸어 둔 것만 지킨다.
  • @Transactional을 붙였다고 안전해진 게 아니다. 두 트랜잭션 모두 정상 커밋됐고 ACID를 위반하지도 않았다. MySQL 기본 격리 수준인 REPEATABLE READ는 각자가 일관된 스냅샷을 보는 것까지만 보장하지, 읽고 나서 쓰는 사이의 경쟁은 보장 범위 밖이다. 원자성과 동시성 제어는 다른 문제다.

다음 편 예고 

이 한줄을 지키는 방법은 락, 원자적 업데이트, Redis, 큐 등 여러가지다. 각각 정확성,처리량, 복잡도의 트레이드오프가 다르다.

중요한 건 무엇이 정답인가가 아니라 무엇을 포기할 것인가다. 다음 편에서는 초과발급을 실제로 재현해보고, 하나씩 붙여가며 부하 테스트로 비교한다.

 


1편에서 만든 건 느린 시스템이 아니라 정확하지만 아직 안 빠른 시스템이다. 이 순서가 중요하다. 빠른데 틀린 시스템을 나중에 맞게 고치는 것보다, 맞는데 느린 시스템을 빠르게 만드는 쪽이 훨씬 쉽다, 전자는 무엇이 맞는지에 대한 기준이 아직 없기 때문이다.

 

다음 편에서는 이 정확성을 유지한 채 초당 수만 건을 받아낸다.