checks_total.......: 66809 262.289122/s
checks_succeeded...: 100.00% 66809 out of 66809
checks_failed......: 0.00% 0 out of 66809
✓ [S3 poller] 200
✓ login 200
✓ login has token
✓ [S3] 주문 성공 또는 정상 품절
✓ [S3] 서버 에러 없음
CUSTOM
openrun_5xx_count..............: 0 0/s
openrun_duration_ms............: avg=1.44s min=16ms med=1.26s max=5.3s p(90)=2.68s p(95)=3.03s
openrun_success_rate...........: 100.00% 7822 out of 7822
poller_api_success.............: 100.00% 48765 out of 48765
sold_out_skip..................: 851 3.340988/s
HTTP
http_req_duration..............: avg=543.49ms min=1.78ms med=4.39ms max=5.3s p(90)=2.05s p(95)=2.46s
{ expected_response:true }...: avg=543.49ms min=1.78ms med=4.39ms max=5.3s p(90)=2.05s p(95)=2.46s
http_req_failed................: 0.00% 0 out of 67313
http_reqs......................: 67313 264.267804/s
EXECUTION
iteration_duration.............: avg=11.66s min=59.06ms med=12.34s max=19.64s p(90)=14.72s p(95)=15.05s
iterations.....................: 57438 225.498999/s
vus............................: 2 min=0 max=4367
vus_max........................: 5200 min=1580 max=5200
NETWORK
data_received..................: 322 MB 1.3 MB/s
data_sent......................: 14 MB 55 kB/s
기능적으로는 정상적이었습니다.
주문 성공률 100% (성공 또는 정상 품절, 재고 부족 포함)
비정상 응답, 5xx 오류 0건
HTTP 요청 실패 0건
하지만 주문 응답 p95가 3.03초로 목표였던 3초를 초과했습니다.
처음 의심했던 부분
처음에는 애플리케이션 내부에 병목이 있다고 생각했습니다.
다음 항목들을 우선 확인했습니다.
MySQL 커넥션 풀 부족 여부
Bull Queue 워커의 concurrency 설정
Redis Lua Script 실행 시간
Redis 네트워크 지연
주문 처리 로직의 불필요한 DB 조회
PM2 프로세스의 CPU 및 메모리 사용량
하지만 애플리케이션 로직에서는 응답 시간을 3초까지 지연시킬 만한 명확한 병목을 찾지 못했습니다.
특히 주문 성공률이 100%이고 5xx 오류도 없다는 점이 이상했습니다.
애플리케이션이나 데이터베이스가 한계에 도달했다면 다음과 같은 현상이 함께 나타날 가능성이 높았습니다.
DB 커넥션 타임아웃
Redis 요청 실패
Queue 적체
5xx 오류 증가
주문 성공률 하락
그러나 실제 결과에서는 응답 시간만 느려졌습니다.
그래서 테스트 대상 서버가 아닌, 부하를 생성하는 환경을 확인했습니다.
부하생성기 자체가 병목이었다
부하테스트는 운영 애플리케이션 서버와 분리된 T3.large 인스턴스에서 실행하고 있었습니다.
문제는 해당 서버에서 k6와 Artillery를 동시에 실행하고 있었다는 점입니다.
k6: HTTP API 부하 생성
Artillery: WebSocket 연결 부하 생성
최대 k6 VU: 5,200
k6와 Artillery가 동일한 서버의 CPU와 메모리 사용
테스트 도중 top으로 부하생성기 상태를 확인했습니다.
CPU idle 0% - CPU가 쉬지 않고 모두 사용되고 있었습니다. 부하생성기가 추가 요청을 안정적으로 생성하고 응답을 처리할 여유가 없었다는 의미입니다.
사용자·커널 연산 비중 증가 - 사용자 프로세스가 CPU의 78.1%, 네트워크와 소켓 등을 처리하는 커널 작업이 13.6%를 사용했습니다. 소프트 인터럽트도 7.8%로, 대량의 네트워크 요청을 처리하는 부담이 컸습니다.
I/O wait는 0.5% - 디스크 I/O 대기는 거의 없었습니다. 따라서 이 상황의 핵심은 디스크나 메모리 고갈이 아니라 부하생성기의 CPU 포화 및 네트워크·소켓 처리 부담 증가 였습니다.
결국 k6와 Artillery를 함께 실행하던 부하생성기가 먼저 CPU 한계에 도달했고, 요청 생성과 응답 측정이 지연되면서 부하생성기의 CPU 포화가 요청 생성과 응답 처리에 영향을 주면서, 기존 응답시간 측정값에 부하생성 환경의 병목이 크게 반영된 것으로 판단했습니다.
다른 환경에서 동일 시나리오 재실행
부하생성기 병목이라는 가설을 검증하기 위해, 리소스 여유가 있는 로컬 윈도우 환경에서 동일한 스크립트로 테스트를 다시 실행했습니다.
부하 테스트 결과 Redis와 RDS는 비교적 여유가 있었지만, EC2 CPU 사용률은 지속적으로 높은 상태를 유지했다.
특히 Socket 연결 유지, Polling 요청 처리, 주문 API 처리 등이 모두 애플리케이션 서버에 집중되면서 CPU 사용률이 병목 구간에 진입하는 현상을 확인할 수 있었다.
초기에는 동일한 범용 인스턴스 계열(m5.xlarge)로의 증설도 고려했지만, 병목 원인이 CPU 성능이라고 판단하여 Compute Optimized 계열인 C7i를 우선 검토하였다.
먼저 c7i.xlarge 환경에서 동일한 부하 테스트를 수행하였다.
모든 환경은 동일
EC2 m5.large → c7i.xlarge 변경
시나리오
Polling User 90%
Ordering User 10%
동시 접속
1000명
2000명
3000명
4000명
C7i.xlarge 신버전 1000명 테스트
artillery 소켓연결 1000명 이후 k6 실행 후 7분 30초 경과
1000명 규모에서 충분히 안정적인 성능을 보였다.
C7i.xlarge 신버전 2000명 테스트
artillery 소켓연결 2000명 이후 k6 실행 후 7분 30초 경과
2000명 규모에서 충분히 안정적인 성능을 보였다.
C7i.xlarge 신버전 3000명 테스트
artillery 소켓연결 3000명 이후 k6 실행 후 7분 30초 경과
3000명 규모에서 충분히 안정적인 성능을 보였다.
C7i.xlarge 신버전 4000명 테스트
artillery 소켓연결 4000명 이후 k6 실행 후 7분 30초 경과
4000명 규모에서도 충분히 안정적인 성능 보였다 .
신버전 5000명 테스트
artillery 소켓연결 5000명 이후 k6 실행 후 7분 30초 경과
구조 개선 후 c7i.xlarge 환경에서 5000명 규모까지 안정적으로 처리할 수 있었다.
-결과-
테스팅 목표 4000명 동시 시청 방송중 극한의 상황 에서도 3초 이내에 응답하는 환경 만들기
오픈런 테스트 (극한상황에서 집중 테스트)
4000명이 방송을 보는 도중 갑자기 1200명이 30초 안에 동시에 주문 버튼 연속으로 누르면 서버가 버티는가?
(Redis Lua / Bull Queue / DB 한계 확인)
테스트 시나리오
00:00 ~ 01:00 - 시청자 4,000명 접속 - 상품 상태 주기 조회
01:00 - 오픈런 시작 - 구매자 1,200명 동시 진입
01:00 ~ 01:30 - 상품 조회 - 주문 요청 집중 발생
01:35 - 오픈런 종료
04:00 - 테스트 종료
시청자 그룹 : 상품 목록 및 상태를 10~15초 간격으로 반복 조회하며, 오픈런이 발생하는 순간에도 지속적으로 API를 호출하도록 구성하였다.
구매자 그룹: 각 사용자는 로그인 후 상품 목록을 조회하고, 별도의 대기 시간 없이 즉시 주문을 시도하였다. 또한 1~3초 간격으로 주문을 재시도하여 실제 사용자가 반복적으로 구매 버튼을 누르는 상황을 재현하였다. 특히 sleep 시간을 최소화하여 서버가 감당할 수 있는 최대 처리량을 확인하는 데 초점을 맞추었다.
서버가 장애 없이 모든 요청을 처리했다
Redis Lua Script 기반 재고 선점 로직 역시 정상적으로 동작하였으며, 과매도(Oversell) 현상 없이 모든 주문 요청이 처리되었다.
다만 주문 응답 시간(P95)은 4.77초로 측정되어 목표 기준인 3초 이내를 만족하지 못하였다.
솔드 아웃 테스트
품절(Sold Out) 테스트는 동시성 환경에서 재고 정합성이 올바르게 유지되는지를 검증하기 위한 시나리오이다.
2,800명의 시청자와 1,200명의 구매자가 동시에 접속하는 상황을 구성하였다.
구매자는 상품 조회 과정을 생략하고 즉시 주문을 시도하도록 설정하였으며, 재고가 소진된 이후에도 1~3초 간격으로 지속적으로 주문을 재시도하였다.
이번 테스트를 통해 현재 주문 시스템은 약 138 TPS 수준의 처리량을 안정적으로 처리할 수 있음을 확인하였다.
주문 성공률은 99.99%, 서버 오류율은 0%로 측정되었으며, 약 84,000건 이상의 주문이 정상 처리되었다.
반면 응답시간 측면에서는 개선이 필요한 것으로 확인되었다.
주문 응답시간 P95는 약 4.2초로 측정되었으며, 전체 주문의 약 19%는 3초를 초과하였다.
흥미로운 점은 서버 CPU, Redis CPU 사용률이 포화 상태가 아니었음에도 응답시간이 증가했다는 점이다.
이는 단순한 CPU 부족 문제가 아니라 Queue 처리 대기, DB 처리 대기, 네트워크 지연 등 시스템 내부의 대기시간이 누적된 결과로 판단된다.
따라서 본 테스트는 현재 구조가 높은 처리량을 확보했음을 확인하는 동시에, 향후 응답시간 개선을 위해 추가적인 병목 분석이 필요함을 보여주었다.
종합 현실 시나리오
앞선 테스트들은 각각 하나의 목적에 집중하였다.
하지만 실제 라이브커머스 방송은 단일 상황만 발생하지 않는다.
시청자가 방송을 시청하고, 일반 구매가 발생하고, 특정 시점에는 오픈런 이벤트가 열리며, 방송 종료 직전에는 마감 특가 구매가 몰리는 등 다양한 트래픽 패턴이 연속적으로 발생한다.
따라서 마지막 단계에서는 실제 방송 15분을 그대로 재현한 종합 시나리오를 구성하여 테스트를 수행하였다.
방송 중 지속적인 조회 트래픽 처리
일반 구매자의 반복 주문 처리
오픈런 이벤트 발생 시 재고 정합성 유지
마감 특가 이벤트 처리
전체 방송 구간 동안 서비스 안정성 유지
시나리오 타임라인
00:00 ~ 01:00
방송 시작
시청자 4,000명 점진 진입
01:00 ~ 03:00
일반 구매 시작
600명 구매자 활동
03:00 ~ 03:35
오픈런 이벤트
500명 동시 진입
한정 상품 300개 판매
03:35 ~ 11:00
일반 방송 지속
11:00 ~ 13:30
마감 특가
300명 추가 구매
13:30 ~ 15:00
방송 종료
시청자 (Poller)
시청자는 실제 방송을 시청하는 사용자로 가정하였다.
10~15초 간격으로 상품 상태 API를 조회하며 지속적으로 조회 트래픽을 발생시킨다.
구매자 수가 증가하는 구간에서는 전체 접속자 수가 4,000명을 유지하도록 시청자 수를 조정하였다.
일반 구매자
일반 구매자는 실제 쇼핑 행동과 유사하게 구성하였다.
상품 목록 조회 → 상품 선택 → 주문 과정을 수행하며,
30~60초 간격으로 반복 구매한다.
오픈런 상품은 제외하여 일반 방송 구매 행동만 측정하였다.
오픈런 구매자
오픈런 구매자는 특정 한정 상품에 집중하도록 구성하였다.
500명이 5초 이내에 동시에 진입하며,
30초 동안 집중적으로 구매를 시도한다.
재고 300개가 모두 소진되면 이후 요청은 409(품절) 응답을 반환한다.
이를 통해 Redis Lua Script 기반 재고 차감 로직이 실제 오픈런 상황에서도 과매도 없이 동작하는지 검증하였다.
마감 특가 구매자
마감 특가 구매자는 방송 종료 직전 구매를 서두르는 사용자를 가정하였다.
15~30초 간격으로 반복 구매하며 일반 구매자보다 높은 구매 빈도를 가진다.
이를 통해 방송 종료 직전 발생하는 트래픽 집중 현상을 재현하였다.
최종 결과
Redis Lua Script 기반 재고 선점과 Bull Queue 기반 비동기 주문 처리 구조를 적용한 후 다양한 부하 테스트를 수행하였다.
단순 TPS 측정이나 스트레스 테스트에 그치지 않고,
오픈런 상황(S3)
품절 및 재고 정합성 검증(S4)
최대 처리량 측정(S5)
실제 방송 시뮬레이션(S7)
을 각각 분리하여 검증하였다.
특히 S7 종합 현실 시나리오에서는 4,000명의 시청자와 1,400명의 구매자가 참여하는 15분 방송을 재현하였다.
테스트 결과
일반 구매 8,300건 처리
오픈런 상품 300건 판매
품절 응답 4,131건 정상 처리
마감 특가 2,140건 처리
과매도 0건
서버 오류 0건
을 기록하였다.
또한 Redis Lua Script 기반 재고 선점 구조를 통해 대량 동시 주문 환경에서도 재고 정합성을 유지할 수 있음을 확인하였고, Bull Queue 기반 비동기 처리 구조를 통해 높은 처리량에서도 안정적인 주문 처리가 가능함을 검증하였다.
다만 오픈런(S3) 및 High Stock(S5) 시나리오에서는 주문 응답시간 P95가 약 4~5초 수준으로 측정되어 목표로 설정한 3초 이내 응답시간에는 도달하지 못하였다.
이는 서버 자원 부족보다는 Queue 처리 대기, DB 처리 대기 등의 내부 병목 가능성을 시사하며, 향후 Worker Concurrency 조정, 주문 처리 로직 최적화, Queue 처리 구조 개선 등을 통해 추가적인 성능 개선이 가능할 것으로 판단된다.
이번 개선을 통해 단순히 성능을 높이는 것을 넘어, 대규모 동시 접속 환경에서도 정합성과 안정성을 유지할 수 있는 주문 처리 구조를 구축할 수 있었다.
처음 캐싱을 이렇게 시도 했었는데, TTL 10초가 완료된 후 1800명이 10초마다 호출하는 상황에서 캐시가 만료되는 상황에 수백 명이 동시에 Redis Miss 발생 하면서 모든 요청이 DB 로 가는 현상이 발생했다.
// 1차: 캐시 히트
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
if (cached === '__NONE__') throw new NotFoundException('정보없음');
return JSON.parse(cached);
}
// 캐시 미스 → 락 획득 시도 (NX: 없을 때만 SET, PX: 3초 자동 해제)
if (lock !== 'OK') {
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 100));
const retried = await this.redis.get(cacheKey);
if (retried !== null) {
if (retried === '__NONE__') throw new NotFoundException('정보없음');
return JSON.parse(retried);
}
}
throw new NotFoundException('정보없음');
}
// 락 보우자면 DB 조회
적용 후 결과
█ THRESHOLDS
http_req_duration{endpoint:live_status}
✗ 'p(95)<500' p(95)=8s
http_req_duration{endpoint:order}
✗ 'p(95)<2000' p(95)=7.87s
http_req_failed
✓ 'rate<0.05' rate=0.17%
order_duration_ms
✗ 'p(95)<2000' p(95)=7.87s
order_success_rate
✓ 'rate>0.90' rate=98.41%
poller_api_success
✓ 'rate>0.99' rate=99.99%
캐시 스탬피드 문제는 존재했고 어느 정도 해결 되었지만 목표치에는 함잠 멀었다.
Polling = 8s / Order = 7.87s 거의 똑같았다. 즉 서버 전체가 느려지는 상황이였다.
Redis Hit Rate, Queue 상태 확인
상품리스트 캐싱 부분에
console.log([product list] HIT redis=${redisGetMs}ms total=${Date.now() - t0}ms);
라이브커머스 프로젝트의 기존 구조는 캐시를 사용하지 않고, 재고 조회, 주문 등등 모든것을 DB로 사용하는 구조였다.
구조를 수정 후 Redis LuaScript 와 BullQueue 를 사용하여 상품의 재고 정합성과,
비동기로 주문을 처리하는 구조로 DB앞단의 Buffer 를 만들어 안정화 시켰다.
테스트는 artillery 로 목표 명수인 n명 분의 소켓을 발생시킨 후 유지, K6 파일을 사용하여 n명 유저 수 와 주문수(10%) 다르게 했고, 다른 조건은 모두 똑같이 진행했다.
구버전 테스트 환경
- Backend: NestJS
- DB: MySQL
- Redis: 없음 (구버전)
- 서버: EC2 m5.large
- DB: RDS MySQL
- 테스트 도구: k6(API) / artillery (socket)
시나리오
- Polling User 90%
- Ordering User 10%
동시 접속
- 1000명
- 2000명
- 3000명
- 4000명
구버전 1000명
- artillery 소켓연결 1000명 이후 k6 실행 후 7분 30초 경과 (서버상태)
█ THRESHOLDS
http_req_duration{endpoint:live_status}
✗ 'p(95)<500' p(95)=3.12s
http_req_duration{endpoint:order}
✗ 'p(95)<2000' p(95)=3.73s
http_req_failed
✓ 'rate<0.05' rate=0.04%
order_duration_ms
✗ 'p(95)<2000' p(95)=3.73s
order_success_rate
✓ 'rate>0.90' rate=99.41%
poller_api_success
✓ 'rate>0.99' rate=100.00%
동시 시청자 1000명 환경에서는 주문 성공률 99.41%를 기록하며 기능적으로는 안정적으로 동작했다.
다만 상품 상태 조회 API p95 응답시간은 3.12초, 주문 API p95 응답시간은 3.73초로 목표치(500ms, 2s)를 크게 초과하였다.
특히 실시간 상품 상태 조회 트래픽이 DB에 직접 집중되면서 조회 지연이 발생하였고,
주문 처리 역시 재고 조회 및 차감 과정에서 DB 락 경합 및 커넥션 대기의 영향을 받는 것으로 추정된다.
즉, 1000명 규모에서는 서비스는 유지되지만 그 이상의 트래픽 증가에 대응할 수 있는 확장성은 부족한 상태였다.
구버전 2000명
- artillery 소켓연결 2000명 이후 k6 실행 후 7분 30초 경과 (서버상태)
█ THRESHOLDS
http_req_duration{endpoint:live_status}
✗ 'p(95)<500' p(95)=18.17s
http_req_duration{endpoint:order}
✗ 'p(95)<2000' p(95)=19.31s
http_req_failed
✓ 'rate<0.05' rate=0.06%
order_duration_ms
✗ 'p(95)<2000' p(95)=19.31s
order_success_rate
✓ 'rate>0.90' rate=98.89%
poller_api_success
✓ 'rate>0.99' rate=100.00%
동시 접속자 2000명 환경에서는 주문 API p95가 19.31초, 상품 조회 API p95가 18.17초까지 증가하였다.
기능적으로는 동작하였으나, 실시간 서비스로 보기 어려운 수준의 응답 지연이 발생하였다.
구버전 3000명
- artillery 소켓연결 3000명 이후 k6 실행 후 7분 30초 경과 (서버상태)
█ THRESHOLDS
http_req_duration{endpoint:live_status}
✗ 'p(95)<500' p(95)=42.05s
http_req_duration{endpoint:order}
✗ 'p(95)<2000' p(95)=43.08s
http_req_failed
✓ 'rate<0.05' rate=0.07%
order_duration_ms
✗ 'p(95)<2000' p(95)=43.08s
order_success_rate
✓ 'rate>0.90' rate=98.49%
poller_api_success
✓ 'rate>0.99' rate=100.00%
주문 성공률은 98.49% 이지만 응답시간이 약 43초 이다, 즉 주문 버튼을 클릭하면 — 43초 뒤에 응답이 온다.
DB가 처리 가능한 처리량을 초과하여 락 대기와 커넥션 대기가 누적된 결과로 판단된다.
구버전 4000명
- artillery 소켓연결 4000명 이후 k6 실행 후 7분 30초 경과(서버상태)
█ THRESHOLDS
http_req_duration{endpoint:live_status}
✗ 'p(95)<500' p(95)=1m0s
http_req_duration{endpoint:order}
✗ 'p(95)<2000' p(95)=1m0s
http_req_failed
✗ 'rate<0.05' rate=19.89%
order_duration_ms
✗ 'p(95)<2000' p(95)=1m0s
order_success_rate
✗ 'rate>0.90' rate=76.43%
poller_api_success
✗ 'rate>0.99' rate=79.91%
4000명 환경에서는 요청 타임아웃이 다수 발생했고, 성공률은 76.43% 까지 하락하였다.
모든 조회/주문/재고처리 등 로직이 DB에 직접 의존하는 구조로 커넥션 풀과 락 경합이 누적된 결과로 분석된다.
더 이상 서비스 운영이 어려운 수준의 장애 상태가 확인되었다.
구버전 구조의 정리
1000명 환경에서는 기능적으로 서비스가 동작하였다.
2000명 환경부터 응답시간이 급격히 증가하기 시작하였으며,
3000명 환경에서는 주문 API p95가 43초를 초과하였다.
4000명 환경에서는 대량의 timeout이 발생하였고, 주문 성공률은 76.43%까지 감소하였다.
모든 요청이 DB에 직접 의존하는 구조로 인하여, 동시 접속자가 증가하면서
- DB Connection Pool 대기
- Lock 경합
- Transaction 대기
가 누적 되었고, 결과적으로 응답시간이 급격하게 증가하였다.
신버전 테스트 환경
- Backend: NestJS
- DB: MySQL
- Redis: 없음 (구버전)
- 서버: EC2 m5.large
- DB: RDS MySQL
-Redis cache + LuaScript + BullQueue
- 테스트 도구: k6(API) / artillery (socket)
시나리오
- Polling User 90%
- Ordering User 10%
동시 접속
- 1000명
- 2000명
- 3000명
---
신버전 1000명
- artillery 소켓연결 1000명 이후 k6 실행 후 7분 30초 경과 (서버상태)
동일한 1000명 환경에서도 성능이 크게 개선되었다.
상품 상태 조회 API p95는 3.12초에서 142ms로, 주문 API p95는 3.73초에서 193ms로 감소하였다.
특히 주문 API 평균 응답시간은 1.69초에서 53ms까지 감소하여 약 32배 이상의 개선 효과를 확인할 수 있었다.
동일한 부하 환경에서도 모든 Threshold를 만족하였으며, Redis를 활용하여 조회 트래픽을 DB로부터 분리한 효과가 크게 나타났다.
신버전 2000명
- artillery 소켓연결 2000명 이후 k6 실행 후 7분 30초 경과 (서버상태)
구버전과 비교하면, 성능은 좋아졌지만 신버전의 경우 1000명 → 2000명의 결과가 193ms → 13s 로 뛰어버렸다.
Redis Cache, Redis Lua Script, Bull Queue를 적용한 이후에도 2000명 환경에서는 응답시간 증가가 발생하였다.
다만 Grafana 확인 결과 CPU 사용률이 100%에 도달하였고 System Load가 155%를 기록하였다.
Polling API와 Order API가 유사한 응답시간을 보인 점을 고려하면, 해당 시점의 병목은 Redis 또는 DB가 아니라 애플리케이션 서버의 CPU 자원 부족으로 판단된다.
만약 Redis 또는 DB 가 병목 이었다면 특정 API만 응답시간이 증가하는 경향을 보였을 것이다.
그러나 Polling API와 Order API가 동시에 지연되었고,Grafana 상 CPU 사용률이 100%에 도달한 점을 고려하면 애플리케이션 서버가 처리 가능한 Request 수 자체를 초과한 것으로 판단된다. 이는 Redis Cache, Lua Script, Bull Queue 적용을 통해 기존의 DB 병목이 상당 부분 해소되었음을 의미한다.
즉, 기존 구조의 DB 병목이 상당 부분 해소되었으며 병목 지점이 애플리케이션 서버로 이동하였다고 예상해 볼 수 있다.
신버전 3000명
- artillery 소켓연결 3000명 이후 k6 실행 후 7분 30초 경과 (서버상태)
처음에는 Redis Lua Script와 Queue를 적용하면 동시성 문제와 성능 문제가 모두 해결될 것으로 생각했다.
그러나 실제 부하 테스트를 진행하면서 병목은 사라지는 것이 아니라 다른 계층으로 이동한다는 사실을 확인할 수 있었다.
기존 구조에서는 DB Lock과 Connection Pool이 한계였지만, 개선된 구조에서는 CPU가 새로운 병목 지점으로 나타났다.
이를 통해 성능 개선은 특정 기술을 도입하는 것이 아니라, 시스템 전체의 병목을 지속적으로 찾아가는 과정이라는 점을 체감할 수 있었다.
결과
이번 개선의 핵심은 단순히 응답시간을 줄인 것이 아니었다.
기존 구조는 모든 요청이 DB에 직접 접근하는 구조였기 때문에 트래픽 증가 시 DB가 가장 먼저 한계에 도달했다.
반면 개선된 구조는
- Redis Cache로 조회 트래픽 분산
- Lua Script로 원자적 재고 차감
- Bull Queue로 DB Write Buffer 구성
을 통해 DB 부하를 크게 감소시켰다.
그 결과 병목 지점은 DB에서 애플리케이션 서버의 CPU로 이동하였다.
이는 시스템이 처리할 수 있는 최대 트래픽이 증가했음을 의미하며, 실제 서비스 운영 환경에서도 훨씬 안정적인 구조를 확보할 수 있었다.
예를 들어 사용자가 1초 동안 결제 버튼을 10번 누르면, 그 자체만으로 불필요한 부하가 발생한다.
Redis SET NX EX 를 사용하여 3초 동안 짧은 cooldown lock 을 적용 하여 불필요한 중복 요청을 차단했다.
2. Lua Script
Redis를 사용하더라도 GET(조회) 후 DECR(차감) 사이에 다른 요청이 끼어 들 수 있고, 이로 인하여 Oversell(초과 판매) 문제가 발생 할 수 있다.
Redis는 싱글 스레드로 동작하지만, 여러 명령어가 순차적으로 실행되는 동안, 다른 클라이언트의 명령이 끼어들 수 있기 때문이다.
예를 들어:
요청 A → 재고 조회 (1)
요청 B → 재고 조회 (1)
요청 A → 차감 (재고 0)
요청 B → 차감 (재고 -1)
이를 해결 하기 위하여, 조회와 차감연산을 하나로 묶어 실행하는 Lua Script를 도입했다.
Redis는 LuaScript가 실행 되는 동안 하나의 원자적(Atomic) 연산으로 취급하므로, 싱글 스레드의 장점을 극대화 하여, 동시성 문제를 해결 할 수 있다.
local qty = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', KEYS[1]))
-- 재고 없음
if not stock then
return -1
end
-- 재고 부족
if stock < qty then
return 0
~~end~~
-- 원자적 차감
local remaining = redis.call('DECRBY', KEYS[1], qty)
return remaining
실제 운영 코드에서는 추가로:
옵션 상품 처리
멱등성 (IdempotencyKey)
Redis 캐시 miss 처리
음수 방어 rollback
TTL 기본 Lock관리
등을 함께 처리했다.
3. Queue
기존에는 주문 요청이 들어올 때마다 DB에서 연산을 했다면 Lua Script 로 Redis에서 안전하게 재고를 선점(확보) 후, Bull Queue를 통해 비동기 처리 하도록 하였다.
이로 인하여 Queue처리 중에도 동일 재고의 중복 차감(oversell)을 방지 할 수 있었다.
Queue 저장소 역활을 하며, 요청 받은 Job을 FIFO(firts in , first out) 방식으로 관리한다.
Worker(processor) 가 Queue에서 Job을 가져와 실제 주문 로직 (DB처리) 을 수행한다.
Queue Event가 Job의 완료/실패 상태를 감지하여 후처리를 수행한다.
특히 서비스가 PM2 cluster로 운영 중이기 때문에, Redis를 Queue 저장소로 사용하면 모든 인스턴스가 단일 Queue를 공유하므로 Job 중복 처리나 누락 없이 Worker를 수평 확장할 수 있다.
4. 장애/실패 처리
Redis 로 Lua Script + Queue 구조로 변경하면서 동시성 문제는 어느 정도 해결 할 수 있었다.
하지만 구조가 복잡해진 만큼, 장애 상황에서 어떻게 데이터 정합성을 유지할 것인지도 중요했다.
장애 상황
Worker 장애
Redis 재시작
Queue 적체
DB insert 실패
재고 rollback 실패
이를 위해 retry, idempotency, 재동기화 전략 등을 함께 구성했다.
Woker 장애 및 retry 처리
→ BullQuque 는 Worker 장애 또는 예외 상황 시 자동 retry 기능을 제공한다.
retry 처리 시 주의해야 할 점은, 재고 복구는 “마지막 retry 실패 시점” 에서 만 수행 하도록 해야 한다.
예를 들어
1차 실패 → 재고 복구
2차 retry → Lua가 다시 차감
3차 실패 → 또 복구
=> 재고 불일치 가능성
const isLastAttempt =
job.attemptsMade >= (job.opts.attempts ?? 1) - 1;
if (isLastAttempt) {
pipeline.incrby(stockKey, order_cnt);
pipeline.del(idemKey);
}
//idemkey 를 삭제하지 않으면, 재시도 기회가 끝났음에도 키다 남아서 해당 사용자가 다시
//주문을 시도할 때도 차단 된다.
retry 중간 단계에서는 재고 상태를 유지하고, 최종 실패 시에만 rollback 하도록 구성했다.
Redis 재시작 대응
→ Redis는 메모리 기반이기 때문에, 재시작 시 데이터가 유실 될 수 있고, 따라서 재고 상태를 어떻게 복구 할지도 중요하다.
Redis reconnect 후 ready 이벤트를 감지하여 DB 기준으로 재고를 다시 preload 하도록 구성했다.
this.client.on('ready', async () => {
// 루아스크립트 로드
await this.loadLuaScript();
//재연결 확인 플래그
if (!this._wasDisconnected) return;
//데이터 베이스 기준 재고 복구 실행
for (const handler of this._reconnectHandlers) {
await handler();
}
});
최종 재고 기준은 DB로 유지 했으며, reconnect 시마다 Lua Script SHA 를 다시 등록하여 NOSCRIPT 에러도 함께 방어 했다.
중복 요청 방지
→ 사용자의 연속 클릭이나 네트워크 재시도로 인해 동일 요청이 여러 번 들어 올 수 있었다.
Redis idempotency key
DB unique constraint
를 사용하여, 이를 방지 했다.
Redis 에서는 Lua Script단계에서 먼저 중복 요청을 차단하고,
if redis.call('EXISTS', idem_key) == 1 then
return {-2, 0}
end
Worker 단계에서는 DB unique constraint 및 idempotencyKey 기반 조회로 한 번 더 검증했다.