과거에 대량 쿠폰 시스템을 기획 ~ 개발 진행했던 경험이 있습니다.

그런데, 최근 종종 보는 기술 관련 유튜브 영상에 비슷한 접근 & 지식을 공유해주시는 좋은 분(감사합니다.)이 있어서 메모 목적으로 글을 작성해둡니다.

 

  1. 영상 링크
  2. 내용을 간단히 정리한 내용(저도 과거 거의 유사한 요구사항으로 개발 진행)

요구사항 정리

 

간략한 개발 구체화 내용

 

 

데이터 설계

1) coupon_campaign (쿠폰 캠페인/정책 테이블)

쿠폰 발급의 기준이 되는 마스터 테이블입니다. 식별 코드(Prefix)와 혜택, 유효기간 등을 관리합니다. 이 테이블의 데이터는 Redis에 캐싱하여 조회 성능을 극대화하기 좋습니다.

컬럼명 데이터 타입 제약 조건 설명
id BIGINT PK, Auto Increment 캠페인 고유 ID
name VARCHAR(100) NOT NULL 캠페인명 (예: 2026 신규가입 이벤트)
prefix VARCHAR(5) NOT NULL, UNIQUE 쿠폰 식별 코드 (예: NEW26)
reward_type VARCHAR(20) NOT NULL 혜택 유형 (예: DISCOUNT_RATE, ITEM_GIFT)
reward_value INT NOT NULL 혜택 값 (예: 10(%), 5000(원))
total_limit INT NOT NULL 총 발급 가능 수량 (무제한일 경우 -1 등 규칙 정의)
valid_from DATETIME NOT NULL 유효기간 시작일시
valid_until DATETIME NOT NULL 유효기간 종료일시
status VARCHAR(20) NOT NULL 상태 (ACTIVE, PAUSED, ENDED)
created_at DATETIME NOT NULL 생성일시

 

 

2) coupon (쿠폰 마스터 테이블 - 대용량)

실제 발급된 25자리 쿠폰 번호들이 저장되는 테이블입니다. 수백만 건 이상이 적재되므로 인덱스 설계와 용량 최적화가 매우 중요합니다.

컬럼명 데이터 타입 제약 조건 설명
id BIGINT PK, Auto Increment 쿠폰 고유 ID
campaign_id BIGINT FK, NOT NULL 연관된 캠페인 ID
coupon_code VARCHAR(25) NOT NULL, UNIQUE 실제 쿠폰 번호 (25자리)
status TINYINT NOT NULL 상태 (0:발급, 1:사용됨, 2:만료, 3:폐기) 공간 절약을 위해 숫자형 권장
user_id BIGINT NULL 쿠폰을 소유/사용한 유저 ID (사용 시점에 업데이트)
used_at DATETIME NULL 사용 완료 일시
version INT NOT NULL, DEFAULT 0 [선택] 낙관적 락(Optimistic Lock)을 위한 버전 관리 컬럼
created_at DATETIME NOT NULL 발급일시

 

인덱스(Index) 고려사항:

  • idx_coupon_code (Unique): 사용자가 쿠폰 번호를 입력했을 때 가장 먼저 조회하는 조건이므로 단일 유니크 인덱스 필수.
  • idx_user_id_status: 특정 유저가 보유한 '사용 가능한' 쿠폰 목록을 조회할 때 복합 인덱스로 활용.

 

3) coupon_usage_history (쿠폰 사용 이력 테이블)

쿠폰 사용 상태가 변할 때마다 기록을 남기는 감사(Audit) 테이블입니다. 고객 CS 처리나 어뷰징 검증에 필수적입니다.

컬럼명 데이터 타입 제약 조건 설명
id BIGINT PK, Auto Increment 이력 고유 ID
coupon_id BIGINT NOT NULL 사용된 쿠폰 ID (FK 제약조건은 제거 권장)
user_id BIGINT NOT NULL 사용 시도한 유저 ID
transaction_id VARCHAR(50) NOT NULL 주문 번호 등 연관된 트랜잭션 식별자
action_type VARCHAR(20) NOT NULL 액션 (USE, CANCEL_USE)
status VARCHAR(20) NOT NULL 처리 결과 (SUCCESS, FAIL)
fail_reason VARCHAR(100) NULL 실패 시 사유 (예: "이미 사용된 쿠폰", "유효기간 만료")
created_at DATETIME NOT NULL 이벤트 발생 일시

 

