MVP인데 메시지큐 꼭 필요한 거 맞아?
LLM Gateway를 만들면서, LLM 호출 정보를 분석 서버에 비동기로 넘겨줘야 했다. 토큰 사용량, 레이턴시, 요청/응답 본문, 서비스 자체 식별자 등등
이벤트 데이터를 분석 서버 쪽으로 넘겨야 하는데, 당연하게도 메시지 큐를 써야 한다는 생각이 들었다.
다만 지금 시기에는 개발 속도가 생명이었다. Kafka를 도입하자니 MVP라는 타겟에 비해 지금 필요한 것인가 하는 의문이 따라온다. 그렇다면 가벼운 Amazon SQS를 도입해볼까? 그런데 정합성 측면에서는 메시지 큐의 사용 자체가 관리 포인트가 많아진다.
그럼 그냥 분석 서버로 직접 호출하면 되는 거 아닌가? 고루틴으로 던져두고 응답은 안 기다리는 식으로.
걱정은 이 구조에서 분석 서버가 게이트웨이 단의 요청들에 대한 부하를 견딜 수 있냐는 것이다. 그럼 어디까지 견딜 수 있는데? 그걸 알면 MVP라는 타겟에는 가볍게 붙여둬도 될지 말지를 판단할 수 있지 않을까. 나이브 하더라도 내 눈으로 직접 확인해보자.
측정하고 싶은 것
단건 INSERT 시에, PostgreSQL이 RPS를 얼마까지 버틸 수 있는가?
추가적으로 LLM event의 본문 저장에는 S3 저장도 포함되는데, S3 PUT은 RPS 어디까지 가능할까?
처리량을 올리다가, 응답 시간이 급격히 꺾이는 지점인 Knee RPS 지점을 찾자.
이 값을 알면 우리 서비스의 예상 트래픽에 대해 여기까지는 안전하겠다는 판단을 할 수 있을 것 같았다.
목표 RPS는 LLM 호출 뿐만이 아니라, Client SDK로 부터 들어오는 이벤트까지 합산한 전체 인입에 대한 RPS로 잡았다.
테스트 환경을 AWS에 실제로 세운 이유
처음엔 로컬에서 재도 되지 않나 싶었는데, 그러면 숫자가 의미가 없어보였다.
맥북에서 잰 처리량은 DB가 같은 머신에 있고, 네트워크 왕복도 없고, 스펙 산정도 모호해서 운영 환경에 대한 판단 근거가 못 된다고 판단했다.
그래서 Terraform으로 AWS에 실제 구성을 세웠다.
부하발생기 EC2 1대 + 분석서버 EC2 1대 + RDS 1대
| 구성 | 스펙 |
|---|---|
| k6 발생기 | EC2 c6i.large (2 vCPU, 4 GiB) |
| 분석 서버(코프링) | EC2 c6i.large (2 vCPU, 4 GiB), HikariCP pool max 10 |
| DB | RDS PostgreSQL 17, db.m7g.large (2 vCPU / 4 GiB), gp3 50GB |
구성하면서 신경 쓴 건 아래 2가지
- 부하발생기와 분석서버의 인스턴스를 분리해서 분석 서버 자원에 영향이 없도록 하고
- 버스터블 인스턴스인 t계열 대신 논버스터블 인스턴스로 DB를 사용해서 측정이 오염되지 않게하자
k6 설정
부하는 constant-arrival-rate 방식으로 500, 1000, 1500, 2000, 2500, 3000 RPS를 각각 60초씩 따로 돌렸다.
ramping-arrival-rate 방식도 있지만, RPS를 고정해서 나눠 돌려야 구간별 값이 깨끗하게 나올 것이라 판단했다.
1
2
3
4
5
for r in 500 1000 1500 2000 2500 3000; do
RATE=$r DURATION=60s PROFILE=const \
BASE_URL='http://<분석서버>:8080' k6 run ingest_load.js
sleep 5
done
사실 분석서버에 인입되는 이벤트 종류는 Biz, Exposure, Call이라는 3가지 종류로 나뉘는데, Call이 가장 데이터 크기가 크며 S3로의 저장도 포함한다. (Biz는 Client SDK로 부터의 커스텀 메트릭 정보, Exposure는 요청에 대한 모델 배정 정보, Call은 실제 호출의 요청/응답 정보인데, 만들던 서비스 관련이니 일단 넘어가자)
보수적으로 가장 처리량이 많을 데이터인 Call Event를 기준으로 테스팅을 진행했다. 요청 본문 4,000자와 응답 본문 6,000자를 채워서 건당 약 10KB 짜리 데이터로 잡았다.
결과
DB Single – 1 요청당 1 DB INSERT 하는 경우
| RPS | 달성 | p90 | p95 | dropped |
|---|---|---|---|---|
| 500 | 500 | 2.45ms | 2.74ms | 0 |
| 1000 | 1000 | 3.28ms | 4.26ms | 0 |
| 1500 | 1500 | 4.09ms | 6.04ms | 0 |
| 2000 | 2000 | 21ms | 31ms | 0 |
| 2500 | 2500 | 105ms | 136ms | 0 |
| 3000 | 2911 | 721ms | 818ms | 3993 |
2000까지는 요구한 만큼 그대로 처리하면서 레이턴시도 낮게 유지됐다. 2500부터 조짐이 보이고, 3000에서는 목표를 못 채우며 드롭이 붙었다.
안정적으로 쓸 수 있는 선은 2000/s로 봤다. 3000/s가 되니 응답이 지연되면서 k6에서 드롭되는 데이터들이 나타났다.
DB Batch – 요청들을 모아서 Batch INSERT 시키기
처리량만 보면 압도적이었는데, 메모리 버퍼라 프로세스가 죽으면 통째로 날아갈 것이라고 생각했다.
배포 재시작이나 요청 처리 중 버퍼가 찼을 때 전부 조용히 사라질 것이기에, 추가적인 처리가 필요할 것이라 보았고 제외했다.
DB Single + S3 PUT
Call 본문을 S3로 PUT하고 메타만 DB에 넣는 구성이다.
안정적인 선이 500/s로, DB Single의 4분의 1이었다. 1000부터 레이턴시가 흔들리는게 보였다.
S3 PUT 구간을 타이머로 감싸 프로메테우스로 노출해뒀는데, 요청당 평균 50ms 정도가 소요됐다.
DAU로 환산
RPS만으로는 판단이 안 서서 사용량으로 바꿔봤다. DAU<->RPS 계산식을 찾아보니 아래와 같았다.
1
2
평균 RPS = DAU × 유저당 일 이벤트 / 트래픽 발생 시간(초)
피크 RPS = 평균 RPS × 피크 배수
트래픽 발생 시간은 보수적으로 8시간인 28800초로 잡고, 피크 배수는 5배로 잡았다.
유저 한 명이 하루에 Biz 200, Exposure 10, Call 10을 만든다고 가정해서 합산인 220을 유저당 일 이벤트 수로 잡았고,
이 중에서 S3 저장이 필요한 것은 Call Event에 해당하는 10개 뿐이었다.
DB 기준
1
2000 * 28800 / (220 * 5) ~= 52000 DAU
S3 기준
1
500 * 28800 / (10 * 5) = 288000 DAU
낮은 쪽이 실제 상한이니까 예측되는 감당 가능한 DAU는 5만이 나왔고, 실제 운영에서 병목일 것은 S3가 아니라 결국 DB일 것이라고 결론이 났다.
판단
초기 MVP에서 고객사 합산 DAU 5만은 과분한 수치이고, RPS 2000이라는 숫자도 HikariCP 기본값 pool 10에서 나온 값이다.
CloudWatch로 RDS를 봤을때 DB CPU는 29%, IOPS는 한도의 3분의 1만 쓰고 있었기에 커넥션 풀이 병목이었지 DB 자원 자체의 문제가 아니었다.
즉 이 숫자는 손도 안 댄 기본값에서 나온 것이고, 필요하면 올릴 여지가 남아 있었기에, 결과적으로 큐 없이 직접 호출로 가기에 충분하다고 판단했다!
결론
결과적으로 메시지 큐를 MVP 인프라에서 뺐다. 게이트웨이/SDK -> 분석 서버 직접 호출로 간다.
부하 테스트로 확인한 것은 이렇다.
- 단건 INSERT 기준 RPS 2000까지 안정적이었다.
- 이걸 사용량으로 환산하면 DAU 5만 명까지 버틴다는 것
SQS 도입은 인프라 구성만으로 끝나지 않는다.
- 큐에 넣기 전(인바운드)과 Consumer 적재 전(아웃바운드) 양쪽에 여전히 유실 가능성이 있다.
- Consumer 구조를 잡고, 재시도와 DLQ 정책을 정하는 등 추가적인 리소스가 필요하다.
- 우린 MVP를 2주안에 끝내야 했다..
직접 호출로 충분한 트래픽이 나온다면 그대로 가는 게 리소스와 확장 양쪽에서 합리적이다.
가장 단순한 구조로 만들어두는게 확장성이 가장 높고, 변경 시점을 판단할 숫자를 이번에 얻었으니, 해당 시점에 도달했을 때 변경하자.
그리고 데이터 유실에 대한 대응은, 게이트웨이 단의 로그 DB 운용을 통해 문제 발생 시 차후 백필해주는 방향으로 대응 방향을 잡았다.
DB Single 원본
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
===== RATE=500 =====
http_req_duration..............: avg=2.43ms min=1.61ms med=2.31ms max=38.3ms p(90)=2.45ms p(95)=2.74ms
http_req_failed................: 0.00% 0 out of 30001
http_reqs......................: 30001 499.985312/s
dropped_iterations.............: 0 0/s
===== RATE=1000 =====
http_req_duration..............: avg=3.5ms min=1.68ms med=2.65ms max=235.91ms p(90)=3.28ms p(95)=4.26ms
http_req_failed................: 0.00% 0 out of 60000
http_reqs......................: 60000 999.946073/s
dropped_iterations.............: 0 0/s
===== RATE=1500 =====
http_req_duration..............: avg=3.42ms min=1.63ms med=2.67ms max=166.79ms p(90)=4.09ms p(95)=6.04ms
http_req_failed................: 0.00% 0 out of 90001
http_reqs......................: 90001 1499.901737/s
dropped_iterations.............: 0 0/s
===== RATE=2000 =====
http_req_duration..............: avg=7.47ms min=1.66ms med=2.73ms max=307.78ms p(90)=21.06ms p(95)=31.06ms
http_req_failed................: 0.00% 0 out of 120002
http_reqs......................: 120002 1999.855481/s
dropped_iterations.............: 0 0/s
===== RATE=2500 =====
http_req_duration..............: avg=38.21ms min=1.66ms med=17ms max=881.33ms p(90)=104.92ms p(95)=136.17ms
http_req_failed................: 0.00% 0 out of 150001
http_reqs......................: 150001 2499.822933/s
dropped_iterations.............: 0 0/s
===== RATE=3000 =====
http_req_duration..............: avg=419.94ms min=1.77ms med=387.17ms max=2.67s p(90)=721.33ms p(95)=818.05ms
http_req_failed................: 0.00% 0 out of 175998
http_reqs......................: 175998 2911.60741/s
dropped_iterations.............: 3993 66.057844/s