Published on

Spring Boot 3.x JFR로 p99 지연 30% 줄이기

Authors

서버 성능 최적화는 평균 지연이 아니라 p95, p99 같은 꼬리 지연(tail latency)을 줄이는 싸움입니다. 특히 Spring Boot 3.x는 기본적으로 Java 17 이상을 사용하고, JVM 관측 도구가 성숙해져서 JFR(Java Flight Recorder) 만 잘 써도 “어느 구간이 느린지”를 상당히 정확히 좁힐 수 있습니다.

이 글은 Spring Boot 3.x 서비스에서 p99 지연을 약 30% 줄이기 위해, JFR로 병목을 찾고 개선을 검증하는 과정을 재현 가능한 형태로 정리한 실전 가이드입니다. (수치는 워크로드와 환경에 따라 달라질 수 있지만, 접근 방식은 그대로 적용됩니다.)

왜 JFR인가: p99 최적화에 강한 이유

JFR은 프로파일러처럼 샘플링만 하는 도구가 아니라, JVM 내부 이벤트를 저오버헤드로 기록합니다. p99 문제는 대개 “가끔 터지는” 이벤트(Stop The World GC, 락 경합, 스레드 스케줄링 지연, 파일 IO 스파이크, DNS 지연 등) 때문에 생기는데, 이런 이벤트를 타임라인으로 엮어 원인-결과를 연결하기 좋습니다.

특히 운영 환경에서 아래 이유로 JFR이 유리합니다.

  • 오버헤드가 낮아 상시 또는 온디맨드로 켜기 쉬움
  • GC, 스레드, 락, 클래스 로딩, JIT, 네이티브 메모리 등 p99에 직결되는 이벤트를 한 번에 수집
  • “특정 2분 동안만” 같은 짧은 구간 레코딩이 가능해서 장애 순간을 포착하기 쉬움

목표 정의: p99를 어떻게 측정하고, 무엇을 30% 줄일 것인가

최적화를 시작하기 전에 지표 정의가 먼저입니다.

  • 대상 엔드포인트(예: POST /api/payments/confirm)
  • 측정 구간(예: LB 인입부터 응답까지, 혹은 앱 내부 핸들러 시작부터 끝까지)
  • p99 계산 윈도우(예: 5분 롤링, 1시간 집계)

실무에서 흔한 실수는 “JFR로 뭔가 개선했는데 p99가 안 내려감”인데, 이는 측정 지점이 다르거나(예: 앱은 빨라졌는데 네트워크가 병목), 집계 윈도우가 달라서 생깁니다.

Spring Boot에서 Micrometer와 Prometheus를 쓴다면, 최소한 아래는 확보하세요.

  • http.server.requestspercentile 또는 histogram 기반 p95, p99
  • GC pause 총합과 최대값
  • 스레드 풀 큐 길이/활성 스레드(서버 스레드, DB 풀)

인증/인가가 간헐적으로 지연을 만드는 경우도 있어, 보안 필터 체인을 점검하는 글도 함께 보면 좋습니다: Spring Boot JWT 인증 401 간헐 발생 원인 7가지

JFR 준비: 운영 친화적으로 켜는 방식

JVM 옵션으로 상시(저강도) 프로파일링

운영에서는 “항상 켜두되 부담이 적게”가 유리합니다. 예를 들어 15분짜리 순환 레코딩을 두고, 장애 시점의 파일을 보관하는 방식입니다.

아래는 예시입니다.

JAVA_TOOL_OPTIONS="\
-XX:StartFlightRecording=name=continuous,settings=profile,delay=10s,dumponexit=true,\
filename=/var/log/jfr/continuous.jfr,maxsize=256m,maxage=15m \
-XX:FlightRecorderOptions=stackdepth=128" 
  • settings=profile은 기본적으로 오버헤드가 낮고, 원인 추적에 필요한 이벤트가 들어있습니다.
  • maxagemaxsize로 파일이 무한히 커지는 것을 막습니다.
  • stackdepth는 너무 크면 오버헤드가 늘 수 있으니 64~256 사이에서 조정합니다.

jcmd로 온디맨드 레코딩

장애가 났을 때 2~5분만 강하게 따는 방식도 자주 씁니다.

# PID 확인
jcmd

# 3분 동안 레코딩 후 파일로 저장
jcmd `PID` JFR.start name=spike settings=profile filename=/tmp/spike.jfr duration=180s

# 즉시 덤프
jcmd `PID` JFR.dump name=spike filename=/tmp/spike-dump.jfr

PID 같은 표기는 반드시 인라인 코드로 감싸야 MDX에서 안전합니다.

분석 흐름: p99 스파이크를 “시간축으로” 쪼개기

