https://chobo-backend.tistory.com/54
[바로] 단일 주문 성능 개선 삽질기 (Ft. 목표 TPS 1666 vs 현실 187.4)
상품을 주문하는 행위는 E-Commerce 도메인에서 가장 중요한 기능 중 하나이다. 먼저 재고 관리 측면에서 예를 들면, 남은 재고는 10개였으나 12개의 주문이 발생할 수 있다. 이로 인해 사용자는 결
chobo-backend.tistory.com
지난 포스팅에서 다음과 같은 고민들을 남기며 마쳤었다
1. DB Connection Pool을 늘린다면 개선 될까
2. 서버나 DB를 Scale-up 한다면 개선 될까
3. 비즈니스 로직이 훨씬 더 복잡해진다면 UPDATE로 원자적 주문이 가능할까
4. Redis를 도입하면 개선 될까
먼저 여러가지 방법들을 생각해보며 이번 개선의 방향성을 잡아보려고 한다
Part 0. 개선 방향성 잡기
1. DB Connection Pool을 확장하면 개선이 될까??

단일 품목 테스트 당시 그라파나(위)를 보면 67개의 Thread가 Pending 상태에 머무르고 있는 것을 볼 수 있다. 그래서 현재 성능 병목의 원인 중 하나가 DB Connection Pool 부족일 가능성이 있다. 따라서 Connection Pool 크기를 늘리면 더 많은 요청이 동시에 DB에 접근할 수 있어 처리량 향상을 기대할 수 있다. 하지만 주문 API의 특성상 동일한 상품에 대한 동시 접근 시 Lock Contention이 발생하게 된다. 이는 Connection 수를 늘려도 결국 Lock 대기 시간으로 인해 성능 향상이 제한적일 수밖에 없을 것이다. 또한 과도한 Connection은 오히려 DB 서버에 부하를 가중시켜 전체적인 성능 저하를 일으킬 수 있다고 생각하여 제외하였다
2. 서버나 DB를 Scale-up 한다면 개선 될까
서버나 DB의 하드웨어 사양을 업그레이드하면 CPU 처리 능력과 메모리 용량 증가로 인해 전반적인 성능 향상을 기대할 수 있다. 특히 DB 서버의 경우 더 빠른 디스크 I/O와 많은 메모리로 인해 쿼리 처리 속도가 개선될 것이다. 그러나 재고 동시성 처리는 본질적으로 데이터 무결성을 보장하기 위해 순차적 처리가 필요한 부분이 존재한다. 아무리 하드웨어 성능이 좋아져도 동일 상품에 대한 재고 차감은 Race Condition을 방지하기 위해 Lock을 사용해야 하므로, 순차적으로 진행되기에 성능 상한선이 존재한다. Scale-up은 분명 도움이 되겠지만 목표 TPS에 도달하지 못했을 때 무제한적으로 Scale-up을 할 수는 없으며 이는 근본적인 해결책이 되지 못한다
3. 비즈니스 로직이 훨씬 더 복잡해진다면 UPDATE로 원자적 주문 처리가 가능할까
쇼핑몰에서 주문 시 필요한 로직들을 살펴보면, 재고 차감 외에도 쿠폰 사용 검증, 적립금 차감, 배송비 계산, 회원 등급별 할인 적용, 상품별 재고 분산 처리 등이 필요하다. 예를 들어, 고객이 여러 상품을 장바구니에 담고 10% 할인 쿠폰과 적립금 5000원을 사용하여 주문할 때를 생각해보겠다
먼저 각 상품의 재고를 확인하고 차감해야 하는데, 상품 A는 서울 창고에 50개, 부산 창고에 30개가 있고, 상품 B는 대구 창고에만 20개가 있다면 여러 테이블에서 재고를 조회하고 업데이트해야 한다. 또한 동시에 쿠폰 테이블에서 해당 쿠폰이 유효한지, 이미 사용되었는지 확인하고 사용 상태로 변경해야 한다. 이후에 고객의 적립금 잔액을 확인하고 5000원을 차감한 후, 주문 금액에 따라 새로운 적립금을 계산해서 추가해야 한다
이런 복잡한 로직을 단일 UPDATE 쿼리로 처리하려면 서브쿼리와 조인이 극도로 복잡해지며, 데이터베이스 성능이 급격히 저하된다. 그리고 중간에 하나라도 실패하면 전체 롤백이 어렵고, 어느 단계에서 실패했는지 파악하기도 어렵다. 또한 쿠폰 중복 사용이나 적립금 부족 같은 예외 상황에 대한 세밀한 에러 처리가 불가능하다. 따라서 검증과 처리는 구분해야한다
4. Redis를 도입하면 개선 될까
Redis를 도입하면 재고 조회와 업데이트 모두에서 큰 성능 개선을 기대할 수 있다. 재고 조회의 경우 Redis에서 먼저 확인하고 캐시 미스가 발생했을 때만 DB에 접근하므로 응답 시간이 대폭 단축된다. 더 중요한 것은 재고 차감 로직인데, Redis의 싱글 쓰레드로 동작하는 특성상 DB 레벨의 복잡한 락 없이도 동시성을 제어할 수 있다. 재고 업데이트 시에도 Redis에서 먼저 처리한 후 DB로 비동기 동기화하면 사용자 응답 시간을 크게 줄일 수 있다. Redis는 메모리 기반이므로 DB 대비 훨씬 빠른 처리가 가능하고, Lua Script를 통해 복잡한 재고 로직도 원자적으로 처리할 수 있다. 다만 Redis와 DB 간의 데이터 일관성을 보장하는 것이 중요한 부분일 것이다. 결론적으로 이러한 장점들을 종합하면 Redis 도입이 목표 TPS 달성에 가장 현실적이고 효과적인 방안으로 보인다
Part 1. Distributed Lock을 도입?
일괄 주문 흐름은 다음과 같다
1. 모든 상품 재고 검증
2. 모든 상품의 재고 차감
3. 주문 접수
4. 주문 상품 등록
기존에 단일 UPDATE 방식으로 구현을 했었는데 개선 방향성 3번(비즈니스 로직이 훨씬 더 복잡해진다면?)을 만족하려면 결국 검증과 재고 차감이 분리될 수 밖에 없다. 결국 검증 시점에서 FOR UPDATE로 지속적으로 Lock을 걸고 재고 차감을 하지 않는 이상 동시성으로 인한 데이터 불일치가 발생할 수 밖에 없다. 여러 방법들을 찾아보던 중 이러한 재고 관리에 분산 락을 사용한다는 것을 알게되었고, 이게 정말 현재 상황에 맞는 해결책인지 체크해야했다
1. 분산 락(Distributed Lock) 이란?
분산 락(Distributed Lock)은 여러 서버나 프로세스가 동시에 같은 자원에 접근하는 것을 제어하기 위해 사용하는 동기화 메커니즘이다. 일반적인 락과 달리 네트워크상의 여러 노드 간에 공유되는 락으로, Redis, Zookeeper, DB 등을 통해 구현된다
분산 환경에서는 각 서버가 독립적으로 동작하기 때문에 단일 서버의 synchronized나 ReentrantLock 같은 방법으로는 동시성 제어가 불가능하다. 예를 들어 A서버와 B서버가 동시에 같은 상품의 재고를 차감하려고 할 때, 각각의 로컬 Lock으로는 서로를 인지할 수 없어 동시성 문제가 발생한다. 이때 Redis 같은 외부 저장소에 락을 걸어두고 모든 서버가 이를 확인하도록 하여 한 번에 하나의 서버만 해당 작업을 수행할 수 있도록 보장한다.
주로 재고 차감, 쿠폰 발급, 순번 생성, 중복 결제 방지, 배치 작업의 중복 실행 방지 등과 같이 정확히 한 번만 실행되어야 하는 중요한 비즈니스 로직에서 사용된다. 특히 MSA나 로드밸런싱된 다중 서버 환경에서 데이터 정합성을 보장하기 위해 필수적으로 활용되는 기술이다.
2. 분산 락 적용 후 구조
먼저 분산락을 적용했다고 가정하고 구조를 다음과 같이 생각해 볼 수 있다. 먼저 애플리케이션 서버에서 Redis를 통해 Lock을 획득한 후, Lock을 보유한 상태에서 DB에 데이터를 저장하고, 작업 완료 후 Lock을 해제하는 방식이다.