설계 팁: 대용량 트래픽 환경에서는 이 테이블에 FK(Foreign Key) 제약 조건을 걸지 않는 것이 일반적입니다. FK는 삽입/수정 시 참조 무결성을 확인하느라 불필요한 DB 락(Lock)과 성능 저하를 유발할 수 있기 때문입니다. 논리적으로만 연결해 둡니다.

 

 

DB 설계 시 추가 고려사항 (성능 및 확장성)

  • 이력 테이블 파티셔닝 (Partitioning): coupon_usage_history 테이블은 데이터가 폭발적으로 증가합니다. created_at 기준 월별 또는 분기별로 Range Partitioning을 적용해 두면, 오래된 이력을 백업하거나 삭제(Drop)할 때 시스템 부하 없이 처리할 수 있습니다.
  • 상태 값(status) 최적화: coupon 테이블은 row 수가 엄청나게 많아지므로, 상태 값을 VARCHAR(예: 'USED', 'EXPIRED') 대신 TINYINT(0, 1, 2)로 설계하고 애플리케이션 단(Java Enum)에서 매핑하여 사용하는 것이 스토리지와 인덱스 용량 확보에 유리합니다.

 

 

대규모 트래픽이 발생하는 쿠폰 시스템에서 **'동시성 제어'**는 시스템의 신뢰성을 결정짓는 가장 중요한 요소입니다. 특히 한 명의 유저가 다중 디바이스로 동시에 쿠폰 사용을 요청하거나(Double-spending), 선착순 발급 시 제한된 수량보다 많이 발급되는(Over-issuance) 문제를 막기 위해 **Redis 분산 락(Distributed Lock)**은 필수적입니다.

 

Redisson을 활용한 AOP 기반 분산 락 설계


1. 왜 Redisson인가? (기본 Lettuce와의 차이)

Spring Boot의 기본 Redis 클라이언트인 Lettuce로 SETNX 명령어를 사용해 직접 락을 구현할 수도 있습니다. 하지만 락을 얻기 위해 지속적으로 Redis에 요청을 보내는 스핀 락(Spin Lock) 방식이라 Redis에 엄청난 부하를 줍니다. 반면 Redisson은 Pub/Sub 방식을 사용하여 락이 해제될 때 대기 중인 프로세스에 알림을 주므로 Redis 부하가 극히 적고, 타임아웃 처리 등 분산 락에 필요한 기능이 내장되어 있어 실무 표준으로 사용됩니다.

2. 분산 락 키(Key) 설계 (Lock Granularity)

락의 범위를 어떻게 잡느냐에 따라 병목이 생길 수도, 성능이 극대화될 수도 있습니다.

  • 쿠폰 발급 (선착순): lock:campaign:{campaign_id} (해당 캠페인 전체에 대한 락 - 발급 수량 카운트 보호용)
  • 쿠폰 사용 (단건): lock:coupon:{coupon_code} (개별 쿠폰 번호에 대한 락 - 중복 사용 방지용)

3. AOP 기반 분산 락 코드 구현 (Spring Boot 3 + Java 21)

비즈니스 로직에 락 제어 코드가 섞이면 유지보수가 매우 어려워집니다. 커스텀 어노테이션과 AOP를 분리하여 깔끔하게 설계하는 것을 강력히 권장합니다.

1) 커스텀 어노테이션 생성 (@DistributedLock)

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    
    /**
     * 락의 이름 (예: "coupon_use")
     */
    String key();

    /**
     * 락 획득을 위해 기다리는 시간 (기본값: 3초)
     */
    long waitTime() default 3L;

    /**
     * 락을 획득한 후 점유하는 최대 시간 (기본값: 5초)
     * 이 시간이 지나면 락은 자동 해제됨 (Deadlock 방지)
     */
    long leaseTime() default 5L;
}
      1.  

 

