첫 번째 부하테스트
첫 번째 부하테스트는 기존의 헤커톤 프로젝트를 있는 그대로 부하테스트를 진행하였습니다
해당 코드는 쿼리 최적화도 진행하지 않았고 DB 접근 기술도 Mybatis입니다
사실 DB접근 기술은 성능에 상관은 없는데 언급하는 이유는 Mybatis에서 JPA로 마이그레이션 하면서
N+1 문제를 해결하면서 모든 쿼리를 작성했기 때문입니다!!
결과는!!

ㅋㅋㅋ 최악입니다!!!!
CPU 이용률이 100%를 찍었고 , P90 , P 95 등 몇몇 지표는 측정조차 안 됐습니다.
왜냐면 중간에 서버가 터졌기 때문입니다!!

오류율도 거의 99.96% 로 오류 없이 통과한 게 0.004 %입니다 ㅋㅋㅋㅋ
성능 최적화 진행..
두괄식으로!! 결론부터 말하자면 다음 3가지를 개선하였습니다!
- HikariCP와 Tomcat Thread Pool 최적화
- Redis 호출 구조 개선
- DB 인덱스 설계
그럼 이제 각각을 어떻게 최적화했는지 확인해 보겠습니다!
1. HikariCP와 Tomcat Thread Pool 최적화
Tomcat Thread vs HikariCP
최근에 “주니어 백엔드 개발자가 반드시 알아야 할 실무 지식” 책을 감명 깊게 읽었습니다
그곳에서 Tomcat Thread와 CP의 관계에 관한 내용이 있었습니다
각각 이 어떤 역할을 하는지 간단하게 알아보면!
Tomcat Thread는 우리 서버에 한 번에 들어오는 요청수를 담당합니다.
HikariCP는 우리 서버에서 DB에 쿼리를 보내기 위해선 Connection을 맺어야 하는데 이때 드는 비용이 엄청 심합니다.
이를 단축하기 위해서 미리 Connection을 맺어두고 Pool에 저장하는데 이걸 담당하는 게 HikariCP입니다
(이전에는 많은 CP 가 있었지만 스프링에선 HikariCP를 사용합니다! )
근데 HikariCP를 작게 잡고 TomcatThread를 크게 잡는다면
각 요청은 서버로 한 번에 많이 들어오지만 Connection을 잡지 못해 대기를 하게 됩니다.
반대로 CP를 크게 잡고, TomcatThread를 작게 잡는다면? 요청은 적게 들어오는데 Connection이 크니 이번에는 커넥션이 계속 유휴상태에 있습니다
예시를 통해 알아보면 다음과 같습니다
- Tomcat 20, HikariCP 50 → 커넥션 30개가 영원히 유휴
- Tomcat 200, HikariCP 20 → 스레드들이 커넥션 대기 큐에서 블로킹
기본 원칙은 하나입니다
Tomcat threads ≥ HikariCP connections
그럼 왜 Tomcat을 HikariCP보다 크게 잡을까?
요청 하나의 생명주기를 생각해 보면
[JWT 검증] → [Redis 조회] → [HikariCP 대기] → [DB 쿼리] → [JSON 직렬화] → 응답
↑ 여기서만 커넥션 필요
Tomcat 스레드가 JWT 검증, Redis 호출, JSON 직렬화하는 동안은 DB 커넥션 안 잡아도 됩니다.
그래서 Tomcat > HikariCP가 자원을 더 효율적으로 쓰기 때문에 Tomcat Thread를 크게 설정하였습니다.
이러한 관계를 생각하면서 우리 서버에 맞는 Thread 수와 CP를 설계해 보겠습니다.
Thread수와 ConnectionPool 정하기
어떻게 정할까 찾아보는 도중 리틀의 법칙을 찾게 되었습니다.
리틀의 법칙
- TPS = 동시 처리량 / 평균 처리 시간(데이터베이스의)
해당 공식에서 동시처리량은 Thread Pool 혹은 ConnectionPool이라고 생각해도 됩니다.
여기서는 ThreadPool이라고 언급하겠습니다
우리는 사전에 1000 TPS 환경에서 300ms 이내라는 목표를 잡았습니다.
그러면 리틀의 법칙 사용해 목표로 하는 최악의 환경에서 필요한 Thread 수는 리틀의 법칙에 적용하면
1000 TPS × 0.3s = 300
이 나옵니다
하지만 300개 스레드가 동시에 돌아도 전부 동시에 DB 쿼리 하진 않기 때문에, 실제로는 DB 쿼리 구간 비율이 약 30~50%입니다
결국 정리를 하자면
Tomcat 300 → 리틀의 법칙 기준 최악값
근데 2코어 서버엔 과함
컨텍스트 스위칭 비용 증가
→ 100으로 줄임
HikariCP 50 → 스레드 100개 중
동시에 DB 쿼리하는 비율 절반
→ 50으로 설정
Redis 100 → Tomcat 스레드 수에 맞춤
그래서 Tocat max Trhead = 200 , HikariCP max는 50 , Redis max connection 은 100으로 설정했습니다.!!
DB 인덱스 설계
인덱스 설계의 핵심 원칙은 단순합니다.
실제 쿼리의 WHERE, ORDER BY, JOIN 패턴을 먼저 파악하고, 그 패턴에 맞는 인덱스만 설계하는 것입니다. 불필요한 인덱스는 오히려 INSERT와 UPDATE 비용을 증가시키기 때문에, 필요한 것만 정확하게 설계하려고 노력하였습니다.
announce 테이블
announce 테이블에는 세 개의 인덱스를 설계하였습니다.
- reqst_start_date
"현재 유효한 공고만 조회"하는 기간 필터 쿼리에 사용됩니다. BETWEEN이나 >= today 형태의 범위 조건이 반복적으로 발생하기 때문에 단독 인덱스를 설계하였습니다. - pubDate DESC
API에서 사용하는 findAnnounceWithFavorite 쿼리가 항상 최신순으로 결과를 반환하기 때문에 설계하였습니다.
정렬 방향까지 인덱스에 명시해서, MySQL이 filesort 없이 인덱스를 순서대로 읽을 수 있게 하였습니다. - viewNum DESC
인기 공고를 조회하는 배치 쿼리에 사용됩니다. 실행 빈도는 낮지만, 인덱스가 없으면 실행 시 전체 테이블을 정렬해야 하므로 배치 성능이 크게 저하됩니다. 실시간 쿼리와 목적이 다르기 때문에 독립된 인덱스로 분리하여 관리하였습니다.
festival / article 테이블
두 테이블 모두 핵심 쿼리 패턴이 날짜 기준 최신순 정렬 혹은 기간 내 필터 하나이기 때문에, 복합 인덱스 없이 날짜 칼럼 단독 인덱스로 충분하다고 판단하였습니다.
notice 테이블 — 복합 인덱스 (status, created_date)
notice 테이블의 주요 쿼리는
WHERE status = 'ACTIVE' ORDER BY created_date DESC
형태입니다.
이때 인덱스를 (status, created_date)로 구성하면, MySQL이 인덱스 하나로 필터와 정렬을 동시에 처리할 수 있습니다.
만약 status만 단독 인덱스로 설정하면, 필터 이후 정렬 단계에서 filesort가 추가로 발생하기 때문에 복합 인덱스를 사용하여 선두 칼럼으로 필터링한 뒤 두 번째 컬럼으로 정렬하는 구조로 설계하였습니다.
docment 테이블 — announce_id
MySQL은 외래키 칼럼에 자동으로 인덱스를 생성하지 않는 경우가 있습니다. announce 테이블과 조인할 때 docment.announce_id에 인덱스가 없으면 풀스캔이 발생하기 때문에 명시적으로 설계하였습니다.
memberFavorite 테이블 — 복합 인덱스 (announce_id, member_id)
이 인덱스에는 두 가지 목적이 있습니다.
첫째, announce_id가 선두 칼럼이기 때문에 특정 공고의 즐겨찾기 수 집계 쿼리에서 빠르게 동작합니다.
둘째, WHERE announce_id =? AND member_id = ? 조건으로 중복 즐겨찾기 여부를 확인할 때, 두 칼럼이 모두 인덱스에 포함되어 있어 테이블 접근 없이 인덱스만으로 결과를 반환하는 커버링 인덱스로 동작합니다.
MemberDocumentCheck 테이블 — member_id
"특정 회원이 확인한 서류 이력 전체 조회"가 핵심 쿼리 패턴입니다. 항상 회원 기준으로만 조회하기 때문에 member_id 단독 인덱스로 충분하며, 불필요하게 복합 인덱스를 구성하지 않았습니다.
Redis 호출 설정
기존에는 Announce에 들어가면 하나의 Trasnaction 안에서 Redis에 보내는 조회수 증가 로직과 최근 방문 공고 로직 두 개가 같이 실행되었습니다. 그렇다 보니 DB 쿼리 작업이 다 끝나도 Redis 작업이 끝날 때까지 기다렸습니다.
즉 하나의 Transaction 내에서 Redis 작업과 DB 작업이 둘 다 존재하였고 이 때문에 , DB 작업이 끝나도 Connection을 반납하지 않고 Redis 작업이 끝날 때까지 Connection을 들고 있었습니다
만약 Redis에서 병목이 생기면 Connection을 놓아주지 않아 자연스럽게 DB 에도 병목이 생기는 구조였습니다
그래서 두 개의 Redis 작업을 비동기로 진행하도록 재 설계 하였습니다.
@Bean(name = "viewNumExecutor")
public Executor viewNumExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(5000);
executor.setThreadNamePrefix("VIEW_NUM-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
executor.initialize();
return executor;
}
// 이전
@Transactional
public AnnounceDetailResponse getAnnounceDetailAndPlus(Long announceId, Long memberId) {
announceRepository.updateViewNum(announceId); // ← 동기 쓰기, 응답 차단
Announce announce = announceRepository.findAnnounceById(announceId);
List<DocumentCheckItemDto> allChecklist = memberDocumentCheckService.getAllChecklist(memberId, announceId);
...
}
// 이후
@Transactional(readOnly = true) // ← 읽기 전용으로 변경
public AnnounceDetailResponse getAnnounceDetail(Long announceId, Long memberId) {
Announce announce = announceRepository.findAnnounceById(announceId);
List<DocumentCheckItemDto> allChecklist = memberDocumentCheckService.getAllChecklist(memberId, announceId);
AnnounceDetailResponse response = AnnounceDetailResponse.of(announce);
response.setChecklist(allChecklist);
viewNumAsyncService.incrementViewNum(announceId); // ← 비동기 분리, 응답 안 막음
saveRecentAndPopularToRedis(announceId, memberId, response);
return response;
}
다시 부하테스트 돌려보자!
나름의 성능 최적화를 진행하였고 이제 다시 부하테스트를 진행해 보겠습니다

지난번 테스트 때는 오류율도 엄청 심했고 테스트도 끝까지 돌리지 못했는데 , 나름의 성능 최적화를 한 뒤 테스트는 끝까지 돌릴 수 있게 되었습니다.
하지만 오류율도 많이 잡게 되었지만 여전히 너무 느립니다 P95를 보면 2877.00..........
우리가 목표로 하는 300ms 에는 턱없이 부족합니다
당연히 cpu도 버티지 못해서 거의 100 % 찍었습니다.
다시 성능 최적화를 진행해 보겠습니다..

'🖥️ 컴퓨터 공부 > 부하테스트 & 성능최적화' 카테고리의 다른 글
| 해커톤 성능 최적화 -3 (0) | 2026.04.24 |
|---|---|
| 해커톤 성능 최적화 -2 (0) | 2026.04.24 |
| k6 와 그라파나로 성능최적화 도전 (0) | 2026.04.24 |
| K6 테스트 기본 구조 및 실행 (0) | 2026.04.04 |
| K6 설치하기 (0) | 2026.04.04 |