본문 바로가기
Research Notes (개인)

Redis + RDB 쿠폰 발급 예시 코드


전체 아키텍처

[쿠폰 발급 요청]
       │
       ▼
① Redis에서 재고 확인 + 차감 (Lua Script - 원자적)
       │
       ├── 재고 없음 → 발급 실패 반환
       │
       └── 재고 있음
              │
              ▼
       ② RDB에 발급 내역 저장 (Source of Truth)
              │
              ├── 실패 → Redis 재고 복구 (보상 트랜잭션)
              │
              └── 성공 → 발급 완료 반환

도메인 모델

// 쿠폰 템플릿 (발급 가능한 쿠폰 정보)
@Entity
@Table(name = "coupon_template")
@Getter
public class CouponTemplate {

    @Id @GeneratedValue
    private Long id;

    private String name;          // 쿠폰명
    private int    totalQuantity; // 총 발급 가능 수량
    private int    issuedQuantity;// 발급된 수량
    private int    discountAmount;// 할인 금액
    private LocalDateTime expiredAt; // 만료일

    public void issue() {
        if (this.issuedQuantity >= this.totalQuantity) {
            throw new IllegalStateException("쿠폰 재고 없음");
        }
        this.issuedQuantity++;
    }
}


// 발급된 쿠폰 (유저가 보유한 쿠폰)
@Entity
@Table(name = "user_coupon")
@Getter
public class UserCoupon {

    @Id @GeneratedValue
    private Long id;

    private Long   userId;
    private Long   couponTemplateId;
    private boolean used;
    private LocalDateTime issuedAt;
    private LocalDateTime usedAt;

    public static UserCoupon of(Long userId, Long couponTemplateId) {
        UserCoupon coupon = new UserCoupon();
        coupon.userId            = userId;
        coupon.couponTemplateId  = couponTemplateId;
        coupon.used              = false;
        coupon.issuedAt          = LocalDateTime.now();
        return coupon;
    }
}

Repository

public interface CouponTemplateRepository extends JpaRepository<CouponTemplate, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM CouponTemplate c WHERE c.id = :id")
    Optional<CouponTemplate> findByIdWithLock(@Param("id") Long id);
}

public interface UserCouponRepository extends JpaRepository<UserCoupon, Long> {

    boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId);
}

Redis 재고 관리

@Component
public class CouponStockRedisService {

    private final StringRedisTemplate redisTemplate;

    // Redis 키
    private String stockKey(Long couponId) {
        return "coupon:stock:" + couponId;
    }
    private String issuedSetKey(Long couponId) {
        return "coupon:issued:" + couponId;  // 발급된 유저 Set
    }

    // ============================================
    // 쿠폰 재고 초기화 (쿠폰 생성 시 호출)
    // ============================================
    public void initStock(Long couponId, int totalQuantity) {
        redisTemplate.opsForValue().set(
            stockKey(couponId),
            String.valueOf(totalQuantity)
        );
    }

    // ============================================
    // Lua Script: 원자적 재고 차감 + 중복 발급 방지
    // ============================================
    private static final String ISSUE_SCRIPT = """
        local stock_key  = KEYS[1]
        local issued_key = KEYS[2]
        local user_id    = ARGV[1]
                
        -- 중복 발급 확인
        if redis.call('SISMEMBER', issued_key, user_id) == 1 then
            return -1  -- 이미 발급됨
        end
                
        -- 재고 확인
        local stock = tonumber(redis.call('GET', stock_key))
        if not stock or stock <= 0 then
            return 0  -- 재고 없음
        end
                
        -- 재고 차감 + 발급 유저 등록 (원자적)
        redis.call('DECRBY',  stock_key,  1)
        redis.call('SADD',    issued_key, user_id)
                
        return 1  -- 발급 성공
        """;

