-
#1. 특정 상품에 동시 주문 요청 증가 시 DB 커넥션 부족 문제 해결 과정개발 이야기 2024. 11. 13. 23:16
[상황 설명]
선착순 구매 프로젝트에서 주문 API를 완성하고, 서버 환경에서 부하 테스트를 진행하였다.
특정 상품에 100개의 주문 요청이 동시에 몰릴것을 가정한 시나리오로 테스트를 진행했는데, [실패가 많았다.]
[원인 분석]
주문 API에서 재고 차감 시 해당 상품 row에 묵시적 X-Lock이 걸리며, 이는 트랜잭션이 종료될 때까지 유지된다.
외부 API와 통신하는 결제 요청은 거의 800 ~ 900ms 가까이 걸리기 때문에 하나의 주문 API 실행 시간은 평균 1s를 넘게된다.

[그림1. 주문 정상 처리 과정 중 일부] 특정 상품에 주문이 몰렸을 때 X-Lock으로 인해 순차적으로 실행되는데, 자신보다 앞선 주문 개수 x 1s의 시간을 대기하게 된다.
문제는 자원 대기 시간 제한에 있는데, Hikari Pool의 경우 connection-timeout이 30s 이다.
시나리오에서 30번째 이상의 요청이 부분적으로 실패한 이유를 알 수 있었다.

[그림n. A 상품에 주문 요청이 몰렸을 때] 원인 분석을 바탕으로 해결해야 할 문제를 정의할 수 있었다.
- 특정 상품에 주문이 몰리면, 긴 트랜잭션으로 DB 커넥션이 부족해져 신규 주문들은 타임아웃과 함께 실패한다.
- 특정 상품 주문의 DB 커넥션 독점으로 다른 상품의 주문 처리가 불가능해진다.
[접근 방법]
# 커넥션 설정을 변경하기
당장 떠오르는 해결 방법은 timeout 시간과 connection-pool 크기를 늘려주는 방법이다.
더 많은 요청들이 더 오래 대기할 수 있지만 대기 시간만 길어질 뿐이다. 더 많은 주문 요청이 오면 결국 실패하기 때문에 근본적인 문제는 해결되지 않는다.
실제로 hikari 설정을 변경하고 같은 부하를 주었을 때, 아래와 같은 결과가 나온다.
- Hikari Pool 설정 변경(connection-timeout : 30s -> 3m, pool-size : 10 -> 20) 했을 때 : Client Read Timeout 발생
- 클라이언트 단 주문 API Read Timeout 설정 늘리기 : Client 기약없이 대기
# 대기열로 자원을 빠르게 release 하기
긴 대기로 인한 timeout과 DB 커넥션 독점 문제를 해결하기 위해 자원을 빠르게 release 하는 방법으로 접근했다.
캐치 테이블의 웨이팅하기 처럼 가게(서버)는 빈자리가 있을 때만 손님을 입장 시키고, 들어가지 못한 손님은 가게에서 발급한 번호를 주어 가게 밖에서 대기하도록 하는 방법이다.

[그림n. 캐치 테이블과 대기열 예시] 정리하면 아래와 같다.
문제 1. 특정 상품에 주문이 몰리면, 긴 트랜잭션으로 DB 커넥션이 부족해져 신규 주문들은 타임아웃과 함께 실패한다.
- 접근 방법 :
- 주문 가능한 요청은 즉시 주문한다.
- 대기가 필요한 요청은 서버에 대기 순서를 기록하고 즉시 반환한다.
- 서버에서 대기 순서를 기록해 순서가 온 사용자를 입장을 시킨다.
- 기대 효과 :
- 서버 내 대기 요청이 감소하여 DB 커넥션 획득 지연으로 인한 타임아웃이 줄어든다.
- 요청 대기로 인한 서버 메모리 사용량이 감소한다.
- 순서가 보장되어 공정한 선착순 주문이 가능해진다.
문제 2. 특정 상품 주문의 DB 커넥션 독점으로 다른 상품의 주문 처리가 불가능해진다.
- 접근 방법 :
- 상품마다 rate-limit(동시 주문 개수)를 지정한다.
- rate-limit 수치를 시스템 운영자가 조절한다.
- 기대 효과 :
- 특정 상품이 DB 커넥션을 독점하는 문제를 해결할 수 있다.
- rate-limit 적용 값에 따라 대기열이 필요한 상품만 적용할 수 있다.
- 이벤트 트래픽에 유연하게 대응할 수 있다.
## 대기열 설계
### 대기열 저장소와 자료구조 선택
대기열 구현을 위해 in-memory DB인 Redis를 선택했다.
Redis의 뛰어난 성능뿐만 아니라 다양한 자료구조를 지원해 대기열 구현에 적합했다.
특히, sorted-set 자료구조는 중복이 없는 member와 score로 정렬되는 특징 때문에 Queue 같은 FIFO(First-In-First-Out) 구조를 구현하기 이상적이었다.
<예시 : sorted-set 구조>
key (member , score) - rank
Product1 (User1, 2024/10/14/16:15:08) - rank : 1
(User2, 2024/10/14/16:15:10) - rank : 2
(User3, 2024/10/14/16:16:12) - rank : 3
이처럼 유니크한 member가 score를 기준으로 정렬된다.### 대기열 구성
설계한 대기열의 다음과 같이 구성되어 있다.
Processing Queue(진행 큐)
- 지정된 threshold 만큼만 사용자를 받을 수 있다.
- 사용자는 진행 큐에 들어가야만 주문을 진행할 수 있다.
- 사용자는 진입 시간 + TTL 동안만 주문이 가능하다.
Waiting Queue(대기 큐)
- 진행 큐의 크기가 threshold에 도달하면 대기 큐에 진입하고 대기 번호를 순서대로 발급받는다.
Scheduler
- 지정된 주기마다 진행 큐의 빈자리만큼 대기 큐의 대기 순위가 높은 member들을 진행 큐로 이동시킨다.
- 지정된 주기마다 진행 큐에서 TTL이 만료된 member를 삭제한다.
### 대기열 동작 방식

