데이터로 증명하는 성능 개선 여정 (k6, Datadog)

Jayson·2025년 9월 8일
0
post-thumbnail

들어가며: "우리 서버는 과연 몇 명까지 버틸 수 있을까?"

'터닝 페어웰' 서비스의 아키텍처는 완성되었지만, 이것이 실제 트래픽을 감당할 수 있다는 보장은 없었습니다. 약 1,500명의 사용자가 동시에 몰릴 것을 대비해, 시스템의 한계를 명확히 파악하고 병목을 제거하는 과정이 반드시 필요했습니다.

이번 포스트에서는 부하 테스트 도구 k6와 APM(Application Performance Monitoring) 솔루션 Datadog을 활용하여, 데이터에 기반해 문제를 진단하고 시스템을 단계적으로 개선해나간 여정을 공유합니다.


1. 성능 측정 환경 구축: 올바른 도구 선택과 목표 설정

개선을 시작하기 전, 추측이 아닌 데이터로 시스템을 바라보기 위해 객관적인 측정 환경을 구축하는 것이 가장 중요하다고 생각했습니다. 이 과정에서 어떤 도구를 선택할지 신중하게 고민했습니다.

도구 선택의 과정

  • 부하 테스트 도구: nGrinder vs k6
    처음에는 국내에서 많이 사용되는 nGrinder를 고려했습니다. 강력한 기능과 UI가 장점이지만, 컨트롤러와 에이전트를 별도로 설치하고 관리해야 하는 등 초기 설정에 다소 시간이 소요됩니다. 이번 프로젝트는 빠르게 진행해야 했기에, 별도의 EC2 인스턴스에 간단히 설치하여 스크립트 기반으로 즉시 테스트를 실행할 수 있는 k6를 최종적으로 선택했습니다. EC2를 테스트 전용 머신으로 활용하면 더 빠르고 유연하게 원하는 부하를 생성할 수 있을 것이라 판단했습니다.

  • 모니터링 도구: Prometheus & Grafana vs Datadog
    오픈소스 조합인 PrometheusGrafana는 강력하고 확장성이 뛰어나지만, 직접 서버를 구축하고 메트릭 수집기(Exporter)를 일일이 설정해야 하는 번거로움이 있습니다. 저는 서버의 상태(Metrics), 코드 레벨의 동작(APM Traces), 그리고 로그(Logs)를 한곳에서 통합적으로 보고 싶었고, 이러한 Observability 환경을 가장 빠르고 편리하게 구축해 주는 SaaS 솔루션인 Datadog을 경험해보고 싶은 마음이 컸습니다. Datadog Agent 설치만으로 이 모든 것을 해결할 수 있다는 점이 매력적이었습니다.

최종 테스트 환경과 목표

  • 환경: 실제 운영 서버(AWS EC2)와는 별개의 EC2 인스턴스에 k6를 설치하여 부하를 발생시키고, 운영 서버에는 Datadog Agent를 설치하여 성능을 모니터링했습니다. 이를 통해 테스트 자체가 서버 성능에 영향을 주는 것을 방지했습니다.
  • 시나리오: 약 1,500명의 터닝 사용자를 고려하여, 1,500명의 가상 사용자(VUs)가 동시에 이벤트 신청 API를 요청하는 상황을 가정했습니다.
  • 성공 기준(목표): 쾌적한 사용자 경험을 위해, 상위 95%의 요청(p95)이 1초(1000ms) 이내에 응답되어야 하며, 요청 실패율은 0%여야 한다는 명확한 목표를 설정했습니다.

2. 가설과 검증: 병목 지점 추적기

모든 준비를 마치고 테스트를 시작했지만, 첫 결과는 처참했습니다. 이제부터 데이터와 로그들 가지고 시스템 속 병목을 찾아 나섰습니다.

첫 번째 테스트: t3.micro의 명백한 한계

가장 기본적인 t3.micro 인스턴스에서 진행한 첫 테스트부터 시스템은 비명을 질렀습니다.

  • k6 결과:

    • 요청 실패율: 10.13%
    • 평균 응답 시간: 2.58초
    • p95 응답 시간: 4.85초 (목표의 약 5배)
  • 분석: 1,500명의 요청 중 152건이 실패했고, 응답 시간은 목표치를 훨씬 초과했습니다. 시스템이 동시 요청을 전혀 감당하지 못하고 있었습니다. 진짜 문제 해결의 시작이었습니다.


