안녕하세요,
라포랩스 Server Platform Team의 Server Engineer 하성준입니다.
Kubernetes 환경에서 Spring Boot Application을 운영하다 보면 아래와 같은 상황들을 자주 마주하게 됩니다.
상황 1.
갑작스러운 트래픽 증가로 HPA가 Scale Out을 시작했습니다. 새로운 Pod들이 뜨기 시작했지만 부트업 속도가 너무 느립니다. 개발자는 발만 동동 구르고 있고, 그 사이 유저들은 에러 화면을 만납니다.상황 2.
Engineer C 씨는 Spring Application으로 Kubernetes CronJob을 만들어서 운영 중입니다. 1분마다 돌아야 하는 작업인데, pod가 뜨는데만 60초가 걸립니다. 결국 C씨는 CronJob을 없애고 서버 Pod에 비동기 스레드를 하나 만듭니다.상황 3.
DevOps K 씨는 비용 절감을 위해 Karpenter를 사용하고, 일부 노드를 Spot Instance로 운영 중입니다. 새로운 노드에 Pod가 이주될 때마다 부트업이 너무 느려서, 순간적으로 트래픽을 받는 Pod 수가 줄어듭니다. 심할 때는 장애가 발생하기도 합니다.
위 상황들, 혹시 한 번쯤은 겪어보지 않으셨나요?
라포랩스도 Spring의 느린 Startup으로 인해 같은 어려움을 겪었습니다. 느린 Startup Time은 단순한 개발 편의성을 넘어 UX와 서비스 가용성에 직접 영향을 주는 문제라고 판단했고, 다양한 방법으로 최적화를 시도했습니다.
Spring Boot Application의 Startup Time, 그래서 어디까지 줄일 수 있을까요?
90초에서 31초로, 66%를 줄여낸 6단계 최적화 과정을 공유합니다.
Spring Boot Startup 과정, 한 섹션 요약
Spring Boot가 시작될 때 내부에서는 property 로딩, Bean 인스턴스 생성, 웹서버 준비 등 수많은 작업이 실행됩니다. 중요한 점은, 이 모든 과정이 Main Thread 하나에서 순차적으로 실행된다는 것입니다.
여러 작업 중, 하나라도 블로킹되면 전체 부팅이 지연됩니다. 따라서, 다양한 도구와 지표를 통해 정확히 어떤 구간이 오래 걸리는지 파악하는 것이 핵심입니다.
Startup 과정을 분석하기 위한 도구들
1. Async Profiler
Async Profiler는 Java(JVM) Application의 상태를 분석하는 Low-overhead 샘플링 프로파일러입니다. 다양한 Profiling mode를 지원하는데, 그 중 Wall-clock profiling 모드로 부팅 과정을 프로파일링하기로 했습니다.
Wall-clock 모드는 스레드 상태(Running, Sleeping, Blocked)에 관계없이 모든 스레드의 stackTrace를 일정 주기마다 동일하게 샘플링합니다. 따라서 I/O 대기나 락 대기까지 포함한 전체 실행 흐름에서 어떤 메서드가 시간을 잡아먹는지 분석할 수 있습니다.
2. Flame graph
Async Profiler는 수집한 stackTrace 샘플들을 Flame Graph 형태로 시각화 해줍니다.
Flame Graph의 X축은 시간 비율을, Y축은 호출 스택을 나타내는데요. 예를 들어 아래와 같은 flame graph가 있다고 하면,
A 함수의 대부분의 시간은 B와 C를 호출하는데 쓰였음을 알 수 있습니다. 단, 샘플의 배치 순서가 호출 순서는 아닙니다.
AsyncProfiler는 캡처 시점의 모든 스레드를 샘플링하기 때문에 수백 개의 스레드가 한꺼번에 표시됩니다. 실제 운영 중인 서버를 프로파일링 해보면 아래 사진처럼 매우 복잡한데요, 그렇기 때문에 Spring 초기화가 진행되는 "main" 스레드를 필터링하면 부팅 관련 스택만 추출할 수 있습니다.
3. BufferingApplicationStartup
BufferingApplicationStartup은 Spring Boot 2.4에서 도입된 ApplicationStartup 구현체로, 애플리케이션 시작 과정의 각 단계를 메모리에 버퍼링하여 기록합니다. 버퍼링된 결과는 /actuator/startup 엔드포인트를 활성화하여 HTTP로 조회하거나, BufferingApplicationStartup을 Spring 컴포넌트에 주입받아 getBufferedTimeline()으로 직접 꺼낼 수도 있습니다.
버퍼링 결과는 각 단계별 소요 시간, 자신에게 의존하는 부모 단계의 id 등이 포함된 데이터의 배열로 구성되어 있습니다. 소요 시간에는 자기 자신의 초기화 로직 외에, 의존하는 단계를 기다린 시간도 포함되기 때문에, 순수하게 해당 단계에서 소비하는 시간을 구하려면 자식 단계의 소요 시간을 빼야 합니다.
한눈에 보기 좋은 형태의 정보는 아니기 때문에 Claude를 통해, 간단한 시각화 툴을 만들고 이를 통해 분석하기로 했습니다.
이제 준비된 도구들과 함께, Spring Boot Application의 Start up 과정을 자세히 분석하고, 개선하는 과정을 살펴보도록 하겠습니다.
당시 서버들의 상태
Spring Boot Actuator는 기동 직후부터 ApplicationReadyEvent 까지의 시간을 나타내는 application_ready_time_seconds 메트릭을 제공하는데요, 이를 통해 Startup Time을 확인한 결과 당시 상황은 다음과 같았습니다.
Pod 간 극심한 Startup Time 차이
동일한 애플리케이션, 동일한 이미지인데도 Pod마다 Startup Time이 크게 달랐습니다. 빠른 Pod는 55초, 느린 Pod는 130초 이상이 걸렸습니다.
평균 부팅 시간 과다
Pod 간 차이를 차치하더라도, 평균 시간 자체가 약 90초로 너무 오래 걸렸습니다.
1. Pod 간 편차의 원인을 찾다: 초기화 시점의 외부 API 호출
먼저 극심한 Startup Time 편차의 원인을 파악하기로 했습니다. BufferingApplicationStartup의 결과를 활용해 빠른 Pod와 느린 Pod의 단계별 소요 시간을 비교해 보니, outboxingKafkaDomainEventPublisher 빈 생성 과정이 편차의 가장 큰 원인임을 확인할 수 있었습니다.
Flame Graph에서, 해당 bean의 생성 과정을 분석한 결과, 느린 Pod에서 __libc_poll 샘플이 비정상적으로 많다는 것을 발견했습니다. __libc_poll은 Linux의 poll() 시스템 콜로, 파일 디스크립터(fd)의 이벤트를 대기하는 syscall입니다. 즉, I/O 대기를 하는 샘플이 많다는 뜻인데, 호출부가 NioSocketImpl 이므로, 이는 네트워크 호출임을 알 수 있습니다.
해당 Bean 생성자의 호출 체인을 추적하자 원인이 아래처럼 두 가지로 명확해졌습니다.
동기 블로킹 호출 — 자신이 사용할 Kafka Connector 조회 및 생성 API 호출이 Main Thread에서 동기적으로 실행되고 있었습니다. 배포 과정에서 여러 Pod들이 동시에 Connector 생성 요청을 보내면 Kafka Connect 서버에 부하가 걸려 Latency가 증가했습니다.
RetryTemplate — 이 호출에는 RetryTemplate이 걸려 있어, 늘어난 latency로 인해 readTimeout으로 실패하면 대기→재시도→재실패가 반복되는 사이클로 빠졌습니다.
해결 방안
Main Thread 블로킹을 제거하기 위해, Connector 등록을 아래처럼 fire-and-forget 방식으로 변경했습니다.
이 Bean은 사내 라이브러리에 구현되어 있던 Outbox 패턴을 지원하는 메시지 퍼블리셔입니다. 여기서 Kafka Connector 등록이 실패하더라도, 실제 서비스가 하는 일은 DB에 write 하는 것뿐이라 서비스가 동작하는 데는 영향이 없습니다. Connector 등록 실패는 알람/노티를 통해 추후에 인지하고 재처리하면 되는 문제였습니다.
해당 코드를 배포한 결과 Startup Time 시간의 분산이 크게 줄어 안정성이 향상되었습니다. 120초를 넘는 극단적인 케이스가 완전히 사라졌습니다.
2. Bytecode 변환이 많은 시간을 잡아먹고 있었다: 불필요한 Java Agent 제거
Pod간 편차가 많이 줄었으니, 이제 전반적으로 느린 이유를 확인하기로 했습니다. 복잡한 Flame Graph를 아무 전략 없이 뜯어보는 것은 모래사장에서 바늘 찾기와 같습니다.
총 두 가지 접근법을 정하고, 하나씩 시도해 보기로 했습니다.
Bottom-up
Main Thread 아래 모든 Leaf 샘플들의 분포 경향을 파악하여, 전반적으로 어디에 시간을 많이 쓰는지 확인
Top-down
BufferingApplicationStartup를 통해, 생성하는데 오래걸리는 Bean을 하나 정하고, 해당 Bean의 생성 함수를 Flame Graph로 분석하여 병목을 탐지
먼저 Bottom-up 방식으로 Main Thread의 Leaf 샘플을 분석한 결과, 전체 샘플의 약 30%가 TransformerManager.transform에 집중되어 있었습니다.
TransformerManager.transform은 JVM이 클래스를 로딩할 때 InstrumentationImpl로부터 위임받아, 등록된 ClassFileTransformer를 순회하며 바이트코드를 변환하는 bytebuddy 라이브러리의 메서드입니다. 보통 Java Agent가 바이트코드 변환을 할 때 호출됩니다.
라포랩스에서는 Datadog Agent, OTEL Agent, Scavenger Agent, 총 3개의 java agent를 활용하고 있는데요, 바이트코드 변환 샘플 중 약 70%가 Scavenger Agent에서 발생하고 있었습니다.
Scavenger는 dead code를 탐지하기 위한 Java Agent인데요, 클래스 로딩마다 바이트코드 변환을 수행하여 부팅을 지연시키고 있었습니다.
당시 라포랩스에서는 대부분 Scavenger를 유의미하게 활용하지 않았기 때문에, Scavenger Agent의 비활성화를 기본으로 하고, 필요 시 옵셔널로 활성화할 수 있도록 변경했습니다.
이렇게 Scavenger Agent를 비활성화하자, 평균 Startup Time이 10초 줄어들었습니다.
3. 생각보다 미미했던 일반적인 최적화
시도 1, 2 이후 프로파일을 다시 분석했습니다. 이전과 달리 Leaf 샘플에서, 특정 과다 샘플 없이 모든 샘플이 고르게 분포되어 있었고, 뚜렷한 핫스팟이 보이지 않는 상황이었습니다.
딱히 눈에 띄는 지점이 없었기에, 불필요한 Auto Configuration들을 제거하고, Entity Scan 범위를 최적화하는 등, 일반적으로 알려진 방법들을 시도했고, 결과는 1~2초 감소로 미미한 수준이었습니다.
Bottom-Up으로는 더 이상 의미 있는 인사이트가 나오지 않아, Top-Down으로 전환했습니다. 초기화가 오래 걸리는 Bean을 먼저 식별하고, 소스 코드로 초기화 로직을 파악한 뒤, 그 결과를 가지고 Flame Graph를 정밀하게 drill down하는 순서입니다.
4. EntityManagerFactory 초기화 최적화
BufferingApplicationStartup 결과에서, 가장 눈에 띄게 느린 부분은entityManagerFactory(이하 EMF)였습니다(약 6.5초). Spring 소스코드와, Flame Graph를 통해, EMF 초기화의 내부 비용을 분해해 보면 다음과 같습니다.
특정 부분이 느리다기보다는, 대부분 Entity와 Repository가 많아지면 자연스럽게 늘어나는 EMF 자체의 초기화 비용입니다. Spring과 JPA를 사용한다면, 서비스가 커지면서 자연스럽게 겪을 수밖에 없는 문제입니다.
그렇다면 이 문제는 우리만 겪는 것일까요?
그 답을 찾기 위해 리서치를 시작했습니다.
해결 방안: EntityManagerFactory 비동기 초기화와 JPA의 bootstrapMode: DEFERRED
여러 아티클과 Spring 공식 문서를 통해 몇 가지 기능을 발견했습니다.
첫 번째: EMF 초기화를 별도 Thread에서 실행할 수 있습니다.
entityManagerFactoryBean에bootstrapExecutor를 설정하면, EMF 초기화가 별도의 Thread Pool에서 실행됩니다. Main Thread는 EMF 완료를 기다리지 않고 다른 Bean 초기화를 계속 진행할 수 있습니다.
두 번째: JPA Bootstrap Mode를 통해, Repository 를 Lazy처리할 수 있습니다. (
bootstrapMode: DEFERRED).EMF를 별도 Thread로 분리해도, Repository Bean이 초기화될 때 EMF를 필요로 하므로 결국 Main Thread가 EMF 완료를 기다리며 블로킹됩니다.
DEFERRED 모드는 Repository Bean에 Lazy Proxy를 주입하여 이 블로킹을 방지합니다. Lazy 처리가 되지만 Application Ready 전에 초기화가 완료됨을 보장하기에, cold start 문제가 없습니다.
부푼 희망을 안고 테스트했지만, 결과는 약 2초 감소에 그쳤습니다.
혹시 제대로 적용이 안 된 건지 Flame graph로 확인해 봤지만, EMF 초기화가 jpa-bootstrap-1 이라는 별도의 thread 하위에서 수행되는 것을 보아, 옵션은 잘 동작하는 것 같았습니다.
EMF 초기화에 6.5초가 걸리는 것을 확인했기 때문에, 비동기로 전환하면 이론상 6.5초가 단축되어야 하는데 왜 2초밖에 줄지 않았을까요? 원인을 파악하기 위해 프로파일링을 다시 돌렸습니다.
5. Spring Data의 버그 발견(feat. 오픈소스 기여)
Flame Graph로 main thread 샘플을 다시 분석해 본 결과, DEFERRED mode와 EMF bootstrapExecutor를 설정한 후에도, main thread에서 여전히 getNativeEntityManagerFactory함수를 통해, EMF를 참조하는 호출이 있고, 이 때문에 Main Thread에서 EMF 완료를 대기하는 샘플이 발견되었습니다.
해당 함수의 대부분을 FutureTask.get → LockSupport.park 가 차지하고 있는데, 이는 다른 thread의 작업 완료를 기다리고 있음을 나타냅니다
이를 해결하기 위해, DEFERRED mode가 어떻게 동작하는지 소스 코드를 분석했습니다.
EMF를 별도로 지정한 executor에서 초기화 되도록 위임하고, 그동안 main thread는 다른 bean들을 처리합니다.
DEFERRED mode가 활성화 되었기 때문에, Repository에 lazy proxy를 주입하고 실제 초기화는 하지 않습니다.
이후 Context Refresh가 끝나면, Spring Data의
DeferredRepositoryInitializationListener가ContextRefreshedEvent를 받아 Repository 타입의 Bean들을 의도적으로 참조함으로써 초기화를 일으킵니다.이때는 이미 EMF가 초기화가 되었으므로, 별도의 블로킹이 없습니다.
DeferredRepositoryInitializationListener 에서 event를 처리하는 함수에 Break Point를 찍고 디버그 모드로 실행해 보니, 문제가 명확히 드러났습니다.
문제 1: Feign Client의 Child Context
Feign Client는 내부적으로 Child Context를 생성합니다. 문제는 이 Child Context도 ContextRefreshedEvent를 발생시키고, 앞서 봤던 DeferredRepositoryInitializationListener가 Child Context의 이벤트에도 반응한다는 것이었습니다. 결과적으로 의도치 않은 시점에 Repository 초기화가 이루어졌고, 이 시점에 EMF가 아직 초기화 중이라면 Main Thread가 이를 기다려야 했습니다.
실제로, 부팅 과정 중 한 번만 멈춰야 할 것 같은 Break Point가 여러 번 멈췄고, 각 순간의 Application Context를 확인해 보면 Feign이 생성한 Child Context임을 알 수 있었습니다.
해결: Spring Data 버그를 임시 패치하고, 오픈소스 기여까지
Child Context 이벤트 문제를 해결하기 위해, DeferredRepositoryInitializationListener와 같은 빈 이름으로 No-op 구현체를 먼저 등록하고, Child Context 이벤트에는 반응하지 않도록 개선한 커스텀 Listener를 따로 등록했습니다. 원본 Bean 등록 시 같은 이름의 no-op bean이 이미 존재하므로 스킵되고, 커스텀 Listener가 대신 동작합니다.
동시에 spring-data-commons에 이슈를 제보하고 문제를 수정하여 PR을 생성 했습니다. PR은 main branch에 머지되었으며, v3.5.10부터 공식 적용될 예정입니다. 우선 임시 패치로 이 문제를 회피하고, spring-data-commons 정식 릴리즈 후에 임시 패치를 제거하고 Spring Boot 버전업으로 대체할 계획입니다.
Spring Data Commons 3.5.10 Release Note
문제를 해결했다는 기쁨도 잠시, 여전히 Flame graph 결과엔, Main Thread 블로킹이 발생하고 있었습니다. 다음 범인은 jpaMappingContext였습니다.
jpaMappingContext는 Repository 초기화 시 사용되는데, DEFERRED 모드가 활성화 됨에도 불구하고, 이 bean이 main thread를 blocking 하는 것이 이상했습니다. 원인을 찾기 위해 jpaMappingContext 를 의존하는 bean들을 모두 찾아보기로 했습니다.
bean이 초기화될 때, spring은 의존하는 bean들을 getBean함수로 가져옵니다. 따라서 getBean 내부에 break point 찍고, 요구하는 bean의 이름이jpaMappingContext 일 때만 멈추도록 조건을 설정한 뒤, stack trace 따라 올라가면 상위 스택에서 어떤 bean을 만들고 있는지 볼 수 있습니다.
상위 스택에서, 대부분 repository들은 아래 사진처럼 proxy 처리되어 이름 앞에 ‘&’ 가 붙어있는 것을 볼 수 있었지만, 일부 Repository 들은 ‘&’ 가 없는 것을 볼 수 있었습니다. DEFERRED 모드를 활성화 했음에도 불구하고 proxy처리가 되지않은 Repository들이 범인이었습니다.
문제 2: Lazy Proxy 주입 누락
Proxy 처리되지 않은 repository들을 모두 나열하고, Spring 소스 코드를 분석하여 왜 해당 Repository들은 처리가 안되는지 확인했습니다.
DEFERRED 모드는 LazyRepositoryInjectionPointResolver를 통해 lazy 처리할 대상 Repository를 찾아내는데, 로직 분석 결과 라포랩스에서 사용하는 두 가지 패턴들이 대상에 걸리지 않음을 알게 되었습니다.
CrudRepository를 사용하는 경우다중 인터페이스를 상속하는 경우 — 아래 예시처럼 Repository 선언은
JpaRepository를 상속하지만, 주입하는 쪽에서는 다른 타입으로 주입하면 감지하지 못했습니다.
해결
JpaRepository가CrudRepository의 기능을 모두 포함하고 있고, 사내 컨벤션에서도 JpaRepository를 표준으로 사용하고 있어,CrudRepository를 모두JpaRepository로 변경했습니다.다중 인터페이스 상속은 과거 Hexagonal Pattern 적용을 위해 도입했으나, 현재는 해당 컨벤션이 유지되지 않아 형식만 남은 레거시 코드였습니다. 따라서 해당 구조를 제거하고
JpaRepository를 직접 주입받도록 변경했습니다.
위 해결책을 코드에 반영하고 배포한 결과, 전반적으로 약 15초의 감소 효과가 있었습니다.
6. 네? EntityManagerFactory를 직접 주입 받는다고요? 😇
이게 진짜 마지막이라 생각했지만, Flame Graph에는 여전히 EMF 때문에 Main Thread가 블로킹되는 샘플이 남아 있었습니다. 다행히 원인은 빠르게 찾았습니다. 시도 1에서 Pod 간 편차의 주범이었던 사내 라이브러리의 DomainEventPublisher가 또다시 범인이었습니다.
생성자에서 CDC 대상이 되는 DB 테이블을 생성하는 로직이 있었고 이를 위해 EMF를 직접 주입받아 사용 중이었습니다.
해당 Bean 을 만드는 시점에 EMF를 기다리며 Main Thread가 블로킹되었습니다.
Kafka Connect API 호출을 fire-and-forget으로 바꿨던 것과 달리, DB 테이블은 Application이 트래픽을 받으면 바로 사용해야 하므로 단순한 fire-and-forget이 불가능했습니다.
해결 방안
두 가지 해결책을 검토했습니다.
방법 1: SmartInitializingSingleton — 이 인터페이스를 상속받은 Bean은 afterSingletonsInstantiated 메서드를 구현해야 하고, Spring이 모든 Singleton 생성 직후 이 메서드를 호출합니다. 생성자에서 테이블 생성 로직을 비동기로 수행하고, 해당 메서드에서 await하면, 비동기로 수행하면서도 Application Ready 전에 테이블 생성이 완료됨을 보장할 수 있습니다.
방법 2: @Bean(bootstrap = BACKGROUND) — Spring Boot 3부터 지원하는 기능으로, 해당 Bean을 별도 스레드에서 초기화합니다. 선언적이고 Spring이 생명주기를 관리해 줍니다.
방법 1은 별도 구현이 필요하고 코드 구조가 복잡해지는 단점이 있습니다. 반면 방법 2는 어노테이션만으로 Spring이 생명주기를 관리해 주므로, 방법 2를 채택했습니다.
Background 초기화가 설정된 Bean과 의존관계가 있는 다른 Bean들은 여전히 main thread, 혹은 또 다른 thread 에서 초기화될 수 있기 때문에, 아래와 같은 주의 사항이 필요합니다.
이 Bean을 주입받는 쪽은 해당 bean에 @Lazy를 붙이거나, ObjectProvider 로 감싸주어야 합니다.
이 Bean이 의존하는 Bean을 @DependsOn 어노테이션으로 명시해야 합니다.
마지막으로 위 수정사항을 배포한 뒤, flame graph에서 EMF 를 블로킹하는 샘플은 더 이상 발견되지 않았고, 최종적으로 가장 빠른 pod는 27초라는 쾌거를 이루어냈습니다.
최종 성과
6단계에 걸친 최적화를 통해 달성한 최종 결과입니다. 90초가 우습게 넘어가던 어플리케이션이 이제는 30초 만에 시작됩니다.
이 여정을 마치며 …
긴 과정을 거쳐왔지만, 크게 두 가지를 말씀드리고 싶습니다.
측정된 데이터로부터 근거를 도출해야 합니다.
Spring 의 느린 Startup Time은 이미 널리 알려진 고질적인 문제이고, 그만큼 해결책에 관련된 많은 자료들이 있습니다. 하지만 이런 일반적인 방법들이 항상 정답은 아닙니다.
Workload의 특성은 환경과 서비스의 비즈니스 패턴에 따라 다르므로 실제 메트릭과 프로파일을 분석하여 원인을 분석하고, 정확한 엔지니어링 근거를 통해 해결책 도출하는 것이 중요합니다.
프레임워크의 내부를 이해해야 합니다.
Spring Data의 deferred bootstrap이 기대만큼 동작하지 않았을 때, Spring 소스 코드로부터 해답을 얻었습니다. 프레임워크를 블랙박스로 두지 않고 내부 동작을 이해하는 것이 중요합니다.
Further Task: AOT Cache
이제 Flame Graph에서 남은 최적화 대상은 대부분 Class Loading과 Linking 관련 샘플들 입니다.
이는 일반적인 코드 최적화로는 해결이 어려운 경우가 많고, CDS(Class Data Sharing) / AOT Cache가 근본적인 해결책이 될 수 있습니다.
CDS보다는 비교적 최근 기능인 AOT Cache(Ahead-of-Time Class Loading & Linking)를 활용하기로 했고, 현재 프로덕션 환경에 적용하기 위해 여러 테스트를 진행 중입니다.
The ahead-of-time cache proposed here is a natural evolution of an old feature in the HotSpot JVM, class-data sharing (CDS).
로컬에서 구동 시간을 측정한 결과 부트업 시간이 기존 대비 50% 단축된 결과를 확인했습니다.
프로덕션 환경에 완전히 배포한 후 또 다른 아티클로 찾아뵙겠습니다.