JFR을 열 때는 JDK Mission Control(JMC)에서 보통 다음 순서로 봅니다.

  1. Overview: CPU, GC, Heap, Threads, IO가 튄 시점 확인
  2. GC: Pause가 p99 스파이크와 겹치는지
  3. Threads: Runnable이 부족한지, Blocked/Waiting이 많은지
  4. Locks: 특정 모니터 락 경합이 있는지
  5. Method Profiling: 핫 메서드가 CPU를 태우는지
  6. Socket/IO: 네트워크/파일 IO 지연이 있는지

핵심은 “p99가 튄 시각”을 기준으로 그 전후 10~30초를 확대해서, GC pause, 락 경합, 스레드 정체가 같이 튀는지 상관관계를 찾는 것입니다.

케이스 1: GC Pause가 p99를 밀어 올리는 패턴

JFR에서 보이는 신호

  • GC Pause 이벤트가 p99 스파이크와 같은 타임라인에 존재
  • Young GC가 잦고, Old GC 또는 Mixed GC가 간헐적으로 길게 발생
  • Allocation Rate가 순간적으로 급증

개선 전략

  1. 불필요한 객체 할당 줄이기
  2. 버퍼/컬렉션 재사용(단, 동시성 안전성 주의)
  3. JSON 직렬화 비용 절감
  4. 로그 과다/스택트레이스 생성 줄이기

Spring Boot에서 흔한 예는, 요청마다 큰 Map을 만들거나, 예외를 “제어 흐름”으로 써서 스택트레이스를 대량 생성하는 경우입니다.

예: 예외를 제어 흐름으로 쓰지 않기

// 나쁜 예: 실패를 예외로 제어하고, 대량 트래픽에서 스택트레이스 비용이 커짐
public User findUser(String id) {
    try {
        return userRepository.findById(id).orElseThrow();
    } catch (NoSuchElementException e) {
        return null;
    }
}

// 개선: 분기 처리로 예외 생성 자체를 피함
public User findUser(String id) {
    return userRepository.findById(id).orElse(null);
}

예: Jackson 직렬화에서 불필요한 변환 제거

// 나쁜 예: ObjectMapper를 매번 생성하거나, 중간 문자열로 변환
public String toJson(Object o) throws Exception {
    return new ObjectMapper().writeValueAsString(o);
}

// 개선: 싱글턴 ObjectMapper 주입 + 불필요한 중간 변환 제거
@Configuration
class JacksonConfig {
    @Bean
    ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}

검증 포인트

  • Allocation Rate 감소
  • Young GC 횟수 감소 또는 pause 감소
  • p99가 GC pause와 함께 튀던 상관관계가 약해지는지

케이스 2: 스레드 풀 고갈과 큐 적체로 p99가 증가하는 패턴

p99가 높아지는 가장 흔한 원인 중 하나는 “내 코드가 느려서”가 아니라 “기다리느라 느려서”입니다.

JFR에서 보이는 신호

  • Java Monitor Blocked 또는 Thread Park가 증가
  • Runnable 스레드 수가 낮고, Waiting이 많음
  • 특정 스레드 풀(웹/DB/외부 호출)에서 병목이 발생

개선 전략

  • DB 커넥션 풀과 서버 스레드 수를 워크로드에 맞게 조정
  • 블로킹 IO를 최소화하거나, 타임아웃을 명확히 설정
  • 외부 의존성 호출에 벌크헤드(격리) 적용

예: HikariCP와 Tomcat 스레드 수의 균형

Tomcat worker가 200인데 Hikari가 10이면, 많은 요청이 DB 커넥션을 기다리며 p99가 튈 수 있습니다.

application.yml 예시:

server:
  tomcat:
    threads:
      max: 120
spring:
  datasource:
    hikari:
      maximum-pool-size: 40
      minimum-idle: 10
      connection-timeout: 1000
      validation-timeout: 500

여기서 중요한 건 “무조건 키우기”가 아니라, DB가 감당 가능한 동시성 범위 내에서 대기 시간을 최소화하는 균형점입니다.

검증 포인트

  • 커넥션 대기 시간 감소
  • 요청 대기열(큐) 감소
  • p99가 계단식으로 개선되는지(대기 제거는 효과가 크게 나타나는 편)

케이스 3: 락 경합(모니터 락)으로 특정 코드가 p99를 잡아먹는 패턴

JFR에서 보이는 신호

  • Lock Instances 또는 Java Monitor Blocked에서 특정 클래스/메서드가 상위
  • 짧은 시간에 Blocked 이벤트가 폭증

흔한 원인

  • 전역 캐시를 synchronized로 보호
  • 로깅 appender의 동기화
  • SimpleDateFormat 같은 비스레드세이프 객체를 동기화로 감싼 경우