2) 분산 락 AOP 구현 (DistributedLockAspect)

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
@Order(1) // ★매우 중요: 트랜잭션 AOP보다 먼저 실행되어야 함
public class DistributedLockAspect {

    private final RedissonClient redissonClient;

    public DistributedLockAspect(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @Around("@annotation(DistributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        DistributedLock distributedLock = signature.getMethod().getAnnotation(DistributedLock.class);

        // 메서드 인자에서 동적인 값을 읽어와 락 키 생성 (SpEL 등을 활용하도록 고도화 가능)
        String lockKey = "lock:" + distributedLock.key();
        RLock rLock = redissonClient.getLock(lockKey);

        try {
            // 락 획득 시도 (waitTime 동안 대기, 획득 성공 시 leaseTime 만큼 유지)
            boolean isLocked = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), TimeUnit.SECONDS);
            if (!isLocked) {
                // 락 획득 실패 시 (동시 요청 발생) - 빠른 실패(Fail-fast) 처리
                throw new IllegalStateException("현재 처리 중인 요청입니다. 잠시 후 다시 시도해주세요.");
            }

            // 실제 비즈니스 로직(타겟 메서드) 실행
            return joinPoint.proceed();

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("락 획득 중 인터럽트가 발생했습니다.", e);
        } finally {
            // 락 해제 (현재 스레드가 락을 보유하고 있을 때만 해제)
            if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
                rLock.unlock();
            }
        }
    }
}
 

 

3) 비즈니스 로직에 적용 (CouponService)

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CouponService {

    private final CouponRepository couponRepository;
    private final CouponUsageHistoryRepository historyRepository;

    public CouponService(CouponRepository couponRepository, CouponUsageHistoryRepository historyRepository) {
        this.couponRepository = couponRepository;
        this.historyRepository = historyRepository;
    }

    // 쿠폰 코드를 락의 식별자로 사용하여, 같은 쿠폰에 대한 동시 접근 차단
    // (실무에서는 SpEL을 활용해 @DistributedLock(key = "'coupon:' + #couponCode") 형태로 고도화합니다)
    @DistributedLock(key = "coupon_use") 
    @Transactional
    public void useCoupon(String couponCode, Long userId) {
        // 1. 쿠폰 조회
        Coupon coupon = couponRepository.findByCouponCode(couponCode)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));

        // 2. 검증 (상태, 유효기간 등)
        if (coupon.getStatus() != 0) { // 0: 발급됨(사용가능)
            throw new IllegalStateException("이미 사용되었거나 만료된 쿠폰입니다.");
        }

        // 3. 쿠폰 상태 변경 (사용 처리)
        coupon.use(userId); 
        couponRepository.save(coupon);

        // 4. 이력 저장
        historyRepository.save(new CouponUsageHistory(coupon.getId(), userId, "USE", "SUCCESS"));
    }
}

 

4. 🚨 설계 시 가장 주의해야 할 핵심 

이 구조를 설계할 때 반드시 고려해야 하는 치명적인 이슈가 있습니다. 바로 "트랜잭션(Transaction)과 락(Lock)의 생명주기 불일치" 입니다.

  • 잘못된 예 (Deadlock 또는 Double Spending 발생): DB 트랜잭션이 아직 Commit 되지 않았는데 분산 락이 먼저 풀려버리는 경우입니다. 락이 풀렸으니 대기하던 '스레드 B'가 진입해서 DB를 읽는데, '스레드 A'가 아직 Commit을 안 했기 때문에 '스레드 B'는 쿠폰이 아직 "사용 가능"한 상태라고 판단하고 중복 사용을 해버립니다.
  • 해결책 (AOP Order 설정): 반드시 [락 획득 ➔ 트랜잭션 시작 ➔ 비즈니스 로직 ➔ 트랜잭션 커밋 ➔ 락 해제] 순서로 동작해야 합니다. 위 코드에서 @Order(1)을 DistributedLockAspect에 준 이유가 이것입니다. Spring의 @Transactional은 기본 우선순위가 낮으므로, Lock AOP가 트랜잭션 AOP를 감싸도록(Wrap) 설계해야 완벽하게 중복 사용을 막을 수 있습니다.

+ Recent posts