[쿠폰 발급 요청]
│
▼
① 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()
));
}
}