Skip to content

[Week 5] 강현우 - 학술제 프로젝트에 로드 테스트 해보기 #21

Description

@WooDDANG

1. 시작은 단순한 호기심이었다: "우리 서비스, 몇 명까지 버틸까?"

컴공인지 학술제 출품작인 TAMMY 서비스의 백엔드(NASA_backEnd)를 개발하면서 한 가지 근본적인 궁금증이 생겼습니다. 1-Tap 기록과 별여행 게이미피케이션 트랜잭션을 처리하는 이 백엔드가 실제 트래픽 환경에서 과연 어디까지 버텨줄 수 있을까?

이 호기심을 실제 수치로 검증하기 위해 Grafana k6 부하 테스트 환경을 구축하고, 가장 널리 쓰이는 공식 대시보드(템플릿 ID: 2587)를 연동했습니다.

image로드 테스트 관련 내용 정리.png


2. 현실을 모사한 트래픽 믹스(Traffic Mix)와 램핑 시나리오 설계

단순히 특정 단일 API만 기계적으로 찌르는 비현실적인 테스트를 피하기 위해, 실제 TAMMY 모바일 앱 사용자의 일상 사용 여정을 분석하여 70% Read : 30% Write 비율의 정교한 트래픽 믹스를 구성했습니다.

📊 2.1 전체 트래픽 믹스 비율 (70% Read : 30% Write)

  • 📖 Read 워크로드 (70.0%)
    • 🪐 Two-Gauge 행성 상태 조회 (42.0%): GET /api/v1/planet-travel/state ➔ Two-Gauge 연료(0~100) 및 4대 행성 잔여 거리 데이터 검증
    • 📊 대시보드 요약 조회 (28.0%): GET /api/v1/dashboard/summary ➔ 유저 대시보드 요약 및 타미 펫 상태 조회
  • ✍️ Write 워크로드 (30.0%)
    • 💬 AI 타미 텍스트 대화 (10.5%): POST /api/v1/chat/message ➔ 타미 AI 응답 텍스트, 모션 태그 생성 및 연료(+10) 적립
    • 💧 1-Tap 수분 섭취 기록 (10.5%): POST /api/v1/quick-log (WATER) ➔ 수분 250ml 기록, 연료(+10), 수분별 거리(-5km) 차감 (10% 멱등성 네트워크 재전송 모사)
    • 🏃 1-Tap 운동/감정 기록 (6.0%): POST /api/v1/quick-log (EXERCISE/EMOTION) ➔ 30분 운동 또는 감정 퀵기록, 생활습관별 거리(-10km) 차감
    • 📝 감정 일기 작성 (3.0%): POST /api/v1/quick-log (JOURNAL) ➔ 데일리 감정 일기 작성 및 감정별 거리(-10km) 차감

📈 2.2 가상 사용자(VUs) 5단계 램핑 스테이지 (총 3분 30초)

실제 저비용 클라우드 소형 인스턴스(CPU 0.5 코어 / RAM 512MB) 제약 환경에서 시스템의 처리 한계선(Breaking Point)을 단계적으로 관측하기 위해 5단계 램핑 프로파일을 적용했습니다:

단계 지속 시간 동시 접속자 (VUs) 테스트 목적
1단계 30초 10 VUs 시스템 웜업 (Warm-up) 및 기본 트래픽 처리
2단계 1분 30 VUs 일반적인 피크 시간대 트래픽 안정성 검증
3단계 1분 50 VUs 0.5 CPU / 512MB RAM 리소스 제약 하 고부하 지속성 검증
4단계 30초 100 VUs 시스템 한계치(Breaking Point) 및 병목 지점 탐색
5단계 30초 0 VUs 트래픽 종료 및 리소스 회수 (Cool-down)

3. 그런데 문제는 100명 VUs에 도달하기도 전에 계속해서 딜레이가 발생하는 것이었습니다

테스트를 진행하면서 가장 의아했던 점은, 100명의 최대 피크에 도달했을 때만 문제가 터진 것이 아니라는 사실이었습니다.

