쿠폰 발급 로직 단계별 개발
쿠폰 발급하는 로직을 3단계에 나누어 개발을 진행했다.
각 단계별로 어떻게 개발을 했고 어떤 특징과 장단점이 있는지 알아보자.
단계는 다음과 같다.
1. v1 - db를 사용한 기본 구현 방법
2. v2 - Redis를 이용한 분산락 방법
3. v3 - Kafka를 통한 비동기 처리 방법
1. v1 - db를 사용한 기본 구현
db 트랜잭션과 동시요청 처리에 초점을 맞춤
쿠폰 정책의 잔여 수량을 확인하고 성공하면 db에 저장하고 실패하면 실패응답을 반환한다.
동시성제어를 db 레벨에서만 처리하기 때문에 적절한 lock이 없다.
그러므로 트랜잭션내 연산으로 동시성 문제를 최대한 방지하도록 개발함
다음은 v1 의 issueCoupon 메서드이다.
@Transactional
public Coupon issueCoupon(CouponDto.IssueRequest request) {
CouponPolicy couponPolicy = couponPolicyRepository.findByIdWithLock(request.getCouponPolicyId())
.orElseThrow(() -> new CouponIssueException("쿠폰 정책을 찾을 수 없습니다."));
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(couponPolicy.getStartTime()) || now.isAfter(couponPolicy.getEndTime())) {
throw new CouponIssueException("쿠폰 발급 기간이 아닙니다.");
}
long issuedCouponCount = couponRepository.countByCouponPolicyId(couponPolicy.getId());
if (issuedCouponCount >= couponPolicy.getTotalQuantity()) {
throw new CouponIssueException("쿠폰이 모두 소진되었습니다.");
}
Coupon coupon = Coupon.builder()
.couponPolicy(couponPolicy)
.userId(UserIdInterceptor.getCurrentUserId())
.couponCode(generateCouponCode())
.build();
return couponRepository.save(coupon);
}
private String generateCouponCode() {
return UUID.randomUUID().toString().substring(0, 8);
}
couponPolicyRepository.findByIdWithLock(...)은 다음과 같다.
해당 findByIdWithLock을 사용한다.
public interface CouponPolicyRepository extends JpaRepository<CouponPolicy, Long> {
// 쿠폰을 발급할때 해당 row에 대해서 쓰기락을 걸거다 (비관적락)
// 해당 데이터에 접근하는 트랜잭션에 대해서 쓰기 잠금을 한다. 다른 트랜잭션이 수정,삭제 하려고 하면 대기 상태가 된다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT cp FROM CouponPolicy cp WHERE cp.id = :id")
Optional<CouponPolicy> findByIdWithLock(Long id);
}
CouponPolicyRepository의 findByIdWithLock 은 v1에서만 사용한다.
CouponRepository 코드는 다음과 같다.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
Optional<Coupon> findByIdAndUserId(Long id, Long userId);
@Query("SELECT COUNT(c) FROM Coupon c WHERE c.couponPolicy.id = :policyId")
Long countByCouponPolicyId(@Param("policyId") Long policyId);
Page<Coupon> findByUserIdAndStatusOrderByCreatedAtDesc(Long userId, Coupon.Status status, Pageable pageable);
/**
* PESSIMISTIC_WRITE 를 사용하는 이유는 데이터의 일관성을 보장하기 위함
* 동시에 여러 트랜잭션이 동일한 데이터를 수정하려고 할 때 충돌을 방지하고, 데이터 무결성을 유지하기 위해 사용
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findByIdWithLock(@Param("id") Long id);
}
해당 CouponRepository의 findByIdWithLock 은 v2와 v3 에서 사용한다.
또한 countByCouponPolicyId 는 v1 에서만 사용한다.
v1 의 문제점
1. Race Condition 발생 가능성
findByIdWithLock으로 CouponPolicy에 대해 락을 걸지만 쿠폰 수량을 판단하는 부분인
long issuedCouponCount = couponRepository.countByCouponPolicyId(couponPolicy.getId());
해당 부분에서 Coupon 테이블에 락이 걸리지 않는다.
이 부분은 CouponRepository 에서 호출한 쿠폰정책 id에 의한 Coupon 의 수를 카운트하면 request의 couponId 에 해당하는 쿠폰의 개수를 조회한다.
쿠폰 수량을 couponRepository.countByCouponPolicyId 으로 조회해도 그건 락을 건게 아니기 때문에 다른 트랜잭션과 경쟁이 발생할 수 있다.
현재는 여러 트랜잭션이 동시에 카운트를 조회하고 조건을 통과한 후 쿠폰을 저장할 수 있다.
결과적으로 totalQuantity 보다 더 많은 쿠폰이 발급될 수 있다.
2. 성능 이슈
매 요청마다 발급된 쿠폰 수를 카운트하는 쿼리를 실행하는데 쿠폰 수가 많아질수록 카운트 쿼리의 성능이 저하될 수 있으며 PESSIMISTIC_LOCK으로 인한 병목 현상 발생도 가능하다.
3. Dead Lock 가능성
(둘 이상의 프로세스가 다른 프로세스가 점유하고 있는 자원을 서로 기다릴 때 무한 대기에 빠지는 상황)
여러 트랜잭션이 동시에 같은 쿠폰 정책에 대해 락을 획득하려 할 때 트랜잭션 타임아웃이 발생할 수 있음
4. 정확한 수량 보장의 어려움
분산 환경에서 여러 서버가 동시에 쿠폰을 발급할 경우 DB 레벨의 락만으로는 정확한 수량 제어가 어려움
v1의 장점
설계가 단순하고 복잡한 추가 기술 도입이 필요하지 않고 시스템 리소스 사용이 비교적 적다.
동시요청이 많지 않으면 데이터 정합성이 비교적 잘 보장된다.
v1의 단점
매 요청마다 발급된 쿠폰 수를 카운트하는 쿼리를 실행하는데 쿠폰 수가 많아질수록 카운트 쿼리의 성능이 저하될 수 있다.
PESSIMISTIC_LOCK는 비관적 락으로 쓰게에 대해 동시성을 제한하는데 이는 동시에 여러 트랜잭션이 메서드를 호출하면 하나의 트랜잭션만 접근이 가능하고 나머지는 락이 풀릴 때 까지 blocking 상태로 대기해서 트랜잭션이 완료 될 때 까지 대기 큐가 생기고 이는 병목 현상이 발생해서 대기 시간이 증가한다.
요청량이 많은 경우에는 database에 과도한 부하가 있고 처리량이 많아서 트래픽 증가에 취약한 구조이다.
또한 해당 로직은 단일 서버 기준으로 작성 되었기 때문에 확장성이 제한된다.
v1의 결론
그러므로 소규모 서비스에 적합한 구조이다.
동시 요청이 많지 않고 초기 단계의 시스템 설계에 유리한 구조이다.
2. v2 - Redis를 이용한 분산락
다중 서버 환경에서도 데이터 정합성을 유지하면서 동시성 문제를 해결
쿠폰 발행 요청마다 redis에서 lock 키를 생성을 해서 lock 획득 여부를 기반으로 요청 처리를 진행함
쿠폰 정책과 잔여 수량은 redis 또는 db에 상태 데이터를 조회해서 결정함
처리 완료후에 lock을 해제해서 다른 요청을 처리할 수 있도록 구성함
v2 에서는 CouponService에서 CouponRedisService 와 CouponStateService를 호출하는 방식으로 사용한다.
다음은 CouponService의 issueCoupon메서드이다.
@Transactional
public CouponDto.Response issueCoupon(CouponDto.IssueRequest request) {
Coupon coupon = couponRedisService.issueCoupon(request);
couponStateService.updateCouponState(couponRepository.findById(coupon.getId())
.orElseThrow(() -> new CouponNotFoundException("쿠폰을 찾을 수 없습니다.")));
return CouponDto.Response.from(coupon);
}
다음은 호출한 CouponRedisService의 issueCoupon 메서드이다.
@Transactional
public Coupon issueCoupon(CouponDto.IssueRequest request) {
String quantityKey = COUPON_QUANTITY_KEY + request.getCouponPolicyId();
String lockKey = COUPON_LOCK_KEY + request.getCouponPolicyId();
RLock lock = redissonClient.getLock(lockKey);
try {
// 동시성 충돌, 중복 차감, 잘못된 수량 감소 방지를 위해 RLock 을 사용한다.
// lock.tryLock(...)은 다른 누군가가 락을 잡고 있으면 최대 LOCK_WAIT_TIME 초 만큼 기다렸다가 락을 얻으려는 시도를 하고
// 그 시간 내에 락을 못 얻으면 false가 리턴되고, 락을 얻으면 true가 리턴된다
boolean isLocked = lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS);
if (!isLocked) {
throw new CouponIssueException("쿠폰 발급 요청이 많아 처리할 수 없습니다. 잠시 후 다시 시도해주세요.");
}
// redis에서 쿠폰 정책을 가져온다.
CouponPolicy couponPolicy = couponPolicyService.getCouponPolicy(request.getCouponPolicyId());
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(couponPolicy.getStartTime()) || now.isAfter(couponPolicy.getEndTime())) {
throw new IllegalStateException("쿠폰 발급 기간이 아닙니다.");
}
// 수량 체크 및 감소
RAtomicLong atomicQuantity = redissonClient.getAtomicLong(quantityKey);
long remainingQuantity = atomicQuantity.decrementAndGet();
if (remainingQuantity < 0) {
atomicQuantity.incrementAndGet();
throw new CouponIssueException("쿠폰이 모두 소진되었습니다.");
}
// 쿠폰 발급
return couponRepository.save(Coupon.builder()
.couponPolicy(couponPolicy)
.userId(UserIdInterceptor.getCurrentUserId())
.couponCode(generateCouponCode())
.build());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CouponIssueException("쿠폰 발급 중 오류가 발생했습니다.", e);
} finally {
if (lock.isHeldByCurrentThread()) { // 현재 스레드가 락을 보유하고 있으면 락을 해제한다
lock.unlock();
}
}
}
해당 코드에서는
쿠폰 발행 요청마다 redis에서 lock 키를 생성을 해서 lock 획득 여부를 기반으로 요청 처리를 진행한다.
RLock lock = redissonClient.getLock(lockKey); 코드로
분산락(distributed lock)을 걸기 위한 준비 작업을 한다.
RLock은 Redisson 라이브러리에서 제공하는 분산 락 객체이다.
즉, 동시에 접근할 수 있는 공유 자원(예: 쿠폰 수량)에 대해 한 번에 하나의 작업만 실행되도록 보장하는 역할을 한다.
그리고 lock.tryLock(...) 코드로 분산락을 획득하도록 시도한다.
lock.tryLock(...)은 다른 누군가가 락을 잡고 있으면 최대 LOCK_WAIT_TIME 초 만큼 기다렸다가 락을 얻으려는 시도를 하고
그 시간 내에 락을 못 얻으면 false가 리턴되고, 락을 얻으면 true가 리턴된다.
다음은 CouponPolicyService의 getCouponPolicy 이다.
public CouponPolicy getCouponPolicy(Long id) {
String policyKey = COUPON_POLICY_KEY + id;
RBucket<String> bucket = redissonClient.getBucket(policyKey);
String policyJson = bucket.get();
if (policyJson != null) {
try {
return objectMapper.readValue(policyJson, CouponPolicy.class);
} catch (JsonProcessingException e) {
log.error("쿠폰 정책 정보를 JSON으로 파싱하는 중 오류가 발생했습니다.", e);
}
}
return couponPolicyRepository.findById(id)
.orElseThrow(() -> new CouponPolicyNotFoundException("쿠폰 정책을 찾을 수 없습니다."));
}
issueCoupon에서 호출하면 쿠폰 정책 정보를 조회하는데
redis에서 쿠폰 정책을 찾고 가져오며 없다면 db에서 찾고 가져온다.
또한
issueCoupon 메서드에서 남은 쿠폰 수량을 Redis의 RAtomicLong으로 관리하고 있다.
참고로 RAtomicLong 이란 Redis 기반의 분산형 AtomicLong 객체이며
AtomicLong은 Long 자료형을 갖고 있는 Wrapping 클래스이다. Thread-safe로 구현되어 멀티쓰레드에서 synchronized 없이 사용할 수 있으며 synchronized 보다 적은 비용으로 동시성을 보장할 수 있다.
- 남은 쿠폰 수량을 Redis의 AtomicLong으로 관리하고 있다.
- decrementAndGet()을 통해 남은 수량을 감소시키면서 동시에 체크한다.
- 현재 값에서 1을 빼고 그 결과를 바로 리턴한다. 즉, 감소 + 읽기를하나의 원자적(atomic) 연산으로 처리한다.
- 만약 남은 쿠폰 수량이 0보다 작아지면 다시 되돌리고 예외를 던진다.
즉, 쿠폰의 수량이 남아 있는지 체크하고 감소시키는 로직이 Redis 위에서 동시성으로 안전하게 수행되고 있다.
이걸 RDBMS 트랜잭션 안에서 했다면? → DB 락 걸리고 성능 병목이 발생한다.
Redis는 메모리 기반이라 초고속 + 동시성 제어에 적합하다.
다음은 CouponStateService의 updateCouponState 메서드이다.
public void updateCouponState(Coupon coupon) {
try {
String stateKey = COUPON_STATE_KEY + coupon.getId();
String couponJson = objectMapper.writeValueAsString(CouponDto.Response.from(coupon));
// RBucket은 Redis의 단일 key-value 저장 구조이다.
RBucket<String> bucket = redissonClient.getBucket(stateKey);
bucket.set(couponJson);
log.info("Coupon state updated: {}", coupon.getId());
} catch (Exception e) {
log.error("Error updating coupon state: {}", e.getMessage(), e);
throw new RuntimeException("쿠폰 상태 업데이트 중 오류가 발생했습니다.", e);
}
}
쿠폰 상태를 Redis에 저장하고 있다.
Redis의 RBucket을 사용해서 쿠폰 정보를 key-value 형태로 캐싱하는 방식이다.
v2의 장점
분산 환경에서도 데이터 정합성을 유지할 수 있음
또한 처리량이 v1 버전보다 더 증가한다.
redis를 사용함으로써 database의 부하가 줄어들고 응답시간이 개선됨
v2의 단점
lock 획득 대기 시간이 있어서 처리량이 제한될 수 있다.
(즉 동시에 여러 요청이 들어오면 락 대기시간안에 락을 획득하지 못하므로 if(!isLocked) 코드에서 쿠폰 발급 요청이 많아 처리할 수 없다는 에러가 발생해서 db에 저장도 안되고 응답 실패한다.)
또한 락 키를 잘못 관리하면 데드락이 발생할 수 있으므로 redis의 속도가 빨라진 만큼 redis의 의존성이 높아진 구조이다.
redis가 장애가 나면 요청 처리가 불가능 해질 수 있다. -> 시스템이 장애 없이 지속적으로 동작할 수 있도록 하는 고가용성 필요 (ex. redis sentinel)
v2의 결론
중규모 이상의 서비스에 적합하다
동시 요청 수가 많아지고 단일 서버라는 처리하기 어려운 상황에서 redis 를 활용해서 분산락을 처리해서 활용할 수 있다.
3. v3 - Kafka를 통한 비동기 큐 처리
kafka 기반의 비동기 메시지 시스템을 도입해서 대규모 트래픽과 높은 tps 를 처리하기 위한 아키텍처를 구성함
클라이언트의 요청은 카프카의 큐에 저장되고 요청은 컨슈머 시스템에서 비동기로 처리된다.
처리 완료후에 결과를 database에 저장하거나 응답상태를 업데이트한다.
처리중 실패시 로그를 기록하고 redis에서 잔여 수량 복구를 동작 하도록함
다음은 v3의 CouponService 코드이다.
@Transactional(readOnly = true)
public void requestCouponIssue(CouponDto.IssueRequest request) {
String quantityKey = COUPON_QUANTITY_KEY + request.getCouponPolicyId();
String lockKey = COUPON_LOCK_KEY + request.getCouponPolicyId();
RLock lock = redissonClient.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS);
if (!isLocked) {
throw new CouponIssueException("쿠폰 발급 요청이 많아 처리할 수 없습니다. 잠시 후 다시 시도해주세요.");
}
CouponPolicy couponPolicy = couponPolicyService.getCouponPolicy(request.getCouponPolicyId());
if (couponPolicy == null) {
throw new IllegalArgumentException("쿠폰 정책을 찾을 수 없습니다.");
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(couponPolicy.getStartTime()) || now.isAfter(couponPolicy.getEndTime())) {
throw new IllegalStateException("쿠폰 발급 기간이 아닙니다.");
}
// 수량 체크 및 감소
RAtomicLong atomicQuantity = redissonClient.getAtomicLong(quantityKey);
long remainingQuantity = atomicQuantity.decrementAndGet();
if (remainingQuantity < 0) {
atomicQuantity.incrementAndGet();
throw new CouponIssueException("쿠폰이 모두 소진되었습니다.");
}
// Kafka로 쿠폰 발급 요청 전송
couponProducer.sendCouponIssueRequest(
CouponDto.IssueMessage.builder()
.policyId(request.getCouponPolicyId())
.userId(UserIdInterceptor.getCurrentUserId())
.build()
);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CouponIssueException("쿠폰 발급 중 오류가 발생했습니다.");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
@Transactional
public void issueCoupon(CouponDto.IssueMessage message) {
try {
CouponPolicy policy = couponPolicyService.getCouponPolicy(message.getPolicyId());
if (policy == null) {
throw new IllegalArgumentException("쿠폰 정책을 찾을 수 없습니다.");
}
Coupon coupon = couponRepository.save(Coupon.builder()
.couponPolicy(policy)
.userId(message.getUserId())
.couponCode(generateCouponCode())
.build());
log.info("Coupon issued successfully: policyId={}, userId={}", message.getPolicyId(), message.getUserId());
} catch (Exception e) {
log.error("Failed to issue coupon: {}", e.getMessage());
throw e;
}
}
v2의 issueCoupon과 다른 점은
v2는 쿠폰을 발급할때 couponRepository에 coupon 정보만 저장하는데
v3는 kafka의 couponProducer의 sendCouponIssueRequest 메서드로 쿠폰 발급 요청을 전송한다.
다음은 CouponProducer이다.
@Component
@RequiredArgsConstructor
@Slf4j
public class CouponProducer {
private static final String TOPIC = "coupon-issue-requests";
private final KafkaTemplate<String, CouponDto.IssueMessage> kafkaTemplate;
public void sendCouponIssueRequest(CouponDto.IssueMessage message) {
// send 는 비동기로 Kafka 메시지를 보내고 결과를 확인하기 위해 whenComplete를 사용
// whenComplete 메서드는 비동기 작업이 성공하든 실패하든 상관없이, 작업이 끝나면 실행되는 콜백 함수
kafkaTemplate.send(TOPIC, String.valueOf(message.getPolicyId()), message)
.whenComplete((result, ex) -> {
if (ex == null) { // 예외가 없다면 (성공했다면)
log.info("Sent message=[{}] with offset=[{}]", message, result.getRecordMetadata().offset());
} else { // 예외가 발생하면
log.error("Unable to send message=[{}] due to : {}", message, ex.getMessage());
}
});
}
}
비동기로 kafka 메시지를 보내고 결과를 확인한다.
다음은 CouponConsumer 코드이다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CouponConsumer {
// 해당 메시지를 consume해서 실제 쿠폰을 발급하는 로직을 구현
private final CouponService couponService;
// CouponProducer에서 쿠폰 발급 요청에 대한 메시지를 쓰게 되고
// 이 메시지가 쓰여지면 CouponConsumer에서 해당 메시지를 읽어서(consume해서) issueCoupon을 호출한다.
@KafkaListener(topics = "coupon-issue-requests", groupId = "coupon-service", containerFactory = "couponKafkaListenerContainerFactory")
public void consumeCouponIssueRequest(CouponDto.IssueMessage message) {
try {
log.info("Received coupon issue request: {}", message);
couponService.issueCoupon(message);
} catch (Exception e) {
log.error("Failed to process coupon issue request: {}", e.getMessage(), e);
}
}
}
CouponProducer에서 쿠폰 발급 요청에 대한 메시지를 쓰게 되고
이 메시지가 쓰여지면 CouponConsumer에서 해당 메시지를 읽어서(consume해서) v3의 issueCoupon을 호출한다.
실제 사용자가 쿠폰 서비스에서 엄청나게 많은 requestCouponIssue를 요청을 받더라도
실제 쿠폰을 발급하는 로직에 대해서는 redis에서 빠르게 비즈니스로직을 처리하고 (redis를 이용해 수량을 체크하고 선점)
실제 db에 발급 처리하는 부분은 Consumer로 위임을 해서 순차적으로 db에 쌓이는 구조를 비동기 구조를 만들 수 있다.
그러므로 더 안정적인 순차 처리가 가능하다.
v3의 장점
쿠폰 발급 요청을 kafka 큐에 넣고 비동기적으로 처리하기 때문에 매우 높은 TPS(Transaction Per Second)를 지원한다.
database와 redis의 부하를 비동기적으로 분산을 시켜서 안정성을 높임
확장성이 뛰어나서 대규모 트래픽을 효과적으로 처리할 수 있다.
v3의 단점
시스템 리소스 사용량이 증가한다.
카프카를 중간에 넣음으로써 아키텍처가 복잡해지고 개발 및 운영의 부담이 증가할 수 있다.
메시지 요청 처리가 비동기로 이루어져서 요청을 즉시 처리하지 않기 때문에 쿠폰 발급 요청을 한 후로 실시간 처리에 한계가 있을 수 있다.
완벽한 실시간 확인이 아니기 때문에 발급 요청을 한 뒤에 정상적으로 발급이 되었는지 polling을 하는 비즈니스 로직도 필요할 수도 있다.
또한 카프카의 장애처리 메커니즘을 신중하게 설정해야해서 카프카 운영 노하우가 필요하다.
v3의 결론
대규모 서비스에 적합한 아키텍처이다.
실시간성이 절대적이지 않고 대량 요청을 효율적으로 처리해야하는 경우 사용하기 좋다.
대형 이커머스의 타임세일 시스템이나 대규모 이벤트 시스템 처럼 큰 시스템에 어울리는 구조이다.
결론
서비스의 목적과 상황을 고려해서 어떤 아키텍처를 구성할지 결정해야한다.