Tiny Star

프로젝트/쿠폰

[쿠폰] 쿠폰 생성 (@Transactional, CustomException)

하얀지 2025. 4. 28. 20:49

쿠폰 정보를 받아서 DB에 저장하고 Redis에도 올리는 작업을 했다. (자세한 내용은 Git Issue 참고)

 


 

1. 요구사항

1. 목적

  • 쿠폰 등록 시 DB와 Redis를 일관성 있게 관리하기 위해 등록 흐름을 명확히 분리한다.
  • 쿠폰 정보를 먼저 DB에 저장하고, 생성된 coupon_id를 기반으로 Redis에 재고를 등록하는 프로세스를 구현한다.

 

2. 작업 내용

  1. 쿠폰 종류 등록 API 구현
    POST /api/admin/coupons API 생성
    쿠폰 이름(name), 총 수량(total_quantity) 등을 받아 DB에 저장
    저장 후 생성된 coupon_id를 반환
  2. 쿠폰 재고 Redis 등록
    반환된 coupon_id를 사용하여 Redis에 coupon:{coupon_id}:stock으로 재고 등록
    필요 시 TTL(만료시간) 설정 기능 추가
  3. 흐름 통합
    쿠폰 등록 요청 시 DB 저장 + Redis 초기화를 하나의 트랜잭션성 흐름으로 관리
    DB 저장 실패 시 Redis 등록 취소
    Redis 등록 실패 시 DB 저장 롤백 고려 (향후 트랜잭션 적용 가능성 검토)

 

3. 요청/응답 예시

요청

POST /api/admin/coupons
{
  "name": "크리스마스 할인 쿠폰",
  "totalQuantity": 1000,
  "ttlSeconds": 86400
}

 

응답

{
  "couponId": 5,
  "message": "쿠폰 등록 및 재고 초기화 완료"
}

 

 

 

 

 

2. 작업 내용

// 초기 구상
public Long createCoupon(CreateCouponRequest request) {
    try {
        // 1. DB에 쿠폰 저장
        Coupons coupon = Coupons.builder()
                .name(request.getName())
                .totalQuantity(request.getTotalQuantity())
                .build();
        Coupons savedCoupon = couponsRepository.save(coupon);

        // 2. Redis에 재고 저장
        String key = COUPON_STOCK_PREFIX + savedCoupon.getId() + ":stock";
        if (request.getTtlSeconds() != null && request.getTtlSeconds() > 0) {
            redisTemplate.opsForValue().set(key, request.getTotalQuantity(), Duration.ofSeconds(request.getTtlSeconds()));
        } else {
            redisTemplate.opsForValue().set(key, request.getTotalQuantity());
        }
        return savedCoupon.getId();
    } catch (Exception e) {
        log.error("{} ERROR!", request.toString(), e);
    }
    return null;
}

처음에는 단순하게 DB 저장 >> Redis 저장만 하면 돼서 간단한 작업으로 생각했다.

하지만 Redis 에 저장 할 때 에러가 났을 경우, DB에 남아있으면 재등록 시 동일한 이름의 쿠폰이 생기기 때문에 '데이터 중복'이라고 판단했다.

 

 

 

@Transactional
public Long createCoupon(CreateCouponRequest request) {
    try {
        ...
    } catch (Exception e) {
        throw new RuntimeException(e.getMessage());
    }
}

그래서 @Transactional 을 사용해서 메서드 중간에 에러가 발생하면 롤백시키면 되겠다라고 생각했지만

앞으로 롤백할 메서드가 여러개 일텐데, 똑같이 'RuntimeException' 예외를 발생시키는게 맞을까란 고민이 들었다.

일단 에러 원인 파악이 어렵고, 예외를 관리하기 위해서 CustomException으로 관리하는게 좋을 것 같았다.

 

 

 

public enum ErrorCode {
    COUPON_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "쿠폰 생성에 실패했습니다.");

    private final HttpStatus status;
    private final String message;

    ErrorCode(HttpStatus status, String message) {
        this.status = status;
        this.message = message;
    }
}

public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
        ErrorCode errorCode = e.getErrorCode();
        ErrorResponse response = new ErrorResponse(
                errorCode.getStatus().value(),
                errorCode.name(),
                errorCode.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(errorCode.getStatus()).body(response);
    }
}

Enum으로 에러 코드를 관리하고, 별도의 예외를 생성해서 해당 포맷으로 에러메세지를 반환할 수 있도록 구성했다.

내부 직원들에게는 에러 코드를 공개해도 큰 문제는 없을 수 있으나, 클라이언트에게 자세한 에러 메세지를 반환할 수 없기 때문에 message는 별도 지정으로 했다.

 

 

 

// 최종 코드
@Transactional
public Long createCoupon(CreateCouponRequest request) {
    try {
        // 1. DB에 쿠폰 저장
        Coupons coupon = Coupons.builder()
                .name(request.getName())
                .totalQuantity(request.getTotalQuantity())
                .build();
        Coupons savedCoupon = couponsRepository.save(coupon);

        // 2. Redis에 재고 저장
        String key = COUPON_STOCK_PREFIX + savedCoupon.getId() + ":stock";
        if (request.getTtlSeconds() != null && request.getTtlSeconds() > 0) {
            redisTemplate.opsForValue().set(key, request.getTotalQuantity(), Duration.ofSeconds(request.getTtlSeconds()));
        } else {
            redisTemplate.opsForValue().set(key, request.getTotalQuantity());
        }
        return savedCoupon.getId();
    } catch (Exception e) {
        log.error("{} ERROR!", request.toString(), e);
        throw new CustomException(ErrorCode.COUPON_CREATION_FAILED);
    }
}

초기 구상과 코드가 큰 차이가 안나지만, 트랜잭션처리를 했고 에러 코드를 체계적으로 관리할 수 있게 되었다.

 

 

예외를 일부러 발생 시키면 DB에 아무것도 들어가지 않는다.

 

정상적으로 처리된 경우에만 DB에 들어가는 것을 확인했다.

 


 

 

그동안은 @Transactional 을 사용하지도 CustomException을 만들지 않았다.

예외를 발생시키기보단 예외처리하고 ErrorResponse를 반환하는 형식으로만 해봤었기 때문이다.

 

따로 return 처리를 안해줘도 되기 때문에 코드 중복이 줄어들어 정상 흐름만 깔끔하게 유지할 수 있게 되었다.

서비스 로직과 예외 처리 책임을 분리하는 게 좋을 것 같다.

top