가설 #1: "하드웨어(EC2) 사양이 너무 낮은 것 아닐까?"

가장 먼저 의심한 것은 서버의 물리적인 한계였습니다. Datadog 대시보드는 CPU 사용량이 100% 에 머무르며 더 이상 처리하지 못하는 상황을 명확히 보여주었습니다.

  • 개선: EC2 인스턴스 사양을 t3.micro에서 t3.small스케일업(Scale-Up)했습니다.

  • 재테스트 결과:

    • 요청 실패율: 0%
    • 평균 응답 시간: 10.42초
    • p95 응답 시간: 17.83초
  • 검증: 요청 실패는 사라졌지만, 응답 시간은 오히려 크게 늘어났습니다. 이 현상은 매우 중요한 단서라고 생각했습니다. 하드웨어라는 급한 불을 끄자, 그 속애 숨어있던 애플리케이션 레벨의 더 큰 병목이 보였습니다. 서버는 이제 죽지 않고 모든 요청을 처리하느라 더 오랜 시간이 걸리게 된 것입니다.

지표 (1500 VUs 기준)이전 (t3.micro)개선 후 (t3.small)분석
http_req_failed84건 실패 (5.6%)0건 실패 (0%)(안정성 확보) 모든 요청을 처리하기 시작.
http_req_duration (avg)2.58초10.42초(성능 저하) 애플리케이션 병목으로 인해 처리 시간 증가.

가설 #2: "그렇다면 DB 쿼리가 느린 것이 문제일 것이다."

애플리케이션 성능 저하의 가장 흔한 원인은 데이터베이스입니다.

  • 개선: AWS RDS의 파라미터 그룹을 수정하여 Slow Query Log를 활성화하고, 1초 이상 소요되는 쿼리를 모두 기록하도록 설정했습니다.
  • 재테스트 결과:
    • 응답 시간은 이전과 비슷하게 약 10초대.
    • 결정적으로, Slow Query Log에 아무런 기록도 남지 않았습니다.
  • 검증: DB는 제 역할을 훌륭히 수행하고 있었습니다. 문제는 DB가 아니었습니다. 이제 병목은 코드 어딘가에 숨어있다는 확신이 들었습니다.

가설 #3: "혹시 외부 서비스 연동이 발목을 잡고 있나?"

