Coupon Service
최근 동시성 처리를 위한 연습을 하기 위해 쿠폰 발급 서비스를 구현하고 있습니다. 동시성 처리를 위한 방법으로 Redis의 분산락을 사용했습니다. 동시성 처리 방법하면 떠오르는 synchronized와 같은 애플리케이션 단의 Lock을 사용하지 않은 이유는 프로젝트를 시작하면서 처음부터 다중 서버 환경을 기준으로 생각했기 때문입니다. 애플리케이션 단의 Lock은 하나의 서버에서만 동시성을 제어하기 때문입니다.

현재는 서버만 다중화한 상황을 가정했고 DB에 대한 다중화는 고려하지 않았습니다. 환경 세팅은 AWS의 CloudFormation을 사용했습니다.
Erd Diagram

쿠폰 테이블은 타입과 쿠폰의 이름, 쿠폰 발급 가능 갯수와 만료 기한을 컬럼으로 가지고 있습니다.
멤버 쿠폰은 사용자에게 발급된 쿠폰을 저장하는 테이블로 멤버 id와 쿠폰 id, 사용 여부와 사용한 날짜를 저장하고 있습니다.
초기 동시성 처리 로직
@Transactional
public MemberCoupon issue(IssueCouponRequest request) {
String lockName = request.couponId() + ":lock";
RLock lock = redissonClient.getLock(lockName);
try {
if (!lock.tryLock(1, 3, TimeUnit.SECONDS)) {
throw new IllegalArgumentException();
}
if (memberCouponRepository.existsByCouponIdAndMemberId(request.couponId(), request.userId())) {
throw new IllegalArgumentException();
}
Coupon coupon = couponRepository.findById(request.couponId()).orElseThrow();
if (!coupon.issuable() || coupon.isExpired()) {
throw new IllegalArgumentException();
}
coupon.decrease();
Member member = memberRepository.findById(request.userId()).orElseThrow();
MemberCoupon memberCoupon = new MemberCoupon(member, coupon);
return memberCouponRepository.save(memberCoupon);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalArgumentException(e.getMessage());
} finally {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}
처음에는 위 코드처럼 @Transactional 안에서 Redis락을 건 뒤 비즈니스 로직을 처리하고 있습니다. 이 당시에는 Redis락이 절대적인 줄 알아서 lock안의 로직들이 순차적으로 이뤄져 동시성 문제가 발생하지 않을 거라고 생각했습니다.
그래서 테스트 코드를 작성하여 테스트를 진행했습니다.
@Test
@DisplayName("여러 명의 회원이 쿠폰 발급을 동시에 신청해도, 발급 갯수만큼만 발급되어야 한다.")
void createMemberCouponForRedisLockByManyUsers() throws InterruptedException {
List<Member> members = new ArrayList<>();
int memberCount = 1000;
long couponCount = 1000;
for (int i = 0; i < memberCount; i++) {
Member member = memberRepository.save(new Member("member" + i, "pw", Role.MEMBER));
members.add(member);
}
Coupon coupon = new Coupon("치킨 쿠폰", couponCount, CouponType.CHICKEN, LocalDateTime.now().plusDays(1));
couponRepository.save(coupon);
ExecutorService executor = Executors.newFixedThreadPool(memberCount);
CountDownLatch latch = new CountDownLatch(memberCount);
for (int i = 0; i < memberCount; i++) {
Member member = members.get(i);
executor.submit(() -> {
try {
couponService.issue(new IssueCouponRequest(member.getId(), coupon.getId()));
} catch (Exception e) {
log.error("Error issuing coupon for member: {}", member.getId(), e);
} finally {
latch.countDown();
}
});
}
latch.await();
Coupon usedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
assertThat(usedCoupon.getCount()).isZero();
}
쿠폰 갯수와 쿠폰을 발급받을 멤버 수를 1000명으로 설정하고 1000명의 멤버가 동시에 동일한 쿠폰을 발급받는 상황을 테스트로 구현했습니다. 결과는 예상과 전혀 달랐습니다. 아래와 같이 DeadLock이 2건 발생했고 발급된 쿠폰도 DeadLock이 걸린 2건을 제외하고 998개가 발급된 것과 달리 Coupon의 남은 갯수는 99개였습니다.



DeadLock이 발생한 원인을 알아보기 위해 SHOW ENGINE INNODB STATUS; 명령을 통해 조회해보니 두 개의 트랜잭션이 하나의 레코드에 대해 S Lock을 가지고 있는 상태에서 동일한 레코드에 대해 X Lock을 얻을려 했지만 서로가 S Lock 때문에 X Lock을 얻지 못해 DeadKLock이 발생한 상황이었습니다. 자세한 상황은 아래에서 코드와 같이 설명하겠습니다.
위의 쿠폰 발급 코드에서 쿠폰이 만료되었는지, 아직 발급 수량이 남았는지 파악하기 위해 쿠폰을 조회해 가져오는 로직이 있습니다.
Coupon coupon = couponRepository.findById(request.couponId()).orElseThrow();
if (!coupon.issuable() || coupon.isExpired()) {
throw new IllegalArgumentException();
}
이때 메소드에 @Transactional이 있기 때문에 아직 트랜잭션이 끝나지 않아 해당 couponId의 레코드에 S Lock이 걸리게 됩니다. 그 뒤 해당 쿠폰의 갯수를 차감하기 위한 update로직이 있습니다.
coupon.decrease();
이 과정에서 해당 레코드에 X Lock을 얻을려 하는데 다른 트랜잭션에서 S Lock을 가지고 있고 똑같이 X Lock을 얻을려 해서 DeadLock이 발생하는 것 이었습니다.

즉, Redis 락이 제대로 동작하지 않아 서로 다른 스레드가 동시에 DB를 조회하는 상황이 발생한 것이었습니다. 처음에는 tryLock의 leaseTime값이 작아서 로직 수행 중간에 Lock이 해제되서 해당 문제가 발생했을 거라 판단하고 leaseTime의 값을 늘렸습니다. 하지만 테스트 전체 수행시간보다 큰 값을 할당해도 같은 문제가 발생했습니다.
여러 디버깅 과정을 거치면서 알게된 점은 @Transactional이 트랜잭션을 보장하기 위해 AOP를 활용하면서 발생한 문제였습니다.
@Transactional이 붙은 메소드는 AOP를 통해 동작하며 TransactionAspectSupport 클래스의 invokeWithTransaction메소드를 통해 동작합니다. 그리고 트랜잭션을 시작하고 proxy를 통해 메소드를 실행한 뒤 트랜잭션을 커밋합니다.
문제는 lock이 걸리는 위치는 proxy를 통해 메소드를 실행하는 부분에서만 걸린다는 것입니다. 그래서 트랜잭션을 커밋하기 전에 다른 스레드에서 proxy를 통해 메소드를 실행할 수 있습니다. 그래서 우연히 두 개의 스레드가 동시에 트랜잭션을 커밋하게 되어 DeadLock 문제가 발생하는 것입니다.

해결 방법
결국에는 Transaction이 끝나기 전에 Lock이 해제됐기 때문에 발생한 문제였습니다. 그렇다면 Lock의 해제 시점을 Transaction이 끝난 후에 해제되도록 하면 됩니다. 제가 선택한 방법은 @Transactional 안의 로직과 Lock의 로직을 분리하는 것이었습니다.
@Service
@RequiredArgsConstructor
public class CouponService {
private final CouponWriter couponWriter;
private final RedissonClient redissonClient;
public MemberCoupon issue(IssueCouponRequest request) {
String lockName = request.couponId() + ":lock";
RLock lock = redissonClient.getLock(lockName);
lock.lock();
try {
return couponWriter.issue(request);
} finally {
lock.unlock();
}
}
}
@Component
@RequiredArgsConstructor
public class CouponWriter {
private final CouponRepository couponRepository;
private final MemberCouponRepository memberCouponRepository;
private final MemberRepository memberRepository;
@Transactional
public MemberCoupon issue(IssueCouponRequest request) {
if (memberCouponRepository.existsByCouponIdAndMemberId(request.couponId(), request.userId())) {
throw new IllegalArgumentException("이미 발급받은 쿠폰입니다");
}
Coupon coupon = couponRepository.findById(request.couponId()).orElseThrow();
if (!coupon.issuable()) {
throw new IllegalArgumentException("모두 소진된 쿠폰입니다.");
}
if (coupon.isExpired()) {
throw new IllegalArgumentException("쿠폰이 만료되었습니다.");
}
coupon.decrease();
Member member = memberRepository.findById(request.userId()).orElseThrow();
MemberCoupon memberCoupon = new MemberCoupon(member, coupon);
return memberCouponRepository.save(memberCoupon);
}
}
위처럼 Redis Lock을 사용하는 곳 안에서 @Transactional의 메소드를 호출하는 방식으로 Transaction 이후에 Lock이 해제되도록 수정했습니다.
'Side Tech Notes' 카테고리의 다른 글
| 리액터 패턴 / 프로액터 패턴 (1) | 2025.07.19 |
|---|---|
| AWS CloudFormation (0) | 2025.05.27 |
| 빌더 패턴이 정말로 좋을까 (0) | 2025.03.24 |
| 좋은 코드란 무엇일까 (feat. 객체지향) (0) | 2025.02.21 |
| 정적 팩토리 메서드 (정팩메)의 사용에 대해서 (0) | 2025.02.16 |