스프링 커머스 백엔드를 공부하면서 결제/환불 시스템에서 발생할 수 있는 동시성 문제와
그 해결 방법들을 정리했다.
두 요청이 거의 동시에 재고를 읽으면 둘 다 "재고 있음"으로 판단하고 차감할 수 있다.
결과적으로 재고가 음수가 되거나 포인트가 두 번 차감되는 문제가 생긴다.
같은 요청이 짧은 시간 안에 두 번 들어오면 결제가 두 번 처리될 수 있다.
단순한 중복 요청 방지를 넘어, 네트워크 오류로 인한 재시도 상황에서
"이 요청이 이미 처리됐는가?"를 판단하는 멱등성(Idempotency) 문제이기도 하다.
멱등성 키(Idempotency Key): 클라이언트가 요청 시 UUID 같은 고유 키를 같이 보내고,
서버는 이 키로 이미 처리된 요청인지 확인한다. 더블클릭이든 네트워크 재시도든
같은 키면 같은 결과를 돌려주고 중복 처리하지 않는다.중요한 점은 서버가 이 키를 영속적으로 저장하고(예: idempotency 테이블),
Unique 제약(Unique Index) 으로 중복 처리를 막은 뒤,
중복 요청이 오면 이전에 저장한 결과(결제ID/환불ID/상태) 를 그대로 반환하는 방식으로 구현해야 한다.
10명 제한 쿠폰에 동시에 100명이 요청하면, 모두 "아직 10명 안 됐다"고 읽고
쿠폰을 발급하여 10장이 훨씬 넘어버릴 수 있다.
세 문제 모두 같은 패턴을 가진다.
읽기(Read) → 수정하기(Modify) → DB 저장하기(Write)
이 흐름에서 읽은 시점과 쓰는 시점 사이에 다른 스레드가 끼어들어 값을 바꾸기 때문에 문제가 생긴다.
이를 TOCTOU (Time Of Check To Time Of Use) 문제라고 한다.
예시:
Thread A: 재고 읽기 → 재고 10개 확인
Thread B: 재고 읽기 → 재고 10개 확인
Thread A: 재고 9개로 저장 (차감)
Thread B: 재고 9개로 저장 (차감) ← 이미 A가 차감했지만 반영 안 됨
결과: 실제 재고는 8개여야 하는데 9개로 저장됨 (갱신 손실 / 중복 처리)
"어차피 충돌 날 거니까, 처음부터 잠가버리자"
DB에서 SELECT FOR UPDATE로 레코드를 잠근다.
트랜잭션이 끝날 때까지 다른 트랜잭션은 대기하게 된다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Optional<Stock> findByIdWithLock(@Param("id") Long id);
}
@Transactional
public void decrease(Long stockId, int quantity) {
Stock stock = stockRepository.findByIdWithLock(stockId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 재고"));
stock.decrease(quantity);
}
언제 쓸까? 재고 차감, 포인트 차감처럼 충돌이 자주 발생하는 곳
단점: 데드락 위험, 동시 요청이 많을수록 병목 발생(락 대기/타임아웃도 고려 필요)
"충돌이 거의 없을 테니까, 저장할 때 버전으로 확인하자"
엔티티에 @Version 필드를 추가한다.
저장 시점에 버전이 달라져 있으면 OptimisticLockException을 던지고 재시도한다.
아래 예시는 관리자가 상품 정보를 수정하는 상황처럼, 같은 데이터를 동시에 수정할 가능성이 낮은 케이스에 적합하다.
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
@Version
private Long version; // 충돌 감지용 버전 컬럼
private String name;
private int price;
public void changeInfo(String name, int price) {
this.name = name;
this.price = price;
}
}
@Retryable(
value = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public void changeProductInfo(Long productId, String name, int price) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.changeInfo(name, price);
}
언제 쓸까? 상품 정보 수정, 게시글처럼 충돌이 드문 곳
단점: 충돌이 많으면 재시도가 늘어나 오히려 성능 저하
"서버가 여러 대여도 Redis라는 단일 문을 만들어 한 명씩만 들어오게 하자"
단일 서버/단일 DB라면 대부분의 동시성 문제는 DB 트랜잭션/락만으로도 해결할 수 있다.
다만 추후 서버를 여러 대로 늘리거나(수평 확장), 임계영역을 DB 밖 로직까지 포함해 통제해야 하는 상황을 대비해
Redis 기반 분산 락을 적용해보는 방식도 있다.
Redis의 Redisson 라이브러리로 분산 락을 구현한다.
@Component
@RequiredArgsConstructor
public class CouponService {
private final RedissonClient redissonClient;
private final CouponRepository couponRepository;
public void issueCoupon(Long eventId, Long userId) {
String lockKey = "coupon:lock:" + eventId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 예시: 최대 5초 대기, 락 점유 후 leaseTime(여기서는 3초) 지나면 자동 해제
boolean acquired = lock.tryLock(5, 3, TimeUnit.SECONDS);
if (!acquired) {
throw new IllegalStateException("잠시 후 다시 시도해주세요.");
}
Coupon coupon = couponRepository.findByEventId(eventId)
.orElseThrow();
if (coupon.isExhausted()) {
throw new IllegalStateException("쿠폰이 모두 소진되었습니다.");
}
coupon.issue(userId);
couponRepository.save(coupon);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
언제 쓸까? 선착순 쿠폰, 재고처럼 “한 번에 하나만 처리”를 강하게 보장하고 싶은 곳(학습/확장 포함)
단점: Redis 인프라 추가 필요, Redis 장애 시 단일 장애점(SPOF)이 될 수 있음
주의:
leaseTime(여기서는 3초)이 너무 짧으면 처리 시간이 길어질 때 락이 먼저 풀려 동시 실행이 발생할 수 있다.
또한 락을 잡은 채로 외부 결제사(예: PortOne) 호출을 길게 수행하면 락 점유 시간이 커져 병목/장애 전파가 커질 수 있으니, 임계영역을 최소화하는 것이 중요하다.
| 상황 | 추천 전략 |
|---|---|
| 단일 서버 + 충돌 빈번 (재고, 포인트) | 비관적 락 |
| 단일 서버 + 충돌 드묾 (상품 정보 수정) | 낙관적 락 |
| “한 번에 하나”를 전역으로 보장하고 싶음, 임계영역을 DB 밖 로직까지 포함해야 하는 경우가 있음 | 분산 락 (Redis) |
| 결제 중복 요청 방지 | 멱등성 키 + DB Unique 제약 + 이전 결과 반환 |
동시성 문제는 로컬에서 테스트할 때는 전혀 안 보이다가 실제 트래픽에서 터지는 유형이다.
"읽고 → 수정하고 → 저장"하는 모든 로직에서 TOCTOU를 항상 의심해야 한다는 것을 배웠다.
락 방식 선택은 "충돌이 얼마나 자주 발생하는가"와 "임계영역이 DB 안에서 끝나는가/밖까지 확장되는가"로
출발점을 잡으면 될 것 같다.