성능 최적화 / 부하테스트 트러블슈팅
1차부터 5차(Phase 4)에 걸쳐 단계별로 진행한 성능 개선 이력입니다.AS-IS P99 6,887ms / 503 에러 다수에서 캐싱과 인프라 튜닝으로 5,000 VU 수용을 목표로 개선했습니다.
AS-IS: 최적화 전 상태
Seat 서비스에 Tomcat 스레드 200개 vs HikariCP 커넥션 20개의 설정 불균형이 있었습니다. 180개 스레드가 findByIdOrThrow에서 커넥션 대기로 블로킹되면서 P99가 6.8초까지 치솟았습니다.
[모든 요청] → [Tomcat 스레드 200개]
│
├─ matchRepository.findByIdOrThrow() ← 매 요청 DB 조회
├─ sectionRepository.findAll...() ← 매 요청 DB 조회
├─ blockRepository.findBySectionIdIn() ← 매 요청 DB 조회
├─ userRepository.findByIdOrThrow() ← 매 요청 DB 조회
└─ onboardingPreference 조회 ← 매 요청 DB 조회
→ HikariCP 20개 커넥션 풀에 200개 스레드가 경합
→ 커넥션 대기 → 스레드 블로킹 → P99 6,887ms
→ DB 커넥션 270개 한계 도달 → 503 에러 다수 발생핵심 진단: Redis 캐싱이 문제가 아니라 DB 커넥션 풀 + Tomcat 스레드 블로킹이 병목. "코드 레벨에서 할 수 있는 건 DB 커넥션을 쓰는 쿼리 자체를 줄이는 것"
DB 부하 유발 Top 7 쿼리 전수조사
| 순위 | 위치 | 쿼리 | 요청당 호출 | 불변성 | 제안 |
|---|---|---|---|---|---|
| 1 | Seat/SeatCommonService | MatchRepository.findDetailByIdOrThrow (JOIN FETCH) | 2회 | 거의 불변 | Caffeine |
| 2 | Seat/SeatCommonService | SectionRepository.findAllWithAreaOrderBy | 좌석 진입 1회 | 영구 불변 | Caffeine |
| 3 | Seat/SeatCommonService | BlockRepository.findBySectionIdIn | 좌석 진입 1회 | 영구 불변 | Caffeine |
| 4 | Order-Core/OrderService | MatchRepository.findDetailByIdOrThrow | 2회 | 거의 불변 | Caffeine |
| 5 | Queue/QueueService | MatchRepository.findByIdOrThrow | 매 요청 | 거의 불변 | Caffeine |
| 6 | Auth-Guard, Order-Core | UserRepository.findByIdOrThrow | 토큰/주문 매회 | 변동 있음 | Redis |
| 7 | Seat/recommendation | OnboardingPreference/Block | 추천 매회 | 변동 있음 | Redis |
1차: Seat BookingOptions Match Caffeine 캐싱
BookingOptionsService.saveBookingOptions() 내 matchRepository.findByIdOrThrow()를 Caffeine 로컬 캐시(match-exists, TTL 10분)로 교체. DB 커넥션 점유 시간 제거 + Tomcat 스레드 회전율 향상.
@Cacheable(cacheNames = "match-exists", key = "#matchId", unless = "!#result")
public boolean exists(Long matchId) {
return matchRepository.existsById(matchId);
}
# Caffeine 설정
spec: maximumSize=1000,expireAfterWrite=10m,recordStats2차 (Phase 1 확대): Multi-Service Caffeine 전면 확대
Seat/Queue/Order-Core × 6개 캐시로 확대. DB 쿼리 50~60% 감소, DB 커넥션 peak 250 → 150 (-39%).
| 서비스 | 캐시 | TTL | 대상 |
|---|---|---|---|
| Seat | match-exists, match-detail | 10분 | Match 조회 |
| Seat | section-all, blocks-by-section-ids | 1시간 | 스타디움 구조 (영구 불변) |
| Queue | match-for-queue | 1분 | saleStatus 검증 (짧은 TTL) |
| Order-Core | match-detail | 10분 | 주문서용 Match |
3차 (Phase 2): Redis 분산 캐시 — User 데이터
User, OnboardingPreference는 변경 가능한 데이터라 로컬 캐시 대신 Redis 분산 캐시 사용. Pod A에서 닉네임 갱신 → Pod B에서도 즉시 반영.
- Auth-Guard
user-by-id(10분),auth-me(30초) - Order-Core
user-by-id(10분, Auth-Guard 캐시 공유) @CacheEvict로 프로필 변경 시 즉시 무효화DefaultTyping.EVERYTHING으로 다형성 역직렬화 지원
결과: /auth/me P99 400ms → 50ms
4차 (Phase 3): API 응답 Redis 캐시 + 인프라 튜닝
| 파라미터 | 변경 전 | 변경 후 | 이유 |
|---|---|---|---|
| Tomcat max-threads | 200 | 400 | 스레드 점유 시간 감소 → 동시 처리 증가 |
| HikariCP max-pool-size | 20 | 30 | DB 의존 쿼리 감소로 여유 확보 |
| HikariCP min-idle | 20 | 5 | 유연한 커넥션 관리 |
| 총 DB 커넥션 | ~80 | ≤250 | max_connections=270 한계 내 |
- Seat
seat-groups-response5초 캐시 → P99 2000ms → 200ms - Order-Core
matches-list-response30초 캐시
5차 (Phase 4): 커넥션 회전율 · 스레드 점유 핫픽스
(1) Queue OSIV OFF
spring.jpa.open-in-view=false로 변경. HTTP 요청 전체가 아닌 트랜잭션 경계에서만 DB 커넥션 점유.커넥션 회전율 2배 향상.
(2) BookingOptions Resilience4j @Retry
Thread.sleep(50~150ms) 블로킹 재시도 → Resilience4j 비동기 재시도로 교체. 평균 대기: 300ms → 60ms.
(3) Queue Redis Lua 스크립트 통합
대기열 재진입 시 ZREM + ZADD + ZRANK + ZCARD를 각각 호출 → Lua 스크립트 1회 호출로 통합.3 RTT → 1 RTT.
전체 최적화 타임라인
AS-IS (최적화 전)
│ P99: 6,887ms / DB 커넥션 270 한계 / 503 에러 다수
│
├─ 1차: Seat BookingOptions Match Caffeine 캐싱
│ └─ match-exists 캐시 도입 → 요청당 DB 조회 1회 제거
│
├─ 2차 (Phase 1 확대): Multi-Service Caffeine 전면 확대
│ └─ Seat/Queue/Order-Core × 6개 캐시
│ └─ DB 쿼리 50~60% 감소, 커넥션 peak 250→150
│
├─ 3차 (Phase 2): Redis 분산 캐시 — User 데이터
│ └─ user-by-id, auth-me Redis 캐싱
│ └─ /auth/me P99: 400ms → 50ms
│
├─ 4차 (Phase 3): API 응답 Redis 캐시 + 인프라 튜닝
│ └─ seat-groups-response 5초 캐시, matches-list 30초 캐시
│ └─ Tomcat 200→400, HikariCP 20→30
│ └─ seat-groups P99: 2000ms → 200ms
│
├─ 5차 (Phase 4): 커넥션 회전율 · 스레드 점유 핫픽스
│ └─ Queue OSIV OFF (커넥션 회전율 2배)
│ └─ BookingOptions Resilience4j @Retry
│ └─ Queue Redis Lua 통합 (3 RTT → 1 RTT)
│
TO-BE
캐싱이 정적 데이터 쿼리를 흡수하고,
DB 커넥션은 좌석 조회/추천 등 정말 필요한 곳에만 사용.HikariCP 커넥션 풀이란
HikariCP는 Java에서 가장 빠른 JDBC 커넥션 풀 라이브러리로, Spring Boot의 기본 커넥션 풀입니다. DB 커넥션은 TCP 연결이라 생성 비용이 비싸므로(수십~수백 ms), 미리 커넥션을 생성해놓고 재사용합니다.
| 설정 | 값 | 의미 |
|---|---|---|
| maximum-pool-size | 20~30 | 풀에서 최대 몇 개 커넥션을 유지할지 |
| minimum-idle | 5~20 | 최소 유지 커넥션 수 |
| connection-timeout | 30초 | 커넥션을 못 빌리면 이 시간 후 예외 |
| leak-detection-threshold | 10초 | 이 시간 이상 커넥션 반환 안 하면 로그 경고 |
기타 트러블슈팅
- 대기열 오픈 11시 정각 입장 지연: DB
saleStatus의존 → 시간 기반 Lazy 판정으로 변경 - Seat Hold Detached Entity: OSIV OFF 후 JPA dirty checking 실패 → Bulk UPDATE로 교체
- AES-GCM Hibernate 불필요 UPDATE: 랜덤 IV로 매번 다른 암호문 → IV 캐싱으로 해결
- Redis 메모리 초과: Refresh Token TTL 7일 → 4시간 단축, 부하테스트용 15분
- seat-groups N+1 쿼리: JOIN FETCH 추가 + 인덱스 최적화