Dev 부하 테스트 - 100-hours-a-week/KTB4-2nd-Cloud GitHub Wiki

V1 운영 부하 테스트

여담 사용자는 사진을 올린 뒤 여행이 완성되기를 기다리고, 완성된 여행을 수시로 다시 엽니다. 부하 테스트로 확인하려 한 것은 세 가지입니다.

  • 예상 사용자 부하에서 원본 사진과 서비스 기능이 지켜지는가
  • 부하가 커지면 어느 경로와 자원이 먼저 실패하는가
  • 이후 구조를 바꿨을 때 전후를 같은 사용자 결과로 비교할 기준

판정 기준은 SLI/SLO 기준을 따랐습니다.


시험을 어떻게 진행했나

V1의 수용량은 RPS 하나로 표현되지 않습니다. 짧은 조회, 10장씩 이어지는 업로드, AI 결과를 기다리는 긴 요청, Worker 작업 유입이 한 서버 안에서 섞여 있기 때문입니다.

그래서 세 단계로 나눠 진행했습니다.

  1. 예상 부하에서 동작하는지 (1~4번): 정상 기준값을 잡고, 업로드와 조회를 겹치고, 조회 급증과 AI 작업 연속 유입을 따로 확인
  2. 어디서 처음 실패하는지 (5번): 실제 사용 비율을 유지한 채 부하를 배수로 올려 첫 실패 지점을 찾음
  3. 실패 지점 뒤에 가려진 것 (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
Loading
구간 남기는 곳 남긴 값
요청 전체와 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 경로의 객체 버전, 시험 계정과 세션을 삭제했습니다.


1. Baseline: 정상 상태의 기준값

목적

부하를 겹치기 전에 여행 한 건과 조회의 정상 값을 잡습니다. 사진을 올리면 여행이 끝까지 만들어지는지, 완성된 여행을 제때 열 수 있는지 확인하고, 이후 시험은 모두 이 값과 비교합니다.

방식

  • 여행 생성: 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.6178.4초 (160.5175.4초)
꺼진 Worker 준비 36.3~40.4초
App CPU / Memory 최고 76.481.0% / 46.747.2%
완료 여행 조회 실제 요청량 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 처리였습니다.

14분은 어디에 쓰였나

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분 반이 됐습니다.

업로드 40초는 어디서 걸렸나

같은 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 설정 변경은 여행 전체 시간에 주는 효과가 작아서 후순위

2. 혼합 부하와 V1 Peak: 업로드 중에도 조회되는가

목적

사진 업로드와 여행 조회는 같은 App을 씁니다. 업로드가 몇 분씩 이어지는 동안 이미 완성된 여행을 보는 사용자까지 영향을 받는지, V1 검증 상한에서 조회와 여행 생성이 함께 유지되는지 확인합니다.

방식

  • 완료 여행 조회를 계속 보내면서 5초 뒤 업로드를 겹침. 조회와 업로드는 별도 k6 프로세스
  • V1 검증 상한(API 약 1.3 req/s, 동시 업로드 2건)보다 조회를 높인 단계부터 검증 상한 조합까지 진행
  • 판정: 업로드와 겹친 구간과 아닌 구간의 trip_view p95, 여행 생성 결과와 전체 시간, 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절에서 확인

3. 조회 Spike: 급증 뒤 원래 상태로 돌아오는가

목적

사용자가 한꺼번에 여행을 열면 오류가 나거나, 급증이 끝난 뒤에도 연결이 남아 평소 요청을 방해할 수 있습니다. 조회가 갑자기 몰렸을 때의 오류와 지연, 그리고 회복을 확인합니다.

방식

  • 완료 여행 조회를 세 구간으로 이어서 실행. 앞 구간이 실패하면 다음 구간은 보내지 않음
  • 판정: 구간별 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개) 밖. 요청 제한으로 다룸

4. AI 연속 유입: Worker 완료가 여행 완료로 이어지는가

목적

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의 결과 저장 잠금에서 났습니다.

Worker는 3건을 어떻게 처리했나

세 작업의 대기 시간이 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 검토

5. 혼합 한계 시험: 어디서 처음 실패하는가

목적

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는 끝까지 영향이 없었습니다.

HTTP 실패율은 왜 낮게 나왔나

여행 하나는 업로드 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 결과까지 완료됐습니다.

3배수는 통과했는데 4배수는 왜 실패했나

여행 한 건이 약 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으로 끝나는 게 사용자 영향의 원인입니다.

Nginx는 무엇을 했나

배수 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 요청 제한으로는 이번 실패를 막을 수 없습니다.

왜 4배수에서 멈췄나

더 올리면 같은 사진 변환 거부가 반복될 뿐, 그 뒤에 있는 App, Worker, Nginx의 한계는 보이지 않습니다. 운영 환경에 같은 실패를 더 만들 이유가 없어 혼합 시험은 4배수에서 멈추고, 읽기 경로(6절)와 Worker(7절)를 따로 확인했습니다.

판단

  • 문제: 여행 생성이 겹치면 사진 변환 접수에서 거부되고 그대로 업로드 500이 됨. 4배수에서 50% 실패
  • 조치: 한도를 올리기보다 활성, 대기, 거부 건수와 점유 시간을 먼저 계측하고, 한도에 닿았을 때의 계약(대기, 명시적 거부, 재시도)을 Backend와 결정. 수정 후 같은 4배수로 재시험

6. 조회 경로 한계: 읽기 경로만 올리면 어디까지 버티는가

목적

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건으로 맞아서, 로그 수집 지연으로 확인했습니다.

판단

  • 문제 없음: 읽기 경로는 지금 개선 대상이 아님
  • 남는 것: 최대 수용량은 미측정. 더 올리려면 스크립트 상한과 중단 기준을 새로 정해야 함

7. 기존 실행 재분석: Worker 점유와 App 요청 증폭

목적

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이 상태 확인 요청과 긴 연결로 점유되는 것을 확인했습니다.

왜 Worker 작업이 겹치지 않았나

두 실행 모두 앞 작업이 끝나고 다음 작업이 오기까지 2분 이상 비어 있었습니다. 150장 작업 하나의 실행이 약 3~3.5분인데, 여행이 시작되고 마지막 업로드까지 몇 분이 걸려서 Worker에 도착하는 간격이 실행 시간보다 길었습니다.

4배수에서는 절반의 여행이 Backend에서 먼저 실패해 Worker까지 오지 못했습니다. Worker 앞에서 Backend가 먼저 막혀서 Worker는 한계에 닿을 기회가 없었습니다.

Worker 시간은 어디에 쓰였나

실행 시간의 약 98.5%는 Embedding 단계였습니다. 4배수 작업이 평균 30초 길었는데 그 증가분도 대부분 Embedding에 있었고, 시험 시각과 시스템 상태가 달라 원인은 특정하지 못했습니다.

Worker가 병목이 된다면 가장 먼저 볼 곳은 Embedding입니다. 다만 지금은 Worker가 병목이 아니라서 개선 대상으로 두지 않았습니다.

준비 시간은 첫 작업만 36.4초였고, 이후는 0.2초 안팎이었습니다. 유휴 종료 뒤 처음 들어오는 작업만 기동 시간을 부담합니다.

AI를 기다리는 동안 App에서는 무슨 일이 있었나

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의 계산 처리량은 저절로 늘지 않습니다.

⚠️ **GitHub.com Fallback** ⚠️