허진혁Backend Developer
전체 프로젝트

2023.09 ~ 2024.01 / Backend

Givemeticon

만료가 임박한 기프티콘을 사고파는 중고거래 플랫폼

쿠폰의 재고 정합성과 인기순 조회의 성능을 개선했습니다.

Java · Spring Boot · MyBatis · MySQL · RedisGitHub

01 / CONSISTENCY · Redisson, MySQL

재고와 중복 발급 규칙을 DB까지 보호

문제동시 요청에서 재고를 초과하거나 같은 사용자에게 쿠폰을 중복 발급할 수 있었습니다.

경합 제어와 데이터 정합성의 역할 분리
BEFORE 조회 결과만으로 발급 가능 여부 판단
요청 A
남은 재고 1
발급 진행
요청 B
남은 재고 1
발급 진행

초과 발급 / 같은 사용자의 중복 발급 가능

AFTER Redis + MySQL
Redisson 분산락stockId 단위 진입 제어
트랜잭션 진입 · ZSet 순서 확인
DB에서 지키는 두 가지 규칙
조건부 재고 차감remain > 0
중복 발급 차단UNIQUE(user, stock)

변경 행 수 확인 · 실패 시 예외로 롤백

락이 풀려도 재고와 중복 발급 규칙은
DB에서 지켜져야 했습니다.

  • 경합 제어: stockId 기준 Redisson 분산락으로 같은 재고의 동시 진입을 제어했습니다.
  • 재고 보호: 조회 후 차감 사이의 경쟁을 없애기 위해 remain > 0인 경우에만 DB가 원자적으로 차감하게 했습니다.
  • 중복 보호: (user_id, stock_id) UNIQUE와 변경 행 수 확인으로 발급 성공을 판단했습니다.
Redis만으로 충분하지 않다고 본 이유

lease 만료·Redis 장애 등으로 락의 보호가 끊기는 경우에도 DB에는 잘못된 발급 결과가 남아서는 안 됩니다. Redis는 요청 경합을 제어하고, DB의 조건과 제약은 저장할 수 있는 데이터의 범위를 제한하도록 역할을 나눴습니다.

INSERT IGNORE가 중복을 무시했더라도 성공으로 처리하지 않습니다. 반환된 행 수가 1이 아니면 예외를 발생시켜 재고 차감도 함께 롤백되도록 구성했습니다.

SQL / Java · 핵심 발췌
UPDATE coupon_stock
SET remain = remain - 1
WHERE id = #{id} AND remain > 0;
int updatedRows = couponStockMapper.decreaseStockIfEnough(stockId);
if (updatedRows != 1) {
    throw new NotEnoughCouponStockException();
}
// UNIQUE(user_id, stock_id)를 가진 테이블에 INSERT IGNORE
int insertedRows = couponMapper.saveIfNotIssued(coupon);
if (insertedRows != 1) {
    throw new AlreadyIssuedCouponException();
}

재고·쿠폰 서비스의 검사 부분을 모았습니다. 락 획득 후 AopForTransaction이 발급 흐름을 REQUIRES_NEW로 감싸며, 예외 시 함께 롤백하는 구조입니다.

100개초기 재고 100개 → 발급 100개
0개최종 DB 잔여 재고
0건사용자별 중복 발급

로컬 애플리케이션 2대 환경에서 동시 요청 후 MySQL의 최종 발급 수·잔여 재고·중복 건수를 조회한 결과입니다.