컨테이너 환경에서 Spring Boot 콜드 스타트 6가지 개선 전략

이번 포스팅에서는 Spring Boot 콜드 스타트 문제와 6가지 개선 전략을 정리하고자 한다. ECS Fargate에서 Spring Boot 서비스를 운영하다 보면, 오토스케일링이 걸릴 때마다 새 태스크가 트래픽을 받기까지 10초 넘게 걸리는 상황을 자주 겪게 된다. JVM 클래스 로딩, JIT 컴파일, Spring Context 초기화가 겹치면서 생기는 문제인데, 여기에 Fargate 특유의 이미지 풀 시간까지 더해지면 체감은 더 심하다. 이 글에서는 설정 한 줄로 끝나는 Lazy Initialization부터 CRaC, GraalVM Native Image, AWS SOCI까지 6가지 전략을 코드와 수치로 비교한다.

Spring Boot 콜드 스타트의 원인 — JVM이 컨테이너에서 유독 느린 이유

Spring Boot 콜드 스타트의 해결책을 보기 전에, cold start가 왜 이렇게 오래 걸리는지부터 짚어야 한다. 원인은 크게 세 가지이다.

JVM은 애플리케이션 시작 시 수천 개의 클래스를 로드하고 검증한다. 여기에 JIT 컴파일러가 바이트코드를 네이티브 코드로 변환하는 워밍업 구간이 추가된다. 그리고 Spring Framework의 Bean 스캐닝, 의존성 주입, Auto Configuration이 전부 런타임에 돌아간다.

ECS Fargate에서는 여기에 인프라 오버헤드까지 얹힌다. 태스크 프로비저닝, 컨테이너 이미지 풀, ENI 네트워크 연결을 합치면 인프라 레벨에서만 10~90초가 소요된다. 2025년 Prime Day 기간 ECS가 Fargate에서 하루 평균 1,840만 태스크를 실행했다는 Amazon의 수치를 생각하면, 태스크 하나당 몇 초의 차이가 전체 비용에 얼마나 영향을 주는지 감이 올 것이다.

일반적인 Spring Boot 애플리케이션의 cold start 시간 분포는 다음과 같다.

구간소요 시간
ECS 태스크 프로비저닝 + 이미지 풀10~60초
JVM 시작 + 클래스 로딩2~5초
Spring Context 초기화3~15초
JIT 워밍업 (첫 요청 지연)1~5초

이 각 구간을 줄이는 Spring Boot 콜드 스타트 개선 전략이 바로 이 글의 핵심이다. Spring Boot 성능 최적화 방법 6가지를 난이도 순서대로 정리한다.

한 줄이면 끝 — Lazy Initialization으로 시작 시간 깎기

Spring Boot 콜드 스타트 개선에서 제일 간단한 방법부터 시작한다. Lazy Initialization은 Spring Boot 2.2에서 도입되었으며, 모든 싱글톤 Bean의 생성을 실제 사용 시점까지 미룬다.

# application.yml
spring:
  main:
    lazy-initialization: true  # 모든 Bean을 lazy로 전환
YAML

이 한 줄이면 애플리케이션 시작 시점에 Bean을 만들지 않고, 실제 호출될 때 초기화한다. Bean이 수백 개인 서비스에서 시작 시 쓰이는 게 일부뿐이라면 효과가 꽤 크다.

다만 함정이 있다. 설정 오류나 의존성 문제가 시작 시점이 아니라 런타임에 터진다. 배포하고 며칠 뒤 특정 API를 호출했더니 Bean 생성 실패로 500이 나는 식이다. 전역 설정보다는 무거운 Bean에만 선택적으로 @Lazy를 거는 편이 안전하다.

import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

@Service
@Lazy // 이 Bean만 lazy 초기화 적용
public class HeavyReportService {

    private final ExternalApiClient apiClient;

    // 생성자 주입 — 이 Bean이 처음 호출될 때 비로소 ExternalApiClient도 함께 초기화
    public HeavyReportService(ExternalApiClient apiClient) {
        this.apiClient = apiClient;
    }

