
이번 주는 지난주에 작성했던 동시성 테스트를 실제로 성공시키는 것이 목표였다. 단순히 테스트가 통과하는 게 아니라, 트랜잭션과 락을 제대로 이해하고 적용해서 “운영 가능한 수준”의 신뢰성을 확보하는 것이 진짜 목적이었다. 그리고 동시에, 4주간 진행해온 이커머스 프로젝트도 실제 배포 가능한 형태로 마무리하는 걸 목표로 설정했다.
지난주까지 작성했던 동시성 테스트들은 대부분 실패하도록 설계된 테스트였다. 쿠폰이 중복 발급되거나, 재고가 음수가 되거나, 하나의 주문에 두 번 결제되는 등 의도적으로 레이스 컨디션을 유도해 문제를 확인하는 테스트였다.
그 테스트들을 이번 주에는 "진짜 통과하게" 만들기 위해 다음과 같은 문제들을 다시 정면으로 마주했다:
@Transactional로 트랜잭션을 묶어도, 테스트 코드에서는 각각의 스레드가 별도 트랜잭션을 사용하는 바람에 제대로 동작하지 않았다.FOR UPDATE가 실제로 적용되지 않거나, 트랜잭션 경계 밖에서 호출되는 문제가 있었다.이번 주는 기능을 많이 구현했다기보다는, 매 순간 구조를 어떻게 바꿔야 진짜 문제가 해결되는지를 고민했던 밀도 높은 시간이었다.
처음엔 테스트만 통과시키면 되니까 decrease() 메서드 안에 @Transactional(REQUIRES_NEW)를 박아서 강제로 트랜잭션을 분리했었다.
지금 돌이켜보면 굉장히 부끄러운 코드다.
왜냐하면 책임 분리는 엉망이 되고, 트랜잭션의 흐름은 꼬였고, 테스트가 통과한다고 해서 코드가 제대로 설계된 건 아니었으니까.
테스트에 구조를 맞추는 게 아니라, 구조를 바꿔서 테스트가 자연스럽게 통과하게 해야 한다는 걸 깨달았다.
결국 파사드 레이어에서 트랜잭션 책임을 갖도록 조정하고, decrease()는 순수한 비즈니스 로직을 갖도록 정리했다. 그와 함께 락 시점도 명확하게 트랜잭션 안에서 획득되도록 수정했다. 이 과정을 거치자 비로소 테스트들이 "의도한 대로" 통과하기 시작했다.
이번 프로젝트에서 실제로 동시성 문제가 발생할 가능성이 있었던 주요 기능들에 대해 구체적인 문제 상황과 적용한 해결 전략을 정리했다.
@Version 필드를 사용한 Optimistic Lock@Retryable로 충돌 시 자동 재시도requestId 기반 멱등성 처리로 이미 처리된 요청은 무시@Lock(PESSIMISTIC_WRITE) 적용userId + couponId 조합으로 중복 발급을 차단PESSIMISTIC_WRITE 락 적용findByProductIdAndSizeForUpdate()로 락을 선점한 뒤, 재고 차감과 저장을 같은 트랜잭션 안에서 수행PESSIMISTIC_WRITE 락 선점REQUESTED인지 선점 확인이 4가지 케이스 모두 실제 테스트로 검증되었고,
단순한 트랜잭션 선언만으로는 해결되지 않는 문제들이었기에 락 전략과 멱등성, 재시도, 트랜잭션 경계까지 모두 고려한 설계가 필요했다.
| 기능 | 문제 | 해결 전략 |
|---|---|---|
| 잔액 충전 | 중복 요청 | Optimistic Lock + 멱등성 + 재시도 |
| 쿠폰 발급 | 초과 발급 | Pessimistic Lock + 중복 발급 차단 |
| 재고 차감 | 음수 재고 | Pessimistic Lock + 트랜잭션 내 검증 |
| 결제 | 복수 결제 | 주문 상태 선점 + 단일 트랜잭션 처리 |
@Transactional만 붙이는 것으로는 경쟁 조건을 방지할 수 없다FOR UPDATE는 트랜잭션 안에서 실행되어야 의미가 있다.decrease()에서 product를 먼저 조회하고 이어서 stock을 락 걸려고 할 때, 이게 다른 트랜잭션에서 조회됐다면 이미 락이 무의미해진다.@Transactional(propagation = NOT_SUPPORTED)를 써서 트랜잭션 없이 초기 데이터를 세팅해야 한다.이 기능들은 서블릿 레벨에서 작동하는 컴포넌트이기 때문에 일반적인 단위 테스트로는 검증하기 어려웠다. 그래서 MockMvc 환경에서의 흐름 테스트, 또는 스케줄러는 직접 호출 테스트처럼 별도의 전략을 고민해봤다.
“이런 건 어떻게 테스트하지?”라는 고민 자체가 성장의 계기가 됐다.
차주에는 Redis와 Kafka를 활용한 구조 실험을 진행할 예정이다.
이번에는 “이걸 이렇게 하면 되지 않을까?” 하고 머릿속에서만 떠돌던 걸 실제로 구현해볼 수 있을 것 같아 기대된다.
이번 주는 기능 하나하나보다, 그 기능을 감싸는 구조를 진짜 운영 가능한 형태로 바꿔낸 주였다.
동시성 테스트는 결국 실패를 통해 구조를 고치는 과정이라는 걸 온몸으로 체감했고,
그 결과 테스트는 물론, 실제 운영에서도 견고할 수 있는 코드를 만들 수 있었다.