image로드 테스트 관련 내용 정리.png

1단계(10 VUs)와 2단계(30 VUs) 등 낮은 부하 단계에서부터 이미 응답 시간이 일정하지 않고 불길한 딜레이가 지속적으로 발생하고 있었습니다. 시스템이 워밍업되는 과정에서도 네트워크 소켓이 원활하게 순환되지 못하고 조금씩 지연이 누적되고 있었던 것입니다.


4. 100명의 유저 도달, 그리고 눈앞에 펼쳐진 3초의 빨간 불

그리고 마침내 부하가 100 VUs에 도달하는 순간, 누적되던 병목이 임계점을 넘으며 충격적인 수치로 터져 나왔습니다.

image로드 테스트 관련 내용 정리-1.png
image로드 테스트 관련 내용 정리-2.png

100명이라는 숫자는 대규모 트래픽이라고 부르기에도 민망할 정도로 작은 규모였습니다. 하지만 Grafana 차트의 평균 응답 지연 시간은 순식간에 3~4초대로 치솟았고, 대시보드 하단 좌측의 HTTP Timing Stacked Breakdown 차트 바닥이 온통 시뻘건 색(Blocked)으로 뒤덮였습니다.

무언가 단단히 잘못 돌아가고 있었습니다.


5. 첫 번째 의심과 반전: "서버가 느린 게 아니다"

레이턴시가 3초대로 치솟으면 대부분의 백엔드 개발자는 본능적으로 API 컨트롤러나 Prisma ORM의 복잡한 DB 쿼리부터 의심하기 마련입니다. "0.5 코어짜리 소형 인스턴스라 CPU가 터졌나? DB 커넥션 풀이 말라버렸나?"

하지만 Grafana 2587 대시보드의 HTTP Timing Breakdown 지표를 차근차근 뜯어보면서 완전히 다른 사실을 발견했습니다.

image로드 테스트 관련 내용 정리-3.png

  • Waiting (http_req_waiting / TTFB): 서버 내부 로직과 DB 쿼리가 수행되어 첫 응답을 돌려줄 때까지의 시간은 불과 수십 ms에 불과했습니다. 즉, 서버는 일을 아주 빠르게 끝내고 있었습니다.
  • Blocked (http_req_blocked): 혼자서 3초라는 무지막지한 시간을 전부 잡아먹고 있던 진짜 범인이었습니다.

💡 핵심 발견: Blocked 시간은 "서버의 연산이나 DB가 느린 것"이 아니라, "클라이언트(k6)가 서버와 새로운 TCP 네트워크 소켓을 맺기 위해 OS 레벨에서 줄을 서서 기다린(Queuing) 시간"이었습니다.


6. 범인을 찾아서: TCP Connection Churn과 TIME_WAIT의 습격

서버는 한가한데 왜 클라이언트는 3초 동안 줄을 서야 했을까요? 원인은 네트워크 소켓의 라이프사이클에 있었습니다.

  1. 소켓 갈아치우기 (TCP Connection Churn)
    • 100명의 가상 유저가 3분간 9,000번 넘게 쉴 새 없이 요청을 전송했습니다.
    • 이때 기존 TCP 연결을 유지하지 못하고 매 요청마다 연결이 끊어지면서, 매번 새로운 TCP 소켓을 생성하고 3-Way Handshake를 맺는 오버헤드가 발생했습니다.
  2. OS 소켓 포트 고갈 (TIME_WAIT 누적)
    • 닫힌 수천 개의 TCP 소켓은 즉시 사라지지 않고 OS 커널 레벨에서 데이터 유실 방지를 위한 TIME_WAIT 상태로 일정 시간 묶여 있게 됩니다.
    • 단시간에 가용한 로컬 포트가 고갈되면서, k6 부하 생성기는 "기존 소켓 슬롯이 닫혀서 풀릴 때까지 3초 동안 멍하니 대기(Blocked)" 상태에 빠진 것입니다.

