[쿠러미] 쿠폰 발급 시 (동시성 제어 + 중복 체크) 문제해결
서론
쿠러미 프로젝트가 출시가 임박해, 미뤄뒀던 쿠폰 발급 시 동시성 제어를 하기로 했다.

지금 쿠폰의 erd는 다음과 같고, 개수가 쿠폰 테이블에 같이 있는 형태이다.
@Transactional
public void issuedCoupon(Long memberId, Long couponId) {
Member member = findMemberById(memberId);
Coupon coupon = findCouponByIdWithRock(couponId);
// 쿠폰의 잔여 갯수가 0개일 경우
if (coupon.getStock() == 0 ){
throw new CustomException(COUPON_SOLD_OUT);
}
// 회원이 이미 해당 쿠폰을 발급받았는지 확인
if (!memberCouponRepository.existCouponByMemberId(member.getId(), coupon.getId())) {
memberCouponRepository.save(MemberCoupon.builder()
.member(member)
.coupon(coupon)
.isUsed(false)
.isExpired(false)
.build());
coupon.reduce();
} else {
throw new CustomException(COUPON_ALREADY_ISSUED);
}
}
서비스 코드는 위와 같은데, 쓰기 작업 쿼리는 두 가지가 나가게 된다.
- member_coupon 생성(insert)
- coupon 갯수 차감(update)
하지만 이때, db에서 FK를 가지고 있는 테이블(member_coupon)에 대해 읽기 쿼리를 제외한 작업을 할 경우, 해당 FK 레코드(coupon)에 대해 공유락(S-Rock)을 걸게된다. 또한 쿠폰 갯수 차감(update) 쿼리를 날려 해당 레코드(coupon)에 배타락(X-Rock)을 걸게 돼, 동일한 레코드(coupon)에 두 가지 락이 걸리게된다.
순차적으로 실행이 될 경우 문제가 없으나, 스프링부트는 멀티 쓰레드로 동작하기 때문에 동시에 요청이 오게되면 락이 꼬여 데드락이 발생하게 된다. 따라서 락을 개발자가 제어해야하므로, 테이블 정규화를 하지 않는 한, 낙관적 락은 사용하지 못하고 비관적 락을 사용해야한다.
구현
@Lock(LockModeType.PESSIMISTIC_WRITE) // X-Lock 설정
@Query("SELECT c FROM Coupon c WHERE c.id = :couponId")
Optional<Coupon> findCouponByIdWithLock(@Param("couponId") Long couponId);
비관적 락은 쿠폰 조회시 해당 메서드를 통해 조회하면 X-Rock을 걸게 돼, 처음부터 coupon에 대해 X-Rock을 걸어 데드락을 방지할 수 있다.
테스트
@BeforeEach
void setUp() {
Market market = marketRepository.findById(1L).orElseThrow(() -> new CustomException(MARKET_NOT_EXIST));
Member member = memberRepository.findById(201901658L).orElseThrow(() -> new CustomException(MEMBER_NOT_EXIST));
memberId= member.getId();
CouponReq couponReq = new CouponReq(
"WELCOME10", // couponName
"10% 할인 쿠폰입니다", // description
LocalDateTime.now().plusDays(30), // deadLine
5 // stock
);
Coupon coupon = couponRepository.save(couponReq.ofCreate(market));
couponId=coupon.getId();
}
// @AfterEach
// public void tearDown() {
// memberCouponRepository.deleteAll();
// couponRepository.deleteById(couponId);
// }
@Test
@DisplayName("쿠폰 다운로드 정합성 테스트")
void testConcurrentReservation() throws InterruptedException {
int numberOfThreads = 10; // 요청 수
ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
AtomicInteger successCount = new AtomicInteger(0);
List<Exception> exceptions = new ArrayList<>();
List<Long> responseTimes = new ArrayList<>();
// 테스트 시작 시간 기록
long testStartTime = System.nanoTime();
for (int i = 0; i < numberOfThreads; i++) {
executorService.submit(() -> {
long startTime = System.nanoTime(); // 시작 시간 기록
try {
memberCouponService.issuedCoupon(memberId, couponId);
successCount.incrementAndGet();
} catch (PessimisticLockingFailureException e) {
synchronized (exceptions) {
exceptions.add(e);
}
} catch (CustomException e) {
synchronized (exceptions) {
// 다른 예외도 처리
exceptions.add(e);
}
} catch (Exception e) {
synchronized (exceptions) {
// 다른 예외도 처리
exceptions.add(e);
}
}
finally {
long endTime = System.nanoTime(); // 종료 시간 기록
long responseTime = endTime - startTime; // 응답 시간 계산
synchronized (responseTimes) {
responseTimes.add(responseTime); // 응답 시간 리스트에 추가
}
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
// 테스트 종료 시간 기록
long testEndTime = System.nanoTime();
// 전체 테스트 시간 계산 (단위: 밀리초)
long totalTestTimeMillis = (testEndTime - testStartTime) / 1_000_000; // nano -> milliseconds
System.out.println("성공한 다운로드 수: " + successCount.get());
System.out.println("발생한 예외 수: " + exceptions.size());
// 응답 시간 계산
List<Long> responseTimeList = new ArrayList<>(responseTimes);
long fastestResponse = responseTimeList.stream().min(Long::compare).orElse(0L) / 1_000_000; // nanoseconds -> milliseconds
long slowestResponse = responseTimeList.stream().max(Long::compare).orElse(0L) / 1_000_000; // nanoseconds -> milliseconds
double averageResponse = responseTimeList.stream().mapToLong(Long::longValue).average().orElse(0) / 1_000_000.0; // nanoseconds -> milliseconds
exceptions.forEach(ex -> System.out.println("예외 종류: " + ex.getClass().getSimpleName()));
System.out.println("최단 응답 시간: " + fastestResponse + "ms");
System.out.println("최장 응답 시간: " + slowestResponse + "ms");
System.out.println("평균 응답 시간: " + averageResponse + "ms");
// 전체 테스트 시간 출력
System.out.println("전체 테스트 시간: " + totalTestTimeMillis + "ms");
assertEquals(1, successCount.get(), "동시에 하나만 성공해야 합니다");
assertEquals(numberOfThreads - 1, exceptions.size(), "나머지는 예외가 발생해야 합니다");
}
개수가 5개인 쿠폰을 생성하고, 동일한 사용자가 10개의 쿠폰을 동시에 발급하는 시나리오 테스트를 해보았다.
예상 결과는 동일한 사용자가 발급하기에 1개만 발급돼야 한다.
결과

테스트 결과는 실패다. 우선 5개의 쿠폰만 발급받은 건 락을 통한 정합성 테스트 의도가 맞다. 하지만 동일한 사용자가 5개의 쿠폰을 발급받는 건 생각치도 못한 결과이다.
의문
// 회원이 이미 해당 쿠폰을 발급받았는지 확인
if (!memberCouponRepository.existCouponByMemberId(member.getId(), coupon.getId())) {
memberCouponRepository.save(MemberCoupon.builder()
.member(member)
.coupon(coupon)
.isUsed(false)
.isExpired(false)
.build());
coupon.reduce();
} else {
throw new CustomException(COUPON_ALREADY_ISSUED);
}
memberCouponRepository.existCouponByMemberId(member.getId(), coupon.getId() 해당 코드가 제대로 동작을 하지 않는 것 같다. 그 이유는 무엇일까?
- coupon에는 락이 걸려 있지만, 해당 코드는 member_coupon(발급 쿠폰)에 대해서 단순 조회하는 로직이기 때문에 동시성 제어가 되지않은 것이다.
그렇다면 의문이 들 수도 있다. 해당 코드에는 coupon.getId()가 파라미터로 들어간다. 따라서 락이 걸려있지 않다고 해도 coupon에 락이 걸려있기 때문에 다수의 트랜잭션이 동시에 해당 쿼리를 실행할 수 없다!
락을 가지고 있는 트랜잭션이 커밋된 이후에 락을 해제하기 때문에, 다른 트랜잭션은 coupon에 대해 락을 기다리고 해당 조회 쿼리를 날릴 수가 있다.
문제
그러면 동시에 실행한 게 아니어도 동일한 결과를 가져온 것이라면?
이 문제를 이해하려면, 트랜잭션의 격리 수준에 대해서 알아야 한다.
-
- Read Uncommitted
가장 낮은 수준의 격리를 제공하며, 다른 트랜잭션이 커밋하지 않은 데이터를 읽을 수 있습니다. 이로 인해 더티 리드 (Dirty Read) 문제가 발생할 수 있습니다. - Read Committed
다른 트랜잭션이 커밋한 데이터만 읽을 수 있습니다. 이는 더티 리드를 방지하지만, 논리적 팬텀 리드 (Phantom Read) 문제가 발생할 수 있습니다. - Repeatable Read
트랜잭션이 시작될 때 읽은 데이터를 트랜잭션이 종료될 때까지 유지합니다. 이는 팬텀 리드 문제를 방지하지만, 다른 트랜잭션이 삽입한 데이터를 볼 수 없는 문제가 발생할 수 있습니다. - Serializable
가장 높은 수준의 격리를 제공하며, 트랜잭션이 순차적으로 실행되는 것처럼 보장합니다. 이로 인해 동시성이 크게 떨어지는 단점이 있습니다.
- Read Uncommitted
Mysql의 기본 트랜잭션 격리 수준은 Repeatable Read이다. 이것이 뜻하는 것은 트랜잭션이 시작될 때의 스냅샷을 기준으로 데이터를 읽기 때문에, 동시에 실행이 됐을 때, 트랜잭션이 커밋돼도 발급된 쿠폰을 인지하지 못하는 것이다.
즉, 락을 적용한 coupon에 대해서만 최신 데이터를 제공하고, 단순 조회인 중복 여부 메서드는 제대로 동작을 하지 못하는 것이다.
해결법
- 발급쿠폰(member_coupon)을 조회할 때도 락을 건다
- 유니크 제약조건을 걸어 DB에서 무결성 체크한다.
- 분산락을 이용해, 트랜잭션 범위 밖에서 락을 걸어 트랜잭션을 순차적으로 실행한다.
우선 첫 번째 방법은 추천하고싶지 않다.
한 트랜잭션에서 두개의 테이블에 락을 걸게되면 의도치않은 병목현상이나 데드락을 발생할 수 있기에 적합하지 않은 것 같다.
분산락을 이용한 방법은 Redis를 통해 구현해야하는데, 아직 프로젝트에서 Redis를 사용하지 않기에, 추후 Redis를 사용하면 성능 비교를 통해 채택할 수 있을 듯 하다.
따라서 고르게 된 방법은 두 번째 방법으로, DB에 발급 쿠폰에 복합 유니크 제약조건(회원ID,쿠폰ID)를 걸어 중복 체크를 하는 것이다. 동일한 사용자가 동시에 쿠폰을 발급할 일은 현저히 적다고 느끼고 제일 쉬운 방식이기 때문이다.
구현
@Table(
name = "member_coupon",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"member_id", "coupon_id"})
}
)
테스트
아까전과 똑같은 테스트를 진행했다.

결과는 1개만 쿠폰 발급이 됐고 나머지는 DataIntegrityViolationException으로 db의 무결성 제약 조건을 위반했을 시 나오는 예외가 나왔다.
마무리
사실 동시성 제어는 최근에도 해봤어서 블로그 포스팅은 안할 줄 알았으나, 테스트 중에 개수 정합성과 중복 체크 라는 두 가지 로직이 묶여있어 발생하는 문제점 때문에 흥미로워 하게되었다.
이제 Redis 분산락 vs 비관적락 + DB 복합 유니크 제약조건 구현 방식에 대한 성능 비교를 해 어느 방법이 더 좋은 지 다음 글에 써보도록 하겠다.