해결한 문제와 담당 역할
제어 없이 처리하면 첫 로그인 보상이 두 번 나가고, 쿠폰이 준비한 수량보다 많이 발급되고, 포인트가 마이너스가 됩니다. 반대로 회원 ID 하나로 전부 잠그면 한 사람에게 요청이 몰릴 때 다 같이 느려집니다.
첫 로그인 보상, 선착순 쿠폰 발급, 포인트 차감 API를 만들었습니다.
DB 제약·갱신 조건을 공통으로 유지한 채 Redis 잠금과 RabbitMQ 처리 경로를 구성하고 결과를 비교했습니다.
방식별 결과를 나란히 볼 수 있는 조회 화면을 붙였습니다.
핵심 요구사항
- 첫 로그인 보상은 한 회원에게 한 번만 나갑니다.
- 요청이 어떤 순서로 겹쳐도 포인트는 0 아래로 내려가지 않습니다.
- 쿠폰은 준비한 수량보다 많이 나가지 않습니다.
기술 선택과 이유
마지막 방어선은 DB
- 적용 방식
- 유니크, CHECK 제약과 조건부 UPDATE, 행 잠금으로 DB가 잘못된 값을 받지 않게 합니다.
- 검토한 대안
- Redis 분산 락만 믿기
- 선택 이유
- Redis 락 만료나 장애로 동시 요청이 들어와도 DB 제약과 갱신 조건으로 중복·초과 처리를 막도록 했습니다.
잠금은 필요한 곳에만
- 적용 방식
- 보상은 회원별, 쿠폰은 캠페인별 Redis 잠금을 사용하고 DB 제약·갱신 조건은 유지합니다.
- 검토한 대안
- 회원 ID 하나로 모든 작업을 줄 세우기
- 선택 이유
- 포인트 차감과 쿠폰 발급은 서로 상관이 없는데 같은 락을 기다릴 이유가 없습니다.
몰리는 캠페인은 큐로
- 적용 방식
- 요청이 몰리는 캠페인은 RabbitMQ 소비자 하나가 순서대로 발급합니다.
- 검토한 대안
- 모든 요청이 캠페인 행 잠금을 직접 경쟁
- 선택 이유
- 로컬 인스턴스에서 소비자 경쟁을 줄이는 경로를 비교하려는 선택입니다. 수량 조건은 DB에 유지했으며, 처리량 개선 효과는 측정하지 않았습니다.
주요 구현과 관련 테스트
핵심 로직과 동작을 확인한 테스트를 함께 정리했습니다.
주요 구현
SqlCouponCampaignRepository.issueWithCapacityGuard
backend/src/main/java/com/example/consistency/coupon/SqlCouponCampaignRepository.java
주요 구현
SqlCouponCampaignRepository.issueWithCapacityGuard
backend/src/main/java/com/example/consistency/coupon/SqlCouponCampaignRepository.java코드
소스 파일 열기where id = ?
and status = 'ACTIVE'
and issued_count < capacity
for update
구현 내용
캠페인 행을 잠그고 남은 수량을 SQL 한 번에 확인해서, 동시에 들어와도 수량을 넘겨 발급하지 않습니다.
관련 테스트
SqlCouponCampaignRepositoryTest.issueWithCapacityGuardUsesSingleStatementWithCampaignRowLockbackend/src/test/java/com/example/consistency/coupon/SqlCouponCampaignRepositoryTest.java테스트 결과1건 지급, 7건 거절
추가 구현
SqlPointSpendRepository.tryDebit
backend/src/main/java/com/example/consistency/point/SqlPointSpendRepository.java
추가 구현
SqlPointSpendRepository.tryDebit
backend/src/main/java/com/example/consistency/point/SqlPointSpendRepository.java코드
소스 파일 열기version = version + 1,
updated_at = now()
where member_id = ?
and balance >= ?
구현 내용
잔액이 충분할 때만 UPDATE가 적용되기 때문에 동시에 빼도 마이너스가 되지 않습니다.
관련 테스트
SqlPointSpendRepositoryTest.conditionalDebitKeepsBalanceNonNegativebackend/src/test/java/com/example/consistency/point/SqlPointSpendRepositoryTest.java테스트 결과1건만 성공하고 잔액은 40입니다.
추가 구현
CouponCampaignRabbitMqWorker.handle
backend/src/main/java/com/example/consistency/web/CouponCampaignRabbitMqWorker.java
추가 구현
CouponCampaignRabbitMqWorker.handle
backend/src/main/java/com/example/consistency/web/CouponCampaignRabbitMqWorker.java코드
소스 파일 열기@RabbitListener(queues = COMMAND_QUEUE, concurrency = "1")
public void handle(CouponCampaignRabbitMqCommand command) {
if (!tracker.isActive(command.operationId())) {
return;
}
구현 내용
Spring 인스턴스 하나의 listener concurrency를 1로 설정하고 비활성 실행의 메시지를 무시합니다. 여러 인스턴스의 전역 순서 보장을 뜻하지 않습니다.
관련 테스트
MvpLiveInfrastructureIT.rabbitMqCouponCampaignRouteRunsOnlyWhenLiveDependenciesAreHealthybackend/src/test/java/com/example/consistency/integration/MvpLiveInfrastructureIT.java테스트 결과3건 발급, 5건 거절
테스트 결과
동시 요청 8건
1건 지급, 7건 거절
관련 코드FirstLoginRewardDbConcurrencyIT.uniqueRewardIssueConstraintAllowsOnlyOneFirstLoginRewardPerMemberUnderConcurrentAttempts100 - (2 × 60) → 40
1건만 성공, 잔액은 40
관련 코드PointSpendDbConcurrencyIT.rowLockSpendAllowsOnlyOneConcurrentDebitWhenBalanceCanCoverOneRequest정원 3건 / 요청 8건
3건 발급, 5건 거절
관련 코드MvpLiveInfrastructureIT.rabbitMqCouponCampaignRouteRunsOnlyWhenLiveDependenciesAreHealthy테스트 방법 보기
| 구분 | 테스트 방법 | 결과 |
|---|---|---|
| 컨테이너 테스트 | Testcontainers PostgreSQL에 첫 로그인 보상 요청 8건을 동시에 보냅니다. | 1건 지급, 7건 거절 |
| 컨테이너 테스트 | 잔액 100에서 60 차감을 두 번 동시에 요청합니다. | 1건만 성공하고 잔액은 40입니다. |
| 컨테이너 테스트 | Redis, RabbitMQ, PostgreSQL을 띄우고 수량 3개짜리 캠페인에 8건을 요청합니다. | 3건 발급, 5건 거절 |
프로젝트 범위
검증 한계와 미구현 범위
- Testcontainers의 제한된 요청 수로 정합성을 확인했습니다. 운영 수준의 처리량·지연과 브로커 장기 장애는 검증하지 않았습니다.
- Redis의 다중 프로세스 잠금 경쟁·만료·장애 복구와 RabbitMQ 다중 인스턴스 순서 보장은 검증하지 않았습니다.
- 방식별 성능 우위나 DB 부하 감소 효과는 측정하지 않았습니다.
향후 개선
- 같은 부하에서 방식별 처리량과 p99 지연을 비교해 보려고 합니다.
관련 자료