대규모 트래픽 환경에서 쿠폰 발급 API가 어떤 문제를 겪는지 재현하고, 발급 구조를 개선했을 때 처리량, 응답 시간, 정합성이 얼마나 좋아지는지 확인하기 위한 Spring Boot 토이 프로젝트입니다.
단순한 쿠폰 CRUD가 아니라, 한정 수량 쿠폰에 많은 사용자가 동시에 접근하는 상황을 기준선으로 삼고 개선 전후를 비교하는 것이 핵심입니다.
- 동시에 많은 사용자가 쿠폰 발급을 요청하는 상황을 재현한다.
- 기준선 구현에서 발생하는 병목과 정합성 문제를 확인한다.
- 쿠폰 발급 구조를 단계적으로 개선한다.
- 개선 전후의 수치를 비교해 실제 개선 폭을 기록한다.
주요 비교 대상:
- 처리량: 초당 처리 가능한 발급 요청 수
- 응답 시간: 평균, p95, p99 latency
- 정합성: 초과 발급 여부, 중복 발급 여부, 최종 발급 수
- 안정성: 실패율, DB lock wait, deadlock, timeout
- 리소스 사용량: DB connection, CPU, memory
한정 수량 쿠폰을 대상으로 다수의 회원이 동시에 발급을 요청합니다.
예시:
- 쿠폰 수량: 100장
- 동시 요청 사용자: 1,000명
- 기대 결과: 성공 발급은 정확히 100건
- 실패 요청: 수량 소진으로 일관되게 실패
- 검증 대상: 초과 발급 없이 응답 시간이 얼마나 낮은지
현재 구현은 JPA 엔티티를 조회한 뒤 쿠폰 발급 수량을 증가시키고, 발급 이력을 저장하는 방식입니다.
1. 인증된 회원 조회
2. 쿠폰 조회
3. coupon.issuedCount 증가
4. coupon_issues 발급 이력 저장
5. 트랜잭션 커밋
이 방식은 구현이 단순하지만, 대규모 동시 요청에서는 다음 문제가 발생할 수 있습니다.
- 동시에 같은 쿠폰 row를 읽고 갱신하면서 lost update가 발생할 수 있다.
totalCount와issuedCount비교가 원자적으로 보장되지 않으면 초과 발급 위험이 있다.- 요청이 늘어날수록 DB row 경합과 connection 사용량이 증가한다.
- 같은 회원의 중복 발급을 DB 제약으로 강하게 막고 있지 않다.
- 발급 요청 처리와 발급 이력 저장이 한 트랜잭션에 묶여 있어 쓰기 부하가 직접 API latency로 이어진다.
현재 기준선은 위 문제를 관찰하고 개선 효과를 비교하기 위한 출발점입니다.
쿠폰 row를 읽어서 애플리케이션에서 수량을 증가시키는 대신, DB에서 조건부 update를 수행합니다.
UPDATE coupons
SET issued_count = issued_count + 1
WHERE coupon_id = ?
AND issued_count < total_count;검증 포인트:
- 초과 발급 방지
- lost update 방지
- 기준선 대비 p95/p99 latency 변화
- DB lock wait 증가 여부
회원과 쿠폰 조합에 unique constraint를 추가해 같은 회원이 같은 쿠폰을 두 번 받을 수 없게 합니다.
UNIQUE(member_id, coupon_id)
검증 포인트:
- 동일 회원 반복 요청 시 발급 이력 중복 생성 방지
- 애플리케이션 검증과 DB 제약 간 역할 분리
- 중복 요청이 많은 상황에서 실패 응답 일관성 확인
쿠폰 수량 차감 로직에 대해 비관적 락, 낙관적 락, 원자적 update 방식을 비교할 수 있습니다.
비교 항목:
- 성공 발급 수가 정확히 제한되는지
- 충돌이 많은 상황에서 재시도 비용이 어느 정도인지
- lock wait, deadlock, timeout이 발생하는지
- 트래픽 증가에 따라 처리량이 어떻게 떨어지는지
쿠폰 재고 차감을 Redis의 atomic operation으로 먼저 처리하고, 성공한 요청만 DB에 발급 이력을 저장하는 구조를 검토합니다.
검증 포인트:
- DB write 부하 감소
- API 응답 시간 개선
- Redis와 DB 간 데이터 정합성 보정 방식
- 장애 상황에서 재처리 가능한 구조인지
API 요청에서는 발급 요청을 빠르게 접수하고, 실제 발급 이력 저장은 queue consumer가 처리하는 구조를 검토합니다.
검증 포인트:
- API p95/p99 latency 감소
- 순간 트래픽 흡수 능력
- queue 적체 상황에서 사용자 응답 정책
- 최종 일관성 허용 범위
개선 효과는 같은 조건에서 기준선과 개선안을 반복 측정해 비교합니다.
- 쿠폰 총 수량
- 동시 사용자 수
- 총 요청 수
- 애플리케이션 인스턴스 수
- DB connection pool 크기
- DB 스펙과 초기 데이터
- JVM 옵션
- 테스트 도구와 시나리오
| 지표 | 의미 |
|---|---|
| Total Requests | 전체 발급 요청 수 |
| Success Count | 실제 발급 성공 수 |
| Fail Count | 발급 실패 수 |
| Over-issued Count | 쿠폰 총 수량을 초과한 발급 수 |
| Duplicate Count | 동일 회원 중복 발급 수 |
| TPS/RPS | 초당 처리량 |
| Avg Latency | 평균 응답 시간 |
| p95 Latency | 상위 5% 요청의 응답 시간 |
| p99 Latency | 상위 1% 요청의 응답 시간 |
| Error Rate | 전체 요청 중 오류 비율 |
| DB Lock Wait | DB row 경합 정도 |
처리량 개선 배율 = 개선 후 TPS / 기준선 TPS
응답 시간 개선율 = (기준선 latency - 개선 후 latency) / 기준선 latency * 100
정합성 개선 = 초과 발급 수, 중복 발급 수가 0인지 확인
아직 실제 부하 테스트 결과는 README에 고정하지 않았습니다. 개선안을 적용하면서 아래 표에 같은 조건의 결과를 기록합니다.
| 버전 | 전략 | 동시 사용자 | 쿠폰 수량 | TPS/RPS | Avg | p95 | p99 | 성공 | 실패 | 초과 발급 | 중복 발급 | 비고 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| v0 | JPA 조회 후 수량 증가 | - | - | - | - | - | - | - | - | - | - | 기준선 |
| v1 | DB 조건부 원자 update | - | - | - | - | - | - | - | - | - | - | 예정 |
| v2 | Lock 전략 적용 | - | - | - | - | - | - | - | - | - | - | 예정 |
| v3 | Redis 선차감 | - | - | - | - | - | - | - | - | - | - | 예정 |
| v4 | Queue 기반 비동기 처리 | - | - | - | - | - | - | - | - | - | - | 예정 |
- 회원 가입
- 이메일 중복 검증
- BCrypt 비밀번호 암호화
- 기본 권한
MEMBER부여
- 로그인
- Spring Security 인증
- JWT access token 발급
- 토큰 만료 기간 7일
- 쿠폰 관리
- 쿠폰 생성
- 쿠폰 단건 조회
- 쿠폰 발급
- 인증된 회원에게 쿠폰 발급
- 발급 이력 저장
- 쿠폰 발급 수량 증가
- 공통 처리
- JPA Auditing 기반 생성/수정 시간 관리
- 공통 API 응답 래퍼
- 전역 예외 처리
- Java 17
- Spring Boot 3.4.1
- Spring Web
- Spring Data JPA
- Spring Security
- JWT (
com.auth0:java-jwt) - MySQL Connector/J
- Thymeleaf
- Lombok
- JUnit 5
src/main/java/couponToy/CouponToyProject
├── Coupon
│ ├── controller
│ ├── dto
│ ├── model
│ ├── repository
│ └── service
├── CouponIssue
│ ├── controller
│ ├── dto
│ ├── model
│ ├── repository
│ └── service
├── Member
│ ├── controller
│ ├── dto
│ ├── model
│ ├── repository
│ └── service
└── global
├── api
├── config
├── constant
├── entity
├── exception
└── security
저장소에는 DB 접속 설정 파일이 포함되어 있지 않습니다. 로컬 실행 전 src/main/resources/application.yml 또는 application.properties를 추가해 MySQL datasource를 설정해야 합니다.
예시:
spring:
datasource:
url: jdbc:mysql://localhost:3306/coupon_toy
username: root
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
open-in-view: false./gradlew bootRun테스트 실행:
./gradlew testPOST /members/signup, POST /members/login만 인증 없이 접근할 수 있습니다. 그 외 모든 API는 Authorization 헤더에 Bearer token이 필요합니다.
Authorization: Bearer {accessToken}POST /members/signup
Content-Type: application/json요청:
{
"email": "user@example.com",
"password": "password123!",
"name": "홍길동"
}검증 규칙:
email: 이메일 형식password: 8~20자, 영문/숫자/특수문자 포함, 공백 불가name: 영문 또는 한글 2~12자
응답 상태: 201 Created
POST /members/login
Content-Type: application/json요청:
{
"email": "user@example.com",
"password": "password123!"
}응답 상태: 201 Created
응답의 accessToken 값을 이후 인증 API 호출에 사용합니다.
POST /coupons
Authorization: Bearer {accessToken}
Content-Type: application/json요청:
{
"name": "신규 가입 쿠폰",
"totalCount": 100
}검증 규칙:
name: 필수totalCount: 필수, 양수
응답 상태: 201 Created
GET /coupons/{couponId}
Authorization: Bearer {accessToken}응답 상태: 200 OK
POST /coupons/issue/{couponId}
Authorization: Bearer {accessToken}
Content-Type: application/json요청:
{}현재 구현에서는 요청 본문보다 인증된 회원 정보와 path variable의 couponId를 기준으로 발급합니다.
응답 상태: 201 Created
{
"success": true,
"response": {
"...": "..."
}
}memberIdemailpasswordnamerole
couponIdnametotalCountissuedCountcreatedAtupdatedAt
issuedIdmembercouponcreatedAtupdatedAt
현재 테스트는 회원 저장, 쿠폰 발급, 순차 발급, 동시 발급 시나리오를 포함합니다.
MemberServiceTestIssueCouponServiceTest
테스트도 MySQL datasource 설정에 의존합니다. @AutoConfigureTestDatabase(replace = NONE) 설정이 있어 별도 테스트 DB 또는 로컬 DB가 준비되어 있어야 합니다.
현재 테스트는 동시성 문제를 관찰하기 위한 출발점입니다. 실제 대규모 트래픽 개선 폭은 JMeter, k6, Gatling 같은 부하 테스트 도구를 추가해 같은 조건에서 반복 측정하는 방식으로 확인하는 것이 적합합니다.
- JWT secret key는 코드에 고정되어 있어 운영 환경에서는 외부 설정으로 분리해야 합니다.
- 쿠폰 발급 수량 제한과 동시성 제어는 아직 완성된 정책으로 고정되어 있지 않습니다.
- 쿠폰 조회에서 존재하지 않는 쿠폰 처리 로직은 별도 커스텀 예외로 정리할 여지가 있습니다.