항해 백엔드 5주차 회고(WIL): 동시성 이슈 해결과 Finalize

JUNYOUNG·2025년 4월 25일
post-thumbnail

이번 주는 지난주에 작성했던 동시성 테스트를 실제로 성공시키는 것이 목표였다. 단순히 테스트가 통과하는 게 아니라, 트랜잭션과 락을 제대로 이해하고 적용해서 “운영 가능한 수준”의 신뢰성을 확보하는 것이 진짜 목적이었다. 그리고 동시에, 4주간 진행해온 이커머스 프로젝트도 실제 배포 가능한 형태로 마무리하는 걸 목표로 설정했다.


실패의 원인을 다시 마주하다

지난주까지 작성했던 동시성 테스트들은 대부분 실패하도록 설계된 테스트였다. 쿠폰이 중복 발급되거나, 재고가 음수가 되거나, 하나의 주문에 두 번 결제되는 등 의도적으로 레이스 컨디션을 유도해 문제를 확인하는 테스트였다.

그 테스트들을 이번 주에는 "진짜 통과하게" 만들기 위해 다음과 같은 문제들을 다시 정면으로 마주했다:

  • 트랜잭션이 테스트 환경에서는 분리되어 실행되는 문제 → 애플리케이션에서는 @Transactional로 트랜잭션을 묶어도, 테스트 코드에서는 각각의 스레드가 별도 트랜잭션을 사용하는 바람에 제대로 동작하지 않았다.
  • 조회 + 수정이 한 메서드에서 수행될 때, 락이 의도대로 안 걸리는 문제FOR UPDATE가 실제로 적용되지 않거나, 트랜잭션 경계 밖에서 호출되는 문제가 있었다.
  • “배포 가능한 수준”의 테스트는 어디까지 해야 하는가에 대한 고민 → 통합 테스트, 서블릿 필터 테스트, 스케줄러 테스트 등은 일반적인 단위 테스트와는 다른 전략이 필요했다.

이번 주는 기능을 많이 구현했다기보다는, 매 순간 구조를 어떻게 바꿔야 진짜 문제가 해결되는지를 고민했던 밀도 높은 시간이었다.


“테스트 통과”가 아니라 “구조를 바꿔서 통과시키는 것”

처음엔 테스트만 통과시키면 되니까 decrease() 메서드 안에 @Transactional(REQUIRES_NEW)를 박아서 강제로 트랜잭션을 분리했었다.

지금 돌이켜보면 굉장히 부끄러운 코드다.

왜냐하면 책임 분리는 엉망이 되고, 트랜잭션의 흐름은 꼬였고, 테스트가 통과한다고 해서 코드가 제대로 설계된 건 아니었으니까.

테스트에 구조를 맞추는 게 아니라, 구조를 바꿔서 테스트가 자연스럽게 통과하게 해야 한다는 걸 깨달았다.

결국 파사드 레이어에서 트랜잭션 책임을 갖도록 조정하고, decrease()는 순수한 비즈니스 로직을 갖도록 정리했다. 그와 함께 락 시점도 명확하게 트랜잭션 안에서 획득되도록 수정했다. 이 과정을 거치자 비로소 테스트들이 "의도한 대로" 통과하기 시작했다.


동시성 이슈가 실제로 발생할 수 있는 기능들

이번 프로젝트에서 실제로 동시성 문제가 발생할 가능성이 있었던 주요 기능들에 대해 구체적인 문제 상황적용한 해결 전략을 정리했다.


1. 잔액 충전 – 중복 요청, 과충전 이슈

  • 문제: 동일한 요청이 여러 번 들어올 경우, 동일한 금액이 중복으로 충전되는 현상
  • 해결:
    • @Version 필드를 사용한 Optimistic Lock
    • @Retryable로 충돌 시 자동 재시도
    • requestId 기반 멱등성 처리로 이미 처리된 요청은 무시
    • 히스토리 테이블에 기록함으로써 중복 처리 방지를 강화

2. 쿠폰 발급 – 수량 제한 초과 발급

  • 문제: 동시에 여러 사용자가 같은 쿠폰을 요청하면 남은 수량보다 많이 발급되는 문제
  • 해결:
    • 쿠폰 테이블에 @Lock(PESSIMISTIC_WRITE) 적용
    • 락 획득 → 중복 발급 검사 → 수량 검증 및 차감을 트랜잭션 내에서 순차적으로 처리
    • userId + couponId 조합으로 중복 발급을 차단