[그림n. 대기열 동작 방식] ### 대기열에서 발생한 문제
모든 상품에 대기열이 필요한건 아니다. 주문 요청은 대기열이 필요한지 판단해야 한다.
평소에는 대기열이 동작하지 않다가 진행 큐가 Threshold에 도달하면 대기 큐가 세팅되고 사용자는 대기 번호를 발급받는 로직을 세웠다.

[그림 n] product1의 진행 큐는 - 대기열 판단 로직 : 진행 큐 크기 확인 ➜ (여유가 있다) ➜ 진행 큐 진입 ➜ 주문 진행
➜ (여유가 없다) ➜ 대기 큐 진입 ➜ 대기번호 반환
대기열 판단 로직은 Redis에서 두 개 이상의 복합 커맨드로 처리해야 하는데,
Redis의 단일 커맨드는 싱글 스레드 기반으로 동작해 원자적이지만, 복합 커맨드는 원자적 연산을 보장할 수 없다.
여러 클라이언트가 복합 커맨드를 동시에 보낼 경우 커맨드가 뒤섞여 동시성 문제를 일으키기 때문이다.
때문에 그림 n 의 product1 처럼 여러 클라이언트가 동시에 주문 요청할 경우를 대비해 진행 큐는 동시성 제어가 필요하다.
이런 문제를 방지하고자 Redis 에서 복합 커맨드를 원자적으로 실행하기 위한 방법이 두 가지 있는데, 특징은 다음과 같다.
1. Redis 트랜잭션 + Optimistic Lock
Redis 트랜잭션은 RDBMS 트랜잭션과 다르다.
커맨드들을 하나의 그룹으로 묶어 원자적으로 실행하거나 전체를 취소한다. 커맨드 묶음이 한 번이 Redis 서버로 전달되기 때문에 트랜잭션 도중 fetch 명령어는 사용할 수 없다.
그럼에도 값을 확인 후 특정 로직을 수행하고 싶다면 트랜잭션 시작 전 watch 명령어를 통해 Optimistic Lock을 걸어야 한다.
감시중인 키의 값이 변경되면 트잭잭션 실행을 중지한다.
2. Lua Script
Lua Script로 짠 로직은 하나의 커맨드로서 Redis 서버에서 처리된다.
따라서 복합 커맨드의 원자적 연산을 보장할 수 있다.
하지만 Redis의 명령어 처리기는 싱글 스레드로 동작한다. 따라서 Lua Script와 같이 실행 시간이 긴 단일 커맨드는 전체 시스템을 블로킹 하여 Redis의 전반적인 성능을 저하시킬 수 있으므로 주의가 필요하다.
프로젝트에서 선택한 방법
대기열에서 진행 큐에 진입 실패한 요청은 대기 큐로 진입해야하기 때문에 조건에 따른 연산을 원자적으로 수행할 수 있는 Lua Script가 적합했다.
'개발 이야기' 카테고리의 다른 글
[시스템 디자인] 안정적인 알림 서비스를 구축하기 위해 고민한 것들 (0) 2025.09.14 [온라인 게임 개발] 클라가 서버를 만나면? (4) 2025.08.17 Web Push 전송 Flow (0) 2025.04.11 구매, 구매 취소 Flow (0) 2025.04.11