Part 0. 개선할 기능
https://chobo-backend.tistory.com/56
[바로] 일괄 주문 기능 개선 Vol.1 (Ft. Eventual Consistency)
https://chobo-backend.tistory.com/54 [바로] 단일 주문 성능 개선 삽질기 (Ft. 목표 TPS 1666 vs 현실 187.4)상품을 주문하는 행위는 E-Commerce 도메인에서 가장 중요한 기능 중 하나이다. 먼저 재고 관리 측면에서
chobo-backend.tistory.com
지난 포스팅에서 일괄 주문을 개선하고 목표 성능에 도달하지 못했고 확장성을 고려해 개선을 더 진행해보려고 한다. 기능에 대해서 간단히 설명하면 사용자가 장바구니에 담은 여러 물품들을 한번에 주문하는 기능이다.
Part 1. 문제점 및 설계 고민
Spring Event는 이벤트 유실 가능성 존재
Spring Event는 메모리 기반으로 동작하기 때문에 애플리케이션이 예기치 않게 종료되거나 장애가 발생하면 처리 중이던 이벤트가 유실 가능하다. 특히 현재 기능은 주문, 결제와 같은 비즈니스적으로 정말 중요한 이벤트이기 때문에 절대 유실되면 안된다. 하지만 현재는 이벤트 재처리나 복구 메커니즘이 없어 장애 상황에서 매우 취약한 구조다.
Scale-Out 및 MSA 고려 필요
먼저 Scale-out을 생각해보면 각 인스턴스는 독립적으로 동작하여 이벤트를 공유할 수 없는 구조적 한계가 있다. 또한 MSA로의 전환을 생각해보면 서비스 간 이벤트 기반 통신이 필수가 되었는데 Spring Event로는 JVM 경계를 넘나드는 이벤트 전달이 불가능하다. 즉, 초기 목표였던 Scale-Out 과 MSA를 고려한다는 목표를 위해서라도 현재 구조는 변화가 필요했다
대안으로는 Kafka!
RabbitMQ의 경우 AMQP 프로토콜을 기반으로 하며 Exchange-Queue-Binding 구조를 통해 다양한 라우팅 패턴을 지원하고, manual acknowledgment 설정으로 메시지 유실 문제도 해결할 수 있다. 하지만 Scale-Out이 제한적이고 병렬 처리가 제한적이라 대용량 트래픽 환경에서는 성능 한계가 있다. 또한 컨슈머 그룹 개념이 없어 동일한 메시지를 여러 마이크로서비스가 독립적으로 소비하려면 각각 별도 큐를 생성해야 하는 복잡성이 있다. 그리고 Redis Pub/Sub은 인메모리 기반의 초고속 성능을 제공하지만 메시지 영속성이 전혀 보장되지 않고, 구독자 장애 시 메시지 재처리가 불가능한 구조적 한계가 있었다.
결과적으로 Kafka를 선택한 이유는 로그 기반 아키텍처로 디스크에 순차 쓰기하여 대용량 스트리밍 환경에서 더 높은 처리량을 제공하면서도 replication factor를 통해 데이터 안전성을 보장하기 때문이다. 또한 파티션 단위로 메시지를 분산 저장해 수평 확장이 용이하고, 컨슈머 그룹별로 독립적인 오프셋 관리를 통해 같은 토픽을 여러 서비스가 각자의 속도로 소비할 수 있다. 특히 메시지 retention 기간 동안 재처리가 가능하고, exactly-once 처리를 지원해 정확한 전달을 보장한다
Part 2. 1차 개선 (Kafka 도입)
💻 테스트 환경
- 단일 WAS(SpringBoot) : AWS EC2 t4g.xlarge(4 vCPU, 16GiB Memory)
- 단일 DB(MySQL) : AWS EC2 t4g.medium(2 vCPU, 4GiB Memory)
- Kafka 단일 Broker : AWS EC2 t4g.medium(2 vCPU, 4GiB Memory)
- 부하테스트 툴 : Locust
- APM 툴 : Pinpoint
- Metric 수집 및 시각화 툴 : Prometheus + Grafana
🧪 테스트 방식
- vUser : 500
- Ramp up : 100
- Run TIme : 5m
- Think Time : 1~3초 랜덤 적용
🕹️ 부하테스트 결과
TPS : 241
p99 latency : 352ms
Average response time : 53ms
📈 개선 전 대비 성능 비교 (Before vs After)
Metric | Before (개선 전) | After (개선 후) | 개선 효과 |
TPS (Transactions/sec) | 108.7 | 241 | 121.7% ↑ |
p99 latency | 402 ms | 352ms | 12.4% ↓ |
Average response time | 159 ms | 53ms | 66.7% ↓ |
참고로 개선 전은 일괄 주문 개선 Vol.1의 마지막 개선 수치이다.
- TPS: 초당 처리 가능한 트랜잭션 수가 108.7 → 241로 약 121.7% 증가
- p99 latency: 응답 지연 상위 1% 구간이 402ms → 352ms로 약 12.4% 단축
- Average response time: 159ms → 53ms로 약 66.7% 단축
Part 3. 2차 개선 (Ft. Transactional Outbox Pattern)
주문 발생 이벤트는 Kafka로 100% 전송이 되는가?
현재 주문 기능에서 Redis에서 재고를 차감한 후에 주문 생성 이벤트를 발급해 DB의 재고 차감을 비동기로 진행하고 있다. 주문 기능은 데이터 일관성이 매우 중요하기에 도중에 이벤트가 유실되어서는 안된다. 그러나 현재 구조에서는 주문 트랜잭션이 Commit된 이후 이벤트를 발행하는 과정에서 서버 장애나 Kafka에 장애가 발생할 경우 메시지 전달에 실패하여 이벤트가 유실될 위험이 있다.
이를 해결하기 위한 방법으로는 Transactional Outbox Pattern이 있다. 이는 Kafka로 전송할 이벤트를 비즈니스 트랜잭션 내부에서 DB에 함께 저장하고, 별도의 프로세스가 주기적으로 모니터링해 kafka로 전송하는 방식이다. 장애로 인해 이벤트 전송이 실패하더라도 데이터베이스에 저장된 이벤트를 기반으로 재전송이 가능하므로 이벤트 유실 없이 안정적으로 메시지를 전달할 수 있다.
Outbox 테이블을 Polling 방식을 조회 후 Kafka로 전송
Outbox에 저장된 메시지를 전송하는 방법에는 2가지가 있다. 먼저 Polling Publisher Pattern은 주기적으로 Outbox 테이블을 조회하여 미발행된 메시지를 찾아 Kafka로 전송하는 방식으로 구현이 간단하다. 반면 Transaction log tailing Pattern은 데이터베이스 테이블을 직접 조회하는 대신 MySQL의 트랜잭션 로그(binlog)를 실시간으로 읽어 변경사항을 메시지 브로커로 전송하는 방식이다.
이중에서는 비교적 구현이 간단한 Polling Publisher Pattern을 선택했다. Transaction log tailing Pattern의 경우 CDC(Change Data Capture) 구성이 필요하고 추가적인 인프라 리소스와 복잡성이 요구되기 때문에, 우선 Polling Publisher Pattern으로 구현하여 검증해보기로 했다.
acks=all 설정으로 전송 성공 여부를 체크
acks 설정에는 acks=0, acks=1, acks=all 3가지 방법이 있다. acks=0은 Producer가 메시지 전송 후 응답을 기다리지 않는다. acks=1의 경우 Leader 브로커만 저장하면 응답한다. 마지막으로 acks=all은 Leader와 모든 In-Sync Replica가 저장을 완료해야 응답한다. 따라서 acks=all의 경우 메시지 전송 성공 여부를 거의 100% 보장할 수 있지만 모든 복제본의 저장을 기다려야 하므로 처리량이 낮다. 일단 주문 기능의 경우 데이터 일관성이 중요하기 때문에 acks=all로 구현하고 추후 성능 문제가 발생할 경우 acks=1를 고려하도록 하겠다
Part 4. 개선 결과
마지막으로 일괄 주문 기능의 아무런 개선이 없었을 때와 Vol.2 개선까지 마친 수치를 비교해 보겠다.
📈 개선 전 대비 성능 비교 (Before vs After)
Metric | Before (개선 전) | After (개선 후) | 개선 효과 |
TPS (Transactions/sec) | 81 | 241 | 197.5% ↑ |
p99 latency | 1340 ms | 352ms | 73.7% ↓ |
Average response time | 625 ms | 53ms | 91.5% ↓ |
- TPS: 초당 처리 가능한 트랜잭션 수가 81 → 241로 약 197.5% 증가
- p99 latency: 응답 지연 상위 1% 구간이 1340ms → 352ms로 약 73.7% 단축
- Average response time: 625ms → 53ms로 약 91.5% 단축
Part 5. 개선을 마치며..
결과적으로 목표 성능 도달!
동시 사용자 수 500명 기준 평균 응답 시간 53ms에 도달하면서 Vol.1 첫 시작의 목표였던 100ms 이하에 성공했다. 지금까지 개발을 해오면서 이정도로 딥하게 여러 과정들을 거치면서 성능 개선을 해본 적이 없었는데 이번 개선을 진행하면서 정말 많은 것을 배웠다. 먼저 성능 측정을 위한 부하 테스트, 모니터링에 필요한 여러 도구들을 직접 설치해보고 삽질도 해보면서(Pinpoint 설치가 너무 힘들었다..) 다음 프로젝트에서는 정말 빠르게 개선을 진행할 수 있을 것 같다. 무작정 Scale-Up 이나 Scale-Out 하지 않았던 것이 큰 도움이 되었다.
하지만 아직 내가 인지하지 못하는 부분에서 허점이 분명히 있을 것이다. 테스트 과정에서 문제가 있었을 수도 있다. 또한 현재 주문 기능의 경우 결제, 배송 등등 부가적인 요소들을 제외한 상태로 실제 실무의 기능에 비하면 정말 쉬운 기능일 것이다. 따라서 이번 개선은 마쳤지만 현재 기능의 한계점과 추후 개선할 수 있는 방향성을 남겨본다.
아키텍처에 SPOF(단일 장애 지점)이 많다
현재 아키텍처는 대부분의 경우 단일 노드로 이루어져있어 단일 장애 지점으로 하나만 장애가 생겨도 서비스 전체에 영향을 준다. 이는 매출에 큰 영향을 줄 수 있는 중대한 문제로 절대 발생해서는 안된다. 따라서 추후 개선에서는 SPOF를 만들지 않고 클러스터로 구축해보며 그 과정에서 생기는 문제들을 만나보고 싶다. 또한 홈서버를 구축해서 k8s 클러스터도 구축해 MSA에서의 문제들도 마주하고 싶다. (얼른 취업을 해서 실무에서의 복잡하고 어려운 문제들을 만나길... )
MSA 환경에서의 분산 트랜잭션 관리?
E-Commerce 실제 주문 기능을 생각해본다면 1. 주문, 2. 재고 관리, 3. 결제, 4. 배송, 5. 알림 이 정도로 크게 나눌 수 있을 것이고 더 존재할 수도 있다. 그렇다면 MSA에서 해당 트랜잭션들이 어떻게 원자적으로 실행될 수 있을까? 대표적으로는 Saga 패턴을 사용한다는 것을 알 수 있었다. Saga 패턴도 기능의 종류에 따라 Orchestration, Choreography 방식으로 나뉜다. 간단하게 이야기하면 중앙에서 관리하냐 아니면 각 서비스가 독립적으로 관리하냐 차이이다.
결과적으로 다음 개선에서는 MSA로 서비스를 전환하고 SAGA패턴도 직접 구현해보면서 더 많은 트래픽을 견딜 수 있는 아키텍처를 구성해보려고 한다. 바로 진행하지는 않고 이번 하반기 취업에 실패하면 진행해보려고 한다.
'프로젝트' 카테고리의 다른 글
[바로] 룩 상세 조회 기능 개선 (Ft. Redis 캐싱) (0) | 2025.09.26 |
---|---|
[바로] 인기상품조회 기능 개선 (Ft. DB Connection Pool) (0) | 2025.09.05 |
[바로] Redis의 Lua Script는 Atomic 하지 않다..? (0) | 2025.09.04 |
[바로] 일괄 주문 기능 개선 Vol.1 (Ft. Eventual Consistency, Lua Script) (0) | 2025.08.27 |
[바로] DeadLock 범인 찾기 (Ft. 위험한 FK?) (3) | 2025.08.25 |