    public ReportResult generateReport(String reportId) {
        return apiClient.fetchAndProcess(reportId);
    }
}
Java

리포트처럼 호출 빈도가 낮은 서비스에 @Lazy를 걸어두면, 리포트 API가 실제로 호출되기 전까지 HeavyReportServiceExternalApiClient가 생성되지 않는다. 시작 시간은 줄지만, 첫 호출 때 Bean 생성 비용이 붙으므로 지연에 민감한 API에는 쓰지 않는 게 좋다.

빌드 타임에 일 시키기 — Spring AOT Processing

Spring Boot 콜드 스타트 개선에서 Lazy Init이 일을 뒤로 미루는 전략이라면, AOT(Ahead-of-Time) Processing은 빌드할 때 미리 다 해놓는 방식이다. Spring Boot 3.0부터 본격 지원되며, Bean 탐색, 조건 평가, 프록시 생성을 빌드 시점에 처리해서 런타임 부담을 줄인다.

Gradle 설정

// build.gradle
plugins {
    id 'org.springframework.boot' version '3.4.4'
    id 'org.graalvm.buildtools.native' version '0.10.6' // AOT 지원 포함
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

// processAot 태스크는 플러그인이 자동 등록하며,
// bootJar 빌드 시 AOT 메타데이터가 JAR에 자동 포함된다.
Groovy

org.graalvm.buildtools.native 플러그인을 추가하면 processAot 태스크가 빌드 라이프사이클에 자동 등록된다. 별도 설정 없이 bootJar 빌드만 돌리면 AOT 메타데이터가 JAR에 포함된다.

AOT 모드로 실행

# AOT 처리된 JAR 빌드
./gradlew bootJar

# AOT 모드 활성화하여 실행
java -Dspring.aot.enabled=true -jar build/libs/my-app.jar
ShellScript

-Dspring.aot.enabled=true가 핵심 플래그이다. 이 옵션을 켜면 Spring Boot가 런타임 클래스패스 스캐닝을 건너뛰고, 빌드 때 생성해둔 AOT 메타데이터를 바로 쓴다. 벤치마크 기준 시작 시간이 3초에서 0.3초로 약 10배 줄어든다.

Dockerfile에서 AOT 적용

FROM eclipse-temurin:21-jre-alpine AS runtime

WORKDIR /app
COPY build/libs/my-app.jar app.jar

# AOT 모드로 시작 — 런타임 리플렉션 최소화
ENTRYPOINT ["java", "-Dspring.aot.enabled=true", "-jar", "app.jar"]
Dockerfile

AOT가 적용된 JAR를 eclipse-temurin:21-jre-alpine 위에서 돌리는 구성이다. JDK 대신 JRE 전용 alpine 이미지를 쓰는 것만으로 이미지 크기가 300MB 넘게 줄어든다.

클래스를 공유하면 빨라진다 — CDS와 AppCDS

CDS는 JVM이 클래스 메타데이터를 공유 아카이브 파일로 저장해두고, 다음 시작 때 디스크에서 메모리로 직접 매핑하여 클래스 로딩 시간을 줄이는 기술이다. Spring Boot 3.3에서 CDS 지원이 강화되었고, 코드 변경 없이 Dockerfile만 수정하면 되기 때문에 Spring Boot 콜드 스타트 개선에서 가성비가 가장 좋다.

훈련 실행으로 아카이브 생성

# 1단계: 훈련 실행 — Spring Context가 초기화된 뒤 자동 종료
java -Dspring.context.checkpoint=onRefresh \
     -XX:ArchiveClassesAtExit=app-cds.jsa \
     -jar my-app.jar

# 2단계: 아카이브를 사용하여 빠르게 시작
java -XX:SharedArchiveFile=app-cds.jsa \
     -jar my-app.jar
ShellScript

1단계에서 -Dspring.context.checkpoint=onRefresh는 Spring Context가 초기화를 마치면 앱을 자동 종료시킨다. 이때 로드된 클래스가 전부 app-cds.jsa에 기록된다. 2단계에서 이 아카이브를 지정하면 JVM이 클래스를 파일에서 바로 메모리 매핑하므로 로드 시간이 크게 줄어든다. 실측 기준 시작 시간이 2.9초에서 1.6초로 약 45% 개선된다.

멀티스테이지 Dockerfile

# --- 훈련 단계: CDS 아카이브 생성 ---
FROM eclipse-temurin:21-jdk-alpine AS training

WORKDIR /app
COPY build/libs/my-app.jar app.jar

# Spring Context 초기화 후 자동 종료하며 클래스 아카이브 기록
RUN java -Dspring.context.checkpoint=onRefresh \
         -XX:ArchiveClassesAtExit=app-cds.jsa \
         -jar app.jar

# --- 실행 단계: 아카이브 적용 ---
FROM eclipse-temurin:21-jre-alpine AS runtime

WORKDIR /app
COPY --from=training /app/app.jar app.jar
COPY --from=training /app/app-cds.jsa app-cds.jsa

# 공유 아카이브를 사용하여 클래스 로딩 시간 단축
ENTRYPOINT ["java", "-XX:SharedArchiveFile=app-cds.jsa", "-jar", "app.jar"]
Dockerfile

빌드 이미지에서 훈련 실행을 돌리고, 생성된 CDS 아카이브를 런타임 이미지로 복사하는 구조이다. 훈련에는 JDK가 필요하지만 실행은 JRE면 충분하다. 참고로 Java 24부터는 JEP 483의 AOT Cache가 CDS를 대체하므로, 새 프로젝트라면 AOT Cache 쪽을 먼저 검토해볼 만하다.

워밍업된 JVM을 통째로 얼리기 — CRaC

Spring Boot 콜드 스타트를 극적으로 줄이는 CRaC(Coordinated Restore at Checkpoint)는 접근 방식 자체가 다르다. 실행 중인 JVM의 메모리 상태를 통째로 디스크에 저장(체크포인트)하고, 이후 그 스냅샷에서 복원하여 밀리초 단위로 시작한다. JIT 컴파일까지 끝난 따뜻한 상태가 그대로 살아나기 때문에, 첫 요청부터 최적 성능이 나온다.

의존성 추가

<!-- pom.xml -->
<dependency>
    <groupId>org.crac</groupId>
    <artifactId>crac</artifactId>
    <version>1.5.0</version>
</dependency>
XML

org.crac 패키지는 체크포인트/복원 시점에 리소스를 정리하고 재연결하는 콜백 인터페이스를 제공한다.

CRaC 리소스 관리 예시

import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
import org.springframework.stereotype.Component;

import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;

@Component
public class DataSourceCracResource implements Resource {

    private final DataSource dataSource;

    public DataSourceCracResource(DataSource dataSource) {
        this.dataSource = dataSource;
        // CRaC 글로벌 컨텍스트에 이 리소스 등록
        Core.getGlobalContext().register(this);
    }

    @Override
    public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
        // 체크포인트 직전: 커넥션 풀을 일시 중지하고 기존 커넥션 제거
        // close()를 호출하면 HikariCP가 완전 종료되어 복원 시 재사용 불가
        if (dataSource instanceof HikariDataSource hikari) {
            hikari.getHikariPoolMXBean().suspendPool();
            hikari.getHikariPoolMXBean().softEvictConnections();
        }
    }

    @Override
    public void afterRestore(Context<? extends Resource> context) throws Exception {
        // 복원 직후: 커넥션 풀 재개 — 새 환경에서 커넥션이 다시 생성된다
        if (dataSource instanceof HikariDataSource hikari) {
            hikari.getHikariPoolMXBean().resumePool();
        }
    }
}
Java

