- Published on
Spring Boot 3.2 GraalVM 네이티브 OOM·지연 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스나 K8s에서 Spring Boot 3.2를 GraalVM 네이티브 이미지로 배포하면, JVM 대비 메모리 풋프린트와 콜드 스타트가 유리해지는 대신 OOM(Out Of Memory) 과 지연(latency) 스파이크가 다른 양상으로 나타납니다. 특히 컨테이너 메모리 제한(cgroup) 아래에서 “잘 되던 JVM 앱이 네이티브로 바꾸자 갑자기 죽는다” 같은 사례가 많습니다.
이 글은 다음 흐름으로 접근합니다.
- OOM을 “리눅스 OOM Kill”과 “프로세스 내부 OOM”으로 나눠 진단
- 네이티브에서 지연이 튀는 지점을 콜드 스타트, 런타임 GC/메모리, I/O로 분해
- Spring Boot 3.2 + AOT + Native에서 바로 적용 가능한 튜닝 레시피 제공
관련해서 리눅스 레벨의 OOM 원인 추적은 아래 글이 진단에 큰 도움이 됩니다.
1) 네이티브 OOM의 두 얼굴: OOM Kill vs 내부 OOM
네이티브 이미지에서 “OOM”이라고 뭉뚱그리면 해결이 어렵습니다. 먼저 누가 죽였는지를 가릅니다.
1.1 리눅스 OOM Kill(컨테이너에서 가장 흔함)
증상
- Pod가
OOMKilled로 재시작 - 애플리케이션 로그에 OOM 흔적이 거의 없음
확인 포인트
- K8s:
kubectl describe pod에서Last State: Terminated와Reason: OOMKilled - 노드/컨테이너:
dmesg또는journalctl -k에서 OOM killer 로그
네이티브는 JVM처럼 -Xmx로 상한을 강제하는 문화가 약해, 메모리 제한에 더 민감하게 죽는 경우가 많습니다(특히 off-heap 성격의 사용 포함).
1.2 프로세스 내부 OOM(할당 실패)
증상
- 프로세스가 죽기 전
malloc실패, 혹은 런타임 예외/크래시 로그가 남음 - 재현 시 동일 지점에서 반복
원인 후보
- 너무 작은 힙/메모리 영역(네이티브 런타임 힙 포함)
- 큰 버퍼 할당(압축/암호화/JSON 파싱/대용량 응답 생성)
- 스레드 과다로 인한 스택 메모리 누적
2) Spring Boot 3.2 네이티브의 메모리 구조 이해
JVM에서는 “힙” 중심으로 사고하지만, 네이티브는 아래가 함께 커집니다.
- 네이티브 런타임 힙(객체/배열 등)
- 코드 영역(컴파일된 머신 코드)
- 스레드 스택(스레드 수
x스택 크기) - 네이티브 라이브러리/버퍼(예: Netty direct buffer, TLS, 압축 버퍼)
따라서 컨테이너 메모리 제한을 잡을 때는 “힙 비슷한 것만” 보지 말고 스레드 수와 direct 메모리까지 포함한 총량을 계산해야 합니다.
3) OOM 재현과 1차 진단 체크리스트
3.1 컨테이너 메모리 제한과 실제 사용량을 같이 보기
requests와limits가 너무 타이트하면 순간 피크에서 죽습니다.- 네이티브는 GC 정책/시점이 JVM과 달라, 피크를 더 자주 밟을 수 있습니다.
K8s 예시
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "512Mi"
cpu: "1"
운영에서는 최소한 requests 대비 limits 여유를 두고, 네이티브가 목표라면 메모리 limit을 먼저 넉넉히 잡고 줄여가며 안정 구간을 찾는 게 빠릅니다.
3.2 스레드 수가 메모리를 잡아먹는지 확인
스레드 스택은 생각보다 큽니다. 예를 들어 스택이 1MiB이고 스레드가 300개면 스택만 300MiB가 됩니다.
- 톰캣/제티/네티 워커 스레드
- DB 커넥션 풀 스레드
- 스케줄러
- 관측(OTel) 익스포터 스레드
Spring 설정에서 스레드를 명시적으로 제한하는 것이 네이티브에선 특히 중요합니다.
Tomcat 예시
server:
tomcat:
threads:
max: 50
min-spare: 10
HikariCP 예시
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 10
3.3 Netty/HTTP 클라이언트 direct buffer 점검
WebFlux/Netty 기반이거나, SDK가 Netty를 쓰면 direct 메모리 사용이 OOM의 주범이 되기도 합니다.
- 큰 응답 바디를 메모리에 올리는 코드
- 무제한 버퍼링
- 압축 응답 처리
가능하면 스트리밍으로 바꾸고, 버퍼 상한을 둡니다.
예: WebClient에서 큰 바디를 bodyToMono(String)로 한 번에 받지 말고 스트림 처리(파일/바이트 스트림)로 변경.
4) 네이티브 지연의 유형 분해: 콜드 스타트 vs 런타임 P99
네이티브 지연은 크게 두 갈래입니다.
- 콜드 스타트(프로세스 시작~첫 요청)
- 런타임 지연(P95/P99, 특정 트래픽 패턴에서 튐)
4.1 콜드 스타트 튜닝 포인트
- Spring 컨텍스트 초기화 비용
- 클래스패스 스캐닝/리플렉션 대체(AOT가 줄여줌)
- 초기 커넥션(예: DB, Redis, 외부 API)
네이티브의 장점은 “JIT 워밍업이 없다”이지 “초기화가 공짜”가 아닙니다. DB 커넥션/마이그레이션/시크릿 로딩 같은 외부 I/O가 있으면 콜드 스타트가 길어집니다.
실전 팁
- readiness probe 전에 꼭 필요한 초기화만 수행
- 불필요한 자동 구성 제외
spring.main.lazy-initialization=true를 상황에 따라 검토(모든 서비스에 만능은 아님)
spring:
main:
lazy-initialization: true
4.2 런타임 P99 지연 튜닝 포인트
- GC/메모리 압박(할당률이 높을수록 지연이 튐)
- 로깅/직렬화 비용
- 동기 I/O 블로킹(특히 가상 스레드와 혼용 시)
네이티브는 GC가 없지 않습니다. 그리고 “메모리를 적게 쓰는 대신 CPU를 더 쓰는” 형태로 지연이 바뀌기도 합니다.
5) GraalVM Native 빌드 단계에서 OOM 줄이기(빌드 OOM 포함)
운영 OOM만큼 흔한 게 네이티브 이미지 빌드 중 OOM입니다. CI에서 메모리가 부족하면 native-image가 죽습니다.
5.1 Gradle/Maven 네이티브 빌드 메모리 확보
GitHub Actions나 컨테이너 빌드 환경에서 메모리를 넉넉히 주거나, 빌드 단계만 큰 머신을 쓰는 게 비용 대비 효과가 큽니다.
Gradle 예시
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
Maven 예시(환경 변수)
export MAVEN_OPTS="-Xmx4g"
5.2 PGO(Profile-Guided Optimization)로 지연/성능 균형 잡기
GraalVM 네이티브는 PGO를 적용하면 핫패스가 개선되어 P99가 안정되는 경우가 있습니다.
- 1차 빌드(인스트루먼트)
- 대표 트래픽으로 프로파일 수집
- 2차 빌드(프로파일 적용)
Spring Boot 플러그인/빌드 도구에 따라 옵션이 다르므로, 사용하는 툴체인의 공식 문서를 보고 pgo 관련 설정을 적용하세요.
6) 런타임 메모리·지연 튜닝 레시피
6.1 관측부터: “어디서 죽는지” 로그를 남기기
네이티브는 JVM처럼 표준화된 옵션이 적어서, 먼저 “죽기 직전 상태”를 남기는 게 중요합니다.
- K8s 이벤트/컨테이너 종료 코드
- 애플리케이션에서 주기적으로 RSS/스레드 수/큐 길이 로깅
/actuator/metrics로 힙/GC/스레드 지표 수집(가능한 범위 내)
예: 간단한 메모리/스레드 로깅(주기 작업)
import java.lang.management.ManagementFactory;
import java.time.Duration;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class RuntimeStatsLogger {
@Scheduled(fixedDelayString = "PT30S")
public void log() {
int threads = ManagementFactory.getThreadMXBean().getThreadCount();
long used = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
long total = Runtime.getRuntime().totalMemory();
System.out.printf("threads=%d used=%d total=%d\n", threads, used, total);
}
}
이 값은 네이티브에서 JVM과 의미가 완전히 같진 않지만, “증가 추세”를 보는 데 도움이 됩니다.
6.2 스레드/풀 크기 상한을 먼저 고정
OOM과 지연 스파이크를 동시에 줄이는 가장 흔한 방법은 동시성 상한을 명시하는 것입니다.
- 톰캣 워커 스레드
- 커넥션 풀
- 비동기 executor
Spring TaskExecutor 예시
spring:
task:
execution:
pool:
core-size: 10
max-size: 30
queue-capacity: 500
큐를 무한대로 두면 메모리로 부하를 흡수하다가 결국 OOM로 터집니다. 네이티브는 특히 “버티다가 죽는” 패턴이 많아, 큐 상한 + 빠른 실패(또는 백프레셔) 가 안정적입니다.
6.3 큰 객체/버퍼를 피하는 코드로 바꾸기
지연과 OOM을 동시에 만드는 대표 코드가 “대용량 JSON을 문자열로 한 번에 만들기/읽기”입니다.
나쁜 예(메모리 피크)
String body = webClient.get()
.uri(url)
.retrieve()
.bodyToMono(String.class)
.block();
개선 방향
- 스트리밍 처리
- 페이징
- 압축/해제 비용이 큰 경우 chunk 단위 처리
(구체 API는 사용 스택에 따라 달라, 여기서는 패턴만 기억해도 충분합니다.)
6.4 불필요한 자동 구성 제외로 초기화/메모리 절감
Spring Boot는 편리한 대신 자동 구성이 많습니다. 네이티브에선 “안 쓰는 빈이 올라오는 비용”이 더 아깝습니다.
예시
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class App {
}
또는 spring.autoconfigure.exclude로 제외할 수도 있습니다.
6.5 리플렉션/리소스 누락으로 인한 재시도 폭탄 방지
네이티브에서 리플렉션/리소스 설정이 누락되면, 특정 요청에서만 예외가 나고 클라이언트가 재시도하면서 부하가 폭증해 OOM로 이어지기도 합니다.
- Jackson 직렬화 대상
- ServiceLoader 기반 로딩
META-INF리소스
Spring Boot 3.2는 AOT가 많은 부분을 해결하지만, 라이브러리 조합에 따라 수동 힌트가 필요합니다.
예: 런타임 힌트
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.ImportRuntimeHints;
@ImportRuntimeHints(MyHints.class)
class HintConfig {}
class MyHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(MyDto.class);
}
}
이 문제는 “기능 오류”로만 보이지만, 운영에서는 재시도/큐 적체로 지연과 메모리 사용량을 키우는 도화선이 됩니다.
7) 컨테이너에서 특히 중요한 튜닝: 메모리 한도와 종료 전략
7.1 메모리 limit을 너무 낮추지 말고, 안정 구간을 찾기
네이티브라고 해서 무조건 128Mi 같은 극단적 제한이 가능한 건 아닙니다.
- 스레드 수가 많은 MVC 앱
- TLS를 많이 쓰는 게이트웨이
- 큰 JSON을 다루는 BFF
이런 서비스는 네이티브여도 메모리 피크가 큽니다. 먼저 512Mi 또는 1Gi에서 안정화한 뒤 내려가세요.
7.2 종료 시그널과 graceful shutdown
OOM Kill은 SIGKILL이라 graceful shutdown이 안 됩니다. 그래서 더더욱 “죽기 전에” 메모리 압박을 감지하고 부하를 줄이는 전략이 필요합니다.
- readiness를 내려서 트래픽 차단
- 큐 상한/백프레셔
- 레이트 리밋
8) 지연 최적화 실전 체크리스트(우선순위 순)
- 스레드/풀 상한 설정: 톰캣, Hikari, executor
- 큰 바디/버퍼 스트리밍화: 문자열/바이트 배열 한 방에 만들지 않기
- 자동 구성/빈 줄이기: 불필요한 스타터 제거
- 콜드 스타트 I/O 최소화: 외부 의존 초기화 지연 또는 분리
- 프로파일 기반(PGO) 적용 검토: P99 개선에 유효한 경우가 많음
- 관측 강화: OOM Kill인지 내부 OOM인지부터 분리
리눅스/컨테이너 레벨에서 OOM을 추적할 때는 다음 글의 절차를 그대로 따라가면 “추측”을 줄일 수 있습니다.
9) 마무리: 네이티브는 ‘작게’가 아니라 ‘예측 가능하게’
Spring Boot 3.2의 GraalVM 네이티브는 분명 강력하지만, 운영에서 중요한 건 절대적인 최소 메모리보다 예측 가능한 메모리 곡선과 안정적인 P99입니다.
- OOM은 대개 “스레드/큐/버퍼 무제한”에서 시작합니다.
- 지연은 “초기화 I/O”와 “할당률/GC/직렬화”에서 튑니다.
먼저 상한을 고정하고(동시성/큐/바디 크기), 그 다음에 빌드 최적화(PGO)와 구성 축소(AOT 친화)로 다듬으면 네이티브의 장점을 안전하게 가져갈 수 있습니다.