컬리 풀필먼트 입고서비스(RMS)에서 카프카 중복 발주·더블클릭·동시 재고 트랜잭션 같은 동시성 문제를 해결하기 위해, Redisson 기반 분산락을 어노테이션(AOP)으로 손쉽게 쓰도록 만든 방법을 다룬다. 락 처리 로직을 비즈니스 로직과 분리하고, 트랜잭션 커밋 이후 락을 해제해 데이터 정합성을 보장하는 게 핵심이다.
핵심 포인트- 멀티 인스턴스 환경에서 공통 락이 필요해 Redis와 Redisson을 채택했다: 팀이 이미 Redis를 써 추가 인프라가 불필요했다.
- Redisson은 Lettuce와 달리 Lock interface(타임아웃 등)를 제공하고, 스핀락 대신 Pub/Sub 방식이라 Redis 부하가 작다.
- @DistributedLock 어노테이션과 AOP로 waitTime·leaseTime·SpEL 기반 동적 락 이름을 커스텀했다.
- 락 메서드를 Propagation.REQUIRES_NEW로 별도 트랜잭션 실행하고, 반드시 커밋 이후 락을 해제한다.
- 쿠폰 100장 동시 100요청·중복 발주 10건 테스트로 락 유무에 따른 정합성 차이를 검증했다.
상세 정리- 동시성 이슈: 카프카 중복 발주 수신, 검수/검품 더블클릭 중복 요청, 이동 출고 시 다중 작업자 CTA로 잘못된 재고 트랜잭션 등으로, 애플리케이션 예외 처리만으론 부족했다.
- 라이브러리 선택: 분산락은 Redis·MySQL·Zookeeper로 가능하나, MySQL은 별도 커넥션 풀 관리와 RDS 부하가 부담이라 Redis가 효율적이었다.
- Redisson vs Lettuce: Lettuce는 setnx/setex로 직접 구현하고 retry·timeout을 수동 처리하며 스핀락이라 요청이 많을수록 Redis 부하가 커진다. Redisson은 Lock interface와 Pub/Sub(해제 신호 수신 후 획득)로 안전하고 부하가 작다.
- 컴포넌트 설계 규칙: 락 로직을 비즈니스와 분리, waitTime·leaseTime 커스텀, 락 이름 사용자 지정, 공통 요구사항 일괄 관리를 규칙으로 삼았다.
- 구현: DistributedLockAop의 @Around에서 SpEL로 락 key를 파싱(#lockName, #model.getName() 등 동적 조합)해 RLock을 획득하고, tryLock(waitTime, leaseTime) 후 AopForTransaction.proceed로 대상 메서드를 실행하며, finally에서 무조건 unlock한다. 이미 해제된 경우 IllegalMonitorStateException을 로깅한다.
- 트랜잭션 순서: 락 해제가 커밋보다 빠르면 재고 10 조회 후 차감(미커밋) 상태에서 락을 풀어, 다른 클라이언트가 여전히 10으로 조회해 중복 차감하면 정합성이 깨진다. 커밋 후 해제하면 후속 클라이언트가 갱신된 재고를 조회해 정확히 차감한다.
- 검증 1: 쿠폰 100장에 100스레드 동시 요청 시 분산락 적용은 정확히 100장 차감(잔여 0), 미적용은 79장만 차감돼 정합성이 붕괴했다.
- 검증 2: 동일 코드 발주 10건 동시 수신 시 락 적용은 1건만 등록됐고, 미적용은 validation을 통과해 다수 등록됐다. 등록 개수는 커넥션 풀 크기에 따라 변동한다.
- 효과: 락 처리를 핵심 로직과 분리해 가독성·생산성이 오르고, 안전한 동시성 제어를 어노테이션으로 재사용할 수 있게 됐다.
왜 읽나Spring에서 멀티 인스턴스 동시성을 다루는 백엔드 개발자에게 Redisson AOP 분산락 구현과 커밋 후 해제 원칙을 코드로 보여주는 실전 예제.