배경: 성능 개선의 필요성을 느끼다
나만의 무기 만들기 폴리싱 주간을 맞아, 현재 서비스의 성능을 한 단계 끌어올려야 했습니다. To-Do 리스트는 아래와 같이 산더미처럼 쌓여 있었죠.
- FastAPI 비동기 처리 구현 (try-on)
- 검색엔진 구현
- try-on 알고리즘 구현
- 개인화 추천상품 알고리즘 구현
- TIO 애플리케이션 성능개선
저는 이 중에서 성능 개선을 맡았습니다. 왜 성능 개선이 필요하다고 생각했을까요? 이전에 간단히 진행했던 부하 테스트에서 문제의 징후를 발견했기 때문입니다.
징후: nGrinder 테스트에서 발견된 이상 신호
이전에 nGrinder를 이용해 메인 페이지, 상세 페이지, 검색 페이지에 대한 간단한 GET 요청 테스트를 진행한 적이 있습니다. 가상 사용자(VUser) 수를 늘려가며 5분간 테스트한 결과는 충격적이었습니다.
| API | VUser 200명 | VUser 400명 | VUser 1,000명 | VUser 2,000명 |
|---|---|---|---|---|
| 메인 페이지 | 3-100ms | 26-128ms | 42-108ms | 1-317ms |
| 상세 페이지 | 357-2,099ms | 310-2,973ms | 4,099-16,685ms | 최대 17,265ms |
| 검색 페이지 | (테스트 안됨) | (테스트 안됨) | 937-1,434ms | 1,396-8,146ms |
메인 페이지는 사용자가 늘어도 안정적인 반면, 상세 페이지는 사용자가 200명만 되어도 응답시간이 2초를 넘기 시작하더니, 1,000명에 이르자 최대 17초까지 폭증했습니다.
단순 페이지 호출만으로도 이런 병목이 발생한다는 것은, 상세 페이지를 불러오는 과정에 **복잡하고 비효율적인 쿼리, 즉 N+1 문제**가 숨어있음을 직감하게 했습니다.
계획: 무엇을 어떻게 개선할 것인가?
"감"만으로는 부족합니다. 정확한 데이터를 기반으로 개선 작업을 진행하기 위해, 먼저 성능 테스트를 통해 현재 상태(Before)의 지표를 명확히 측정하기로 했습니다.
개선 대상 목록
- DB 최적화
- N+1 문제 해결 (가장 시급)
- 커넥션 풀 최적화
- 읽기 전용(Read Replica) RDS 도입
- Spring 애플리케이션 최적화
- try-on 기능 비동기 처리
- JVM 최적화
- 프론트엔드 최적화
- Next.js 이미지 최적화
- Lazy Loading 적용
- 캐싱
- Redis 캐시 도입
테스트 도구: Grafana K6
nGrinder, JMeter, Locust 등 다양한 도구가 있지만, 이번에는 Grafana K6를 선택했습니다.
- Go 언어로 개발되어 성능이 우수합니다.
- 테스트 스크립트를 JavaScript로 작성하여 개발자에게 친숙합니다.
- CI/CD 파이프라인에 통합하기 쉽습니다.
- Grafana, Datadog 등 다양한 모니터링 도구와 연동이 뛰어납니다.
K6 설치 (macOS 기준)
brew install k6
설치 후 k6 version 명령어로 설치를 확인합니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image.png)
실험: 실제 사용자 여정 시나리오 테스트
단순 API 호출이 아닌, 실제 사용자의 행동 패턴을 모방한 복합적인 시나리오를 설계했습니다.
- 테스트 환경: 동시 사용자 10명 → 20명 → 40명(피크)으로 점진적 증가 (총 22분)
- 테스트 방식: 실제 계정으로 로그인하여 인증 토큰(JWT) 기반으로 진행
🎭 단계별 사용자 여정
- 🔐 로그인: 이메일/비밀번호로 로그인하여 JWT 토큰 획득
- 📍 배송지 관리: 기존 배송지 확인 후, 없으면 신규 추가
- 📱 개인화 추천 조회: 로그인 유저 기반의 메인 페이지 추천/랭킹 상품 조회
- 👀 상품 상세 조회 + 옵션 선택 (핵심): 추천 상품 3개를 둘러보며, 실제 사용자처럼 랜덤 사이즈/색상/수량을 선택하고 3~8초간 머무름
- 🏠 메인페이지 재방문: 상품을 본 뒤 메인으로 돌아오는 행동
- 👗 가상 피팅: 선택한 상품과 랜덤 신체 정보로 가상 피팅 시도
- 💖 찜하기: 마음에 드는 상품 1개를 찜 목록에 추가
- 👔 옷장 조회: 개인 옷장 기능 확인
- 🛒 장바구니 추가: 상세 조회 시 선택했던 옵션(사이즈, 색상 등)을 그대로 장바구니에 추가
- 📝 주문서 작성: 등록된 배송지를 선택하여 주문서 작성
- 💳 결제: 실제 결제는 안전을 위해 제외
K6 테스트 스크립트
위 시나리오를 바탕으로 작성된 JavaScript 테스트 스크립트입니다. (전체 코드는 글의 마지막에 첨부)
// k6-authenticated-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// ...
export let options = {
stages: [
{ duration: '2m', target: 10 }, // 10명으로 시작
{ duration: '5m', target: 20 }, // 20명으로 증가
{ duration: '10m', target: 20 }, // 20명 유지
{ duration: '3m', target: 40 }, // 40명으로 피크
{ duration: '2m', target: 0 }, // 종료
],
// ...
};
// ... (전체 시나리오 구현)
결과: 모든 것이 터져나갔다
테스트를 시작하자 처음에는 순조로워 보였습니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image2.png)
하지만 그것도 잠시, 터미널은 곧 에러 메시지로 뒤덮이기 시작했습니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image3.png)
급히 테스트를 종료하고 AWS 모니터링 대시보드를 확인했습니다. 인스턴스가 죽었다 살아나기를 반복하고 있었습니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image5.png)
최종 결과는 처참했습니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image7.png)
- API 성공률: 12.90% (목표: 90%)
- 로그인 성공률: 12.98% (목표: 95%)
- 사용자 여정 완료율: 0.00% (목표: 85%)
- HTTP 요청 실패율: 81.06%
- 평균 응답시간: 27.21초 (목표: 3초 이하)
모든 지표가 박살 났습니다. CPU 사용률은 테스트가 진행된 10분 남짓한 시간에 100%를 찍었습니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image8.png)
분석: 범인은 역시 N+1
1. N+1 쿼리의 증폭 효과
문제의 원인은 ProductService 코드에 있었습니다. 비회원 메인 페이지를 구성하기 위해 모든 카테고리를 조회(categoryRepository.findAll())한 뒤, 각 카테고리별로 상품을 또 조회하는 로직이었습니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image9.png)
이로 인해 발생하는 쿼리 증폭은 이렇습니다.
- 사용자 1명의 메인페이지 요청 →
findAll1번 + 카테고리 수(N)만큼 추가 쿼리 → 약 101번의 쿼리 발생 - 동시 사용자 20명 → 20명 x 101번 = 2,020번의 쿼리 발생
테스트 중 ALB에는 최대 63,000건 이상의 요청이 기록되었습니다. 이를 N+1 쿼리 수와 곱하면,
63,000 요청 × 101 쿼리/요청 = 약 636만 번의 DB 쿼리!
이 엄청난 쿼리 폭풍이 DB 커넥션 풀을 고갈시키고, 결국 시스템 전체를 마비시킨 것입니다. RDS의 DBLoad와 EC2의 네트워크 패킷 수가 순간적으로 치솟은 것이 그 증거입니다.
_%EC%8B%A4%ED%8C%A8%EA%B8%B0/image12.png)
2. 미스터리: 왜 이전 테스트는 멀쩡했을까?
이전의 단순 nGrinder 테스트는 인증 없이 페이지만 호출했습니다. 이 요청들은 대부분 CDN(CloudFront)에서 캐싱된 정적 HTML 페이지를 반환하며 끝났습니다. 즉, 백엔드 API 서버까지 요청이 도달하지 않아 N+1 문제가 발생할 일이 없었던 것입니다.
- 단순 테스트: CDN 성능 테스트 (DB 부하 없음)
- 이번 테스트: 실제 사용자 시나리오 테스트 (DB 부하 폭증)
결국 이번 실패는 CDN 뒤에 숨어있던 백엔드 API의 취약점을 정확히 드러낸, 값진 실패였습니다.
결론: 당장 N+1 문제부터 해결하자
성능 개선의 첫걸음은 가장 큰 병목 지점인 N+1 문제 해결이 되어야 함을 명확히 알 수 있었습니다. 다음 편에서는 이 문제를 해결하고, 다시 K6 테스트를 통해 성능이 얼마나 개선되었는지 측정해보겠습니다.
'Jungle' 카테고리의 다른 글
| TIO 개선 소요 파악 - 성능 최적화 (0) | 2025.07.29 |
|---|---|
| 상세이미지 버킷 링크 노출 (0) | 2025.07.24 |
| 17일? 못 기다려! 크롤러 성능 개선 삽질기 (Selenium → Requests) (0) | 2025.07.23 |
| nGrinder 설치 및 실행 가이드: 트러블슈팅 포함 (0) | 2025.07.23 |
| 이미지 트러블슈팅 완전정복: S3 리전 오류, Mixed Content, Next.js 최적화 (0) | 2025.07.23 |