위 코드는 CRaC의 Resource 인터페이스를 구현하여 HikariCP 커넥션 풀을 관리하는 예시이다. beforeCheckpoint에서 풀을 일시 중지(suspend)하고 기존 커넥션을 제거한 뒤, afterRestore에서 풀을 재개(resume)하는 것이 핵심이다. close()를 호출하면 HikariCP가 완전 종료되어 복원 시 재사용이 불가능하므로, 반드시 suspend/resume 패턴을 사용해야 한다. Spring Boot 3.2 이상에서는 DataSource를 포함한 주요 리소스를 자동으로 처리하지만, 커스텀 리소스는 이처럼 직접 관리해야 한다.

자동 체크포인트 모드 설정

# CRaC 지원 JDK로 체크포인트 생성 (Azul Zulu 또는 BellSoft Liberica)
# -Dspring.context.checkpoint=onRefresh로 Context 초기화 완료 시 자동 체크포인트
java -XX:CRaCCheckpointTo=/checkpoint \
     -Dspring.context.checkpoint=onRefresh \
     -jar my-app.jar

# 체크포인트에서 복원 — 50ms 이내에 시작
java -XX:CRaCRestoreFrom=/checkpoint
ShellScript

위 명령에서 -Dspring.context.checkpoint=onRefresh는 Spring Context의 refresh() 완료 시점에 자동으로 체크포인트를 생성하는 시스템 프로퍼티이다. -XX:CRaCCheckpointTo 플래그로 체크포인트를 저장할 디렉토리를 지정하고, -XX:CRaCRestoreFrom으로 해당 체크포인트에서 복원한다. 실측 기준 시작 시간이 5초에서 50밀리초로 100배 단축된다.

