이전 글
[프로젝트/기술적 의사결정] Redis 분산락으로 재고 감소 동시성 이슈 해결하기 (1/2)
필자는 MSA 기반 이커머스 프로젝트에서 상품/재고/예약구매 도메인을 맡아 진행중이다. 지난 프로젝트에서 쿠폰 도메인을 맡아 개발했을 때 Race condition 문제를 예상치 못하게 겪고 (..) 이번 프
developer-jinnie.tistory.com
이전 글에서 redisson 라이브러리를 사용해 분산락을 구현하기로 결정했었다. 이번 글에선 분산락으로 재고 감소 동시성 이슈를 해결하는 과정을 기술해보려 한다.
1. Redis 및 Redisson 의존성 추가
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.23.2'
2. facade 클래스 생성
redisson의 경우에는 락 관련된 클래스들을 라이브러리에서 제공해주기 때문에, 개발자가 별도의 락을 위한 repository 클래스를 작성해주지 않아도 된다.
그래도 로직 실행 전, 후로 락 획득과 해제는 해주어야 하므로 facade 패턴을 통해 락을 구현해주자.
💡 Facade 패턴이란?
복잡한 내부 구현을 숨기고, 외부에서는 단순한 인터페이스만 제공하는 디자인 패턴.
위에서 작성한 RedissonLockStockFacade 클래스는 바로 이 개념을 적용한 구현체임
3. 왜 굳이 Facade 클래스를 따로 만들었을까
처음엔 그냥 StockService 내부에서 Redisson 락을 걸고 재고를 감소시키면 된다고 생각했다. 하지만 이렇게 되면 비즈니스 로직과 분산락 로직이 얽혀버려서 코드가 지저분해지고, 유지보수도 힘들어진다.
또한 재고 외에도 락을 써야 할 도메인이 생긴다면, 같은 방식으로 또 반복해서 락 로직을 작성해야 할 수도 있다. 이런 상황은 너무 비효율적이라고 판단했다.
그래서 사용한 것이 Facade 클래스다.
도식화:
구조를 도식화해보면 아래와 같다.
[Controller or Application Service]
|
v
[RedissonLockStockFacade] ---> 락 획득 및 해제 책임
|
v
[StockService] ---> 순수 비즈니스 로직 (재고 감소)
4. 정리하면, Facade 클래스는 이런 역할을 한다
역할 | 설명 |
락 처리 캡슐화 | 락 획득/해제를 책임져서 비즈니스 로직을 깔끔하게 분리 |
재사용성 향상 | 다른 도메인에서도 동일한 방식으로 사용 가능 |
유지보수성 향상 | 락 처리 방식이 바뀌어도 Facade만 수정하면 됨 |
테스트 용이성 | 락 로직이 분리되어 있어 각각의 테스트도 간결하게 가능 |
5. 구현
1) 락 객체 생성:
redissonClient.getLock("stockLock:" + productId)를 통해 상품 ID에 기반한 고유한 락을 생성
2) 락 획득 시도:
tryLock 메서드를 사용하여 최대 10초 동안 락을 획득하려고 시도함. 락을 점유할 수 있는 최대 시간은 1초로 설정
3) 락 획득 실패 시 처리:
락을 획득하지 못했을 경우 로그를 남기고 메서드를 종료
4) 재고 감소 메서드 호출:
락을 성공적으로 획득한 경우, stockService.decreaseStockQuantity(productId)를 호출하여 재고를 감소시킴
5) 예외 처리:
- InterruptedException 예외를 처리
- finally 블록: 모든 로직이 종료된 후, 현재 스레드가 락을 보유하고 있을 경우에만 락을 해제
6. 테스트 코드 작성
마지막으로, 구현한 RedissonLockStockFacade가 실제로 동시에 여러 요청이 들어왔을 때 올바르게 동작하는지 확인해보자.
6-1. 테스트 환경
- 초기 재고는 50개로 설정한 Stock 엔티티를 생성하고 저장.
- productId와 stockId는 테스트 고정 값을 사용해 UUID로 지정.
- Stock은 Product와 연관 관계를 맺고 있으며, 이 테스트에서는 실제 Product 객체도 함께 생성해서 Stock에 할당했다.
6-2. 테스트 시나리오
- 동시에 100개의 재고 감소 요청을 보내고, 락이 정확히 작동해서 재고가 음수로 내려가지 않고 정확히 0개로 떨어지는지를 검증한다.
- 스레드는 ExecutorService로 병렬 실행하고, 모든 요청이 완료될 때까지 CountDownLatch로 대기한다.
6-3. 테스트 실행 결과
7. 실제 서비스 적용 시 고려사항
1) 락 점유 시간 설정은 신중하게
- tryLock의 파라미터 중 waitTime(대기 시간)과 leaseTime(점유 시간) 설정은 트래픽과 로직의 수행 시간에 따라 조정해야 한다.
- 점유 시간이 너무 짧으면 처리 도중 락이 풀려 Race Condition이 발생할 수 있고, 너무 길면 불필요하게 자원이 오래 점유된다.
2) 락 키 네이밍 규칙 통일
- 락 이름은 "stockLock:" + productId처럼 도메인과 식별자를 조합한 네이밍 패턴을 사용하여 고유성을 확보했다.
- 동시에 여러 상품에 대한 요청이 들어올 수 있으므로, 상품 단위로 락을 분리하기 위함이었다.
3) 성능에 미치는 영향
- Redis는 단일 스레드 기반이므로 락 요청이 몰릴 경우 Redis 자체가 병목 지점이 될 수 있다.
- 분산 환경에서도 Redis 인스턴스가 단일 장애 지점(SPOF, Single Point of Failure)이 될 수 있으므로 Redis Sentinel, Cluster, 또는 멀티 노드 구성 등을 고려하자.
4) 트랜잭션 커밋과 락 해제 시점 관리해야 함
- JPA의 @Transactional 범위와 락 해제 시점이 겹치지 않도록 주의해야 한다.
- 락은 트랜잭션 외부에서 관리되므로, 락 해제는 DB 트랜잭션 커밋보다 먼저 수행되지 않도록 설계해야 한다.
- 실제로 필자는 락은 잘 구현해놓고 이 부분을 놓쳐서 삽질을 상당시간 했는데, 관련 트러블슈팅은 다른 글로 포스팅 하도록 하겠음 ..
8. 마무리 💫
이번 글에서는 기술적 의사결정에 이어서 실제로 Redisson 분산락을 적용한 재고 감소 기능을 구현하고, 테스트를 통해 안정성을 검증한 과정을 정리해보았다.
MSA 환경에서 Race Condition을 방지하는 것은 단순한 트랜잭션 설정만으로는 부족하며, 이렇게 분산 환경을 고려한 동기화 전략이 필수적이라고 생각한다.
다음 글에서는 이 로직을 기반으로 예약구매 기능에 어떻게 적용했는지, 그리고 고도화 과정에서의 고민들도 공유해볼 예정이다.
땡큐포와칭
'Project > 대용량 트래픽 프로젝트' 카테고리의 다른 글
[프로젝트/구현] Redis Replication 마스터-슬레이브 구조를 통한 분산 처리 적용 과정 (0) | 2025.05.04 |
---|---|
[기술적 의사결정] MSA 환경에서 배송 정보 임시 저장소로 Redis를 사용한 이유 (3) | 2025.05.03 |
[프로젝트] Windows 환경에서 JMeter 설치 및 부하 테스트 하기 (8) | 2024.12.11 |
[프로젝트] QueryDSL 사용 시 페이징 응답 JSON 데이터 최적화 하기 (1) | 2024.12.11 |
[프로젝트/기술적 의사결정] Redis 분산락으로 재고 감소 동시성 이슈 해결하기 (1/2) (1) | 2024.12.11 |