3. 재고 차감 – 동시 주문 시 음수 재고 발생

  • 문제: 여러 사용자가 동시에 같은 상품을 주문하면 재고보다 많이 팔리는 현상
  • 해결:
    • 재고 row에 PESSIMISTIC_WRITE 락 적용
    • findByProductIdAndSizeForUpdate()로 락을 선점한 뒤, 재고 차감과 저장을 같은 트랜잭션 안에서 수행
    • 재고 부족 시 예외 발생으로 즉시 실패 처리

4. 결제 요청 – 복수 결제 발생 가능성

  • 문제: 하나의 주문에 대해 결제 API가 여러 번 호출되면 결제가 중복으로 처리될 수 있음
  • 해결:
    • 주문 row에 PESSIMISTIC_WRITE 락 선점
    • 결제 진행 전 주문 상태가 REQUESTED인지 선점 확인
    • 잔액 차감 → 결제 기록 저장 → 주문 상태 변경까지 하나의 트랜잭션에서 처리

이 4가지 케이스 모두 실제 테스트로 검증되었고,

단순한 트랜잭션 선언만으로는 해결되지 않는 문제들이었기에 락 전략과 멱등성, 재시도, 트랜잭션 경계까지 모두 고려한 설계가 필요했다.


요약

기능문제해결 전략
잔액 충전중복 요청Optimistic Lock + 멱등성 + 재시도
쿠폰 발급초과 발급Pessimistic Lock + 중복 발급 차단
재고 차감음수 재고Pessimistic Lock + 트랜잭션 내 검증
결제복수 결제주문 상태 선점 + 단일 트랜잭션 처리

인사이트

  • 단순히 @Transactional만 붙이는 것으로는 경쟁 조건을 방지할 수 없다
  • 락의 적용 시점, 트랜잭션 경계, 재시도 전략, 멱등성 처리는 모두 함께 고려되어야 한다
  • 테스트에서도 실제 장애를 유도해서 설계를 검증해야 구조적 결함을 잡을 수 있다

알게 된 것들

  • FOR UPDATE트랜잭션 안에서 실행되어야 의미가 있다.
    • 예: decrease()에서 product를 먼저 조회하고 이어서 stock을 락 걸려고 할 때, 이게 다른 트랜잭션에서 조회됐다면 이미 락이 무의미해진다.
    • → 락은 트랜잭션 시작 이후, 가장 먼저 필요한 데이터에 걸어야 한다.
  • 테스트 환경에서 동시성을 검증할 때는 @Transactional(propagation = NOT_SUPPORTED)를 써서 트랜잭션 없이 초기 데이터를 세팅해야 한다.
  • 테스트는 단순히 "통과"하는 게 목적이 아니라, 설계가 올바르게 되어 있는지를 검증하는 도구여야 한다.

부가기능: 필터 / 인터셉터 / 스케줄러도 다뤄봤다

  • Interceptor: 요청 헤더에 사용자 ID를 주입하는 인증 역할
  • Filter: 간단한 로깅 처리
  • 스케줄러: 매일 0시에 만료된 쿠폰 비활성화

이 기능들은 서블릿 레벨에서 작동하는 컴포넌트이기 때문에 일반적인 단위 테스트로는 검증하기 어려웠다. 그래서 MockMvc 환경에서의 흐름 테스트, 또는 스케줄러는 직접 호출 테스트처럼 별도의 전략을 고민해봤다.

“이런 건 어떻게 테스트하지?”라는 고민 자체가 성장의 계기가 됐다.


다음 주 목표

차주에는 Redis와 Kafka를 활용한 구조 실험을 진행할 예정이다.

  • Redis로 Rate Limiting이나 글로벌 락 처리
  • Kafka로 주문/결제/이벤트 기반 메시지 처리 구조 구성
  • 지금까지 학습한 트랜잭션 설계, 테스트 전략, 동시성 처리를 기반으로 “분산 시스템에서도 안정적으로 동작하는 구조”를 직접 실험해볼 계획이다.

이번에는 “이걸 이렇게 하면 되지 않을까?” 하고 머릿속에서만 떠돌던 걸 실제로 구현해볼 수 있을 것 같아 기대된다.


총평

이번 주는 기능 하나하나보다, 그 기능을 감싸는 구조를 진짜 운영 가능한 형태로 바꿔낸 주였다.

동시성 테스트는 결국 실패를 통해 구조를 고치는 과정이라는 걸 온몸으로 체감했고,

그 결과 테스트는 물론, 실제 운영에서도 견고할 수 있는 코드를 만들 수 있었다.

profile
Onward, Always Upward - 기록은 성장의 증거

0개의 댓글