7. 1차 해결: 단 2줄의 TCP Keep-Alive 튜닝

원인을 알고 나니 네트워크 소켓 해결책은 명확했습니다. 매번 소켓을 열고 닫지 말고, 이미 열려 있는 TCP 파이프라인을 계속 재활용(Keep-Alive)하도록 만드는 것입니다.

7.1 백엔드 Express 서버 튜닝 (src/app.ts)

Node.js의 기본 keepAliveTimeout은 고작 5초입니다. 고빈도 부하 상황에서는 5초가 지나면 소켓이 닫혀버리므로, 이를 65초로 대폭 늘렸습니다:

  • server.keepAliveTimeout = 65000 (65초 동안 연결 유지)
  • server.headersTimeout = 66000 (Node.js 18+ 권장: Race Condition 및 408 에러 방지를 위해 keepAliveTimeout보다 1초 이상 길게 설정)

7.2 k6 클라이언트 설정

  • k6 요청 헤더에 Connection: keep-alive를 명시하고 커넥션 풀 재사용을 보장했습니다.

8. 또 다른 반전: "Blocked는 사라졌는데, 왜 아직도 3초가 걸릴까?"

TCP Keep-Alive 설정을 적용하자 하단 좌측 차트의 빨간색 Blocked 지연 시간은 3.0초 ➔ 0.01ms 수준으로 완벽하게 소멸했습니다.

하지만 여기서 두 번째 미스터리이자 더 깊은 2차 반전이 발생했습니다. 소켓 큐잉(Blocked)은 사라졌는데, 전체 HTTP 응답 시간(http_req_duration)을 확인해 보니 여전히 약 3초대의 딜레이가 끈질기게 남아있었던 것입니다.

이번에는 클라이언트 소켓 대기(Blocked)가 아니라, 진짜 서버 내부 처리 시간인 Waiting (TTFB) 영역에서 정확히 3초가 소요되고 있었습니다.


9. 최종 범인 검거: AI 서버 호출 과정의 '1초 대기 × 3회 반복' 루프

백엔드 코드의 내부 트랜잭션을 세밀하게 추적한 끝에 마침내 최종 범인을 찾아냈습니다.

원인은 POST /api/v1/chat/message 등 타미 AI 대화 처리 과정에서 발생하고 있었습니다. 백엔드가 외부 AI 마이크로서비스(tammy-ai-server)를 호출하는 통신 파이프라인 내부에서:

  • AI 서버의 응답을 폴링하거나 재시도(Retry)하는 과정에서 "1초 대기(Sleep/Wait)를 고정적으로 3회 반복"하도록 작성된 로직이 존재했던 것입니다.
  • 즉, 1초 × 3회 = 정확히 3초의 인위적인 지연이 매 AI 대화 요청마다 백엔드 스레드를 붙잡고 있었습니다.

10. 2단계 완전 최적화와 최종 결론

image로드 테스트 관련 내용 정리-4.png
image로드 테스트 관련 내용 정리-6.png

결국 이번 부하 테스트에서 발생한 3초 지연은 2개의 서로 다른 계층에서 발생한 복합 병목이었습니다:

  1. 1계층 (네트워크 소켓 계층): TCP 연결 단절 및 OS TIME_WAIT 고갈 ➔ TCP Keep-Alive 튜닝으로 Blocked 3초 ➔ 0.01ms 제거 완료
  2. 2계층 (애플리케이션/마이크로서비스 계층): 백엔드 ↔ AI 서버 간 1초 대기 3회 반복 루프 ➔ 불필요한 동기식 1초 대기 루프 제거 및 비동기 처리로 Waiting 3초 완전 해소

두 계층의 병목을 모두 걷어내고 나서야 비로소 TAMMY 백엔드는 100명의 동시 사용자가 쏟아붓는 고빈도 1-Tap 기록과 AI 대화 트래픽을 밀리초(ms) 단위의 초저지연과 무결점 안정성으로 완벽하게 처리할 수 있게 되었습니다.

3. 알게된 것

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions