[Sansam E-commerce] 10. MVP 이후 리팩토링 #8 : 단일 서버에서 Redis기반 재고 관리
- -
👀 들어가기 전에
다시 단일 서버로 돌아왔다.
머릿 속에 남는 의문은 하나였다.
단일 서버 환경에서 재고 정합성을 어떻게 잘 맞추면서 빠른 서버를 만들 수 있을까?
우선, 지금까지 시도해본 재고 처리 방식은 크게 두 가지였다.
- 낙관락 기반 재고 업데이트 (JPA Optimistic Lock)
- Next-Key Lock 기반 재고 업데이트
낙관락은 충돌 시 재시도가 잦았고,
Next-Key Lock은 정합성은 훌륭했지만 생각보다 응답 시간이 길었다.
실제 피크테스트 결과를 보면, 주문 API의 Max 응답 시간이 12~13초까지 튀는 구간이 관측되었고,
Jaeger 트레이싱 기준으로 재고 차감 로직이 최대 2초 이상을 점유하고 있었다.
정합성은 확보했지만 이 정도 응답 시간은 사용자 경험 관점에서 받아들이기 어려울 것이라는 판단이 섰다.
그래서 질문으로 다시 돌아와서 또다른 질문이 꼬리를 물었다.
DB 락만으로 이 트래픽을 끝까지 감당하게 하는게 맞을까?
👀 본론
재고 처리를 다양한 방식으로 만들어 보기
1. Redis 분산 락 : 가장 먼저 떠올랐지만 가장 빠르게 버린 선택
첫 번째로 떠오른 선택지는 Redis 기반 분산 락이었다.
실제로 떠오르자마자 구현까지 해보았다.
하지만 실험 결과는 명확했다.
- 재고 차감은 짧은 구간만 보호하면 가능
- 외부 락 도입으로 인해
- 네트워크 I/O 증가
- 락 획득/해제 비용 추가
- 전체 트랜잭션이 오히려 늘어지는 구조가 되었다.
결론적으로 RDB 트랜잭션 위에 외부 분산 락을 얹는 구조는 이 시나리오에서 복잡도 대비 이득이 거의 없다.
분산 락은 서비스가 물리적으로 분리된 MSA 환경에서 DB를 여러개 사용할 때 강점이 드러난다.
단일 서버 + 단일 DB 환경에서는 오히려 병목을 하나 더 추가하는 선택에 가까웠다.
그래서 이 선택지는 빠르게 롤백했다.
2. Redis 기반 재고 카운터 설계
DB 락으로만 버티기엔 한계가 명확했다.
- 락 범위가 넓어질 수록 대기 시간은 계속 증가
- 피크 트래픽에서 Tail Latency 악화
- DB가 동시성의 병목 지점이 된다.
그래서 관점을 바꿔 고민해보았다.
정합성 자체가 꼭 DB 락을 통해서 보장시켜야만 할까?
핵심 아이디어는 아래와 같다.
- 재고의 기준값과 실시간 사용량을 분리한다.
- 역할에 맞는 저장소를 사용하자
- 전체 재고량은 영속성과 관리의 편의를 생각해서 RDB에 저장한다
- 실시간 사용량은 빠른 연산과 동시성 처리 효율을 생각해서 메모리(Redis)를 사용한다
즉, RDB는 정합성을 맞추는 용도(?)이고 Redis는 동시성 처리 엔진으로 역할을 명확히 나눈다.
🔭 (추가) Redis를 왜 선택했는가?
여기서 Redis의 특성을 정확히 이해할 필요가 있다.
Redis는 단일 스레드 서버다.
Redis는 기본적으로 하나의 이벤트 루프에서 명령을 순차적으로 처리한다.
따라서
- 동일 키에 대한 연산은 절대 동시에 실행되지 않는다
- 모든 명령은 원자적으로 처리된다
- 별도의 락 없이도 자연스러운 직렬화가 보장된다.
즉,
INCRBY stock:used:123 1
이 연산이 실행되는 동안 다른 클라이언트가 같은 키를 동시에 변경할 수 없다.
(추가) Lua Script란? (feat. Redis의 관점)
Redis에 대해서 알아보다가 Lua Script에 대해 알게 되었다.
Lua Script는 여러 Redis 명령을 하나의 스크립트로 묶어 서버 내부에서 실행하게 하는 기능이다.
클라이언트가 GET → 검사 → INCR 처럼 여러 명령을 순차적으로 요청하며 네트워크 왕복을 반복하는 방식이 아니라, Redis 서버가 스크립트를 한 번에 실행하는 구조다.
Redis는 명령 실행은 단일 스레드 이벤트 루프에서 직렬적으로 처리된다. Lua Script 역시 스크립트 전체가 결국 하나의 명령처럼 Redis 실행 큐에 들어가 직렬 처리되기 때문에, 스크립트 실행되는 동안 다른 클라이언트의 명령이 중간에 끼어들 수 없다.
즉, 나의 예시에서 GET하고, 조건 검사하고, INCRBY 하고, 실패면 롤백하거나 리턴하는 이 일련의 과정이 하나의 원자적 블록으로 실행된다. 결과적으로 이는 단일 트랜잭션 블록처럼 동작하는 효과를 가지게 된다.
왜 Lua Script 였는가
이 방식은 내가 처한 조건에 너무나도 잘 맞는 선택이었다.
재고 처리 로직의 특성상, 충돌이 발생해 재시도가 일어나는 순간 들어가는 비용이 매우 크다고 판단했다.
재고가 충분하더라도 단순히 타이밍 충돌로 인해 재시도가 발생하면 응답 시간이 급격히 늘어날 수 있었다.
Redis 트랜잭션을 사용해도 조건부 로직이 필요한 경우에는 결국 WATCH가 함께 사용된다. 하지만, WATCH는 본질적으로 낙관적 락 기반이기 때문에 충돌이 발생하면 트랜잭션이 실패하고 재시도를 전제로한 흐름을 갖게 된다.
재고처럼 많은 요청이 몰릴 수 있는 데이터에 대해
- 충돌을 허용하고
- 재시도를 반복하는 구조는
성능과 안정성 모두에서 바람직하지 않다고 판단했다.
그래서 아예 충돌 이후의 재시도 비용을 감수하는 방식이 아니라, 충돌 자체가 발생하지 않도록 조회 / 검증 / 상태 변경을 하나의 원자적 연산으로 묶는 방향을 선택했고, 그 해답이 바로 Redis Lua Script였다.
재고 사용량 관리 구조
위와 같은 Redis의 특성들을 공부하고 나니 키 설계가 중요할 것 같다는 생각이 바로 들었다.
키를 어떻게 정할것인가?
재고를 차감하는 방식은 상품별로 차감이 되도록 구성되어있다.
상품을 나타내는 detailId별로 재고를 차감시킬 수 있도록 해야했다.
그러면서 총 사용량을 알 수 있도록 해야했으며 현재 차감되어야할 재고 정보가 들어간 키가 필요했다.
따라서 아래와 같이 결정했다.
stock:total:{detailId}
stock:used:{detailId}
Redis와 RDB의 정합성은 어떻게 맞출까?
물론 Redis는 메모리 기반 저장소이다.
그렇기에 만에 하나 메모리가 날라갈 경우를 대비해야 한다.
이를 위해 다음 전략을 생각해냈다.
- 저부하 시간대인 새벽 3시에 스케줄러를 실행 시킨다
- Redis의 used값을 기준으로 RDB의 재고를 보정한다
완벽한 실시간 강한 일관성은 아니지만 재고 도메인 특성상 충분히 현실적인 선택이라는 생각이 들었다.
Peak VUser 1,000명까지의 부하 테스트 (피크 테스트)
우선, 이전에 RabbitMQ를 활용해 재고 처리를 비동기 서버로 분리했을 때, Peak VUser 700 환경에서 수행한 피크 테스트 결과는 다음과 같았다.

서버 분리 이전 부하테스트 결과는 아래와 같다. (Peak VUser 600환경에서의 피크테스트 결과이다.)

이후 Redis 기반으로 재고 처리 방식을 보강한 뒤, 단일 서버 구조에서 동일한 Peak VUser 700 환경으로 다시 피크 테스트를 진행했다.



위 Peak VUser 700 환경에서 이전 결과들과의 차이를 보면 눈에 띄는 점은 크게 두 가지였다.
☝🏻 최저 응답 시간(min)의 유의미한 감소
이전에는 최저 응답 시간조차 300ms 수준에 머물렀지만 이번 테스트에서는 min 응답 시간이 크게 낮아졌다.
이는 곧 병목이 없는 구간에서는 요청이 훨씬 더 빠르게 처리되고 있다는 의미다.
이를 해석하면 전체 시스템의 기본 처리 경로 자체가 가벼워졌다고 해석할 수 있다.
✌🏻 Tail Latency 개선 (Worst-case 및 p95 감소)
- Worst-case 지연이 줄었고
- 특정 구간에서만 발생하던 스파이크 빈도 역시 눈에 띄게 감소했다
- p95 값이 내려가며 Tail Latency가 개선되었다.
(단일서버와 비교했을 때이다. 다중서버와 비교하더라도 비용대비 감소했다고 판단했다.)
즉, 동시성 상황에서 발생하던 대기열 누적 자체가 상당 부분 완화되었음을 시사한다.
특히 놀라웠던 점은 단일 서버 구조임에도 불구하고 이전의 서버를 분리하고 RabbitMQ로 비동기화했던 구조보다 더 안정적인 지표가 나왔다는 점이다.
Peak VUser 1,000 환경으로 확장
위 결과를 토대로, 동일한 구조에서 Peak VUser 1,000 환경까지 부하를 확장해 보았다.

☝🏻 Max값은 Peak VUser 700 환경과 거의 동일한 수준에서 머물렀다.
즉, 시스템이 붕괴되거나 큐가 무한정 밀리는 상태는 아니고, 그 상한선에서 계속 많은 요청이 걸리고 있다는 것이다.
✌🏻 평균값과 P95는 느려졌다.
대부분의 요청이 전반적으로 느린 구간으로 수렴되었다.
원인에 대한 분석

피크 테스트 결과를 토대로 병목의 원인을 구조적으로 분석해보았다.
결론적으로 병목은 요청-응답 경로에 포함된 worker thread 점유 시간이 길어진 상태에서 동시성이 증가하며 발생한 처리 모델의 한계로 판단했다. 현 구조에서 처리량의 상한을 결정하는 요소는 worker thread가 점유되는 동기 처리 시간이다.
이 값은 단일 서버 내부에서의 미세한 튜닝 (DB, 스레드 수, 커넥션 풀 설정)으로는 의미 있게 줄이기 어렵다.
즉, Peak VUser 1,000에서 관측된 지연은 동기 처리 기반 구조가 자연스럽게 도달한 한계에 가깝다.
주문,재고,결제 도메인의 특성상 핵심 경로는 정합성을 위해 반드시 동기 트랜잭션으로 처리되어야 하며 이 영역을 더 쪼개거나 비동기로 분리하는 것은 오히려 위험하다고 판단했다. (비동기 분리가 가능한 영역은 핵심 경로 이외의 후처리에 한정되는데 현재 로직에서 후처리 가능한 로직은 이미 비동기였다.) 이번 개선을 통해 단일 서버에서 적용할 수 있는 재고처리, 락 최소화, 트랜잭션 범위 분리, Read/Write 분리 등 핵심적인 최적화는 충분히 수행되었다고 판단한다.
따라서 이 지점 이후의 개선은 튜닝의 문제가 아니라 동일한 처리 모델을 유지한 채 서버를 확장할 것인지에 대한 선택의 문제로 넘어간다.
다만 애초에 설정했던 목표 Peak VUser는 1,000명이었고 이번 테스트를 통해 Peak VUser 1,000 환경에서도 서비스가 안정적으로 동작함을 확인했다. 이에 따라 현 시점에서는 구조를 더 복잡하게 확장하기 보다는 요구 조건을 만족하는 지점에서 개선을 멈추는 것이 합리적이라고 판단했다. (무엇보다 클라우드 비용이라는 현실적 제약에 눈물이 눈앞을 가려 멈추게 되었다...😭)
👀 마무리하며
서버를 나누지 않고도 재고 동시성 문제를 해결할 수 있는 방법은 분명 존재했다.
이번 프로젝트에서 얻은 결론은 단순하다. 모든 문제에 분산 아키텍처가 무작정 필요한 것은 아니다.
환경에 맞는 책임 분리와 도구 선택이 더 중요하다.
단일 서버 환경에서도
- Peak VUser 1000명
- 재고 정합성 유지
- Tail Latency 개선
- DB 병목 제거
라는 목표를 달성할 수 있었다.
이 프로젝트로 인해 스타트업 혹은 하나의 기업이 어떻게 성장해나가는지 전체적인 성장 과정을 살펴보게 된 것 같다.
추신으로 혼자 프로젝트가 끝난 후, 사업의 혹은 기업의 규모가 대기업까지 커진다면 어떻게 될까에 대해서 고민해봤다.
다음 포스팅은 혼자 고민했던 이 부분에 대해서 작성해보도록 하겠다.
(혼자만의 생각이었지만 재밌었기에 작성해보려 한다~)
'Project' 카테고리의 다른 글
| [Sansam E-commerce] 12. 리팩토링 그 이후의 회고 : Monolithic에서 MSA로... (0) | 2026.01.07 |
|---|---|
| [Sansam E-commerce] 11. MVP 이후 리팩토링 #9 : 주문 조회 성능 최적화 (1) | 2026.01.01 |
| [Sansam E-commerce] 9. MVP 이후 리팩토링 #7 : MSA는 언제 필요한가 (feat. 재고 비동기 처리 실험과 서버 분리 검증) (0) | 2025.12.30 |
| [Sansam E-commerce] 8. MVP 이후 리팩토링 #6 : 캐싱 (캐싱에 대한 고찰) (0) | 2025.12.29 |
| [Sansam E-commerce] 7. MVP 이후 리팩토링 #5 : HikariCP 튜닝 (0) | 2025.12.29 |
당신이 좋아할만한 콘텐츠
소중한 공감 감사합니다