[TIL] 결제/환불 시스템의 동시성 문제와 해결 전략

김재진·2026년 3월 13일

내일배움캠프

목록 보기
58/70

오늘 공부한 것

스프링 커머스 백엔드를 공부하면서 결제/환불 시스템에서 발생할 수 있는 동시성 문제와
그 해결 방법들을 정리했다.


어떤 문제가 발생하는가?

1. 재고 / 포인트 중복 차감

두 요청이 거의 동시에 재고를 읽으면 둘 다 "재고 있음"으로 판단하고 차감할 수 있다.
결과적으로 재고가 음수가 되거나 포인트가 두 번 차감되는 문제가 생긴다.

2. 결제 / 환불 중복 요청 (더블클릭 등)

같은 요청이 짧은 시간 안에 두 번 들어오면 결제가 두 번 처리될 수 있다.
단순한 중복 요청 방지를 넘어, 네트워크 오류로 인한 재시도 상황에서
"이 요청이 이미 처리됐는가?"를 판단하는 멱등성(Idempotency) 문제이기도 하다.

멱등성 키(Idempotency Key): 클라이언트가 요청 시 UUID 같은 고유 키를 같이 보내고,
서버는 이 키로 이미 처리된 요청인지 확인한다. 더블클릭이든 네트워크 재시도든
같은 키면 같은 결과를 돌려주고 중복 처리하지 않는다.

중요한 점은 서버가 이 키를 영속적으로 저장하고(예: idempotency 테이블),
Unique 제약(Unique Index) 으로 중복 처리를 막은 뒤,
중복 요청이 오면 이전에 저장한 결과(결제ID/환불ID/상태) 를 그대로 반환하는 방식으로 구현해야 한다.

3. 선착순 쿠폰 / 이벤트 초과 발급

10명 제한 쿠폰에 동시에 100명이 요청하면, 모두 "아직 10명 안 됐다"고 읽고
쿠폰을 발급하여 10장이 훨씬 넘어버릴 수 있다.


문제의 본질: TOCTOU

세 문제 모두 같은 패턴을 가진다.

읽기(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개로 저장됨 (갱신 손실 / 중복 처리)

해결 방법

비관적 락 (Pessimistic Lock)

"어차피 충돌 날 거니까, 처음부터 잠가버리자"

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);
}

언제 쓸까? 재고 차감, 포인트 차감처럼 충돌이 자주 발생하는 곳
단점: 데드락 위험, 동시 요청이 많을수록 병목 발생(락 대기/타임아웃도 고려 필요)


낙관적 락 (Optimistic Lock)

"충돌이 거의 없을 테니까, 저장할 때 버전으로 확인하자"

엔티티에 @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);
}

언제 쓸까? 상품 정보 수정, 게시글처럼 충돌이 드문
단점: 충돌이 많으면 재시도가 늘어나 오히려 성능 저하


분산 락 (Distributed Lock)

"서버가 여러 대여도 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 안에서 끝나는가/밖까지 확장되는가"로
출발점을 잡으면 될 것 같다.

profile
개발공부 처음해보는 사람

0개의 댓글