[FITDAY] 99.98% 성능 개선 최적화 여정
게시: 2025/5/15
By: 황시우
개요
52,085 ms - > 12 ms, 99.98% 성능 개선에 성공한 과정
FITDAY 프로젝트에서 API의 평균 응답 시간이 52초가 넘는 심각한 성능 이슈가 있었습니다. 저는 DB 인덱스 튜닝, 캐싱 적용, 커넥션 풀 등을 통해 평균 응답 시간(TTFB)을 12 ms로 단축하는 성과를 이뤄냈습니다.
본 과정에서는 실제 성능 최적화 과정과 API 병목을 분석하였고 해결한 방법을 상세히 정리하였습니다.
테스트 툴
API 성능 테스트를 하기 위해 저에게 맞는 APM을 찾던 중, 네이버에 재직중인 선배 개발자분의 추천을 받아 Pinpoint에 대해서 알게 되었습니다.
선배에게 Pinpoint의 장점을 듣고, 가장 많이 사용되는 Prometheus + Grafana 조합과 비교해 보았습니다.
항목 | Prometheus + Grafana | Pinpoint |
트랜잭션 추적 | ❌ 별도 커스터마이징 필요 | ⭕️ Agent 삽입만으로 자동 연동 |
시각화 | Grafana 고급 대시보드 제공 | 기본적인 UI제공, 응답 시간 자동 제공 |
알림 | ⭕️ Alertmanager 연동 | ⭕️ 비정상 지점 즉시 식별 가능 |
언어 | 다수 지원 | Java, PHP |
설치 및 운영 난이도 | 복잡 (Prometheus + Grafana 등) | 간단 (Agent + Collector + Hbase) |
트랜잭션 추적을 쉽게 할 수 있고, Java기반으로 가동되는 Pinpoint를 선택하게 되었습니다!
이유
- Prometheus + Grafana는 시스템, 인프라 모니터링에 강점이 있지만, API 트랜잭션 추적 기능이 부족했습니다.
- 반면, Pinpoint는 개별 API 요청의 실행 시간과 병목 구간을 상세히 분석할 수 있었습니다.
- 따라서 저는 'API 최적화'가 목표였기 때문에 Pinpoint가 최적의 선택이었습니다.
서버 환경
우선 EC2를 2대로 구성하여 하나는 API 서버용, 나머지 하나는 Pinpoint 서버로 구성하였습니다.
위 사진들은 Pinpoint-apm 공식 Github이며, Java 버전에 따른 Pinpoint 호환성을 나타냅니다.
API 서버는 Pinpoint 최신버전을 사용하기 위해 Java 17을 사용함에 따라 Agent를 3.0.0 버전으로 설정하였고,
Pinpoint 서버도 Java 17을 기반으로 3.0.0 버전을 Collector, Web에 모두 적용했습니다.
또한 Pinpoint 3.0.x 버전에서는 HBase 1.x 버전을 지원하지 않으므로 2.x 버전으로 설정하였습니다.
- Pinpoint-Agent : 3.0.0 v
- Pinpoint-Web : 3.0.0 v
- Pinpoint-Collector : 3.0.0 v
- HBase : 2.5.8 v
서버 스펙은 아래와 같습니다.
역할 | 인스턴스 타입 | vCPU | 메모리 | 비고 |
API 서버 | t3a.small | 2 | 2 GB | Spring Boot, Pinpoint-Agent, Java 17 |
Pinpoint 서버 | t3.medium | 2 | 4 GB | Pinpoint-Web, Pinpoint-Collector, HBase, Java 17 |
- t3a.small => 비용 최적화를 위해 구성
- t3.medium => Pinpoint를 구성하기 위해 최소 4GB 확보 필요
Pinpoint 서버에서 Pinpoint-Web, Pinpoint-Collector, Hbase를 각각 띄웠더니 메모리가 최소 2GB를 잡아먹어서 t3a.small 인스턴스에서는 해당 애플리케이션 시작과 동시에 OOM(Out Of Memory)으로 서버가 다운되었습니다 😭
이후 t3.medium 인스턴스로 사양을 올리고 HBase JVM 힙 메모리를 늘려주니 Pinpoint가 정상적으로 동작하였습니다.
동시사용자 100명으로 테스트 했을때 4GB도 간당간당 했습니다. 동시사용자를 늘린다면, 4GB도 부족할 것 같아서 추후엔 서버 사정에 따라 인스턴스를 스케일업 해야겠다고 생각했습니다.
테스트 DB 데이터
커뮤니티 게시글 300만개의 데이터를 넣어서 부하 테스트 환경을 구성하였습니다.
API 성능 테스트 시나리오
테스트 시나리오는 임의로 생성한 300만 건의 커뮤니티 게시글을 대상으로,
사용자가 페이지 이동 시 마지막 페이지까지도 즉시 이동할 수 있도록 전체 게시글 수를 조회하는 COUNT 쿼리와 페이징 처리를 적용하였습니다.
100명의 가상 사용자가 동시에 5분간 초당 1회씩 요청을 보내는 방식으로 설정하여 Pinpoint와 JMeter로 부하 테스트를 진행했습니다.
1차 부하 테스트
항목 | 값 |
가상 유저 | 100 명 |
요청 시간 | 5 분 |
커넥션 풀 | 10 |
요청 수 | 662 |
평균 응답 시간 | 34,541 ms |
에러율 | 약 34 % |
1차 부하 테스트에서 평균 응답 시간이 34초로 측정되어, 유의미한 결과 도출이 어려웠습니다.
이를 개선하고자 커넥션 풀 크기를 조정한 후 에러율 0%를 목표로 다시 부하 테스트를 진행하였습니다.
커넥션 풀 50, 200 테스트 결과
커넥션 풀 조정 및 부하 테스트
커넥션 풀 | 평균 응답 시간 | 에러율 |
10 | 약 34 s | 약 34 % |
50 | 약 52 s | 약 23 % |
200 | 약 52 s | 0 % |
최종적으로 커넥션 풀 크기를 200으로 조정하여 에러율 0%를 달성했습니다.
최적화 과정
이제 에러율 0%을 달성하여 신뢰할 수 있는 부하 테스트 결과를 얻었기 때문에 본격적으로 개별 API 성능 최적화를 시작했습니다.
- 커뮤니티 게시글 페이징 API
hot10, recent 10 조회 API
이중, 커뮤니티 게시글 페이징 API 에 대해서 최적화 과정에 대해서 설명하겠습니다.
병목 지점 분석
커뮤니티 게시글 페이징 API에서 아래와 같은 병목이 발생하였습니다.
최적화
실제 SQL 문이 어떻게 동작하는지 분석해봤습니다.
SELECT
COUNT(*)
FROM
community;
페이징 처리 시 매번 실행되던 COUNT(*) 쿼리가 300만 건 전체 스캔을 유발해 심각한 응답 지연이 발생했습니다.
이는 레코드의 수가 늘어날수록 쿼리 비용이 선형으로 증가한다는 의미였습니다.
해결방안
1. COUNT(*) 쿼리를 제거한다.
=> 보여줄 페이지 갯수만큼 COUNT 결과를 처리한다면 풀 테이블 스캔이 발생하지 않아 응답 속도가 크게 향상됩니다.
하지만, 이 방식은 "사용자가 마지막 페이지를 즉시 이동할 수 있도록 구현한다"는 초기 의도와는 달랐습니다.
2. Redis로 COUNT(*) 쿼리를 캐싱한다.
=> 앞서 "사용자가 마지막 페이지를 즉시 이동할 수 있도록 구현한다" 를 해결하기 위해서는 Redis로 COUNT(*) 쿼리를 캐싱하는 방법이 유일해 보였습니다.
캐싱을 하면 당연히 DB I/O가 줄어들텐데, COUNT(*) 쿼리를 Redis로 캐싱하였을 때 메모리에 어떤 영향이 있을까? 를 고민해 봤습니다.
우선 Redis에 할당된 메모리에 대해서 확인해 본 결과, 1054152 B. 즉, 1 MB 메모리를 가지고 있는걸 확인했습니다.
그렇다면 COUNT(*) 쿼리를 캐싱했을 경우 어느정도 메모리를 차지하는지 확인해 봤습니다.
단일 COUNT키가 쓰는 건 64 B 밖에 안되니 메모리 부담은 거의 없다는 것을 확인했습니다.
왜 INCR 명령어로 캐싱하였는가 ❓
Redis에 INCR을 사용하면 이 상황에서 가장 적합하다고 생각했습니다.
INCR은 key값을 지정한 수만큼 증감시킵니다.
또한 여러 클라이언트가 동시에 같은 키를 증감시키더라도, 중간에 엉키거나 빠진 증감 없이 한 번에 하나씩 처리하며 O(1) 시간에 처리되므로, COUNT 연산을 캐시로 대체할 때 가장 적합합니다.
Spring RedisTemplate 예시
2차 부하 테스트 (Redis INCR 캐싱 적용)
COUNT(*) 쿼리를 Redis에 캐싱함으로써 DB 부하가 크게 줄어들었고, 그 결과 처리할 수 있는 요청 수가 늘어났으며 평균 응답 속도도 크게 개선되었습니다.
항목 | 1차 부하 테스트 | 2차 부하 테스트 (Redis INCR 캐싱 적용) |
가상 유저 | 100 명 | 100 명 |
요청 시간 | 5 분 | 5 분 |
평균 응답 시간 | 52,058 ms | 16 ms (약 99.97 % 감소) |
커넥션 풀 | 200 | 200 |
요청 수 | 616 | 21,853 (약 34배 증가) |
에러율 | 0 % | 0 % |
3. Redis INCR 캐싱 적용 + 커버링 인덱스를 도입한다.
커뮤니티 게시글 리스트에서는 ( community_id, title, category_id )의 값만 필요로 하므로, 이를 더 빠르게 값을 가져올 수 있지 않을까 라는 고민이 있었습니다.
이로인해 커버링 인덱스를 도입한다면 인덱스 온리 스캔으로 테이블 접근없이 더 빠르게 데이터를 긁어올 수 있다고 생각했습니다.
커버링 인덱스는 쿼리에 필요한 모든 컬럼 (SELECT, WHERE, ORDER BY 등)이 인덱스에 포함돼 있습니다.
커버링 인덱스 정의
인덱스의 리프 노드
community_id | title | category_id |
100 | 제목 1 | 3 |
101 | 제목 2 | 4 |
... | ... | ... |
실제 쿼리
Mysql의 옵티마이저는 community 테이블에서 필요한 컬럼
(community_id, title, category_id)이 모두 idx_covering_paging 인덱스에 있음을 확인하고, 해당 인덱스만 사용해 스캔하도록 계획을 수립하게 됩니다.
이때 옵티마이저는 인덱스 리프 노드를 순회하며 OFFSET 만큼 건너뛴 뒤, LIMIT 만큼의 엔트리
(community_id, title, category_id) 를 읽습니다.
따라서 테이블 본문을 전혀 참조하지 않고도 빠른 속도로 결과를 반환할 수 있습니다.
최종 부하 테스트 (Redis INCR 캐싱 적용 + 커버링 인덱스)
항목 | 1차 부하 테스트 | 2차 부하 테스트 (Redis INCR 캐싱 적용) |
최종 부하 테스트 (Redis INCR 캐싱 적용 + 커버링 인덱스) |
가상 유저 | 100 명 | 100 명 | 100 명 |
요청 시간 | 5 분 | 5 분 | 5 분 |
평균 응답 시간 |
52,085 ms | 16 ms (약 99.97 % 감소) | 12 ms (1차 부하 테스트 대비 약 99.98 % 감소) |
커넥션 풀 | 200 | 200 | 200 |
요청 수 | 616 | 21,853 | 21,698 |
에러율 | 0 % | 0 % | 0 % |
최종으로 2차 부하 테스트에 비해 평균 응답 시간이 16 ms 에서 12 ms로 감소하였고, 1차 부하 테스트에 비해 약 99.98 % 감소를 달성하였습니다.
이외 API 최적화
추가로 커뮤니티 게시글의 조회수가 증가할 때마다 매번 DB에 UPDATE 쿼리를 날리면, 트래픽이 몰릴 시 큰 부하로 이어질 수 있다고 판단하였습니다.
이를 방지하기 위해 조회수 캐싱 전략을 적용하였습니다. 조회가 발생한 게시글에 대해서만 Redis Hash에 id를 key로 하고 증가된 조회수를 값으로 저장하는 방식으로 했습니다.
캐싱된 조회수는 스프링 스케줄러를 통해서 5분마다 DB에 flush되도록 구성하였고, 반영이 완료된 후에는 Redis의 해당 조회수 데이터를 삭제하여 일관성을 유지시켰습니다.
또한 Hot 게시글 10개 및 최신 게시글 10개를 반환하는 API의 경우도 매 요청 시 DB에 부하가 있다고 생각했습니다.
이에 따라, 해당 데이터를 10분 주기로 Redis에 캐싱처리하여 최적화 하였습니다.
위 사진은 hot 10 게시글 API 대한 응답 결과입니다.
Redis 캐싱을 적용하여 4ms 라는 속도로 응답하는 것을 볼 수 있었습니다.
마지막으로
이번 경험을 통해 API 성능 테스트에 대해서 처음부터 끝까지 경험 할 수 있어서 앞으로 MSA를 고려한 프로젝트를 도전할 수 있겠다 라는 자신감을 얻을 수 있었습니다.
밤을 새운 날도 있었고, "이게 왜 안돼?" 라는 말을 1만번 반복했던 기억이 납니다.. 이런 것들이 결국 피와 살이 되었고, 공식 문서를 하나하나 뒤져가며 3일동안 원인 분석을 하고 해결했던 경험은 지금 생각해도 너무 좋았던것 같습니다.
혼자 진행해서 정말 힘든 여정이었지만, 그만큼 실력, 자신감도 얻을 수 있어서 좋은 경험이었습니다.!!
참고
https://velog.io/@as9587/Pinpoint-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0