    public IssueResult tryIssue(Long couponId, Long userId) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(ISSUE_SCRIPT, Long.class),
            List.of(stockKey(couponId), issuedSetKey(couponId)),
            String.valueOf(userId)
        );

        if (result == null) return IssueResult.FAILED;
        return switch (result.intValue()) {
            case  1 -> IssueResult.SUCCESS;
            case  0 -> IssueResult.OUT_OF_STOCK;
            case -1 -> IssueResult.ALREADY_ISSUED;
            default -> IssueResult.FAILED;
        };
    }

    // Redis 재고 롤백 (DB 저장 실패 시)
    public void rollbackStock(Long couponId, Long userId) {
        String rollbackScript = """
            redis.call('INCRBY', KEYS[1], 1)
            redis.call('SREM',   KEYS[2], ARGV[1])
            return 1
            """;

        redisTemplate.execute(
            new DefaultRedisScript<>(rollbackScript, Long.class),
            List.of(stockKey(couponId), issuedSetKey(couponId)),
            String.valueOf(userId)
        );
    }

    public int getStock(Long couponId) {
        String stock = redisTemplate.opsForValue().get(stockKey(couponId));
        return stock != null ? Integer.parseInt(stock) : 0;
    }

    public enum IssueResult {
        SUCCESS, OUT_OF_STOCK, ALREADY_ISSUED, FAILED
    }
}

핵심 서비스

@Service
@RequiredArgsConstructor
@Slf4j
public class CouponIssueService {

    private final CouponStockRedisService  redisService;
    private final CouponTemplateRepository couponTemplateRepository;
    private final UserCouponRepository     userCouponRepository;

    // ============================================
    // 쿠폰 발급 (Redis + RDB 연동)
    // ============================================
    public CouponIssueResponse issueCoupon(Long couponId, Long userId) {

        // ① Redis에서 원자적 재고 차감 (빠른 선착순 처리)
        CouponStockRedisService.IssueResult redisResult =
            redisService.tryIssue(couponId, userId);

        switch (redisResult) {
            case OUT_OF_STOCK  -> throw new CouponException("쿠폰 재고 없음");
            case ALREADY_ISSUED-> throw new CouponException("이미 발급된 쿠폰");
            case FAILED        -> throw new CouponException("발급 처리 실패");
        }

        // ② RDB에 발급 내역 저장 (Source of Truth)
        try {
            UserCoupon userCoupon = saveToDatabase(couponId, userId);
            log.info("쿠폰 발급 성공 - couponId: {}, userId: {}", couponId, userId);
            return CouponIssueResponse.success(userCoupon.getId());

        } catch (Exception e) {
            // ③ DB 실패 시 Redis 재고 롤백 (보상 트랜잭션)
            log.error("DB 저장 실패 - Redis 재고 롤백, couponId: {}, userId: {}",
                couponId, userId, e);
            redisService.rollbackStock(couponId, userId);
            throw new CouponException("쿠폰 발급 중 오류 발생");
        }
    }

    @Transactional
    protected UserCoupon saveToDatabase(Long couponId, Long userId) {
        // RDB 재고 차감 (DB도 정합성 유지)
        CouponTemplate template = couponTemplateRepository
            .findByIdWithLock(couponId)
            .orElseThrow(() -> new CouponException("쿠폰 없음"));

        template.issue();  // issuedQuantity++, 재고 검증

        // 발급 내역 저장
        return userCouponRepository.save(
            UserCoupon.of(userId, couponId)
        );
    }

    // ============================================
    // 쿠폰 생성 (재고 Redis 초기화 포함)
    // ============================================
    @Transactional
    public Long createCoupon(CreateCouponRequest request) {
        CouponTemplate template = couponTemplateRepository.save(
            CouponTemplate.builder()
                .name(request.getName())
                .totalQuantity(request.getQuantity())
                .discountAmount(request.getDiscountAmount())
                .expiredAt(request.getExpiredAt())
                .build()
        );

        // Redis 재고 초기화 (DB 저장 후)
        redisService.initStock(template.getId(), request.getQuantity());

        return template.getId();
    }
}

컨트롤러

@RestController
@RequestMapping("/api/coupons")
@RequiredArgsConstructor
public class CouponController {

    private final CouponIssueService couponIssueService;

    @PostMapping("/{couponId}/issue")
    public ResponseEntity<CouponIssueResponse> issueCoupon(
        @PathVariable Long couponId,
        @AuthenticationPrincipal Long userId
    ) {
        CouponIssueResponse response = couponIssueService.issueCoupon(couponId, userId);
        return ResponseEntity.ok(response);
    }

    @GetMapping("/{couponId}/stock")
    public ResponseEntity<Integer> getStock(@PathVariable Long couponId) {
        return ResponseEntity.ok(couponIssueService.getStock(couponId));
    }
}

동시성 테스트

@SpringBootTest
class CouponIssueServiceTest {