예: 전역 락을 쪼개기

// 나쁜 예: 전역 락 1개로 모든 키를 보호
private final Map<String, String> cache = new HashMap<>();

public synchronized String getOrLoad(String key) {
    return cache.computeIfAbsent(key, this::load);
}

// 개선: ConcurrentHashMap으로 락 경합 완화
private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();

public String getOrLoad(String key) {
    return cache.computeIfAbsent(key, this::load);
}

computeIfAbsent 내부 로딩이 느리면 또 다른 병목이 될 수 있으니, 로딩 함수에서 외부 호출을 길게 하지 않도록 설계하거나, 별도 캐시 전략을 고려하세요.

검증 포인트

  • Blocked 이벤트 감소
  • 스레드 상태에서 Waiting 비중 감소
  • p99 스파이크 빈도 감소

케이스 4: 네이티브 메모리 또는 컨테이너 환경 이슈가 p99에 반영되는 패턴

Spring Boot 3.x는 컨테이너 배포가 흔하고, p99 스파이크가 JVM 밖에서 오기도 합니다.

  • 컨테이너 디스크 압박으로 노드가 흔들리거나 Pod가 축출되면 지연이 튈 수 있음
  • 로그 폭증으로 디스크 IO가 증가하면 애플리케이션이 간접적으로 느려짐

EKS 환경이라면 디스크 압박과 축출 문제도 p99에 치명적입니다. 관련 트러블슈팅은 다음 글이 도움이 됩니다: EKS Pod Evicted(디스크 압박) 원인·해결 가이드

JFR만으로 OS 레벨 문제를 100% 규명하긴 어렵지만, JFR 타임라인에서 CPU가 놀고 있는데도 응답이 느리고, 스레드가 이상하게 Park 상태로 길게 머문다면 OS 스케줄링/IO 문제를 의심할 수 있습니다.

실전 절차: p99 30% 개선을 만드는 반복 루프

아래 루프를 2~3회만 제대로 돌려도 체감 개선이 나오는 경우가 많습니다.

1) p99 스파이크가 있는 시간 구간을 고정

  • 대시보드에서 특정 시간대(예: 14:02~14:07)를 찍습니다.
  • 해당 구간의 JFR을 확보합니다.

2) JFR에서 상관관계 찾기

  • p99 스파이크 시각에 GC pause가 있는지
  • Blocked/Waiting이 증가했는지
  • 특정 락 인스턴스가 상위를 차지하는지
  • 외부 호출이 느려졌는지(별도 APM 트레이스와 함께 보면 더 좋음)

3) 개선은 “한 번에 하나씩”

여러 개를 동시에 바꾸면 무엇이 효과였는지 모릅니다.

  • 예: Hikari 풀 조정
  • 예: 특정 synchronized 제거
  • 예: 예외 남발 제거
  • 예: JSON 직렬화 최적화

4) 동일한 부하로 재측정

  • 같은 트래픽 패턴(가능하면 리플레이)에서 p99를 비교합니다.
  • p99만 보지 말고, p50, p95도 함께 봅니다.

5) 회귀 방지 장치 추가

  • JFR 상시 저강도 레코딩 유지
  • 성능 회귀 테스트(간단한 k6, Gatling) 파이프라인에 포함

k6로 p99 재현하는 간단한 부하 스크립트

운영 트래픽을 그대로 재현하기 어렵다면, 최소한 대표 API에 대해 p99를 반복 측정할 스크립트를 준비합니다.

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    steady: {
      executor: 'constant-vus',
      vus: 50,
      duration: '5m',
    },
  },
  thresholds: {
    http_req_duration: ['p(99) < 300'],
  },
};

export default function () {
  const res = http.get('https://example.com/api/orders');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(0.1);
}

여기서 p(99) < 300 같은 표현은 코드 블록 안에 있어 MDX 안전합니다.

마무리: p99는 “원인 규명 속도”가 승부처

Spring Boot 3.x에서 p99를 30% 줄이는 데 가장 큰 기여를 하는 건, 특정 튜닝 값 하나가 아니라 병목을 빠르게 특정하고, 작은 변경을 반복 검증하는 루프입니다. JFR은 그 루프의 “근거”를 제공하는 도구이고요.

정리하면 다음 순서가 가장 재현성이 높습니다.

  • p99 스파이크 시간대를 고정한다
  • JFR로 GC, 스레드, 락, IO 이벤트를 시간축으로 겹쳐 본다
  • 가장 큰 상관관계 1개를 골라 개선한다
  • 동일 부하로 재측정해 p99 개선을 확인한다

운영에서 간헐 장애나 인증 체인 문제로 tail latency가 튀는 경우도 많으니, 필요하면 위에 링크한 JWT 글도 함께 점검해 보세요.