CRaC 적용 Dockerfile

FROM azul/zulu-openjdk:21-crac AS builder

WORKDIR /app
COPY build/libs/my-app.jar app.jar

# 체크포인트 생성 — docker build --privileged 필요 (CRIU 권한)
RUN java -XX:CRaCCheckpointTo=/checkpoint \
         -Dspring.context.checkpoint=onRefresh \
         -jar app.jar

# --- 실행 이미지 ---
FROM azul/zulu-openjdk:21-crac

WORKDIR /app
COPY --from=builder /app/app.jar app.jar
COPY --from=builder /checkpoint /checkpoint

# 체크포인트에서 복원하여 밀리초 단위 시작
ENTRYPOINT ["java", "-XX:CRaCRestoreFrom=/checkpoint"]
Dockerfile

azul/zulu-openjdk:21-crac는 CRaC를 지원하는 Azul Zulu JDK 이미지이다. 빌드 단계에서 체크포인트를 생성하고 실행 이미지로 복사한다. 한 가지 주의할 점은 체크포인트 생성 시 Linux CRIU 권한이 필요하다는 것이다. docker build --privileged 플래그나 SYS_PTRACE capability를 줘야 빌드가 통과한다.

네이티브로 가는 길 — GraalVM Native Image

Spring Boot 콜드 스타트를 근본적으로 해결하는 GraalVM Native Image는 자바 애플리케이션을 JVM 없이 실행 가능한 네이티브 바이너리로 컴파일한다. 시작 시간이 밀리초 단위로 떨어지고, 메모리 사용량도 JVM 대비 5분의 1 수준이다.

Gradle 빌드 설정

// build.gradle
plugins {
    id 'org.springframework.boot' version '3.4.4'
    id 'org.graalvm.buildtools.native' version '0.10.6'
}

graalvmNative {
    binaries {
        main {
            // 빌드 시 메모리 충분히 확보 (최소 8GB 권장)
            buildArgs.add('-H:+ReportExceptionStackTraces')
            // 빠른 빌드를 위한 최적화 레벨 조정
            buildArgs.add('-O1')
        }
    }
}
Groovy

-O1은 빌드 시간과 런타임 성능 사이에서 타협한 옵션이다. 프로덕션에서는 -O2를 쓰되, 빌드가 10~15분까지 늘어나는 점은 감안해야 한다.

네이티브 이미지 빌드 및 실행