DB가 아니라면, 남은 병목 지점은 외부 API 호출, 특히 시간이 오래 걸리는 이메일 발송 로직이었습니다. 초기 테스트에서 대량의 이메일을 보내다 보니 Gmail SMTP 서버로부터 454 Throttling failure 에러를 받으며 전송이 차단되는 현상을 겪었습니다.

  • 개선 1: 이메일 서비스 교체 (PR #93):

    • 대용량 발송에 불안정한 Gmail SMTP 대신, 안정성과 확장성이 뛰어난 AWS SES(Simple Email Service)로 이메일 서비스를 전환했습니다. application-prod.yml의 SMTP 호스트 정보를 변경하여 간단히 적용할 수 있었습니다.
  • 개선 2: 완전한 비동기 처리 도입 (PR #125):

    • 하지만 서비스만 바꾼다고 문제가 해결되진 않았습니다. javaMailSender.send()는 동기적으로 동작하여 외부 SMTP 서버의 응답을 기다리는 동안 Kafka Consumer 스레드를 장시간 붙잡아두는(Blocking) 근본적인 문제가 있었습니다.
    • 이 문제를 해결하기 위해 Spring의 @Async를 적용했습니다. 이메일 발송 전용 스레드 풀(ThreadPoolTaskExecutor)을 설정하고, EmailService의 발송 메소드에 @Async를 붙여 느린 I/O 작업을 메인 처리 흐름에서 완전히 분리했습니다.
  • 재테스트 결과:

    • 평균 응답 시간: 6.2초
    • 선착순 성공 요청(200 OK) 평균 응답 시간: 260.5ms
  • 검증: 엄청난 성능 향상이었습니다! 느린 I/O 작업을 비동기로 전환한 것이 핵심이었습니다. 선착순에 성공한 사용자들은 0.26초 만에 응답을 받으며 목표치를 달성했습니다. 하지만 전체 평균 응답 시간은 여전히 길었고, 이는 시스템 내부에 또 다른 병목이 존재함을 의미했습니다.


가설 #4: "애플리케이션 내부의 처리량이 한계에 부딪혔다."

외부 서비스 연동 문제를 해결하자 응답 시간이 6.2초까지 단축되었지만, 여전히 목표치와는 거리가 멀었습니다. "병목은 이동한다"는 말처럼, 하나의 문제를 해결하자 시스템의 부하는 다음 단계로 옮겨갔습니다. 이제 문제는 애플리케이션의 동시 처리 능력 자체로 좁혀졌습니다. Datadog의 APM 데이터를 깊게 분석하며 시스템의 파이프라인을 단계적으로 확장하는 작업을 진행했습니다.


  • 개선 1: 웹 서버 스레드 풀 확장 - 더 많은 손님 받기 (PR #116)

    • 문제: Datadog에서는 특이점이 없었지만, k6 테스트 결과에서 EOF (End of File) 에러가 간헐적으로 발생했습니다. 이는 "전화가 갑자기 끊겼다"는 의미로, 손님(요청)이 가게(서버)에 들어오기도 전에 문전박대 당하는 상황이었습니다. 내장 웹 서버인 Tomcat이 처리할 수 있는 동시 요청(기본 200개)을 초과한 것이 원인이었습니다.
    • 해결: application.ymlserver.tomcat.threads.max 설정을 추가하여 동시에 처리 가능한 스레드 수를 1600개로 대폭 늘렸습니다. 이는 가게의 입구를 넓혀 1,500명의 손님이 모두 들어올 수 있도록 보장하는 것과 같았습니다.

  • 개선 2: Kafka Consumer 증설 - 계산대 늘리기 (PR #119)

    • 문제: 이제 모든 요청이 서버에 들어왔지만, 새로운 병목이 발생했습니다. API 서버는 빠르게 요청을 Kafka에 전달했지만, 정작 Kafka에 쌓인 메시지를 처리하는 Consumer가 단일 스레드로 동작하고 있었습니다. 이는 "마트에 손님은 1,500명이 꽉 찼는데, 계산대는 단 하나뿐"인 상황과 같았습니다. 이로 인해 Kafka 토픽에 심각한 백로그(Backlog)가 발생하며 시스템 전체가 느려졌습니다.
    • 해결: "계산대"를 늘리기로 했습니다. 먼저 Kafka 토픽의 파티션 수를 10개로 확장하여 물리적인 통로를 넓혔습니다. 그다음 spring.kafka.listener.concurrency를 10으로 설정하여, 10개의 Consumer 스레드가 메시지를 병렬로 처리하도록 변경했습니다. 이론적으로 처리 속도가 10배 빨라지는 효과를 기대했습니다.

  • 개선 3: DB 커넥션 풀 확장 - 계산원의 손 늘리기 (PR #122)

    • 문제: Kafka Consumer를 늘리자 이번에는 DB에 접속하려는 스레드가 많아지면서 DB 커넥션을 얻기 위한 대기 시간이 길어졌습니다. 10명의 계산원이 동시에 계산을 하려는데, 돈 통(DB 커넥션)이 몇 개 없어서 서로 차지하려고 기다리는 상황이었습니다.
    • 해결: HikariCP의 최대 커넥션 풀 사이즈(maximum-pool-size)를 늘려 여러 Consumer가 동시에 DB 작업을 원활히 수행할 수 있도록 길을 열어주었습니다.

3. 최종 테스트: 목표 달성과 한계점 확인

모든 파이프라인 튜닝을 마친 후, 부하 규모를 조절하며 마지막 테스트를 진행했습니다.

  • 최종 결과 (동시 사용자 300명):

    • 요청 실패율: 0%
    • 평균 응답 시간: 344ms
    • p95 응답 시간: 553ms
  • 결론: 마침내 목표했던 'p95 응답 시간 1초 이내'를 달성했습니다. 수많은 가설과 검증 끝에, 시스템은 이제 동시 사용자 300명까지는 매우 안정적으로 처리할 수 있는 상태가 되었습니다. 약 500명부터는 응답 시간이 1초를 넘어가기 시작하며 현재 시스템의 명확한 한계점 또한 데이터로 확인할 수 있었습니다.


4. 다음 단계: 무조건적인 확장이 정답일까? (Trade-off)

현재 시스템은 1,500명의 목표에는 도달하지 못했지만, 300명의 동시 접속을 1초 내로 처리할 수 있는 안정적인 상태가 되었습니다. 여기서 더 많은 트래픽을 처리하기 위한 선택지는 두 가지입니다.

선택 1: 스케일업 (Scale-Up, 수직 확장)

  • 방법: 현재 EC2 인스턴스 사양(t3.small)을 m7i-flex.large 와 같이 더 강력한 인스턴스로 교체하는 것입니다.
  • 장점: 매우 단순합니다. 아키텍처 변경 없이 즉각적인 성능 향상을 기대할 수 있습니다.
  • 단점: 비용 비효율성SPOF(단일 장애점) 문제가 있습니다. 트래픽이 없는 시간에도 비싼 요금을 내야 하며, 해당 서버에 장애가 발생하면 서비스 전체가 중단됩니다.

선택 2: 스케일아웃 (Scale-Out, 수평 확장)

  • 방법: 로드 밸런서와 Auto Scaling Group을 도입하여, 트래픽 양에 따라 EC2 인스턴스 수를 자동으로 늘리거나 줄이는 방식입니다.
  • 장점: 운영 비용 효율성고가용성을 모두 잡을 수 있습니다. 사용한 만큼만 비용을 내고, 인스턴스 하나에 장애가 생겨도 다른 인스턴스가 요청을 처리해 서비스 중단이 없습니다.
  • 단점: 초기 구축 비용과 복잡성이 높습니다. 로드 밸런서, 오토 스케일링 그룹 등 관리해야 할 요소가 늘어나고, 초기 설계 및 구현에 더 많은 시간이 필요합니다.

나의 선택과 결론: 상황에 맞는 합리적인 트레이드오프

언뜻 보기에 사용한 만큼만 비용을 내는 스케일아웃이 항상 더 경제적인 선택처럼 보일 수 있습니다. 실제로 장기적으로 운영되는 서비스라면 그것이 정답에 가깝다고 생각합니다.

하지만 '터닝 페어웰'은 단 한 번 진행되는 단기 이벤트입니다. 이 상황에서 "비용"은 단순히 서버 사용료만을 의미하지 않습니다. 로드 밸런서와 오토 스케일링 그룹을 구축하는 데 들어가는 개발자의 시간과 노력 역시 중요한 비용이라고 생각하였습니다.

  • 스케일아웃의 총비용: (높은 초기 구축 시간 비용) + (단기 이벤트 기간의 낮은 서버 운영 비용)
  • 스케일업의 총비용: (매우 낮은 초기 구축 시간 비용) + (단기 이벤트 기간의 다소 높은 서버 운영 비용)

이 프로젝트의 목적과 기간을 고려했을 때, 복잡한 스케일아웃 아키텍처를 구축하는 데 드는 시간과 노력이 단기간의 서버 비용 절감 효과보다 훨씬 크다고 판단했습니다.

따라서 가장 합리적인 선택은, 현재 최적화된 아키텍처를 유지하되 이벤트가 진행되는 짧은 시간 동안만 일시적으로 스케일업하여 대응하는 것입니다. 이는 총 소유 비용(TCO) 관점에서 가장 효율적인 결정이라고 생각합니다.

이번 성능 개선 여정을 통해 무조건적인 인프라 확장이 정답이 아니라는 것을 배웠습니다. 시스템의 한계를 데이터로 명확히 파악하고, 현재 상황과 비용, 목적에 맞는 최적의 해결책을 찾아나가는 과정이야말로 진정한 엔지니어링의 영역임을 깨달을 수 있었습니다.

profile
Small Big Cycle

0개의 댓글