Dev 부하 테스트 - 100-hours-a-week/KTB4-2nd-Cloud GitHub Wiki
여담 사용자는 사진을 올린 뒤 여행이 완성되기를 기다리고, 완성된 여행을 수시로 다시 엽니다. 부하 테스트로 확인하려 한 것은 세 가지입니다.
- 예상 사용자 부하에서 원본 사진과 서비스 기능이 지켜지는가
- 부하가 커지면 어느 경로와 자원이 먼저 실패하는가
- 이후 구조를 바꿨을 때 전후를 같은 사용자 결과로 비교할 기준
판정 기준은 SLI/SLO 기준을 따랐습니다.
V1의 수용량은 RPS 하나로 표현되지 않습니다. 짧은 조회, 10장씩 이어지는 업로드, AI 결과를 기다리는 긴 요청, Worker 작업 유입이 한 서버 안에서 섞여 있기 때문입니다.
그래서 세 단계로 나눠 진행했습니다.
- 예상 부하에서 동작하는지 (1~4번): 정상 기준값을 잡고, 업로드와 조회를 겹치고, 조회 급증과 AI 작업 연속 유입을 따로 확인
- 어디서 처음 실패하는지 (5번): 실제 사용 비율을 유지한 채 부하를 배수로 올려 첫 실패 지점을 찾음
- 실패 지점 뒤에 가려진 것 (6~7번): 첫 실패가 다른 경로를 가리지 않도록 읽기 경로만 따로 올리고, Worker는 기존 실행을 다시 분석
| 시험 | 왜 했나 | 확인할 것 | 결과 |
|---|---|---|---|
| 1. Baseline | 사진을 올리면 여행이 완성되고, 완성된 여행을 제때 열 수 있는가 | 단일 여행의 원본 저장과 AI 완료, 조회 성공률과 p95 | 통과 |
| 2. 혼합 부하와 V1 Peak | 사진 업로드 중에도 완성된 여행을 끊김 없이 조회할 수 있는가 | 검증 상한 부하에서 조회와 여행 생성이 함께 유지되는지 | 통과 |
| 3. 조회 Spike | 조회가 갑자기 몰려도 오류 없이 응답하고 원래 상태로 돌아오는가 | 급증 중 오류와 지연, 부하를 낮춘 뒤 회복 | 통과 |
| 4. AI 연속 유입 | 작업이 연달아 들어와도 사용자에게 완성된 여행을 전달할 수 있는가 | 작업 유실 여부와 최종 여행 생성 | Worker 통과, 여행 1건 실패 |
| 5. 혼합 한계 시험 | 실제 사용 비율로 부하를 올리면 어디서 처음 실패하는가 | 배수별 여행 완료율, 조회 유지, 첫 실패 단계 | 3배수 통과, 4배수 생성 50% 실패 |
| 6. 조회 경로 한계 | 사진 변환 없이 읽기 경로만 올리면 어디까지 버티는가 | 단계별 오류, 지연, App 자원과 연결 수 | 계획 40 API RPS까지 통과 |
| 7. Worker와 App 재분석 | 150장 작업이 Worker와 App을 얼마나 점유했는가 | 작업 겹침, 실행 시간, 요청 증폭 | 새 부하 없이 기존 실행 분석 |
- 예상 피크에서는 문제없음: 시간당 150장 여행 4건 수준에서 원본 보존, AI 처리, 조회 모두 정상
- 첫 실패는 Backend 사진 변환 접수: 시간당 16건(예상 피크의 4배)에서 여행 생성 50% 실패
- 조회는 업로드와 무관하게 유지: 업로드와 겹쳐도, 읽기 경로만 40 API RPS까지 올려도 실패 없음
- Nginx와 Worker는 이번 범위에서 병목이 아님: 다만 Worker는 작업이 겹친 적이 없어 한계는 아직 모름
- HTTP 실패율은 업무 실패를 가림: 4배수에서 HTTP 실패율 0.54%, 여행 생성 실패율 50%
발견한 문제와 우선순위는 마지막 절에 모았습니다.
부하는 운영 서버와 자원을 나눠 쓰지 않도록 별도 EC2의 k6로 보냈고, 운영 HTTPS 주소의 /api를 바로 호출했습니다. 화면을 거치지 않아 FE 렌더링과 카카오 로그인 흐름은 범위 밖입니다.
사진은 팀원들이 폰과 디카로 찍은 196장에서 기기 비율을 유지해 뽑은 고정 세트(30장, 150장)를 썼습니다.
여행 생성은 동기 구조라, 마지막 업로드 요청 하나가 Nginx, Backend, AI를 거쳐 돌아올 때까지 몇 분 동안 열려 있습니다. k6만으로는 그 시간이 어디에 쓰였는지 알 수 없어서, 각 구간이 로그를 남기고 request_id로 이어서 보도록 구성했습니다.
sequenceDiagram
participant C as FE / k6
participant N as Nginx
participant B as Backend
participant A as AI
C->>N: 마지막 사진 Batch
Note over N: request_id 생성
N->>B: X-Request-ID 전달
Note over B: photo_originals_saved<br/>worker_ready
B->>A: AI 작업 요청 (request_id, job_id)
Note over A: ai_worker_execution<br/>ai_pipeline_step
A-->>B: 분석 결과 전달 (ai_callback)
Note over B: 결과 저장<br/>trip_creation success / failure
B-->>N: 200
Note over N: request_time<br/>upstream_response_time
N-->>C: 200
| 구간 | 남기는 곳 | 남긴 값 |
|---|---|---|
| 요청 전체와 BE 응답 | Nginx JSON Access Log |
request_time, upstream_response_time, upstream_status, 경로(normalized_route) |
| 요청 연결 | Nginx → BE → AI | Nginx가 만든 request_id를 X-Request-ID로 BE에 넘기고, BE가 AI 호출에 이어 붙임 |
| 원본 저장 | BE photo_originals_saved
|
Batch별 기대 장수(expected_count)와 저장 장수(saved_count) |
| Worker 준비 | BE worker_ready
|
꺼진 Worker를 켜고 준비될 때까지 걸린 시간 |
| AI 처리 | AI ai_worker_execution, ai_pipeline_step, ai_callback
|
대기와 실행 시간, 단계별 시간, 결과 전달 성공 여부 |
| 여행 생성 결과 | BE trip_creation
|
시작과 종료, 실패 시 failure_stage
|
| 배포 버전 | Nginx, BE, AI 공통 |
release (실행 중인 커밋) |
Logs Insights에서 request_id, trip_id, job_id 하나로 세 서비스의 로그를 시간순으로 잇고, App, Worker, 발생기의 자원 지표를 같은 시간대로 맞춰 봤습니다. 판정에는 실행 기록이 아니라 로그에 찍힌 실제 release를 썼습니다.
테스트 데이터, 측정 경로 검증, 중단 기준, 제외한 실행
테스트 데이터
| 세트 | 디카 / 휴대폰 | JPEG / HEIC | 크기 | 사용한 시험 |
|---|---|---|---|---|
| 30장 | 5 / 25 | 16 / 14 | 약 81.0MB | 혼합 부하, AI 연속 유입 |
| 150장 | 25 / 125 | 83 / 67 | 약 383.7MB | Baseline, 혼합 부하, V1 Peak, 혼합 한계 시험 |
발생기와 계정: 발생기는 처음 c6i.large를 쓰다가, 150장 업로드를 여러 개 동시에 보내는 한계 시험부터 k6 메모리 때문에 m6i.xlarge로 올렸습니다. 1~4번은 테스트 계정 하나로, 5번부터는 여행마다 생성 계정을 따로 두고 조회 계정을 분리했습니다.
측정 경로 검증: 부하 전에 사진 198장 여행 하나로 20개 Batch의 기대 장수와 저장 장수 합계가 198로 같은지, 마지막 요청의 request_id가 세 서비스에 똑같이 남는지 확인했습니다. trip_creation 종료 로그에는 소요 시간이 없어 같은 job_id의 시작과 종료 시각 차이로 계산했습니다.
중단 기준: 5xx나 Timeout 1% 이상 지속, App CPU 90% 이상 지속, Memory 90% 이상 또는 OOM, 원본 누락이나 AI 작업 유실, 실제 사용자 영향. 이번 시험에서 걸린 경우는 없었습니다.
제외한 실행
| 실행 | 제외한 이유 |
|---|---|
| 10장 Smoke | 다른 여행 작업이 Worker를 먼저 쓰고 있었고, 스크립트가 원본 수를 화면 사진 수와 잘못 비교함 |
| 조회 Baseline 1차 | k6가 반복마다 Cookie를 비워 두 번째 요청부터 401. noCookiesReset 설정 후 재실행 |
| 혼합 1단계 첫 시도 | 토큰 만료로 조회와 업로드가 중간에 401로 중단 |
| 조회 한계 600세션/분 첫 실행 | 약 10초 뒤 토큰 만료로 401. 용량 실패가 아니라 토큰을 갱신해 재실행 |
정리: 시험이 끝나면 결과 원본을 비공개 S3에 보관하고, 시험 여행, 첨부, 전용 S3 경로의 객체 버전, 시험 계정과 세션을 삭제했습니다.
부하를 겹치기 전에 여행 한 건과 조회의 정상 값을 잡습니다. 사진을 올리면 여행이 끝까지 만들어지는지, 완성된 여행을 제때 열 수 있는지 확인하고, 이후 시험은 모두 이 값과 비교합니다.
- 여행 생성: 150장 여행을 1 VU로 3회. 10장씩 15번 업로드하고, 마지막 요청은 AI 처리와 결과 저장까지 기다림
- 조회: 완성된 여행 하나를 1, 10, 30 VU로 각 5분씩 조회. 요청 사이 2초
-
판정: 원본 저장 장수,
trip_creation최종 결과,trip_view성공률과 p95
| 150장 여행 (같은 사진 세트, 같은 릴리스) | 결과 |
|---|---|
| 여행 생성 / 원본 저장 | 3/3건, 450/450장 |
| 여행 생성 전체 | 837.9~863.5초 (약 14분) |
| 중간 업로드 Batch 14회 평균 | 40.2~44.4초 |
| AI Worker 실행 (그중 Embedding) | 163.6 |
| 꺼진 Worker 준비 | 36.3~40.4초 |
| App CPU / Memory 최고 | 76.4 |
| 완료 여행 조회 | 실제 요청량 | trip_view |
Backend p95 |
|---|---|---|---|
| 1 VU | 약 0.5 req/s | 149/149 성공 | 10ms |
| 10 VU | 약 5.0 req/s | 1,494/1,494 성공 | 6ms |
| 30 VU | 약 14.9 req/s | 4,475/4,475 성공 | 5ms |
여행 생성과 조회 모두 실패 없이 SLO 기준을 충족했고, 부하가 끝나고 1~3분 안에 App 자원이 원래 수준으로 돌아왔습니다.
성공률은 문제없었지만 여행 한 건에 약 14분이 걸렸고, 그중 3분의 2는 AI가 아니라 업로드 뒤 Backend 처리였습니다.
3회차를 기준으로 시간을 나눴습니다.
| 150장 여행 약 14분이 쓰인 곳 (3회차) | 시간 |
|---|---|
| 중간 업로드 Batch 14회 | 약 563초 (67%) |
| 마지막 Batch: 원본 저장 후 Worker 준비 완료까지 | 약 78초 |
| 마지막 Batch: AI 처리 | 약 168초 (20%) |
| 마지막 Batch: 나머지 | 약 28초 |
처음에는 AI 처리가 가장 오래 걸릴 거라고 예상했습니다. 실제로 AI는 약 168초로 전체의 20% 정도였고, 가장 큰 몫은 마지막 요청 전까지 14번 반복된 중간 업로드였습니다.
중간 업로드 하나는 사진 10장을 올리는 요청인데 평균 40초가 걸렸습니다. 이게 14번 쌓여 약 9분 반이 됐습니다.
같은 request_id로 Nginx와 Backend 로그를 맞춰 14건의 구간을 나눴습니다.
| 구간 | 평균 |
|---|---|
Nginx 전체 요청 (request_time) |
40.25초 |
Backend 응답 (upstream_response_time) |
40.19초 |
| Backend 원본 저장 시작부터 완료 로그까지 | 0.56초 |
| 원본 저장 완료 로그부터 Nginx 응답까지 | 약 39초 |
Nginx 전체 시간과 Backend 응답 시간의 차이가 0.06초뿐이라, 사진을 전송하는 구간이나 Nginx 앞단은 원인이 아닙니다. Backend가 원본 10장을 S3에 저장하는 데도 0.56초면 충분했습니다.
남는 건 원본 저장이 끝난 뒤 Backend가 응답하기까지의 약 39초입니다.
배포 당시 Backend 코드에서 이 구간에는 파생 이미지 생성, 첨부 정보 저장, S3 객체 유지, Batch 완료 처리가 있습니다. 이 중 파생 이미지 생성기는 서버 전체가 공유하는 Thread 1개와 대기열 2칸으로 동작해서, 10장을 처리하는 동안 사진들이 차례를 기다리는 구조였습니다.
단계별 시간 로그가 없어서 39초 중 무엇이 얼마를 차지하는지는 이 시험만으로 확정하지 못했습니다. 이 생성기는 이후 혼합 한계 시험에서 실제 첫 실패 지점으로 확인됐습니다.
App CPU는 최고 7681%까지 올랐지만 Memory는 47% 수준이었고, 부하가 끝난 뒤 13분 안에 원래 수준으로 돌아왔습니다. 꺼져 있던 Worker를 켜는 데는 36~40초가 걸렸는데, 여행 전체 시간의 5% 정도라 비용을 아끼는 대가로 감수할 만한 수준이었습니다.
- 문제: 업로드 Batch마다 원본 저장 뒤 약 39초 지연. 여행 대기 시간의 67%
- 조치: Backend 후처리 단계별 시간 기록, 파생 이미지 비동기 처리 검토
- 우선순위: AI Embedding 최적화나 Nginx 설정 변경은 여행 전체 시간에 주는 효과가 작아서 후순위
사진 업로드와 여행 조회는 같은 App을 씁니다. 업로드가 몇 분씩 이어지는 동안 이미 완성된 여행을 보는 사용자까지 영향을 받는지, V1 검증 상한에서 조회와 여행 생성이 함께 유지되는지 확인합니다.
- 완료 여행 조회를 계속 보내면서 5초 뒤 업로드를 겹침. 조회와 업로드는 별도 k6 프로세스
- V1 검증 상한(API 약 1.3 req/s, 동시 업로드 2건)보다 조회를 높인 단계부터 검증 상한 조합까지 진행
-
판정: 업로드와 겹친 구간과 아닌 구간의
trip_viewp95, 여행 생성 결과와 전체 시간, Nginx 연결 부족 오류
| 단계 | 조회 | 업로드 |
|---|---|---|
| 혼합 1단계 | 10 VU, 20분 | 150장 1건 |
| 혼합 2단계 | 30 VU, 20분 | 150장 1건 |
| 다중 업로드 | 30 VU, 8분 | 30장 2건 (14초 간격) |
| V1 Peak | 4 VU, 20분 | 150장 1건, 이어서 30장 1건 |
| 단계 | trip_view |
Backend p95 | 여행 생성 / 원본 저장 |
|---|---|---|---|
| 혼합 1단계 | 5,960/5,960 성공 | 7ms | 1/1건, 150/150장 |
| 혼합 2단계 | 17,880/17,880 성공 | 업로드와 겹친 구간 8ms | 1/1건, 150/150장 |
| 다중 업로드 | 7,152/7,152 성공 | 9ms | 2/2건, 각 30/30장 |
| V1 Peak | 2,384/2,384 성공 | 7ms | 2/2건, 180/180장 |
전체 혼합 시험에서 여행 생성 6/6건, 원본 540/540장, 조회 33,376/33,376건이 성공했고 5xx는 없었습니다.
업로드가 겹쳐도 조회는 영향이 없었습니다. 대신 여행 생성 시간은 겹친 부하가 클수록 늘었습니다.
업로드와 겹친 구간의 Backend p95는 7~9ms였고, 업로드가 끝난 뒤는 3ms 수준이었습니다. 차이는 있지만 기준(1초)과는 100배 이상 떨어져 있어, 업로드가 조회 사용자에게 주는 영향은 없다고 봤습니다.
150장 여행 한 건의 전체 시간을 부하 조건별로 나란히 놓았습니다.
| 150장 여행 1건 | 여행 생성 전체 | 단독 대비 |
|---|---|---|
| 단독 (Baseline 3회) | 837.9~863.5초 | - |
| 조회 10 VU와 겹침 | 893.4초 | 약 +40초 |
| 조회 30 VU와 겹침 | 930.2초 | 약 +80초 |
| V1 Peak (30장 업로드와 겹침) | 1,003.0초 | 약 +150초 |
조회만 겹쳐도 30~80초, 업로드끼리 겹치면 약 150초가 늘었습니다. 30장 2건을 겹친 시험에서도 중간 Batch가 단독(약 40초)보다 긴 약 66초였습니다.
이때도 Nginx 전체 시간과 Backend 응답 시간의 차이는 작았습니다. 늘어난 시간은 전송이 아니라 Backend 안에서 생겼습니다.
Baseline에서 찾은 원본 저장 뒤 구간이 업로드끼리 겹칠 때 서로 기다리면서 길어졌을 가능성이 큽니다. 서버 전체가 Thread 1개짜리 파생 이미지 생성기를 나눠 쓰니까, 동시 업로드가 늘면 대기도 그만큼 쌓입니다.
| 후보 | 확인 결과 |
|---|---|
| Nginx 연결 부족 | 같은 구간 Error 로그에 연결 부족 오류 없음. 9/29 Nginx 동시 연결 고갈과는 다른 상황 |
| App CPU | 단계별 최고 약 77~80%. 높지만 이 시험만으로 지연의 원인이라고 단정할 근거는 부족 |
| AI 대기 | 30장 2건은 Worker에서 순서대로 실행돼 두 번째 작업이 약 30초 기다렸지만 유실 없음 |
각 단계를 한 번씩만 돌렸고 운영 트래픽을 통제하지 않아서, 늘어난 시간 전부를 하나의 원인으로 단정하지는 않았습니다.
- 문제 없음: 조회는 업로드와 겹쳐도 영향 없음. Nginx 연결도 여유
- 문제: 업로드가 겹칠수록 여행 생성이 길어짐. V1 Peak에서 단독 대비 약 150초
- 이어서: 동시 업로드를 더 올리면 이 구간이 어떻게 되는지는 5절에서 확인
사용자가 한꺼번에 여행을 열면 오류가 나거나, 급증이 끝난 뒤에도 연결이 남아 평소 요청을 방해할 수 있습니다. 조회가 갑자기 몰렸을 때의 오류와 지연, 그리고 회복을 확인합니다.
- 완료 여행 조회를 세 구간으로 이어서 실행. 앞 구간이 실패하면 다음 구간은 보내지 않음
-
판정: 구간별
trip_view성공과 p95, Nginx 5xx, App CPU와 TCP 연결 수의 회복 시간
| 구간 | 부하 |
|---|---|
| 급증 전 | 4 VU, 2분 |
| 급증 | 60 VU, 1분 |
| 회복 | 4 VU, 3분 |
| 구간 | 실제 조회량 | trip_view |
Backend p95 |
|---|---|---|---|
| 급증 전 | 약 2.02 req/s | 240/240 성공 | 5ms |
| 급증 | 약 30.56 req/s | 1,800/1,800 성공 | 6ms |
| 회복 | 약 2.01 req/s | 360/360 성공 | 3ms |
| App 상태 | 급증 전 | 급증 | 낮춘 뒤 1분 | 낮춘 뒤 2분 |
|---|---|---|---|---|
| TCP 연결 수 | 약 30 | 193 | 41 | 29 |
| CPU | 약 3.5% | 13.3% | 4.6% | 3.6% |
2,400건이 모두 성공했고 5xx는 없었습니다.
조회가 15배(약 30 req/s)로 뛰어도 p95 6ms를 유지했고, 부하를 낮추고 약 2분 뒤 연결 수가 원래 수준으로 돌아왔습니다.
조회량이 15배가 되는 동안 Backend p95는 5ms에서 6ms로 거의 변하지 않았습니다. 대신 TCP 연결 수가 약 30개에서 193개로 6배 넘게 늘었고, CPU는 13.3%까지만 올랐습니다.
조회 요청 하나는 처리 비용이 아주 작아서, 요청이 몰려도 CPU보다 동시 연결 수가 먼저 늘어납니다. 9월 29일 장애에서 연결이 먼저 찼던 것과 같은 방향의 신호입니다.
부하를 낮춘 뒤 1분에 TCP 연결 41개, 2분에 29개로 급증 전 수준을 회복했습니다. 연결이 남아서 평소 요청을 방해하는 일은 없었습니다.
한 계정이 같은 여행을 반복 조회한 결과라 캐시 효과가 섞여 있을 수 있습니다. 30 req/s를 서비스 전체가 버티는 조회량으로 보지 않고, "이 정도 급증에서는 오류와 회복 문제가 없었다"까지만 판단합니다.
- 문제 없음: 급증과 회복 모두 정상. 조회 경로는 개선 대상이 아님
- 남는 위험: 9월 29일처럼 연결 수가 한도(약 1,536개)를 넘는 경우는 이 시험 범위(최대 193개) 밖. 요청 제한으로 다룸
AI Worker가 작업을 마쳐도 Backend가 결과를 저장하지 못하면 사용자는 완성된 여행을 받지 못합니다. 작업이 연달아 들어올 때 Worker 대기열이 비워지는 것과, 사용자에게 여행이 실제로 완성되는 것을 나눠서 확인합니다.
- Worker가 꺼진 상태에서 같은 계정으로 30장 여행 3건을 약 10초 간격으로 시작. 조회는 섞지 않음
- 설계 기준인 동시 업로드 2건을 넘는 부하라 중단 기준을 그대로 두고 진행
-
판정:
ai_worker_execution대기와 실행 시간,ai_callback성공,trip_creation최종 상태와failure_stage
| 작업 | Worker 대기 | Worker 실행 | 여행 생성 |
|---|---|---|---|
trip_id=171 |
36.3초 (Worker 시작) | 35.6초 | 성공 |
trip_id=172 |
23.2초 | 32.2초 | 성공 |
trip_id=173 |
5.9초 | 32.1초 |
실패 (마지막 업로드 500, 최종 FAILED) |
Worker 실행과 결과 전달은 3/3 성공했고 대기열도 모두 비워졌습니다. 여행 생성은 2/3건으로 성공률 99% 기준을 충족하지 못했습니다.
Worker는 작업 3건을 모두 처리했지만, 실패는 AI가 아니라 Backend의 결과 저장 잠금에서 났습니다.
세 작업의 대기 시간이 36.3초, 23.2초, 5.9초로 줄어드는 건 Worker가 작업을 한 번에 1건씩 순서대로 처리했기 때문입니다. 첫 작업은 꺼진 Worker를 켜는 시간을 기다렸고, 뒤의 작업들은 앞 작업이 끝나기를 기다렸습니다.
세 작업 모두 약 32~36초 만에 실행을 마쳤고, 결과 전달도 성공했습니다. Worker 쪽에서는 유실도 적체도 없었습니다.
trip_id=173은 AI 분석이 끝난 뒤 Backend의 결과 저장 단계(failure_stage=result_persist)에서 실패했습니다. 같은 request_id에 MySQL Lock wait timeout exceeded가 남아 있었고, 사용자별 user_stats 행의 FOR UPDATE 잠금을 기다리다 시간이 초과됐습니다.
Backend 코드를 보면 결과를 저장하는 트랜잭션이 이 행을 잠근 채로, 그 사용자의 활성 사진 크기를 S3에서 하나씩 조회합니다. 잠금을 쥔 채 외부 호출을 여러 번 하니, 같은 계정의 여행이 거의 동시에 이 단계에 들어오면 뒤의 여행이 오래 기다리게 됩니다.
사진이 많은 사용자일수록 S3 조회가 늘어 잠금 시간도 길어지는 구조입니다. 어느 트랜잭션이 잠금을 얼마나 잡았는지는 로그만으로 확정하지 못했지만, 실패 위치와 오류가 이 구조와 맞아떨어집니다.
시험 중 관측에서도 문제가 두 가지 드러났습니다.
| 관측 | 문제 |
|---|---|
사용자에게 AI_PROCESSING_FAILED 반환 |
실제 실패는 결과 저장인데 오류 코드만 보면 AI를 의심하게 됨 |
| Nginx 5xx 1건 | 기존 5xx Alarm(5분 5건 이상)에 걸리지 않음. 사용자는 여행을 잃었는데 알림 없음 |
- 문제 없음: Worker 처리와 대기열은 3건 연속 유입을 감당함
- 문제: 같은 계정 여행이 겹치면 결과 저장이 실패해 여행을 잃음. Worker를 늘려서는 해결되지 않음
-
조치: 잠금 구간에서 S3 조회 제거, 결과 저장 실패용 오류 코드 분리,
trip_creation실패 기준 Alarm 검토
2번 시험은 조회와 업로드를 따로 켜서 겹쳤을 뿐 실제 사용 비율은 아니었습니다. 실제 사용 비율을 유지한 채 부하를 올리면 여행 완료율과 조회가 언제까지 유지되는지, 처음 실패하는 단계가 어디인지 찾습니다.
- V1 설계의 피크 1시간 유입(여행 생성 4세션, 조회 11세션)을 기준으로 비율을 고정하고 배수를 올림
- 150장 여행, 생성 계정은 여행마다 따로, 조회 계정 분리
-
판정: 배수별 여행 완료율과 실패 단계,
trip_view성공률, 원본 저장 뒤 지연 구간, Nginx 오류, App과 Worker 자원
| 배수 | 관찰 시간 | 목표 생성 / 조회 | 실제 생성 / 조회 | 생성 간격 |
|---|---|---|---|---|
| 1배수 | 1시간 | 4 / 11 | 5 / 12 | 30분마다 2건씩 |
| 2배수 | 1시간 | 8 / 22 | 8 / 22 | 7분 30초 |
| 3배수 | 30분 | 6 / 17 | 6 / 17 | 5분 |
| 4배수 | 30분 | 8 / 22 | 8 / 22 | 3분 45초 |
1배수는 생성이 두 건씩 묶여 시작돼 2배수부터 스크립트를 고쳤고, 2배수와 3배수 사이에는 Backend가 사진 변환 처리를 개선해 다시 배포했습니다.
| 배수 | 여행 생성 | 조회 | 중간 Batch p95 | 성공한 여행 1건 시간 | App CPU 최대 | 판정 |
|---|---|---|---|---|---|---|
| 1배수 | 5/5 | 12/12 | 93.78초 | 14~26분 | 78.79% | 통과 |
| 2배수 | 7/8 | 22/22 | 128.72초 | 17~31분 | 80.47% | 미통과 |
| 3배수 | 6/6 | 17/17 | 29.43초 | 약 9분 | 84.85% | 통과 |
| 4배수 | 4/8 | 22/22 | 30.02초 | 약 9분 | 85.55% | 미통과 |
조회는 모든 배수에서 실패가 없었고, 실패는 모두 여행 생성에서 나왔습니다. 실패한 배수에서는 HTTP 실패율과 사용자에게 보이는 실패율이 크게 달랐습니다.
| 배수 | HTTP 실패율 | 여행 생성 실패율 |
|---|---|---|
| 2배수 | 0.069% (1,448건 중 1건) | 12.5% (8건 중 1건) |
| 4배수 | 0.54% (737건 중 4건) | 50% (8건 중 4건) |
시간당 12건(3배수)까지 통과했고, 시간당 16건(4배수)에서 Backend 사진 변환 접수가 거부되며 여행 생성 50%가 실패했습니다. 조회와 Nginx는 끝까지 영향이 없었습니다.
여행 하나는 업로드 15건과 상태 확인 요청 백여 건으로 이뤄져 있습니다. 그래서 업로드 한 건의 500이 여행 하나의 실패가 되지만, 전체 HTTP 요청 수로 나누면 실패율은 1%도 되지 않습니다.
HTTP 실패율만 봤다면 4배수의 실패를 놓쳤을 겁니다. 이 시험부터는 여행 생성 결과를 1차 판정 기준으로 봤습니다.
1배수: 두 여행이 겹친 구간의 중간 Batch는 평균 약 76초로, 한 여행만 진행된 구간(약 40초)의 1.9배였습니다. 같은 동안 Worker 실행 시간(166~179초)과 App CPU 최대(약 78%)는 거의 그대로였습니다.
대표 요청 하나를 보면 원본 10장 저장은 0.6초였고 응답은 79초 뒤에 나갔습니다. Baseline에서 39초였던 원본 저장 뒤 구간이, 두 여행이 겹치자 두 배로 길어졌습니다.
2배수: trip_id=197의 다섯 번째 Batch는 원본 10장을 저장한 직후 500으로 끝났습니다.
RejectedExecutionException: ... ThreadPoolExecutor[Running, pool size = 1,
active threads = 1, queued tasks = 2, completed tasks = 77]
서버 전체가 공유하는 파생 이미지 생성기의 Thread 1개와 대기열 2칸이 모두 차 있어 새 작업을 받지 못했고, 이 거부가 별도의 대기나 재시도 없이 그대로 사용자 요청의 500이 됐습니다. Baseline과 혼합 부하에서 지연의 원인으로 의심했던 구간이 실제 첫 실패 지점이었습니다. 같은 요청의 Nginx request_time은 1.3초로, Nginx 대기나 연결 부족과는 무관했습니다.
3배수: Backend가 사진 변환을 HEIC와 경량 이미지로 나눠 처리하도록 바꾼 뒤 6/6 성공했습니다. 중간 Batch p95는 128.72초에서 29.43초로, 여행 1건 시간은 17~31분에서 약 9분으로 줄었습니다. 배포 조합과 겹친 정도가 달라 개선 폭을 이 숫자만으로 계산하지는 않았습니다.
4배수: 생성 간격을 3분 45초로 좁히자 8건 중 4건이 같은 단계(failure_stage=derivative_create)에서 실패했습니다.
java.util.concurrent.RejectedExecutionException: 사진 변환 접수 한도를 초과했습니다.
at TripDerivativeScheduler.submit(TripDerivativeScheduler.java:81)
개선된 스케줄러는 HEIC 10장, 경량 이미지 20장까지만 받고 넘치면 바로 거부합니다. 실패한 네 여행은 AI 단계까지 가지 못했고, 성공한 네 여행은 AI 결과까지 완료됐습니다.
여행 한 건이 약 9분 걸리는 상황에서, 5분마다 시작하면 동시에 진행되는 여행이 평균 2건이 채 안 되고, 3분 45초마다 시작하면 2건을 넘습니다(여행 시간 ÷ 시작 간격으로 계산한 값).
동시에 사진을 올리는 여행이 하나만 늘어도 한 번에 들어오는 변환 작업이 10장 단위로 늘어나고, 접수 한도(HEIC 10장, 경량 20장)에 닿게 됩니다. 네 번의 거부가 각각 어느 쪽 한도에서 났는지는 로그만으로 확정하지 못했습니다.
| 자원 | 4배수 최대 | 판단 |
|---|---|---|
| App CPU | 85.55% | 높지만 포화 전 |
| CPU Credit | 최저 550.93 | 충분 |
| App Memory | 49.05% | 여유 |
| App TCP 연결 | 28개 | 여유 |
| Worker CPU | 약 50% | 여유 |
자원은 남아 있었는데 Backend의 접수 정책이 먼저 사용자 실패로 드러났습니다. 한도를 둔 것 자체보다, 한도에 닿았을 때 대기, 거부, 재시도 같은 계약 없이 500으로 끝나는 게 사용자 영향의 원인입니다.
| 배수 | App TCP 연결 최대 | 연결 부족, upstream timeout | 업로드 실패의 위치 |
|---|---|---|---|
| 1배수 | 29 | 없음 | 실패 없음 |
| 2배수 | 62 | 없음 | Backend 대기열 거부 |
| 3배수 | 32 | 없음 | 실패 없음 |
| 4배수 | 28 | 없음 | Backend 접수 한도 거부 |
1배수에서 요청별 request_time과 upstream_response_time의 차이는 중간 업로드 기준 평균 75ms, p95 114ms였습니다. 늘어난 수십 초에 비하면 아주 작아서, 업로드 지연을 Nginx 쪽 문제로 볼 근거가 없었습니다.
마지막 업로드는 AI 결과를 기다리느라 4~7분 열려 있었지만 /api/trips의 600초 Timeout 안이었습니다. 실패가 Nginx 뒤의 Backend 내부에서 났기 때문에, limit_req나 limit_conn 같은 Nginx 요청 제한으로는 이번 실패를 막을 수 없습니다.
더 올리면 같은 사진 변환 거부가 반복될 뿐, 그 뒤에 있는 App, Worker, Nginx의 한계는 보이지 않습니다. 운영 환경에 같은 실패를 더 만들 이유가 없어 혼합 시험은 4배수에서 멈추고, 읽기 경로(6절)와 Worker(7절)를 따로 확인했습니다.
- 문제: 여행 생성이 겹치면 사진 변환 접수에서 거부되고 그대로 업로드 500이 됨. 4배수에서 50% 실패
- 조치: 한도를 올리기보다 활성, 대기, 거부 건수와 점유 시간을 먼저 계측하고, 한도에 닿았을 때의 계약(대기, 명시적 거부, 재시도)을 Backend와 결정. 수정 후 같은 4배수로 재시험
4배수에서는 여행 생성이 먼저 실패해서, 요청을 더 올려도 조회 처리 특성이 드러나지 않았습니다. 사진 변환을 거치지 않는 읽기 경로만 올렸을 때 어디까지 버티는지 확인합니다.
- 같은 계정의 완료 여행 7개를 돌아가며 조회. 한 여정은 목록, 지도, 상세, 장소 폴더 4건을 2초 간격으로 요청
- 세션 유입률을 단계별로 3분씩 유지하고, 오류와 자원을 확인한 뒤 다음 단계로 올림
- 판정: 여정 성공 수, 유입 누락, Nginx 경로별 상태 코드와 p95, App CPU와 TCP 연결 수, 종료 후 회복
| 세션/분 | 계획 API RPS | 실측 API RPS | 여정 성공 | 상세 k6 p95 | App CPU 최대 | TCP 연결 최대 |
|---|---|---|---|---|---|---|
| 6 | 0.4 | 0.40 | 19/19 | 14.97ms | 3.03% | 20 |
| 24 | 1.6 | 1.55 | 72/72 | 13.06ms | 4.31% | 25 |
| 96 | 6.4 | 6.15 | 288/288 | 12.57ms | 8.58% | 43 |
| 384 | 25.6 | 24.52 | 1,152/1,152 | 14.38ms | 21.34% | 110 |
| 600 | 40 | 38.31 | 1,801/1,801 | 14.53ms | 26.69% | 151 |
모든 단계에서 HTTP 실패와 유입 누락이 0건이었고, 가장 높은 단계도 종료 다음 분에 App CPU 2.44%, TCP 연결 22개로 돌아왔습니다.
계획 40 API RPS(V1 검증 상한의 30배 이상)까지 실패와 지연 증가가 없었고, 최대치는 찾지 못했습니다.
요청량이 100배(0.4 → 40 RPS)로 늘어나는 동안 상세 조회 p95는 12~15ms 사이에서 거의 변하지 않았습니다. 따라 늘어난 건 CPU와 연결 수였습니다.
- CPU: 3%에서 27%로, 요청량에 거의 비례해 증가
- TCP 연결: 20개에서 151개로 증가. 요청이 많아질수록 동시에 열려 있는 연결도 늘어남
지연이 그대로라는 건 아직 어느 자원도 대기열이 생길 만큼 차지 않았다는 뜻입니다.
이 결과를 혼합 한계 시험과 나란히 놓으면 두 경로가 압박하는 자원이 다릅니다.
| 부하 | App CPU 최대 | TCP 연결 최대 |
|---|---|---|
| 조회 40 API RPS | 26.69% | 151 |
| 혼합 3배수 (생성 포함) | 84.85% | 32 |
| 혼합 4배수 (생성 포함) | 85.55% | 28 |
조회는 연결 수를 늘리지만 CPU는 적게 쓰고, 업로드와 사진 변환은 연결은 적지만 CPU를 많이 씁니다. 그래서 조회 40 RPS 통과를 업로드에 남은 여유로 해석하지 않고, 두 경로의 한계는 따로 봐야 합니다.
한 계정이 여행 7개를 반복 조회해서 실제 사용자 분포보다 캐시 효과가 클 수 있습니다. 스크립트 상한인 600세션/분까지 올렸지만 읽기 경로의 최대치에는 닿지 못했습니다. 이 값은 읽기 경로의 통과 하한이지 서비스 전체 한계가 아닙니다.
Nginx 경로별 건수는 k6와 정확히 맞았습니다. 96세션/분 단계를 처음 조회했을 때 272건만 잡혔지만 다시 조회하니 288건으로 맞아서, 로그 수집 지연으로 확인했습니다.
- 문제 없음: 읽기 경로는 지금 개선 대상이 아님
- 남는 것: 최대 수용량은 미측정. 더 올리려면 스크립트 상한과 중단 기준을 새로 정해야 함
Worker 한계를 직접 시험하기 전에, 지금까지의 150장 작업이 Worker와 App을 얼마나 점유했는지 확인하고 무엇을 알고 무엇을 모르는지 정리합니다.
- 새 부하는 넣지 않고, 혼합 한계 시험 3배수와 4배수 실행의 AI, Backend, Nginx 로그와 EC2 지표를 다시 분석
- 판정: Worker 작업의 겹침과 대기, 실행 시간과 단계별 비중, 기동 시간, App에 들어온 요청 구성과 연결 유지 시간
| 실행 | Worker에 도달한 작업 | 실행 평균 | 그중 Embedding | Worker CPU 최대 | 앞 작업 완료 후 다음 시작까지 |
|---|---|---|---|---|---|
| 혼합 3배수 | 6건 모두 성공 | 174.04초 | 171.45초 | 50.17% | 101~135초 |
| 혼합 4배수 | 4건 모두 성공 | 204.08초 | 201.03초 | 50.47% | 216~274초 |
- 실행 시간의 약 98.5%가 Embedding 단계
- Worker 준비 시간은 첫 작업만 36.4초, 이후는 0.2초 안팎
- 3배수에서 k6가 보낸 HTTP 965건 중 714건(약 74%)이 처리 상태 확인 요청. 여행 하나당 평균 119건
- 마지막 업로드는 평균 241.68초 동안 연결 유지
Worker 작업은 한 번도 겹치지 않아 Worker 한계는 아직 모릅니다. 대신 AI를 기다리는 동안 App이 상태 확인 요청과 긴 연결로 점유되는 것을 확인했습니다.
두 실행 모두 앞 작업이 끝나고 다음 작업이 오기까지 2분 이상 비어 있었습니다. 150장 작업 하나의 실행이 약 3~3.5분인데, 여행이 시작되고 마지막 업로드까지 몇 분이 걸려서 Worker에 도착하는 간격이 실행 시간보다 길었습니다.
4배수에서는 절반의 여행이 Backend에서 먼저 실패해 Worker까지 오지 못했습니다. Worker 앞에서 Backend가 먼저 막혀서 Worker는 한계에 닿을 기회가 없었습니다.
실행 시간의 약 98.5%는 Embedding 단계였습니다. 4배수 작업이 평균 30초 길었는데 그 증가분도 대부분 Embedding에 있었고, 시험 시각과 시스템 상태가 달라 원인은 특정하지 못했습니다.
Worker가 병목이 된다면 가장 먼저 볼 곳은 Embedding입니다. 다만 지금은 Worker가 병목이 아니라서 개선 대상으로 두지 않았습니다.
준비 시간은 첫 작업만 36.4초였고, 이후는 0.2초 안팎이었습니다. 유휴 종료 뒤 처음 들어오는 작업만 기동 시간을 부담합니다.
3배수에서 k6가 보낸 요청의 약 74%가 처리 상태 확인이었습니다. 사진을 올리는 요청보다 "끝났는지" 묻는 요청이 훨씬 많았던 겁니다.
마지막 업로드는 평균 241.68초 동안 연결이 열려 있었고, Backend 응답 시간도 241.63초였습니다. 사진 전송이 아니라 Backend가 결과를 기다리며 연결을 붙잡고 있던 시간입니다.
이건 AI가 처리하는 동안 App도 함께 점유되는 V1 동기 계약의 비용입니다. Worker가 아무리 빨라져도 이 구조에서는 요청과 연결이 여행 수에 비례해 App에 쌓입니다.
| 모르는 것 | 이유 |
|---|---|
| Worker 동시 처리와 적체 한계 | 작업이 겹친 적이 없음 |
| Worker 메모리 최고치 | 메모리 지표가 수집되지 않고 있었음 |
| Network, EBS 사용량의 작업별 몫 | Host 전체 지표라 작업 단위로 나눌 수 없음 |
- 구조 변경과의 관계: 비동기 작업 전달은 App 연결 점유를 줄이지만 Worker 처리량을 늘리지는 않음. S3 직접 업로드를 해도 Backend 사진 변환 거부(5절)는 남을 수 있음
- 조치: Worker 메모리와 작업별 유입, 실행, 대기 시각을 먼저 계측. 변환 거부 없이 150장 작업을 연속으로 넣을 방법을 Backend, AI와 합의한 뒤 Worker 시험
| 순위 | 문제 | 위치 | 영향 | 근거 | 조치 |
|---|---|---|---|---|---|
| 1 | 여행 생성이 겹치면 사진 변환 접수에서 거부되어 업로드 500 | Backend 사진 변환 스케줄러 | 4배수에서 여행 생성 50% 실패 | 5절 | 접수 상태 계측, 한도 도달 시 계약 결정, 4배수 재시험 |
| 2 | 같은 계정 동시 생성 시 결과 저장 실패 | Backend 결과 저장 (user_stats 잠금) |
해당 여행 유실 | 4절 | 잠금 구간에서 S3 호출 제거, 같은 계정 회귀 시험 |
| 3 | 업무 실패를 가리는 관측 | HTTP 실패율, 5xx Alarm, 오류 코드, Worker 메모리 | 4배수 HTTP 0.54%인데 생성 50% 실패. 단건 500은 Alarm에 안 걸림 | 4, 5, 7절 |
trip_creation 실패 기준 Alarm, 오류 코드 분리, Backend 단계별 시간과 Worker 메모리 수집 |
| 4 | 여행 생성 중 App CPU 약 85% | App EC2 | 생성 동시성이 더 늘면 CPU 여유가 먼저 줄 수 있음 | 5, 6절 | 1번 수정 후 같은 조건에서 재측정 |
| 5 | AI 대기 중 App 점유 | V1 동기 업로드 계약 | 요청의 약 74%가 상태 확인, 마지막 요청이 약 4분 연결 유지 | 7절 | 비동기 작업 전달 설계의 비교 기준으로 보관 |
1순위는 부하를 올렸을 때 사용자 여정이 처음 끊기는 지점이고, 2순위는 같은 계정이 여행을 동시에 만들 때만 나지만 나면 여행이 사라집니다.
반대로 문제가 없다고 확인되어 지금 개선 대상에서 빼는 것도 있습니다.
- 완료 여행 조회: 읽기 경로만으로 계획 40 API RPS까지, 혼합 4배수에서도 실패 없음
- Nginx: 모든 시험에서 연결 부족이나 upstream timeout 없음
- AI Worker 처리: 접수된 작업은 모두 완료. 다만 작업이 겹친 적이 없어 한계는 미측정
- Worker 기동 시간: 유휴 종료 뒤 첫 작업만 약 36~40초 추가
예상 부하 구간의 동작, 혼합 3배수 통과와 4배수의 첫 실패 지점, 읽기 경로의 통과 하한, 150장 작업 1건의 Worker 점유를 기록했습니다. 현재 구조 전체의 최대 RPS나 Worker의 지속 처리량은 측정하지 않았고, 어느 시험도 30일 SLO 달성을 증명하지는 않습니다.
| 목적 | 다시 시작하기 전에 필요한 것 | 이어서 할 시험 |
|---|---|---|
| 사용자 여정의 다음 실패 지점 확인 | 사진 변환 접수 한도에 닿았을 때의 계약과, 활성, 대기, 거부 건수를 볼 수 있는 계측. 새 Backend Release | 같은 150장, 독립 계정, 4배수 조건 재실행. 생성 완료율, 첫 실패 단계, App 자원, 조회 영향을 비교. 500이 사라진 것만으로 성공으로 보지 않음 |
| Worker 처리량과 적체 경계 확인 | Worker 메모리 지표, 작업별 유입과 실행, 대기 시각. 변환 거부 없이 150장 작업을 연속으로 넣을 방법과 데이터 정리 방법을 Backend, AI와 합의 | 작업 유입을 단계적으로 올려 완료 건수, 대기, 자원, 기동 시간, 실패와 회복 확인 |
| 구조 변경의 효과 비교 | S3 직접 업로드나 비동기 작업 전달이 배포되면 k6 업로드 시나리오를 새 계약에 맞게 갱신 | 같은 사진과 유입 조건에서 원본 보존율, 여행 완료율과 시간, 조회 영향, 자원을 이번 V1 결과와 비교 |
직접 업로드만 적용해도 Backend 사진 변환 거부는 남을 수 있고, 작업 대기열을 도입해도 Worker의 계산 처리량은 저절로 늘지 않습니다.