기술 용어 해설
부하테스트 및 최적화 과정에서 등장한 핵심 기술 개념을 상세히 설명합니다. HikariCP, Caffeine, Redis, OSIV, Resilience4j, Redisson, Lua Script, JOIN FETCH 등 팀이 실제로 도입·튜닝한 요소 위주로 정리했습니다.
1. HikariCP (DB 커넥션 풀)
1.1 커넥션 풀이란?
DB 연결(TCP Connection)은 생성 비용이 비싼 자원입니다. 매 요청마다 연결/해제를 반복하면:
- TCP 3-way handshake (수 ms)
- PostgreSQL 인증 절차 (수십 ms)
- 매번 수십~수백 ms 추가 지연
커넥션 풀은 미리 일정 개수의 커넥션을 만들어 놓고 재사용합니다.
[Tomcat 스레드] ──(1) borrow──▶ [HikariCP 풀] ──▶ [PostgreSQL]
▲ ▲
│ │
└──────(2) 쿼리 실행 ────────────┘
│
└──(3) return ─▶ [풀에 반환]
│
└──(4) 다른 스레드가 같은 커넥션 재사용1.2 HikariCP 설정값 해설
spring:
datasource:
hikari:
maximum-pool-size: 30 # 풀이 유지할 최대 커넥션 수
minimum-idle: 5 # 최소 유휴 커넥션 수
connection-timeout: 30000 # 커넥션 획득 대기 타임아웃(ms)
leak-detection-threshold: 10000 # 10초 이상 반환 안 되면 로그 경고
idle-timeout: 600000 # 10분 이상 놀면 풀에서 제거| 설정 | 설명 | 본 프로젝트 값 |
|---|---|---|
maximum-pool-size | 최대 커넥션 수 | 20 → 30 (Phase 3) |
minimum-idle | 항상 유지할 최소 유휴 커넥션 | 20 → 5 (Phase 3) |
connection-timeout | 커넥션 대기 타임아웃 | 30초 |
leak-detection-threshold | 커넥션 리크 감지 시간 | 10초 |
idle-timeout | 유휴 커넥션 정리 시간 | 10분 |
1.3 왜 Pool Size를 무작정 늘릴 수 없는가?
DB 서버 자체의 한계때문입니다.
- PostgreSQL
max_connections = 270(db.t4g.small 기본값) - 전체 서비스 Pool 합계가 270 이내여야 함
- 넘으면 DB에서 "too many connections" 에러
1.4 Pool Size 계산 공식
합계 = (Seat 30) + (Queue 30) + (Auth-Guard 20) + (Order-Core 20)
+ (Batch / Admin 20) + (여유 50)
≤ 270 (max_connections)1.5 hikaricp_connections_pending — 병목의 증거
pending은"커넥션을 빌리기 위해 줄 서서 기다리는 스레드 수"입니다.
Tomcat 스레드 200개 ────┐
├──▶ HikariCP Pool (20개) ──▶ PostgreSQL (270)
│ │
│ ├─ active: 20
│ └─ pending: 180 ◀── 여기가 병목!
│
└── 스레드가 DB I/O 대기로 블로킹됨pending 지표가 0이 아니면 → 풀이 포화 상태이며, 대기 시간이 응답 지연의 주범.
2. Caffeine (로컬 캐시)
2.1 Caffeine이란?
Caffeine은 Java용 고성능 인-메모리 로컬 캐시 라이브러리입니다.
- Guava Cache의 개선 버전
- Google 엔지니어 Ben Manes가 개발
- 현재 Java 생태계에서 사실상 표준 로컬 캐시
2.2 핵심 개념 — "로컬"의 의미
[HTTP 요청]
│
▼
[Spring Application (JVM 프로세스 안)]
│
├─ Caffeine Cache (JVM 힙 메모리)
│ ├─ ConcurrentHashMap + 통계/만료/교체 알고리즘
│ └─ 네트워크 홉 없음 → sub-microsecond (< 1μs) 접근
│
├─ 캐시 hit → 즉시 반환
└─ 캐시 miss → DB 조회 → 캐시 저장 → 반환"로컬"이라는 것은 각 JVM 프로세스(Pod) 안에 캐시가 저장된다는 뜻입니다.
- 장점: 극도로 빠름 (메모리 내부), 네트워크 부하 없음, Redis 장애와 무관
- 단점: 여러 Pod 간 캐시가 공유되지 않음 (Pod A 갱신 → Pod B는 모름)
2.3 Redis vs Caffeine 비교
| 항목 | Caffeine (로컬) | Redis (원격) |
|---|---|---|
| 저장 위치 | JVM 힙 메모리 | 별도 서버 |
| 접근 시간 | < 1μs | 0.5~2ms (+네트워크) |
| 용량 | JVM 힙 한도 내 | GB~TB 가능 |
| 인스턴스 간 공유 | 불가 (각 Pod 따로) | 가능 (단일 소스) |
| 일관성 | 노드별 다를 수 있음 | 단일 소스 |
| 장애 격리 | JVM과 운명 공유 | Redis 다운 시 영향 |
2.4 W-TinyLFU 알고리즘
Caffeine의 핵심 교체(eviction) 알고리즘 — LRU보다 높은 hit rate를 보장합니다.
TinyLFU = Frequency Sketch (4-bit counting) + Tiny admission window
W-TinyLFU = TinyLFU + 최근 접근 가중치 (Window)기존 LRU는 "가장 최근에 접근한 것을 유지"하는데, 가끔 급등하는 데이터가 자주 쓰이는 데이터를 밀어내는 문제가 있습니다. W-TinyLFU는 빈도도 함께 고려해 hit rate를 높입니다.
2.5 본 프로젝트의 Caffeine 설정
spring:
cache:
type: caffeine
cache-names: match-exists
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m,recordStats| 옵션 | 의미 |
|---|---|
maximumSize=1000 | 최대 1,000개 엔트리 (초과 시 W-TinyLFU eviction) |
expireAfterWrite=10m | 쓴 지 10분 지나면 만료 |
expireAfterAccess=5m | 마지막 접근 후 5분 (옵션) |
refreshAfterWrite=1m | 1분 후 백그라운드 갱신 (옵션) |
recordStats | hit/miss 통계 수집 (/actuator/metrics/cache.gets) |
2.6 본 프로젝트의 Caffeine 맵
| 서비스 | 캐시 | 최대 크기 | TTL | 대상 데이터 |
|---|---|---|---|---|
| Seat | match-exists | 1,000 | 10분 | Match 존재 검증 |
| Seat | match-detail | 1,000 | 10분 | Match 메타 (JOIN FETCH home/away/stadium) |
| Seat | section-all | 16 | 1시간 | 스타디움 섹션 구조 (영구 불변) |
| Seat | blocks-by-section-ids | 512 | 1시간 | 섹션별 블럭 매핑 (영구 불변) |
| Queue | match-for-queue | 1,000 | 1분 | Match saleStatus (짧은 TTL — 판매 개시 반영) |
| Order-Core | match-detail | 1,000 | 10분 | 주문서용 Match 메타 |
2.7 주의할 점
- 멀티 인스턴스 시 캐시 불일치 — 자주 바뀌는 데이터엔 부적합
- JVM 힙 사용 — 너무 큰 객체/많은 엔트리를 담으면 GC 압박
- 재시작 시 휘발 (cold start) — 프로세스 재시작 후 첫 요청은 miss
3. Redis 분산 캐시
3.1 왜 Redis 분산 캐시가 필요한가?
변경 가능한 데이터(User 프로필, 상태)는 로컬 캐시로 못 씁니다.
문제 시나리오 (Caffeine만 쓸 때):
Pod A: user-by-id:42 = {nickname: "새닉네임"} ← 갱신됨
Pod B: user-by-id:42 = {nickname: "옛닉네임"} ← stale!
→ 로그인 Pod에 따라 다른 결과Redis는 모든 Pod이 공유하는 단일 저장소이므로 일관성을 보장합니다.
3.2 본 프로젝트의 Redis 분산 캐시
| 서비스 | 캐시 | TTL | Evict 조건 |
|---|---|---|---|
| Auth-Guard | user-by-id | 10분 | 프로필/상태 변경 시 @CacheEvict |
| Auth-Guard | auth-me | 30초 | 프로필 변경 시 evict |
| Order-Core | user-by-id | 10분 | Auth-Guard 캐시 공유 |
| Seat | seat-groups-response | 5초 | 좌석 상태 변경 시 (자연 만료) |
| Order-Core | matches-list-response | 30초 | (자연 만료) |
3.3 직렬화 이슈 (트러블슈팅)
Redis는 객체를 바이트로 저장하므로 직렬화/역직렬화가 필요합니다.
- 초기:
DefaultTyping.NON_FINAL→ 다형성 역직렬화 실패 - 해결:
DefaultTyping.EVERYTHING으로 전환 +JavaTimeModule추가
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.EVERYTHING, // 다형성 지원
JsonTypeInfo.As.PROPERTY
);
template.setValueSerializer(new GenericJackson2JsonRedisSerializer(mapper));
return template;
}4. OSIV (Open Session In View)
4.1 OSIV란?
Spring Data JPA의 기본 설정으로,HTTP 요청 전체 구간에서 DB 커넥션/영속성 컨텍스트를 유지하는 패턴입니다.
OSIV ON (기본값):
[요청 시작] → HikariCP 커넥션 획득 → Controller → Service → Repository
↓
[응답 완료] ← ─── ─── ─── ─── ─── ─── ← 커넥션 반환 ← ─── ─── ─┘
전 구간에서 커넥션 점유4.2 왜 문제가 되는가?
Controller → View 렌더링 단계에서도 커넥션을 잡고 있음. 실제로 DB를 쓰지 않아도 점유하게 됩니다.
- 커넥션 회전율 저하
- Pending 증가
4.3 OSIV OFF 효과
spring:
jpa:
open-in-view: falseOSIV OFF:
[요청 시작] → Controller → Service → [트랜잭션 시작] HikariCP 획득
↓
Repository → [트랜잭션 종료] 커넥션 즉시 반환
↓
[응답 완료] ← Controller 나머지 로직 (커넥션 없이 진행)같은 Pool 크기로 약 2배의 요청 처리 가능 (Phase 4에서 Queue 서비스에 적용).
4.4 OSIV OFF 부작용
- Lazy 로딩 불가: 영속성 컨텍스트 밖에서 프록시 접근 →
LazyInitializationException - 해결: JOIN FETCH 또는 DTO 프로젝션으로 필요한 연관관계를 한 번에 로드
5. Resilience4j @Retry
5.1 Thread.sleep 재시도의 문제
// 변경 전: Thread.sleep 블로킹 재시도
for (int i = 0; i < 3; i++) {
try {
preQueueRedis.mark(matchId, userId);
return;
} catch (Exception e) {
Thread.sleep(50 * (i + 1)); // 50ms, 100ms, 150ms 블로킹!
}
}문제: Tomcat 워커 스레드가 300ms 동안 block → 스레드 회전율 저하.
5.2 Resilience4j @Retry
Resilience4j는 Netflix Hystrix 후속 라이브러리로, 비동기 재시도/서킷브레이커/Rate Limiter 등을 제공합니다.
resilience4j.retry:
instances:
prequeue-marker:
max-attempts: 3
wait-duration: 20ms
exponential-backoff-multiplier: 2@Retry(name = "prequeue-marker", fallbackMethod = "markFallback")
public void mark(Long matchId, Long userId) {
preQueueStringRedisTemplate.opsForValue()
.set(bookingOptionKey(matchId, userId), "1", Duration.ofSeconds(ttlSeconds));
}
public void markFallback(Long matchId, Long userId, Exception e) {
log.warn("PreQueue marker 재시도 실패: {}", e.getMessage());
throw new CustomException(PREQUEUE_MARKER_SYNC_FAILED);
}효과: 평균 대기 300ms → 60ms (80% 감소).
6. Redis Lua 스크립트 (원자 연산)
6.1 다중 Redis 명령 문제
대기열 재진입 시 여러 Redis 명령이 필요합니다.
→ Redis: ZREM queue:wait:{matchId} {userId} ← 1 RTT
→ Redis: DEL queue:ready:{matchId}:{userId} ← 2 RTT
→ Redis: ZADD queue:wait:{matchId} {score} {userId} ← 3 RTT
→ Redis: ZRANK queue:wait:{matchId} {userId} ← 4 RTT
→ Redis: ZCARD queue:wait:{matchId} ← 5 RTTRTT (Round Trip Time): 네트워크 왕복 시간. 각 명령마다 ~0.5~2ms 추가.
6.2 Lua 스크립트로 통합
-- queue:re-enter atomic script
redis.call('ZREM', KEYS[1], ARGV[1])
redis.call('DEL', KEYS[2])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
local rank = redis.call('ZRANK', KEYS[1], ARGV[1])
local count = redis.call('ZCARD', KEYS[1])
return {rank, count}redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, List.class),
Arrays.asList(waitKey, readyKey),
userId, scoreStr
);6.3 효과
- 3~5 RTT → 1 RTT
- Redis 서버 부하 감소 (명령 디스패치 횟수 감소)
- 원자성 보장 (중간에 다른 명령이 끼어들 수 없음)
7. Redisson 분산 락
7.1 SETNX의 한계
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent("seat:lock:block:" + blockId, "LOCKED", Duration.ofSeconds(5));문제:
- 소유자 검증 없음 — 다른 스레드가 삭제 가능
- Watch Dog 없음 — TTL 만료 시 작업 도중 락 이탈
- 대기/재시도 직접 구현 — polling 비효율적
7.2 Redisson RLock
RLock lock = redissonClient.getLock(key);
boolean locked = lock.tryLock(3, TimeUnit.SECONDS); // 3초 대기
try {
// 임계 영역
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}핵심 기능
| 기능 | 설명 |
|---|---|
| Watch Dog | leaseTime 미지정 시 30초 TTL + 10초마다 자동 갱신 |
| 소유자 검증 | isHeldByCurrentThread() — 스레드 ID 기반 |
| Pub/Sub 대기 | polling 없이 락 해제 알림 수신 |
| 자동 언락 | 스레드 종료 시 락 자동 해제 |
7.3 본 프로젝트의 락 전략
| 시나리오 | 락 키 | Wait | Lease |
|---|---|---|---|
| 추천 블럭 배정 | seat:recommendation:match:{m}:block:{b} | 3s | Watch Dog |
| 일반 좌석 Hold | seat:hold:match:{m}:seat:{s} | 500ms | 5s |
8. 조건부 UPDATE (Optimistic Lock)
8.1 문제 상황
추천 블럭 락과 일반 좌석 락은 서로 다른 키를 사용합니다. 따라서 동시에 같은 좌석을 노리면 충돌이 발생할 수 있습니다.
8.2 해결: 조건부 UPDATE
UPDATE match_seats
SET sale_status = 'BLOCKED'
WHERE id = :matchSeatId
AND sale_status = 'AVAILABLE'- 해당 좌석이
AVAILABLE상태일 때만BLOCKED로 변경 - return 값이 0이면 이미 다른 유저가 선점 → 재시도 트리거
int updated = matchSeatRepository.markBlockedIfAvailable(seat.getId());
if (updated == 0) {
// 충돌 감지 → 지금까지 잡은 좌석 모두 롤백 후 재시도
blocked.forEach(s -> matchSeatRepository.markAvailableIfBlocked(s.getId()));
return false;
}9. JOIN FETCH vs Lazy Loading
9.1 N+1 문제
// Lazy 로딩: 좌석 조회 시마다 Match도 1번씩 추가 쿼리
List<MatchSeat> seats = matchSeatRepository.findAll(); // 1번 쿼리
for (MatchSeat seat : seats) {
seat.getMatch().getHomeTeam(); // N번 쿼리 (N+1)
}100개의 좌석 → 101번의 쿼리 실행.
9.2 JOIN FETCH
@Query("""
SELECT m FROM Match m
JOIN FETCH m.homeTeam
JOIN FETCH m.awayTeam
JOIN FETCH m.stadium
WHERE m.id = :id
""")
Optional<Match> findDetailByIdOrThrow(@Param("id") Long id);한 번의 쿼리로 모든 연관관계를 즉시 로딩 (eager fetch)합니다.
10. 용어 정리 요약표
| 용어 | 핵심 기능 | 본 프로젝트 적용 |
|---|---|---|
| HikariCP | DB 커넥션 풀 재사용 | Pool 20 → 30, Min-idle 20 → 5 |
| Caffeine | JVM 인-메모리 로컬 캐시 | Match/Section/Block 10분~1시간 |
| Redis 분산 캐시 | 여러 Pod 간 공유 캐시 | User / 응답 캐시 |
| OSIV | 요청 전 구간 DB 커넥션 유지 | OFF로 전환 (Queue) |
| Resilience4j @Retry | 비동기 재시도 | Thread.sleep → @Retry |
| Redis Lua Script | 다중 명령 원자 실행 | 대기열 재진입 3 RTT → 1 RTT |
| Redisson RLock | Watch Dog 기반 분산 락 | 블럭/좌석 단위 락 |
| 조건부 UPDATE | Optimistic Lock | markBlockedIfAvailable |
| JOIN FETCH | N+1 제거 | findDetailByIdOrThrow |