이 방식의 문제가 뭘까? 성능 개선이 되는 게 맞을까?
일단 이번 개선을 시작하게 된 가장 큰 이유는 결국 성능 문제였다. 그런데 위 방식을 보면 Redis를 통해 Lock을 할당받고 DB 작업을 끝마친 후 Lock을 반납하는 방식이다. 그럼 결국 분산 락을 적용하기 전처럼 하나의 요청만 순차방식으로 진행되는 것은 똑같다. 따라서 성능측면에서 큰 효과를 낼 것 같지는 않았다.
그리고 Split-Brain 문제가 발생할 가능성도 있다. 예를 들어, Lock을 획득 한 후 DB 작업을 수행하는 동안, Lock을 보유한 서버가 네트워크 장애나 외부 API, GC STW 등으로 인해 응답이 지연될 수 있다. 이 경우 Lock은 TTL이 만료될 때까지 해제되지 않아 다른 서버들이 불필요하게 대기하게 된다. 심지어 TTL이 만료된 후 Redis는 서버에 문제가 있다고 판단하고 Lock을 해제하지만, 원래 서버가 복구되어 DB 작업을 계속 진행할 수 있어 동시성 문제가 발생할 수 있다. 따라서 현재 상황의 경우 UPDATE 쿼리로 동시성 문제가 해결 가능할 것으로 보여 분산 락은 사용하지 않기로 결정했다
Part 2. 성능을 위한 Eventual Consistency
반드시 실시간으로 DB에 반영되어야 할까?
하지만 현재 문제는 순차적으로 수행되는 DB 병목이 문제였고, 결국 DB 작업을 실시간으로 진행하지 않아야 성능이 개선될 수 있다. 이를 해결하기 위해 Redis에서 재고 관리를 수행하되, DB에는 해당 변경사항을 나중에 반영해주는 방식을 생각했다. 다만 이는 Eventual Consistency(궁극적 일관성)가 보장되어야 하는 구조로, Redis와 DB 간의 데이터 일관성을 최종적으로 맞춰주는 메커니즘이 필요하다.
Over-Engineering을 피하자
이를 위한 방법에는 Spring Events or Message Queue 기반 비동기 처리(Kafka, RabbitMQ), Batch 작업을 통한 주기적 동기화, Change Data Capture(CDC), Event Sourcing 등 다양한 선택지가 있었다. 이 중에서 Spring Events 방식이 현재 상황에서 가장 적합하다고 판단했다. Kafka나 RabbitMQ 같은 메시지 큐는 다양한 기능을 제공하지만 별도의 인프라 구축과 운영 복잡성이 증가한다. Batch 처리 방식은 구현이 간단하지만 실시간성이 떨어지고 데이터 불일치 기간이 길어질 수 있다. 그리고 CDC와 Event Sourcing은 매우 복잡한 구현이 예상되어 제외하였다. 반면 Spring Events는 Over-Engineering을 피하고자 하는 목표에 부합하며, 구현상 적은 리소스가 드는 방식이다.
Spring Events 방식의 장점으로는 별도 인프라 없이 애플리케이션 내에서 비동기 처리가 가능하고, 설정과 구현이 간단하며, JVM 내부 처리로 네트워크 오버헤드가 없다는 점이 있다. 또한 MQ에 비해 디버깅이 용이하고 트랜잭션과의 연동도 자연스럽게 처리할 수 있다. 하지만 단점으로는 단일 JVM 내에서만 동작하므로 다른 서버 인스턴스와 이벤트를 공유할 수 없고, 서버 장애 시 이벤트가 유실될 수 있다. 특히 분산 환경에서는 각 서버별로 독립적으로 이벤트가 처리되므로 전체 시스템 관점에서의 순서 일관성은 보장되지 않는다.
Redis는 동시성에서 자유롭다
일괄 주문 API의 재고 관리의 경우 동시성 문제는 싱글쓰레드를 사용하는 Redis 특징에 의해 보장되고 있다. 또한 DB 동기화나 알림 발송 같은 부가 작업들은 순서가 정확할 필요가 없다고 생각했다. 최종적인 데이터만 일치하면 되는 Eventual Consistency 특성상, 이벤트 처리 순서보다는 모든 변경사항이 누락 없이 반영되는 것이 더 중요하다. 따라서 Spring Events의 순서가 보장되지 않는 단점이 현재 요구사항에는 큰 영향을 주지 않으며, 단순함과 효율성이라는 장점이 더 크다고 판단했다. Redis와 관련된 내용은 아래에 자세히 정리해두었다
https://chobo-backend.tistory.com/39
Redis의 동작 방식에 대해 아시나요?
소프티어 프로젝트 최종 평가 당일 면접관 분께 사용한 기술에 대해 말씀드리게 되었다. Redis의 Geospatial Index 부분에서 여러 질문과 답이 오가던 중 Redis 동작 방식에 대해 여쭤보셨다. 당시 싱글
chobo-backend.tistory.com
Part 3. Lua Script 기반 재고 관리(+ 정합성 문제)
Redis Transaction과 Lua Script 중에서는 Lua Script를 선택했다. Redis Transaction의 경우 Redis와 여러번 통신하며 낙관적 동시성 제어를 하기 때문에 Race Condition에 취약하다. 또한 조건부 로직 구현 및 실행 중 값 확인이 불가해 복잡한 비즈니스 로직 구현이 어려워 선택하지 않았다. 반면 Lua Script는 Redis 서버에서 원자적으로 실행되어 실행 중에는 다른 클라이언트의 접근을 완전히 차단해 Race Condition으로부터 안전하며 복잡한 조건부 로직도 스크립트 내에서 자유롭게 구현할 수 있어 선택했다.
재고 감소 Lua Script 작성
for i = 1, #KEYS do
local key = KEYS[i]
local deductAmount = tonumber(ARGV[i])
local currentStock = redis.call('GET', key)
if currentStock == false then
return -1 -- MISSING_KEYS
end
currentStock = tonumber(currentStock)
if currentStock < deductAmount then
return -2 -- INSUFFICIENT_STOCK
end
end
for i = 1, #KEYS do
local key = KEYS[i]
local deductAmount = tonumber(ARGV[i])
redis.call('DECRBY', key, deductAmount)
end
return #KEYS
여러 개의 상품들의 재고를 한번에 원자적으로 감소시키기 위해 다음과 같이 스크립트를 작성했다. 먼저 검증을 진행하고 이후에 감소를 진행하는 방식으로 구현했다. 다양한 에러처리를 위해 -1, -2, -3으로 나눴으며 -1에 해당하는 MISSING_KEYS가 발생하는 경우는 현재 Redis에 해당 상품의 재고 데이터가 없는 경우 발생한다. 이때는 DB를 조회하여 Redis에 반영한 후 재고 감소 로직을 재시도하는 과정을 거치도록 구현하였다. 이렇게 재고 감소 처리를 원자적으로 하는 것에는 성공했으나 실제 DB와 데이터 정합성 측면에서 문제가 없는지 다시 살펴봐야한다.
DB동기화를 비동기로 처리한다면 데이터 정합성 문제는??
Redis에 적재된 재고 데이터가 TTL에 의해 만료처리 된 후 다시 DB를 통해 최신화하는 과정을 생각해보자. 이게 문제가 될 수 있는 부분은 DB 최신화를 진행하는 그 순간, TTL이 만료되기 전에 진행된 다른 주문 요청에 의해서 DB에 비동기적으로 처리되고 있는 상황일 수 있다. 즉 DB에 있는 값이 주문이 전부 반영되지 않은 부정확한 데이터일 수 있다는 것이다.
또 다른 경우를 생각해보면, 주문을 처리하는 과정에서 도중에 실패하는 경우가 발생한다면 어떻게 될까? Redis Lua Script의 경우 스크립트 도중에 실패하는 경우에는 Rollback을 시켜주는 기능은 없다. 추가적인 조치가 필요하다
Rollback 도중 또 실패한다면..?
만약 Rollback을 하는 도중 Redis 장애에 의해 또 실패한다면 어떻게 될까? 무한 재시도를 해야하는 것일까? 아니면 Rollback Event를 따로 저장해 Eventual Consistency를 만족하도록 해야할까? 여러 고민 결과, 성능 향상을 위해 Redis를 캐시 레이어로 도입했기 때문에 실제 데이터가 저장된 메인 DB와의 실시간 일관성 보장은 현실적으로 어렵다고 판단했다. 만약 이러한 일관성을 취하기 위해서는 다시 성능을 포기해야하는 방법을 선택해야한다. 결국 모든 장점들을 가진 은탄환 같은 완벽한 방법은 없기에 현재 구현하려는 상황, 요구사항을 생각해서 그에 적합한 방법을 택해야한다.
그럼 다시 돌아와서 Redis를 왜 도입했는가? 성능을 취하기 위해 도입했다. 즉, 성능을 취하는 방법인 만큼 실제 재고 데이터가 100% 정확하게 실시간으로 적용되는 것은 포기해야한다. 그럼 이 단점이 서비스상에서 괜찮은지를 따져보아야 한다. 처음 요구사항에서 주문을 완료한 후 재고 수량을 초과하여 주문을 받아 이후에 주문이 취소가 되는 경우가 사용자에게 불쾌한 경험을 남길 수 있다고 말했었다. 그럼 재고관리가 제대로 되지 않는 경우의 수를 살펴보자.
1. Redis에서 재고 차감 후 실패하는 경우(ex. 서버 다운)
2. Redis에 재고 데이터가 없어 DB 최신화를 했지만, 주문이 전부 반영 되지 않은 데이터인 경우
1. Redis에서 재고 차감 후 실패하는 경우(ex. 서버 다운)
이 경우 트랜잭션 커밋 이전이기 때문에 실패로 처리되어 사용자에게는 빠른 실패 응답을 줄 수 있다. 하지만 문제는 DB는 롤백되어 재고차감이 되지 않았는데, Redis는 Rollback이 되지 않아 재고 불일치가 발생한다는 것이다. 이 경우 따라서 이를 위해 Redis 재고 복구 로직을 한번은 진행되도록 한다. 이마저도 다른 장애로 인해 반영되지 않았다면 이는 따로 조치를 취하지 않도록 하겠다. Redis는 성능을 취하기 위해서 사용하는 것이지 ACID를 완벽하게 지키기 위해 만들어진 것이 아니기 때문이다. 별도로 Redis의 재고 데이터에 TTL을 두어 주기적으로 DB 최신화를 하는 방식으로 불일치를 줄이도록 하겠다
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
fun recordInventoryDeductionEvent(event: InventoryDeductionRequestedEvent) {
val payload = eventSerializer.serialize(event)
val outboxMessage = OutboxMessage.init(INVENTORY_DEDUCTION_EVENT, payload)
outboxMessageRepository.save(outboxMessage)
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
fun restoreInventoryOnRollback(event: InventoryDeductionRequestedEvent) {
runCatching {
inventoryRedisRepository.restoreStocks(event.items)
}.onFailure { ex ->
log.error(ErrorMessage.INVENTORY_RESTORE_ERROR.format(event.orderId), ex)
}
}
위처럼 Transactional Outbox 패턴을 적용해야하는 등 요구사항이 복잡해진다면 TransactionPhase의 BEFORE_COMMIT과 AFTER_ROLLBACK을 활용해 비즈니스 로직에서 책임을 분리시켜 구현할 수 있다. 현재 상황의 경우 Redis 재고 차감을 가장 마지막 단계에 두면 Rollback을 하지 않아도 되었기에 BEFORE_COMMIT 단계에 Redis 재고 차감을 두는 방식을 택해 해결하였다
2. Redis에 재고 데이터가 없어 DB 최신화를 했지만, 주문이 전부 반영 되지 않은 데이터인 경우
이 경우는 정확한 데이터 최신화를 위해 DB에서 조회하였는데, 사실 그 데이터가 정확하지 않은 경우이다. 이를 해결하기 위한 방법으로는 최신화할 당시 DB의 주문 상태를 체크해 완료되지 않은 주문 개수만큼 감소시켜 정확한 값을 얻는 방법이다. 하지만 이 역시 조회하는 과정에서 시간이 소요되고, 서버가 Scale-out 된 경우라면 동시성 문제도 발생 가능하기 때문에 해당 방식은 사용하지 않았다. 대안으로 주문이 적게 일어나는 시간대를 탐색하여 해당 시간에 최신화를 시켜주는 방식도 가능할 것 같다. 일단은 재고 데이터가 없을 때마다 최신화 하는 방법을 택하고, 추후에 이와 관련된 문제가 많이 발생한다면 다른 방법을 적용해보겠다.
Part 4. 개션 후 테스트
👊🏻 목표 성능
Concurrent Users(동시 사용자 수) = 500명
Average Response Time(평균 응답 시간) = 200ms 이하
TPS(Think Time 100ms 기준) = 1666 TPS
💻 테스트 환경
WAS(SpringBoot) 1대 : AWS t4g.micro(2 vCPU, 1GIB Memory)
부하테스트 툴 : nGrinder
APM 툴 : Pinpoint
Metric 수집 및 시각화 툴 : Prometheus + Grafana
🕹️ 테스트 방식
vUsers : 50명
Duration : 1m
Sleep Time : 500ms
일괄 주문 API - 3가지 물품 하나씩 구매하는 방식으로 진행
개선 전


개선 후


📈 개선 전 대비 결과 비교 (Before vs After)
| Metric | Before (개선 전) | After (개선 후) | 개선 효과 |
| TPS (Transactions/sec) | 81 | 108.7 | 34.2% ↑ |
| p99 latency | 1340 ms | 402 ms | 70.0% ↓ |
| Average response time | 625 ms | 159 ms | 74.6% ↓ |
- TPS: 초당 처리 가능한 트랜잭션 수가 81 → 108.7로 약 34.2% 증가하여 처리량 향상
- p99 latency: 응답 지연 상위 1% 구간이 1340ms → 402ms로 약 70.0% 단축
- Average response time: 평균 응답시간이 625ms → 159ms로 약 74.6% 단축
Part 5. 테스트 결과 해석
AWS t4g.micro의 한계인가?
물론 개선 전보다 퍼센트 수치로 보면 나쁘지 않게 개선되었지만 TPS가 108이기에 목표 TPS에 도달하려면 한참남았다. 응답시간은 상당히 개선되어서 TPS를 올리기 위해 더 높은 부하를 주어보았지만 서버가 바로 터졌다.. CPU도 상당히 높은 수치 관찰되는 것으로 보아 현재 서버 스펙상으로 목표 TPS를 맞추기에는 부담이 있는 것으로 보인다.
Kafka와 같은 외부 메시지큐를 두어야 할까?
부하테스트를 수십번 진행하면서 자주 보았던 로그로는 비동기 풀이 꽉차 기본 톰캣 쓰레드풀로 넘어간다는 로그였다. RejectedExecutionHandler로 CallerRunsPolicy를 세팅해둔 상태였기 때문에 쓰레드 풀이 넘친 이후로는 비동기가 아니라 동기로 진행되면서 응답시간에 영향을 미치는 것을 체크했다. 따라서 쓰레드 풀을 계속 늘려보며 테스트를 진행했을 때는 아래처럼 더 처참한 결과를 확인할 수 있었다.

왜 쓰레드 풀을 늘리는 것으로 해결이 안될까?
쓰레드가 많을수록 서버는 쓰레드 생성에 더 많은 메모리를 할당해야하고, 쓰레드 수가 너무 많아지면 CPU는 대부분의 작업을 Context Switching을 하는데 소비한다. 하나의 쓰레드당 1~2 mb 정도가 필요하고, 현재 서버 스펙상(RAM 1GB) 아무리 최대로 잡아도 500~1000개가 한계다.
이제는 Scale-up 하자!
쿼리 개선부터 Redis까지 뒀지만 이 상태로 목표 TPS로 가는 것은 불가능해보인다. 여기서 Scale-up을 진행하고 더 필요하다면 Kafka를 도입해 추가 개선을 이어나가도록 하겠다
'프로젝트' 카테고리의 다른 글
| [바로] 인기상품조회 기능 개선 (Ft. DB Connection Pool) (0) | 2025.09.05 |
|---|---|
| [바로] Redis의 Lua Script는 Atomic 하지 않다..? (0) | 2025.09.04 |
| [바로] DeadLock 범인 찾기 (Ft. 위험한 FK?) (3) | 2025.08.25 |
| [바로] 단일 주문 성능 개선 삽질기 (Ft. JPA save, FK) (0) | 2025.08.20 |
| [바로] 반복되는 인증,인가 처리 없애버리기(Ft. AOP & ArgumentResolver) (0) | 2025.08.08 |