Playball Logo

Command Palette

Search for a command to run...

목차 열기

기술 용어 해설

부하테스트 및 최적화 과정에서 등장한 핵심 기술 개념을 상세히 설명합니다. 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μs0.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=1m1분 후 백그라운드 갱신 (옵션)
recordStatshit/miss 통계 수집 (/actuator/metrics/cache.gets)

2.6 본 프로젝트의 Caffeine 맵

서비스캐시최대 크기TTL대상 데이터
Seatmatch-exists1,00010분Match 존재 검증
Seatmatch-detail1,00010분Match 메타 (JOIN FETCH home/away/stadium)
Seatsection-all161시간스타디움 섹션 구조 (영구 불변)
Seatblocks-by-section-ids5121시간섹션별 블럭 매핑 (영구 불변)
Queuematch-for-queue1,0001분Match saleStatus (짧은 TTL — 판매 개시 반영)
Order-Corematch-detail1,00010분주문서용 Match 메타

2.7 주의할 점

  1. 멀티 인스턴스 시 캐시 불일치 — 자주 바뀌는 데이터엔 부적합
  2. JVM 힙 사용 — 너무 큰 객체/많은 엔트리를 담으면 GC 압박
  3. 재시작 시 휘발 (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 분산 캐시

서비스캐시TTLEvict 조건
Auth-Guarduser-by-id10분프로필/상태 변경 시 @CacheEvict
Auth-Guardauth-me30초프로필 변경 시 evict
Order-Coreuser-by-id10분Auth-Guard 캐시 공유
Seatseat-groups-response5초좌석 상태 변경 시 (자연 만료)
Order-Corematches-list-response30초(자연 만료)

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: false
OSIV 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 RTT

RTT (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));

문제:

  1. 소유자 검증 없음 — 다른 스레드가 삭제 가능
  2. Watch Dog 없음 — TTL 만료 시 작업 도중 락 이탈
  3. 대기/재시도 직접 구현 — 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 DogleaseTime 미지정 시 30초 TTL + 10초마다 자동 갱신
소유자 검증isHeldByCurrentThread() — 스레드 ID 기반
Pub/Sub 대기polling 없이 락 해제 알림 수신
자동 언락스레드 종료 시 락 자동 해제

7.3 본 프로젝트의 락 전략

시나리오락 키WaitLease
추천 블럭 배정seat:recommendation:match:{m}:block:{b}3sWatch Dog
일반 좌석 Holdseat:hold:match:{m}:seat:{s}500ms5s

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. 용어 정리 요약표

용어핵심 기능본 프로젝트 적용
HikariCPDB 커넥션 풀 재사용Pool 20 → 30, Min-idle 20 → 5
CaffeineJVM 인-메모리 로컬 캐시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 RLockWatch Dog 기반 분산 락블럭/좌석 단위 락
조건부 UPDATEOptimistic LockmarkBlockedIfAvailable
JOIN FETCHN+1 제거findDetailByIdOrThrow