    @Autowired CouponIssueService    couponIssueService;
    @Autowired CouponStockRedisService redisService;
    @Autowired UserCouponRepository  userCouponRepository;

    @Test
    @DisplayName("동시에 100명이 요청해도 재고(10개)만큼만 발급")
    void concurrentIssueTest() throws InterruptedException {
        Long couponId    = 1L;
        int  stock       = 10;   // 재고 10개
        int  threadCount = 100;  // 동시 요청 100개

        // 재고 초기화
        redisService.initStock(couponId, stock);

        // 100개 스레드 동시 요청
        ExecutorService executor = Executors.newFixedThreadPool(32);
        CountDownLatch  latch    = new CountDownLatch(threadCount);

        AtomicInteger success     = new AtomicInteger(0);
        AtomicInteger outOfStock  = new AtomicInteger(0);

        for (long userId = 1; userId <= threadCount; userId++) {
            final long uid = userId;
            executor.submit(() -> {
                try {
                    couponIssueService.issueCoupon(couponId, uid);
                    success.incrementAndGet();
                } catch (CouponException e) {
                    if (e.getMessage().contains("재고")) {
                        outOfStock.incrementAndGet();
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(10, TimeUnit.SECONDS);

        System.out.println("발급 성공: " + success.get());      // 10
        System.out.println("재고 없음: " + outOfStock.get());   // 90

        // 검증
        assertThat(success.get()).isEqualTo(stock);             // ✅ 정확히 10개
        assertThat(userCouponRepository.count()).isEqualTo(10); // ✅ DB도 10개
    }

    @Test
    @DisplayName("같은 유저가 여러 번 요청해도 1개만 발급")
    void duplicateIssueTest() {
        Long couponId = 1L;
        Long userId   = 1L;
        redisService.initStock(couponId, 100);

        // 같은 유저 5번 요청
        for (int i = 0; i < 5; i++) {
            try {
                couponIssueService.issueCoupon(couponId, userId);
            } catch (CouponException ignored) {}
        }

        // DB에 1개만 저장됨
        long count = userCouponRepository
            .countByUserIdAndCouponTemplateId(userId, couponId);
        assertThat(count).isEqualTo(1);  // ✅ 중복 발급 없음
    }
}

전체 흐름 요약

┌─────────────────────────────────────────────────────────────┐
│                    쿠폰 발급 흐름                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  [요청]                                                      │
│     │                                                       │
│  ① Lua Script (Redis) - 원자적 처리                         │
│     ├── 중복 발급 확인 (SISMEMBER)                          │
│     ├── 재고 확인     (GET stock)                           │
│     ├── 재고 차감     (DECRBY 1)                            │
│     └── 발급자 등록   (SADD issued)                         │
│     │                                                       │
│  ② RDB 저장 - Source of Truth                              │
│     ├── 성공 → 발급 완료                                    │
│     └── 실패 → Redis 롤백 (INCRBY + SREM)                  │
│                                                             │
│  핵심 전제:                                                  │
│    Redis = 빠른 선착순 처리 (μs 단위)                       │
│    RDB   = 실제 데이터 보장 (Source of Truth)               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

네, 정확히 짚으셨습니다


문제 상황

redis.call('DECRBY',  stock_key,  1)  -- ✅ 성공 (재고 차감됨)
redis.call('SADD',    issued_key, user_id)  -- ❌ 실패!

결과:
  재고: 10 → 9 (차감됨)
  발급 유저 Set: user_id 없음 (등록 안 됨)

문제:
  유저는 발급 못 받았는데 재고만 줄어든 상태
  → 유저가 다시 요청하면?
     SISMEMBER → 0 (발급 안 됐다고 나옴)
     재고 차감 또 실행 → 재고 2개 소모됐는데 1개만 발급

SADD가 실패할 수 있는 상황

1. 메모리 부족 (OOM)
   Redis maxmemory 초과 시

2. 키 타입 충돌
   issued_key가 Set이 아닌 다른 타입으로 이미 존재

3. Redis 내부 오류
   극히 드물지만 가능

해결: pcall로 실패 감지 + DECRBY 롤백

local ISSUE_SCRIPT = """
    local stock_key  = KEYS[1]
    local issued_key = KEYS[2]
    local user_id    = ARGV[1]

    -- 중복 발급 확인
    if redis.call('SISMEMBER', issued_key, user_id) == 1 then
        return -1  -- 이미 발급됨
    end

    -- 재고 확인
    local stock = tonumber(redis.call('GET', stock_key))
    if not stock or stock <= 0 then
        return 0  -- 재고 없음
    end

    -- ① 재고 차감
    redis.call('DECRBY', stock_key, 1)

    -- ② SADD를 pcall로 실행 (에러 캡처)
    local ok = redis.pcall('SADD', issued_key, user_id)

    -- ③ SADD 실패 시 → DECRBY 롤백
    if ok['err'] then
        redis.call('INCRBY', stock_key, 1)  -- 재고 원복
        return -2  -- 오류
    end

    return 1  -- 발급 성공
"""

더 안전한 방법: 순서 변경 (SADD 먼저)

local ISSUE_SCRIPT = """
    local stock_key  = KEYS[1]
    local issued_key = KEYS[2]
    local user_id    = ARGV[1]

    -- 재고 확인
    local stock = tonumber(redis.call('GET', stock_key))
    if not stock or stock <= 0 then
        return 0
    end

    -- ① SADD 먼저 (중복 발급 방지 + 선점)
    local added = redis.call('SADD', issued_key, user_id)
    if added == 0 then
        return -1  -- 이미 발급됨
    end

    -- ② SADD 성공 후 DECRBY (pcall로 실패 감지)
    local ok = redis.pcall('DECRBY', stock_key, 1)

    -- ③ DECRBY 실패 시 → SADD 롤백
    if ok['err'] then
        redis.call('SREM', issued_key, user_id)  -- 선점 취소
        return -2
    end

    return 1
"""

두 방법의 실패 시나리오 비교

[DECRBY → SADD 순서]

DECRBY 성공 → SADD 실패:
  재고 차감됨, 유저 미등록
  → 유저 재시도 가능 → 재고 중복 차감 위험 ❌

pcall 롤백 적용 시:
  DECRBY 성공 → SADD 실패 → INCRBY로 재고 복구
  → 유저 재시도 가능, 재고 정상 ✅


[SADD → DECRBY 순서]

SADD 성공 → DECRBY 실패:
  유저 등록됨, 재고 미차감
  → 유저 재시도 불가 (이미 발급된 것으로 처리) ❌
  → 하지만 재고는 보존됨

pcall 롤백 적용 시:
  SADD 성공 → DECRBY 실패 → SREM으로 유저 등록 취소
  → 유저 재시도 가능, 재고 정상 ✅

결론: 어떤 방법을 선택할까

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  가장 안전한 방법:                                       │
│    pcall + 롤백 패턴 적용                               │
│    (어느 순서든 실패 시 원상복구)                        │
│                                                         │
│  현실적 관점:                                            │
│    SADD 실패는 매우 드문 케이스                          │
│    하지만 쿠폰처럼 금전 가치 있는 데이터는               │
│    반드시 방어 코드 필요                                 │
│                                                         │
│  Redis만으로 완전한 보장 불가                            │
│    → DB 저장까지 완료되어야 "진짜 발급"                  │
│    → Redis 성공 = 선착순 선점                           │
│    → DB 성공 = 실제 발급 확정                           │
│                                                         │
└─────────────────────────────────────────────────────────┘

couponId는 어디서 오는가?


흐름 전체로 보면

① 관리자가 쿠폰 생성
       │
       └→ DB에 저장 → Auto Increment로 ID 생성
          Redis에 재고 초기화
       │
       ↓
② 생성된 couponId가 DB에 저장됨

③ 유저가 발급 요청 시
       └→ URL 또는 이벤트 페이지에서 couponId를 가져옴

코드 흐름

1단계: 쿠폰 생성 (관리자)

// 관리자가 쿠폰 생성 요청
POST /api/admin/coupons
{
    "name": "신규 가입 10% 할인",
    "quantity": 100,
    "discountAmount": 1000,
    "expiredAt": "2026-12-31"
}

@Transactional
public Long createCoupon(CreateCouponRequest request) {

    // DB 저장 → 여기서 couponId 자동 생성 (Auto Increment)
    CouponTemplate template = couponTemplateRepository.save(
        CouponTemplate.builder()
            .name(request.getName())
            .totalQuantity(request.getQuantity())
            ...
            .build()
    );

    // template.getId() = DB가 생성한 couponId (ex: 42)

    // Redis 재고 초기화
    redisService.initStock(template.getId(), request.getQuantity());
    //                      ↑ couponId = 42

    return template.getId();  // 42 반환
}

// 응답:
// { "couponId": 42 }

2단계: 유저가 발급 요청

// 유저가 이벤트 페이지에서 발급 버튼 클릭
// URL: POST /api/coupons/42/issue
//                         ↑ couponId = 42 (URL에서 옴)

@PostMapping("/{couponId}/issue")
public ResponseEntity<CouponIssueResponse> issueCoupon(
    @PathVariable Long couponId,       // URL에서 추출
    @AuthenticationPrincipal Long userId
) {
    CouponIssueResponse response =
        couponIssueService.issueCoupon(couponId, userId);
    return ResponseEntity.ok(response);
}

전체 흐름 정리

[관리자]
  쿠폰 생성 요청
       │
       ▼
  DB Auto Increment → couponId = 42 생성
  Redis "coupon:stock:42" = 100 초기화
       │
       ▼
  couponId = 42 을 이벤트 페이지에 등록


[유저]
  이벤트 페이지 접속
  → "쿠폰 발급받기" 버튼 클릭
  → POST /api/coupons/42/issue   ← couponId = 42 (URL)
       │
       ▼
  Redis: "coupon:stock:42" 재고 차감
  DB:    user_coupon 테이블에 발급 내역 저장

한 줄 요약

couponId = 관리자가 쿠폰 생성 시 DB가 자동 발급한 PK
           유저는 이벤트 페이지 URL에서 해당 ID로 발급 요청

부분 실패 고려한 쿠폰 발급 전체 코드


전체 아키텍처

[쿠폰 발급 요청]
       │
       ▼
① Lua Script (pcall + 롤백)
   - SADD 먼저 (선점)
   - DECRBY 후 (차감)
   - 각 단계 실패 시 이전 단계 롤백
       │
       ├── 실패 → 즉시 반환 (Redis 자체 롤백 완료)
       │
       └── 성공
              │
              ▼
       ② RDB 저장
              │
              ├── 실패 → Redis 보상 트랜잭션 (SREM + INCRBY)
              │
              └── 성공 → 발급 완료

도메인 모델

// ============================================
// CouponTemplate.java
// ============================================
@Entity
@Table(name = "coupon_template")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CouponTemplate {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String        name;
    private int           totalQuantity;
    private int           issuedQuantity;
    private int           discountAmount;
    private LocalDateTime expiredAt;

    public void issue() {
        if (this.issuedQuantity >= this.totalQuantity) {
            throw new CouponException("쿠폰 재고 없음 (DB)");
        }
        this.issuedQuantity++;
    }
}


// ============================================
// UserCoupon.java
// ============================================
@Entity
@Table(name = "user_coupon",
    uniqueConstraints = @UniqueConstraint(
        columnNames = {"user_id", "coupon_template_id"}  // DB 레벨 중복 방지
    )
)
@Getter
@NoArgsConstructor
public class UserCoupon {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "coupon_template_id")
    private Long couponTemplateId;

    private boolean       used;
    private LocalDateTime issuedAt;
    private LocalDateTime usedAt;

    public static UserCoupon of(Long userId, Long couponTemplateId) {
        UserCoupon coupon    = new UserCoupon();
        coupon.userId           = userId;
        coupon.couponTemplateId = couponTemplateId;
        coupon.used             = false;
        coupon.issuedAt         = LocalDateTime.now();
        return coupon;
    }
}


// ============================================
// CouponException.java
// ============================================
public class CouponException extends RuntimeException {
    private final CouponErrorCode errorCode;

    public CouponException(CouponErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public CouponErrorCode getErrorCode() { return errorCode; }
}

public enum CouponErrorCode {
    OUT_OF_STOCK    ("쿠폰 재고 없음"),
    ALREADY_ISSUED  ("이미 발급된 쿠폰"),
    REDIS_ERROR     ("Redis 처리 오류"),
    DB_ERROR        ("DB 처리 오류"),
    NOT_FOUND       ("쿠폰 없음");

    private final String message;
    CouponErrorCode(String message) { this.message = message; }
    public String getMessage()      { return message; }
}

Repository

// ============================================
// CouponTemplateRepository.java
// ============================================
public interface CouponTemplateRepository
    extends JpaRepository<CouponTemplate, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM CouponTemplate c WHERE c.id = :id")
    Optional<CouponTemplate> findByIdWithLock(@Param("id") Long id);
}


// ============================================
// UserCouponRepository.java
// ============================================
public interface UserCouponRepository
    extends JpaRepository<UserCoupon, Long> {

    boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId);
    long    countByUserIdAndCouponTemplateId (Long userId, Long couponTemplateId);
}

Redis 재고 관리 (핵심 - 부분 실패 처리 포함)

// ============================================
// CouponStockRedisService.java
// ============================================
@Component
@Slf4j
@RequiredArgsConstructor
public class CouponStockRedisService {

    private final StringRedisTemplate redisTemplate;

    // Redis 키
    private String stockKey (Long couponId) { return "coupon:stock:"  + couponId; }
    private String issuedKey(Long couponId) { return "coupon:issued:" + couponId; }

    // ============================================
    // 발급 Lua Script (부분 실패 처리 포함)
    // ============================================
    private static final String ISSUE_SCRIPT = """
        local stock_key  = KEYS[1]
        local issued_key = KEYS[2]
        local user_id    = ARGV[1]

        -- ① 재고 확인
        local stock = tonumber(redis.call('GET', stock_key))
        if not stock or stock <= 0 then
            return 0   -- 재고 없음
        end

        -- ② SADD 먼저 (중복 방지 + 선점)
        --    pcall: 실패해도 스크립트 중단 없이 에러 캡처
        local sadd_result = redis.pcall('SADD', issued_key, user_id)

        if sadd_result['err'] then
            -- SADD 자체 오류 (타입 충돌, OOM 등)
            return -2  -- Redis 오류
        end

        if sadd_result == 0 then
            return -1  -- 이미 발급됨 (Set에 이미 존재)
        end

        -- ③ DECRBY (재고 차감)
        local decrby_result = redis.pcall('DECRBY', stock_key, 1)

        if decrby_result['err'] then
            -- DECRBY 실패 → SADD 롤백 (선점 취소)
            redis.call('SREM', issued_key, user_id)
            return -2  -- Redis 오류
        end

        -- ④ 모든 단계 성공
        return 1
        """;

    // ============================================
    // 발급 시도
    // ============================================
    public IssueResult tryIssue(Long couponId, Long userId) {
        try {
            Long result = redisTemplate.execute(
                new DefaultRedisScript<>(ISSUE_SCRIPT, Long.class),
                List.of(stockKey(couponId), issuedKey(couponId)),
                String.valueOf(userId)
            );

            if (result == null) {
                log.error("Lua Script 반환값 null - couponId: {}, userId: {}",
                    couponId, userId);
                return IssueResult.REDIS_ERROR;
            }

            return switch (result.intValue()) {
                case  1 -> IssueResult.SUCCESS;
                case  0 -> IssueResult.OUT_OF_STOCK;
                case -1 -> IssueResult.ALREADY_ISSUED;
                case -2 -> IssueResult.REDIS_ERROR;
                default -> IssueResult.REDIS_ERROR;
            };

        } catch (Exception e) {
            log.error("Redis 발급 처리 중 예외 - couponId: {}, userId: {}",
                couponId, userId, e);
            return IssueResult.REDIS_ERROR;
        }
    }

    // ============================================
    // DB 실패 시 Redis 보상 트랜잭션
    // ============================================
    private static final String ROLLBACK_SCRIPT = """
        local stock_key  = KEYS[1]
        local issued_key = KEYS[2]
        local user_id    = ARGV[1]

        -- SREM + INCRBY 원자적 롤백
        local srem_result = redis.pcall('SREM',   issued_key, user_id)
        local incr_result = redis.pcall('INCRBY', stock_key,  1)

        if srem_result['err'] or incr_result['err'] then
            return -1  -- 롤백 실패
        end

        return 1  -- 롤백 성공
        """;

    public boolean rollback(Long couponId, Long userId) {
        try {
            Long result = redisTemplate.execute(
                new DefaultRedisScript<>(ROLLBACK_SCRIPT, Long.class),
                List.of(stockKey(couponId), issuedKey(couponId)),
                String.valueOf(userId)
            );

            if (Long.valueOf(1L).equals(result)) {
                log.info("Redis 롤백 성공 - couponId: {}, userId: {}",
                    couponId, userId);
                return true;
            }

            log.error("Redis 롤백 실패 - couponId: {}, userId: {}",
                couponId, userId);
            return false;

        } catch (Exception e) {
            log.error("Redis 롤백 중 예외 - couponId: {}, userId: {}",
                couponId, userId, e);
            return false;
        }
    }

    // 재고 초기화
    public void initStock(Long couponId, int quantity) {
        redisTemplate.opsForValue().set(stockKey(couponId), String.valueOf(quantity));
    }

    // 재고 조회
    public int getStock(Long couponId) {
        String stock = redisTemplate.opsForValue().get(stockKey(couponId));
        return stock != null ? Integer.parseInt(stock) : 0;
    }

    public enum IssueResult {
        SUCCESS, OUT_OF_STOCK, ALREADY_ISSUED, REDIS_ERROR
    }
}

핵심 서비스

// ============================================
// CouponIssueService.java
// ============================================
@Service
@Slf4j
@RequiredArgsConstructor
public class CouponIssueService {

    private final CouponStockRedisService  redisService;
    private final CouponTemplateRepository couponTemplateRepository;
    private final UserCouponRepository     userCouponRepository;

    // ============================================
    // 쿠폰 발급
    // ============================================
    public CouponIssueResponse issueCoupon(Long couponId, Long userId) {

        // ① Redis 원자적 처리 (선착순 선점)
        CouponStockRedisService.IssueResult redisResult =
            redisService.tryIssue(couponId, userId);

        switch (redisResult) {
            case OUT_OF_STOCK   -> throw new CouponException(CouponErrorCode.OUT_OF_STOCK);
            case ALREADY_ISSUED -> throw new CouponException(CouponErrorCode.ALREADY_ISSUED);
            case REDIS_ERROR    -> throw new CouponException(CouponErrorCode.REDIS_ERROR);
        }

        // ② RDB 저장 (Source of Truth)
        try {
            UserCoupon userCoupon = saveToDatabase(couponId, userId);
            log.info("쿠폰 발급 완료 - couponId: {}, userId: {}, userCouponId: {}",
                couponId, userId, userCoupon.getId());
            return CouponIssueResponse.success(userCoupon.getId());

        } catch (Exception e) {
            // ③ DB 실패 → Redis 보상 트랜잭션
            log.error("DB 저장 실패 → Redis 롤백 시작 - couponId: {}, userId: {}",
                couponId, userId, e);

            boolean rollbackSuccess = redisService.rollback(couponId, userId);

            if (!rollbackSuccess) {
                // 롤백도 실패 → 수동 처리 필요
                // (재고는 줄었는데 DB도 없고 Redis 롤백도 실패한 상태)
                log.error("!!! Redis 롤백 실패 - 수동 확인 필요 !!!" +
                    " couponId: {}, userId: {}", couponId, userId);
                // 알람 발송, 별도 복구 테이블에 기록 등
                saveRollbackFailureLog(couponId, userId);
            }

            throw new CouponException(CouponErrorCode.DB_ERROR);
        }
    }

    // ============================================
    // DB 저장 (트랜잭션)
    // ============================================
    @Transactional
    protected UserCoupon saveToDatabase(Long couponId, Long userId) {

        // DB 레벨 중복 방지 (최후 방어선)
        if (userCouponRepository.existsByUserIdAndCouponTemplateId(userId, couponId)) {
            throw new CouponException(CouponErrorCode.ALREADY_ISSUED);
        }

        // 비관적 락으로 재고 차감
        CouponTemplate template = couponTemplateRepository
            .findByIdWithLock(couponId)
            .orElseThrow(() -> new CouponException(CouponErrorCode.NOT_FOUND));

        template.issue();  // DB 재고 검증 + 차감

        return userCouponRepository.save(UserCoupon.of(userId, couponId));
    }

    // ============================================
    // 쿠폰 생성
    // ============================================
    @Transactional
    public Long createCoupon(CreateCouponRequest request) {
        CouponTemplate template = couponTemplateRepository.save(
            CouponTemplate.builder()
                .name(request.getName())
                .totalQuantity(request.getQuantity())
                .discountAmount(request.getDiscountAmount())
                .expiredAt(request.getExpiredAt())
                .build()
        );

        // DB 저장 후 Redis 초기화
        redisService.initStock(template.getId(), request.getQuantity());
        log.info("쿠폰 생성 완료 - couponId: {}, quantity: {}",
            template.getId(), request.getQuantity());

        return template.getId();
    }

    // 롤백 실패 로그 저장 (수동 복구용)
    private void saveRollbackFailureLog(Long couponId, Long userId) {
        // 별도 테이블에 기록하거나 알림 발송
        log.error("[ROLLBACK_FAILURE] couponId={}, userId={}, time={}",
            couponId, userId, LocalDateTime.now());
    }

    public int getStock(Long couponId) {
        return redisService.getStock(couponId);
    }
}

DTO

// ============================================
// DTO
// ============================================
@Getter @Builder
public class CreateCouponRequest {
    private String        name;
    private int           quantity;
    private int           discountAmount;
    private LocalDateTime expiredAt;
}

@Getter @Builder
public class CouponIssueResponse {
    private Long    userCouponId;
    private boolean success;
    private String  message;

    public static CouponIssueResponse success(Long userCouponId) {
        return CouponIssueResponse.builder()
            .userCouponId(userCouponId)
            .success(true)
            .message("발급 완료")
            .build();
    }
}

컨트롤러

// ============================================
// CouponController.java
// ============================================
@RestController
@RequestMapping("/api/coupons")
@RequiredArgsConstructor
@Slf4j
public class CouponController {

    private final CouponIssueService couponIssueService;

    // 쿠폰 발급
    @PostMapping("/{couponId}/issue")
    public ResponseEntity<CouponIssueResponse> issueCoupon(
        @PathVariable Long couponId,
        @AuthenticationPrincipal Long userId
    ) {
        return ResponseEntity.ok(
            couponIssueService.issueCoupon(couponId, userId)
        );
    }

    // 재고 조회
    @GetMapping("/{couponId}/stock")
    public ResponseEntity<Integer> getStock(@PathVariable Long couponId) {
        return ResponseEntity.ok(couponIssueService.getStock(couponId));
    }
}

// 전역 예외 처리
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CouponException.class)
    public ResponseEntity<Map<String, String>> handleCouponException(
        CouponException e
    ) {
        return ResponseEntity
            .badRequest()
            .body(Map.of(
                "error",   e.getErrorCode().name(),
                "message", e.getMessage()
            ));
    }
}

동시성 테스트

@SpringBootTest
class CouponIssueServiceTest {

