이때 부하 테스트 도구로는 시나리오를 코드로 관리(IaC)할 수 있고 오버헤드가 적은 k6를 선택했다.
Baseline 측정
초기 상황
첫 단계로는 현재 상태를 정확히 파악하는 것이었다. 아무런 설정이나 튜닝 없이 기본값 그대로 서버에 부하 테스트를 돌려봤다. 결과는 다음과 같았다.
지표
측정값
목표
평가
에러율
33.3% (636/1910)
< 5%
❌ FAIL
p50 응답시간
8,709ms (8.7초)
< 200ms
❌ FAIL
p95 응답시간
30,001ms (Timeout)
< 500ms
❌ FAIL
p99 응답시간
30,001ms
< 1,000ms
❌ FAIL
Max 응답시간
30,013ms
-
🔴 Timeout 도달
📸 [Baseline Grafana 결과에 대한 대시보드]
API별 상세 분석
API
p95 응답시간
분석
추천 API (/recommendations)
27,536ms (27.5초)
인덱스 없는 복합 조건 쿼리로 Full Scan
인기 API (/popular)
28,507ms (28.5초)
GROUP BY + ORDER BY로 임시 테이블 생성
필터 API (/filters)
30,001ms (Timeout)
동적 필터 조건으로 가장 느림
원인 분석
Grafana 대시보드와 로그를 교차 분석하여, DB 커넥션 풀 고갈이 성능 저하의 직접적인 원인임을 파악했다.
1. DB 커넥션 풀 고갈 (Saturation Point)
로그 확인 결과, 최대 커넥션(10개)이 모두 사용 중인 상태에서 대기열(Queue)이 해소되지 못하고 있었다.
json
HikariPool-1 - Connection is not available, request timed out after 30000ms
(total=10, active=10, idle=0, waiting=7)
이는 전형적인 Saturation Point(임계점) 도달 현상이다. 처리 속도보다 유입 속도가 빨라지면서 대기열이 무한정 늘어났고, 결국 Connection Acquire Time이 30초(Timeout 설정값)에 도달하며 모든 요청이 일괄 실패한 것이다.
2. 근본 원인은 느린 쿼리 (Slow Query)
커넥션을 오랫동안 붙잡고 놔주지 않은 원인은 인덱스 미활용으로 인한 Full Table Scan이었다.
단 1,000명의 데이터임에도 조회에 27~30초가 소요되었다. 쿼리 하나가 커넥션을 30초씩 점유하니, 10개의 커넥션으로는 초당 1건의 요청도 제대로 처리할 수 없는 구조였다.
GC 영향 분석
혹시 GC(Garbage Collection)가 문제일까 싶어 확인해봤다.
지표
결과
Full GC 발생
0회
Minor GC 시간
86~92ms (정상)
Heap 사용량
130~340MB (안정)
결론부터 말하자면 GC는 성능 저하 원인이 아니었다.
JVM에서 GC가 발생하면 애플리케이션의 모든 스레드가 일시 정지된다. 이를 Stop-the-World(STW)라고 부르는데 STW 시간이 길어지면 응답 지연이 발생할 수 있다.
하지만 Grafana의 GC Pause Duration 그래프를 보면 25ms 이하로 안정적이었다. JVM의 G1GC는 기본적으로 200ms 이하의 pause time을 목표로 하는데, 우리 시스템은 이 기준을 충분히 만족하고 있었다.
또한 Heap 사용량이 130~340MB로 안정적이라는 건 메모리 릭(Memory Leak)이 없다는 증거이기도 했다.
이 데이터를 통해 GC 튜닝보다 쿼리 튜닝이 먼저라는 결론을 내렸다.
3. CPU 스파이크은 원인이 아닌 결과
기존 테스트에서 CPU 사용률이 급증했지만, 이는 유의미한 비즈니스 로직 처리 때문이 아니었다. 처리량(Throughput)은 바닥인데 CPU만 높다는 것은 시스템이 비효율적인 작업에 리소스를 낭비하고 있다는 강력한 신호다.
로그와 스레드 덤프를 분석한 결과, CPU는 다음 작업들을 처리하느라 바빴다.
과도한 Context Switching: 커넥션을 얻기 위해 대기하는 스레드(Waiting)와 깨어나는 스레드 간의 전환 비용
예외 처리 비용: 초당 수십 건씩 발생하는 ConnectionTimeoutException 객체 생성 및 스택 트레이스 로깅
재시도(Retry) 로직: 실패한 요청을 다시 시도하며 부하 가중
즉, DB 커넥션 고갈(1차 병목)이 발생하자, 시스템이 이를 수습하고 에러를 뱉어내는 과정에서 CPU 과부하(2차 증상)가 발생한 것이다.
[Phase 1] 인덱스 활용과 조기 종료
숨겨진 병목과 인덱스 미활용
기존 로직을 분석한 결과, 두 가지 주요 개선 포인트가 발견되었다.
무한 루프에 가까운 Full Scan:while(true)로 1,000명의 회원을 전부 순회하며 점수를 계산하고 있었다.
인덱스 설계 미스 (Leftmost Prefix 위반):
DB에는 (status, university_id, college_id, created_at DESC) 순으로 복합 인덱스가 걸려 있었다. 하지만 정작 쿼리에는 status조건이 빠져 있어, 인덱스의 첫 번째 컬럼(선행 컬럼)을 건너뛰는 바람에 Index Full Scan조차 타지 못하고 있었다.
인덱스 태우기와 루프 끊기
먼저 Repository에 status 조건을 추가하여 인덱스를 정상적으로 타도록 수정했다.
java
// Before: 인덱스 미활용 (status 조건 누락)
Page<Member> findByUniversityIdAndIdNot(Long universityId, Long memberId, Pageable pageable);
// After: 인덱스 완전 활용 (Leftmost Prefix 준수)@Query("SELECT m FROM Member m WHERE m.status = :status " + // 선행 컬럼 명시
"AND m.university.id = :universityId AND m.id != :memberId " +
"ORDER BY m.createdAt DESC")
Page<Member> findActiveByUniversityIdAndIdNot(
@Param("status") MemberStatus status,
@Param("universityId") Long universityId,
// ... params
);
또한, 굳이 1,000명을 다 계산할 필요 없이 상위 30명(Candidate Buffer)만 확보되면 루프를 즉시 탈출하도록 조기 종료 로직을 추가했다.
테스트 결과
논리적으로는 빨라져야 정상이다. 하지만 테스트 결과는 예상과 달랐다.
지표
Baseline (Phase 0)
Phase 1 (Index+Logic)
변화
총 요청
1,910건
770건
↓ 60%
에러율
33.3%
62.2%
❌ 2배 증가
p50 응답
8.7s
20.0s
❌ 증가
p95 응답
30.0s
30.0s
Timeout 지속
왜 성능이 오히려 악화됐을까? - 커넥션 풀 경합(Contention)
표면적인 쿼리 실행 횟수는 줄었지만, 시스템의 병목 지점은 여전히 DB 커넥션 풀(Pool Size=10)이었다.
짧고 빈번한 I/O 요청의 폭주
조기 종료 로직으로 30명을 추려냈지만, 이들에 대해 루프를 돌며 findByMemberId 등을 호출하는 과정에서 순식간에 약 90~100회의 DB 접근이 발생했다.
경합(Contention) 심화
10개의 커넥션은 이미 모두 사용 중(Active)인 상태에서, 처리 속도가 빨라진 애플리케이션 로직이 쉴 새 없이 새로운 커넥션을 요구했다. 이로 인해 스레드들이 커넥션을 얻기 위해 대기하는 Handover 시간이 길어졌고, 결과적으로 GetConnectionTimeout이 발생할 때까지 스레드가 멈춰 있는 현상이 지속된 것이다.
실제 로그에서도 스레드들이 커넥션을 얻지 못해 줄을 서 있는 모습이 확인되었다.
json
HikariPool-1 - Connection is not available, request timed out after 20000ms
(total=10, active=10, idle=0, waiting=3)
즉, 단순히 쿼리 총량을 줄이는 것(Count)이 아니라, 커넥션을 점유하는 빈도와 방식(Pattern)을 개선해야 함을 시사했다.
[Phase 2] @BatchSize 적용
N+1 문제를 해결하기 위해 가장 먼저 떠올린 건 JPA의 @BatchSize였다. 이를 통해 IN 절로 묶어서 조회하면 쿼리 수가 획기적으로 줄어들 것으로 기대했다.
Hibernate의 @BatchSize는 영속성 컨텍스트에 의해 관리되는 프록시 객체를 탐색(Lazy Loading)할 때 동작한다. (member.getTimeTables().get(0) 호출 시점 등)
하지만 현재 로직은 서비스 계층에서 명시적으로 Repository 메서드를 호출하고 있었다.
java
// 서비스 코드
timeTableQueryService.findByMemberId(member.getId()); // ❌ 직접 호출
이 코드는 JPA의 탐색이 아니라 단순한 SQL 실행 요청이다. 따라서 영속성 컨텍스트가 개입할 여지가 없어 @BatchSize가 전혀 작동하지 않았던 것이다.
결국 구조적인 N+1 문제를 해결하기 위해서는 쿼리 호출 방식 자체를 뜯어고쳐야 했다.
[Phase 3] IN절 일괄 조회로 N+1 완전 해결
스레드 상태(Thread State) 분석
Grafana의 스레드 상태 그래프를 분석한 결과, 대부분의 스레드가 Waiting(I/O 대기) 상태였다.
이는 전형적인 I/O Bound 병목이다. CPU는 할 일이 없어 놀고 있는데, 스레드들이 DB 응답을 기다리느라 묶여 있는 것이다.
이 상황에서는 스레드를 무작정 늘린다고 해결되지 않는다. 근본적으로 DB 응답을 기다리는 횟수(Round-Trip) 자체를 줄여야 한다.
적용 내용
루프 안에서 쿼리를 날리는 대신, 루프 밖에서 한 번에 조회하고 메모리에서 매핑하는 방식으로 변경했다.
루프 밖에서 일괄 조회
기존 로직은 루프를 돌며 DB를 호출하는 구조였다. 이를 모든 ID를 수집한 뒤IN절로 한 번에 조회하고, 메모리(Map)에서 매핑하는 방식으로 변경했다.
java
// Before: 루프 내 쿼리 실행 (N+1 발생)for (Member m : candidates) {
// 요청당 약 150회의 쿼리가 실행되는 구조
timeTableService.findByMemberId(m.getId());
matchRepository.existsByMatching(currentMember, m);
}
// After: 루프 밖 일괄 조회 (IN 절 사용)
List<Long> ids = candidates.stream().map(Member::getId).toList();
// 단 2번의 쿼리로 모든 데이터 조회
Map<Long, List<TimeTable>> timeTables = timeTableRepository.findByMemberIdIn(ids) ...;
Set<Long> matchedIds = matchRepository.findMatchedMemberIds(currentId, ids);
for (Member m : candidates) {
// 메모리에서 O(1) 조회 -> DB 접근 0회
process(m, timeTables.get(m.getId()), matchedIds.contains(m.getId()));
}
이 변경을 통해 요청당 쿼리 수는 150+개에서 단 3개로 98% 감소했다.
테스트 결과
지표
Before (Phase 2)
After (Phase 3)
결과
순수 로직 시간
2000 ms
50 ~ 180ms
✅ 10배 이상 개선
p95 응답 시간
30.0s (Timeout)
30.0s (Timeout)
❌ 개선 안 됨
❓ 왜 로직은 0.1초인데 응답은 30초가 걸렸나?
이것이 이번 단계에서의 핵심 발견이자 “숨겨진 원인”을 찾은 단서였다.
추천 API는 이제 0.1초 만에 처리가 끝나고, 커넥션도 금방 반납한다.
하지만 테스트 시나리오에는 필터(Filter) API도 섞여 있었다.
이 필터 API는 아직 최적화되지 않아, 한 번 요청에 수천 개의 쿼리를 날리며 DB 커넥션을 30초 이상 붙들고 있었다.
DB 커넥션 풀(최대 10개)은 필터 API 요청 몇 개만으로 꽉 차버림.
정작 빨라진 추천 API 요청이 들어왔을 때, 쓸 수 있는 커넥션이 없어서 대기열(Pool Queue)에서 하염없이 기다리다 30초 타임아웃이 발생한 것.
커넥션 풀은 공유 자원이기 때문에 한 API의 문제가 전체 시스템을 마비시킬 수 있다는 사실을 실제로 확인했다.
[Phase 4] 필터 API N+1 및 Fetch Join
필터 API의 N+1 해결
필터 API도 추천 API와 마찬가지로 GROUP BY와 IN 절을 활용하여 최적화했다.
java
// Before: 루프 돌며 Count 쿼리 실행 (1,000번)for (Member m : members) { countMatches(m); }
// After: 한 방 쿼리로 해결
Map<Long, Long> matchCounts = matchRepository.countMatchesByMemberIds(memberIds);
결과
📸 [필터 API N+1 해결 결과 중 connection Pool]
지표
값
평가
응답 시간 (p95)
~10.9s
Timeout(30s)은 사라졌지만 여전히 느림 (목표 미달)
처리량 (RPS)
~6 req/s
시스템이 멈추지 않고 동작하기 시작함 (Before: 0)
발견된 문제
Department N+1
필터 API에서 부서 정보를 조회할 때마다 쿼리가 발생함
Fetch Join으로 연관 엔티티 조회 최적화
로그 분석 결과, member.getDepartment().getName() 호출 시마다 지연 로딩(Lazy Loading)으로 인한 추가 쿼리가 발생하고 있었다.
이를 Fetch Join을 사용하여 한 번의 쿼리로 데이터를 미리 가져오도록 수정했다.
java
@Query("SELECT m FROM Member m " +
"LEFT JOIN FETCH m.department " +
"LEFT JOIN FETCH m.college " +
"WHERE m.university.id = :universityId AND m.id != :memberId")
List<Member> findWithDetailsByUniversityIdAndIdNot(
@Param("universityId") Long universityId,
@Param("memberId") Long memberId
);
테스트 결과
📸 [Fetch Join 적용 결과]
메트릭
값
상태
설명
순수 로직 처리 시간
10 ~ 20 ms
✅ 성공
N+1 완전 해결 (IN 절 때 50~180ms보다 더 개선)
DB 조회 쿼리
Fetch Join + IN절
✅ 성공
Department 단건 조회 쿼리 사라짐
타임아웃 발생 여부
없음 (0건)
✅ 해결
30초 대기 현상 사라짐
p99 응답 시간
~15 sec
⚠️ 경고
로직은 0.02초인데 응답이 15초
한계 발견
로직은 충분히 빨라졌는데, p99가 아직 15초인 이유를 분석했다.
java
// 문제의 코드 패턴
List<Member> allMembers = memberRepository.findWithDetailsByUniversityIdAndIdNot(...);
// ↑ 수천 명의 데이터를 전부 가져옴
List<Member> filtered = allMembers.stream()
.filter(member -> isFilterMatched(member, req)) // 메모리에서 필터링
.collect(toList());
아무리 Fetch Join을 써서 쿼리 수를 줄여도, "수천 명의 데이터를 전부 DB에서 가져와(SELECT) 메모리에 올린 뒤 필터링(Stream filter)"하는 구조 자체가 문제였다.
특히 t2.micro의 1GB 메모리 환경에서 대량의 엔티티 객체를 생성하는 것은 GC 부하를 일으키고, 심할 경우 OOM(Out Of Memory) 발생 위험이 있었다. 따라서 필터링 로직을 DB 레벨(Where 절)로 완전히 내려야 했다.
[Phase 5] DB 레벨 필터링 및 Enum 적용
메모리 부하 줄이기
애플리케이션 메모리 부하를 줄이고, DB가 잘하는 필터링을 맡기자.
기존에는 수천 명의 데이터를 가져와 자바 메모리(Stream)에서 필터링했으나, 이를 DB의 WHERE 절로 이관하여 불필요한 데이터 전송과 메모리 사용을 차단했다.
구분
이전 (AS-IS)
현재 (TO-BE)
개선 효과
필터링 위치
Java Memory (Stream filter)
Database (WHERE 절)
데이터 전송량 감소, OOM 방지
관심사 필터
m.getInterests() (Lazy Loading)
LEFT JOIN + 조건절
N+1 문제 원천 차단
타입 안전성
String (오타 위험)
Enum (InterestType)
컴파일 타임 안전성 확보
결과
📸 [DB 레벨 필터링 및 Enum 적용 결과]
API
평균 응답 시간
상태
추천 API (api_recommend)
65ms
✅ 성공
인기 API (api_popular)
67ms
✅ 성공
API
평균 응답 시간
p95
원인
필터 API (api_filter)
5,002ms
9,376ms
학번 필터링이 여전히 메모리에서 수행됨
추천/인기 API는 목표치에 도달했으나, 필터 API는 여전히 9초대로 느렸다. 원인은 학번(StudentNo) 필터링이었다.
문자열 파싱 로직(substring) 때문에 여전히 메모리 필터링을 수행하고 있었고, DISTINCT와 LEFT JOIN FETCH 조합으로 인해 임시 테이블 생성 및 정렬 부하가 발생하고 있었다.
[Final Phase] 커버링 인덱스와 Slice
남은 병목을 제거하기 위해 마지막 리팩토링을 진행했다.
Repository JPQL 최적화 (LIKE & EXISTS)
메모리에서 하던 학번 필터링을 LIKE '2023%' 쿼리로 이관했다.
무거운 DISTINCT + JOIN FETCH를 제거하고, 가벼운 EXISTS 서브쿼리로 변경했다.
Page → Slice 변경
무한 스크롤 방식이므로 전체 데이터 개수가 필요 없다. 매 요청마다 발생하는 무거운 COUNT(*)쿼리를 제거하기 위해 Page 대신 Slice를 적용했다.
java
// 최종 Repository 코드@Query("SELECT m FROM Member m " +
"WHERE m.status = 'ACTIVE' " +
"AND m.university.id = :universityId " +
"AND m.id != :memberId " +
// 동적 쿼리 조건들 (DB 레벨 필터링)
"AND (:collegeId IS NULL OR m.college.id = :collegeId) " +
"AND (:departmentId IS NULL OR m.department.id = :departmentId) " +
"AND (:studentNoPrefix IS NULL OR m.studentNo LIKE CONCAT(:studentNoPrefix, '%'))")
Slice<Member> findFilteredMembers(...); // Page -> Slice 변경
커버링 인덱스(Covering Index) 추가
쿼리에 필요한 모든 컬럼을 포함하는 인덱스를 생성하여, 테이블 접근(Random I/O) 없이 인덱스 스캔만으로 데이터를 조회하도록 했다.
sql
CREATE INDEX idx_member_filter_opt ONmember(status, university_id, updated_at DESC);
응답시간 백분위수(Percentile)의 의미
최종 결과를 해석하기 전에, p50/p95/p99의 의미를 짚고 넘어가자.
p50 (median): 일반적인 사용자 경험. 절반의 요청이 이 시간 이내에 완료됨.
p95: 20명 중 1명이 겪는 최악의 경험
p99: 100명 중 1명의 극단적 케이스
평균 응답시간만 보면 함정에 빠질 수 있다. Long-tail distribution 때문에 평균은 좋아 보여도 일부 사용자는 극심한 지연을 겪을 수 있다. 그래서 SLO/SLA에서는 보통 p99를 기준으로 삼는다.
아래의 최종 결과Response Time Trend 지표에서 p50(녹색)과 p95(주황)의 차이가 작아진 것은 응답시간 분산이 줄었다는 의미다. 모든 사용자가 비슷하게 빠른 응답을 받게 된 것이다.
결과
📸 [최종 결과]
지표
Baseline
최종
개선율
에러율
33.3%
0.00%
✅ 100% 개선
p95 응답시간
30,001ms
112ms
✅ 268배 개선
처리량
~1 req/s
~7 req/s
✅ 7배 개선
번외: 커넥션 풀, 무조건 많으면 좋을까?
모든 튜닝이 끝난 후, 문득 궁금증이 생겼다.
"DB 커넥션 개수(Pool Size)는 몇 개로 설정하는 게 가장 빠를까?"
보통 "많으면 많을수록 좋은 거 아니야?"라고 생각하기 쉽다. 그래서 직접 개수를 바꿔가며 실험해 보았다.
1. 실험 설계
쿼리 최적화가 완료된 상태에서 커넥션 개수만 다르게 설정하고 똑같은 부하를 주어보았다.
2개 (너무 적음): 문이 2개뿐인 상황
100개 (너무 많음): 문이 100개인 상황
5개 (이론상 추천): CPU 코어 수 기반
10개 (기존): 현재 설정값
실험 1: Pool Size = 2
📸 [pool size 2 인 경우]
커넥션 2개
결과: 1.64초 걸림 (10개일 때보다 14배 느림)
이유는 "줄 서는 시간" 때문이다. 식당 요리사는 손이 빨라졌는데, 들어오는 문이 2개밖에 없다. 30명이 동시에 밥을 먹으러 왔는데 2명만 들어가고 나머지 28명은 밖에서 기다려야 했다.
(운 좋게 바로 들어간 사람은 0.01초, 기다린 사람은 1.6초가 걸림)
실험 2: Pool Size = 100
📸 [pool size 100 인 경우]
부족한 것(2개)보다 과한 것(100개)이 더 느렸다!
케이스
총 요청 수
p95 Latency
Pool=2
2,985
1.64s
Pool=100
2,095 (30% 감소)
2.57s (56% 느림)
위의 결과의 원인은 DB 서버(Docker 컨테이너)의 CPU 자원 경합(Context Switching)이 심각하게 발생했기 때문이다. 예를 들어서 우리 서버의 CPU(일꾼)는 1명뿐이다. 그런데 동시에 100개의 요청을 처리하려고 하니, 이쪽 조금 하다가 저쪽 조금 하는 식으로 작업을 전환하는 시간(Context Switching)만 잔뜩 쓰고 정작 일은 제대로 못 한 것이다.
실험 3: Pool Size = 5
📸 [pool size 5 인 경우]
HikariCP 권장 공식: 커넥션 크기 = (코어수 × 2) + 디스크수
t2.micro(1 vCPU) 환경에서 이론상 최적값은 3~5개이다. 따라서 Pool=5가 가장 좋은 성능을 낼 것이라 가정했으나, 실제 부하 테스트 결과는 예상과 달랐다.
Pool Size
총 요청 수
p95 Latency
Pool=5
2,502
2.17s
Pool=10
~7,500+
0.11s
Pool=5는 Pool=10보다 훨씬 느렸다!
왜 이론값(5개)보다 2배 많은 설정(10개)이 더 빨랐을까? 이는 관점의 차이에서 비롯된 트레이드오프(Trade-off)였다.
이론적 관점 (DB Server Side)
공식은 CPU가 Context Switching 없이 쉴 새 없이 일할 수 있는 한계치를 의미한다. 즉, DB 리소스 효율의 극대화가 목표다.
실제 서비스 관점 (Client Side)
동시 접속자(VU)가 30명일 때 커넥션이 5개라면, DB가 아무리 빨리 처리해도 나머지 25명은 애플리케이션 레벨에서 대기(Application Wait Time)해야 한다.
결론: 우리 서비스의 트래픽 패턴에서는 DB의 CPU 효율을 약간 희생하더라도, 커넥션 개수를 늘려(10개) 대기열을 빠르게 해소하는 것이 전체 응답 시간(Latency) 단축에 더 유리했다.
결과 종합
Pool Size
상태
p95 Latency
병목 원인 (Bottleneck)
2
부족 (Under)
1.64s
Wait Time (심각)
처리량보다 대기 줄이 훨씬 긺
5
이론값
2.17s
Wait Time
버퍼 부족으로 인한 대기열 발생
10
최적 (Optimal)
0.11s
Sweet Spot
대기열 해소와 리소스 효율의 균형
100
과다 (Over)
2.57s
Context Switching
과도한 스레드 경합으로 CPU 낭비
인사이트(Insights)
"많으면 좋다"는 오해 -Pool=100이 Pool=2보다 성능이 나빴다. 과도한 리소스 할당은 Context Switching 비용을 초래하여 성능을 저하시킨다.
이론값 ≠ 정답 - 공식은 가이드라인일 뿐, 실제 트래픽 패턴과 비즈니스 요구사항(Latency vs Throughput)에 따라 튜닝이 필요하다.
선(先) 쿼리 최적화 - 근본적으로 쿼리 실행 시간을 줄이면, 적은 커넥션으로도 높은 처리량을 감당할 수 있다.
마치며
이번 프로젝트를 통해 '일단 동작하게 만들고 → 측정하고 → 개선한다'는 원칙의 중요성을 체감했다. 성능 개선은 마법이 아니라 데이터에 기반한 논리적 의사결정임을 배운 값진 경험이었다.