# GraalVM Native Image 빌드 (10~15분 소요)
./gradlew nativeCompile

# 네이티브 바이너리 직접 실행 — JVM 불필요
./build/native/nativeCompile/my-app
ShellScript

빌드된 바이너리는 JVM 없이 단독으로 실행된다. 실측 기준 시작 시간 0.065초(JVM의 2.8초 대비 43배), 메모리 62MB(JVM 280MB 대비 78% 절감)이다.

네이티브 이미지용 경량 Dockerfile

# --- 빌드 단계 ---
FROM ghcr.io/graalvm/native-image-community:21 AS builder

WORKDIR /app
COPY . .
RUN ./gradlew nativeCompile

# --- 실행 단계: JVM도 JRE도 불필요 ---
FROM alpine:3.20

WORKDIR /app
# 네이티브 바이너리와 필수 라이브러리만 복사
COPY --from=builder /app/build/native/nativeCompile/my-app my-app

# libc 호환성을 위한 gcompat 설치
RUN apk add --no-cache gcompat

EXPOSE 8080
ENTRYPOINT ["./my-app"]
Dockerfile

실행 이미지에 JVM이 아예 없다. 바이너리와 OS 기본 라이브러리만 들어가므로 최종 이미지가 50~80MB 선이다. 이미지가 작으면 ECS 태스크 프로비저닝 시 풀 시간도 같이 줄어든다.

트레이드오프도 분명히 있다. 리플렉션 쓰는 라이브러리는 힌트 설정을 일일이 잡아줘야 하고, 빌드가 10~15분 걸린다. JIT 컴파일이 없으므로 장시간 도는 워크로드에서는 JVM 대비 throughput이 낮을 수 있다는 점도 고려해야 한다.

코드 변경 없이 Spring Boot 콜드 스타트 줄이기 — 인프라 레벨 전략

여기서부터는 애플리케이션 코드를 손대지 않고 인프라 설정만으로 Spring Boot 콜드 스타트를 줄이는 전략을 알아보자. Spring Boot 성능 최적화를 달성하는 방법이다.

SOCI — 이미지를 다 받기 전에 시작하기

Seekable OCI(SOCI)는 AWS Fargate에서 지원하는 지연 로딩 기술로, 컨테이너 이미지 전체를 다운로드하지 않고 필요한 레이어만 먼저 가져와 애플리케이션을 시작한다.

