선착순 쿠폰 발급을 위한 백엔드 시스템을 구현한 코드는 깃허브에서 볼 수 있다.
🚀 요구사항
- 멀티 서버
- 선착순 쿠폰 발급
- 중복 발급 X
- 짧은 시간 대용량 트래픽 발생
🚀 구현 기술스택
- Language : Java 11
- Framework : Spring Boot 2.7.8
- Database : MySQL 8.0, JPA, QueryDSL, Redis
- API Documentation : Swagger 3.0.0
🚀 해결 방법
1. DB Exclusive Lock(배타적 잠금)


- JPA Pessimistic Lock(비관적 락)을 이용하여 DB에 배타적 잠금 사용
- 다른 트랜잭션에서 읽기, 수정, 삭제 방지

- SQL을 보면 select for update 구문을 사용하는 것을 볼 수 있음
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "5000")})
Optional<Coupon> findLockById(Long id);
- 배타적 잠금은 JpaRepository에 위 코드처럼 작성하여 사용 가능
- 다른 트랜잭션이 대기하는 시간을 @QueryHints로 설정
- 대기시간이 지나면 LockTimeoutException 발생
2. Redis Distribution Lock(분산 락)


- 배타적 잠금에서는 해당 쿠폰 레코드를 Lock 했기에, 해당 쿠폰을 사용하는 다른 서비스에서 데드락 걸릴 가능성 존재
- 따라서 Redis를 이용한 분산락으로 변경하여 해결하였음
- Redis Client는 대표적으로 Redisson과 Lettuce가 있음
- Lettuce : Spin Lock을 사용함. 이는 계속 Lock을 얻으려고 시도하기에 많은 부하 발생
- Redisson : Pub-Sub 기반으로 분산 락 제공함. 따라서 Lettuce보다 부하가 적음
📚 Pub-Sub(Publisher-Subscriber)이란?
메세지 큐 시스템으로 볼 수 있음. Pub을 구독한 Sub들에게 메세지를 알려주는 것. 따라서 여기서는 Pub이 Lock 점유를 끝냈으면 Sub에게 끝냈다고 알려주기에 Sub은 Lock을 얻으려고 시도하지 않음.
2-1. 코드 구현
implementation 'org.redisson:redisson-spring-boot-starter:3.17.0'
public CreateMemberCouponResponse createMemberCoupon(CreateMemberCouponCommand command) {
RLock lock = redissonClient.getLock(couponLockName);
try {
if (!lock.tryLock(10, 3, TimeUnit.SECONDS)) {
throw new RuntimeException("Lock 획득 실패");
}
// Lock 획득했으므로 아래부터는 비즈니스 로직 처리
if (isNotStock(command.getCouponId())) {
throw new CouponNotRemainException();
}
if (isDuplicateCoupon(command)) {
throw new DuplicateCouponException();
}
updateCouponStatePort.decreaseRemainQuantity(command.getCouponId());
MemberCoupon memberCoupon = createMemberCouponPort.createMemberCoupon(command.getMemberId(), command.getCouponId());
return new CreateMemberCouponResponse(memberCoupon.getId(), command.getCouponId(), memberCoupon.getCreateDateTime());
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}

기존 코드와 비교하기위해 사진도 첨부했다. Redis 분산락을 얻기 위한 getLock()과 tryLock()이 생기면서 try/catch 문이 생겨서 코드의 복잡성이 증가했다. 해당 코드는 여기에서 볼 수 있다.
🚀 끝내면서
- 트래픽이 적고, 다른 서비스에서 해당 쿠폰을 많이 사용하지 않으며, 다른 라이브러리를 의존하지 않고 끝내려면 배타적 잠금을 사용
- 트래픽이 많고, 다른 서비스에서 해당 쿠폰을 많이 사용한다면 Redis 분산락 사용
다른 방법으로는 대기열 아키텍처 방식도 있다. 대규모 동시 요청이 들어온다면 Redis 분산락도 많은 부하가 생길 수 있다. 따라서 대기열을 만들어 처리하는 방법이 있다. 이 대기열 방식은 콘서트나 티켓 예매 사이트와 아래 사이트를 참고하여 알게되었는데 나중에 한번 만들어보고 싶다.
- 지마켓 대기열 시스템
- 접속대기 시스템
- 우아한형제들 선착순 이벤트 서버 생존기! 47만 RPM에서 살아남다?!
- 프로모션을 대비한 대기열 시스템 구성하기(Redis, WebSocket, Spring)
참고
'Spring > Spring' 카테고리의 다른 글
로컬에서 동일한 SpringBoot 서버를 여러개 실행하는 방법 (0) | 2023.06.02 |
---|---|
SpringBoot 랜덤포트로 실행하기 (3) | 2023.05.15 |
트랜잭션을 사용할 때 각 DB들의 기본 격리 수준은 무엇일까? (0) | 2023.02.20 |
포스트맨으로 url 요청했는데 405 에러 (0) | 2023.02.13 |
스프링 부트 의존관계 주입 에러 (0) | 2023.02.13 |