    @Autowired CouponIssueService      couponIssueService;
    @Autowired CouponStockRedisService redisService;
    @Autowired UserCouponRepository    userCouponRepository;

    @Test
    @DisplayName("동시 100명 요청 → 재고 10개만 발급")
    void concurrentTest() throws InterruptedException {
        Long couponId = 1L;
        redisService.initStock(couponId, 10);

        ExecutorService executor = Executors.newFixedThreadPool(32);
        CountDownLatch  latch    = new CountDownLatch(100);
        AtomicInteger   success  = new AtomicInteger(0);
        AtomicInteger   failed   = new AtomicInteger(0);

        for (long userId = 1; userId <= 100; userId++) {
            final long uid = userId;
            executor.submit(() -> {
                try {
                    couponIssueService.issueCoupon(couponId, uid);
                    success.incrementAndGet();
                } catch (CouponException e) {
                    failed.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(30, TimeUnit.SECONDS);

        System.out.println("성공: " + success.get()); // 10
        System.out.println("실패: " + failed.get());  // 90

        assertThat(success.get()).isEqualTo(10);
        assertThat(userCouponRepository.count()).isEqualTo(10);
        assertThat(redisService.getStock(couponId)).isEqualTo(0);
    }
}

부분 실패 처리 요약

┌─────────────────────────────────────────────────────────────┐
│               부분 실패 시나리오별 처리                       │
├──────────────────────────┬──────────────────────────────────┤
│ SADD 실패                │ DECRBY 실행 안 됨                │
│                          │ → 그냥 오류 반환 (재고 영향 없음) │
├──────────────────────────┼──────────────────────────────────┤
│ SADD 성공 → DECRBY 실패  │ SREM으로 SADD 롤백              │
│                          │ → 유저 재시도 가능               │
├──────────────────────────┼──────────────────────────────────┤
│ Redis 성공 → DB 실패     │ SREM + INCRBY 보상 트랜잭션      │
│                          │ → Redis 원상복구                 │
├──────────────────────────┼──────────────────────────────────┤
│ Redis 성공 → DB 실패     │ 알람 발송                        │
│ → Redis 롤백도 실패      │ 복구 로그 기록                   │
│                          │ 수동 처리                        │
└──────────────────────────┴──────────────────────────────────┘