# SOCI 인덱스 생성 — ECR에 이미지 푸시 후 실행
# AWS CLI와 SOCI CLI 설치 필요
soci create ${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/my-app:latest

# 생성된 SOCI 인덱스를 ECR에 푸시
soci push ${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/my-app:latest
ShellScript

SOCI 인덱스를 만들어두면, Fargate가 이미지를 전부 받지 않고 필요한 파일만 온디맨드로 가져온다. AWS 테스트 기준 이미지 풀 시간이 50~60% 줄어들며, 500MB 넘는 이미지일수록 효과가 크다.

이미지 경량화와 zstd 압축

# 경량 이미지 사용으로 풀 시간 최소화
FROM eclipse-temurin:21-jre-alpine
# alpine 기반: ~180MB vs ubuntu 기반: ~400MB

# 불필요한 파일 제거
COPY --from=builder /app/build/libs/my-app.jar app.jar

# .dockerignore로 빌드 컨텍스트 최소화
Dockerfile
# zstd 압축으로 이미지 빌드 (Docker BuildKit 필요)
DOCKER_BUILDKIT=1 docker build \
  --output type=image,compression=zstd \
  -t my-app:latest .
ShellScript

alpine 기반 JRE 이미지를 쓰면 크기가 절반 이하로 줄고, zstd 압축까지 적용하면 전송 시간이 27% 더 단축된다. 스케일 아웃이 잦은 서비스라면 이 차이가 누적되면서 꽤 체감된다.

EC2 기반 ECS — Warm Pool 활용

Fargate 대신 EC2 기반 ECS를 사용한다면, Auto Scaling Warm Pool로 사전 초기화된 인스턴스를 대기시킬 수 있다.

{
  "AutoScalingGroupName": "ecs-asg-my-cluster",
  "MinSize": 2,
  "MaxGroupPreparedCapacity": 5,
  "PoolState": "Stopped",
  "InstanceReusePolicy": {
    "ReuseOnScaleIn": true
  }
}
JSON

최대 5개의 EC2 인스턴스를 정지 상태로 미리 준비해둔다. 스케일 아웃 이벤트가 발생하면 새 인스턴스를 부팅하는 대신 Warm Pool에서 꺼내 바로 활성화한다. ReuseOnScaleIn: true를 켜면 스케일 인 때도 인스턴스를 죽이지 않고 Warm Pool로 돌려보낸다.

Spring Boot 콜드 스타트 개선 전략 비교 — 시나리오별 추천 조합

Spring Boot 콜드 스타트 개선을 위한 6가지 전략을 한눈에 비교하면 다음과 같다.

전략시작 시간 개선메모리 절감적용 난이도트레이드오프
Lazy Initialization20~30%약간매우 쉬움런타임 에러 지연 발견
Spring AOT~90% (10배)10~20%보통리플렉션 제한
CDS/AppCDS~45%10~15%보통빌드 파이프라인 변경
CRaC~99% (100배)없음어려움Linux 전용, CRIU 권한
GraalVM Native~98% (43배)~78%어려움빌드 시간 15분, 라이브러리 호환성
SOCI + 이미지 최적화50~60% (이미지 풀)없음쉬움Fargate 전용

상황별로 어떤 조합이 맞을까

당장 코드 변경 없이 효과를 보고 싶다면 Lazy Init + CDS + 이미지 경량화 조합이 현실적이다. CI/CD 파이프라인에 CDS 훈련 단계만 추가하면 시작 시간을 50% 이상 줄일 수 있다.

Fargate에서 오토스케일링을 자주 쓴다면 SOCI + Spring AOT + alpine 이미지 조합이 맞다. 이미지 풀과 애플리케이션 시작 양쪽을 동시에 잡을 수 있고, 코드 변경도 거의 없다.

시작 속도가 최우선이라면 CRaC를 검토한다. CRaC 지원 JDK(Azul Zulu 또는 BellSoft Liberica)와 Linux 환경이 필요하지만, JIT 워밍업 상태까지 복원되므로 첫 요청부터 최대 성능이 나온다.

AWS Lambda나 Fargate Spot처럼 콜드 스타트가 수시로 발생하는 환경에서는 GraalVM Native Image가 가장 적합하다. 시작 시간과 메모리가 둘 다 줄어들기 때문에 비용 절감 효과도 크다.

마치며

지금까지 Spring Boot 콜드 스타트 해결을 위한 6가지 전략 6가지를 단계별로 정리해 보았다.

실무에서 가장 체감이 컸던 조합은 CDS + 이미지 경량화였다. 코드를 한 줄도 건드리지 않고 Dockerfile 수정만으로 시작 시간이 절반 가까이 줄었고, CI/CD 파이프라인에 훈련 단계를 추가하는 것도 30분이면 끝났다. CRaC는 효과가 압도적이지만 CRIU 권한 문제로 ECS Fargate에서 바로 쓰기 어려웠고, EC2 기반 ECS에서 먼저 검증한 뒤 단계적으로 도입하는 편이 안전했다. GraalVM Native Image는 새로 시작하는 마이크로서비스에 적용했을 때 만족스러웠지만, 이미 운영 중인 대규모 애플리케이션에 적용하려면 리플렉션 힌트 작업이 상당하므로 비용 대비 효과를 먼저 따져봐야 한다.

어떤 전략이 “정답”인지는 서비스마다 다르다. 스케일링이 얼마나 자주 일어나는지, 몇 초까지 허용 가능한지, 팀이 CRaC나 GraalVM을 운영할 역량이 되는지를 따져보고 골라야 한다.

참고 자료