클래스 로딩과 기동 시간 - 리플렉션의 비용과 AOT가 바꾸는 것
Spring Boot 애플리케이션이 뜨는 데 몇 초가 걸린다. 로컬에서는 불편 정도지만, 오토스케일링으로 인스턴스를 급히 늘려야 할 때나 서버리스에서는 그 몇 초가 설계 제약이 된다. 그 시간이 어디로 가는지 보면 무엇을 줄일 수 있는지가 갈린다.
클래스 로딩의 세 단계
로딩: 클래스 파일을 찾아 읽고 Class 객체를 만든다. 클래스패스를 뒤지는 IO가 여기 있다.
링킹: 검증(바이트코드가 유효한가), 준비(정적 필드에 기본값 할당), 해석(심볼 참조를 실제 참조로).
초기화: 정적 초기화 블록과 정적 필드 초기화를 실행한다. 최초 사용 시점에 일어난다.
마지막이 중요하다. JVM은 게으르다. 클래스는 실제로 쓰일 때 로드되고 초기화된다. 그래서 기동 시간이 짧아 보여도 첫 요청이 느린 경우가 생긴다. 로딩이 그때로 미뤄진 것이다.
위임 모델도 알아 둘 값이 있다. 클래스로더는 먼저 부모에게 묻고, 부모가 못 찾으면 자기가 찾는다. 같은 이름의 클래스가 여러 JAR에 있을 때 어느 것이 로드되는지가 클래스패스 순서로 정해지는 이유이고, NoSuchMethodError처럼 “컴파일은 됐는데 런타임에 없다”는 오류의 배경이다.
Spring Boot의 기동 시간이 가는 곳
| 단계 | 하는 일 |
|---|---|
| 클래스패스 스캔 | @Component, @Configuration을 찾아 JAR을 훑는다 |
| 자동 설정 평가 | 수백 개의 조건(@ConditionalOnClass 등)을 평가한다 |
| 빈 정의 등록·생성 | 의존 관계를 풀고 인스턴스를 만든다 |
| 프록시 생성 | AOP 대상마다 CGLIB 서브클래스를 생성한다 |
| 기타 초기화 | 커넥션 풀 워밍업, JPA 엔티티 메타데이터 등 |
앞의 둘이 리플렉션과 클래스 로딩에 집중된 구간이다. @ConditionalOnClass를 평가하려면 그 클래스가 있는지 확인해야 하고, 확인은 클래스패스 탐색이다.
실무적으로 줄일 수 있는 것들.
- 컴포넌트 스캔 범위를 좁힌다. 최상위 패키지에서 스캔하면 모든 하위를 훑는다.
- 쓰지 않는 스타터를 제거한다. 자동 설정 후보가 줄면 평가도 준다.
spring.main.lazy-initialization을 켜면 빈 생성을 첫 사용까지 미룬다. 기동은 빨라지고 첫 요청이 느려진다. 시간을 옮기는 것이지 없애는 것이 아니다.
측정이 먼저다. -Ddebug=true로 자동 설정 평가 리포트를 보고, ApplicationStartup(BufferingApplicationStartup)으로 단계별 시간을 뽑을 수 있다. 어디가 느린지 모른 채 최적화하면 대개 엉뚱한 곳을 건드린다.
리플렉션의 비용
리플렉션은 세 가지 비용을 낸다.
- 탐색:
getDeclaredMethod같은 조회. 문자열 비교와 배열 순회다. - 호출: 접근 검사와 박싱. JIT가 인라이닝하기 어렵다.
- 메타데이터 메모리: 리플렉션 데이터가 캐시된다.
기동 시간에 영향을 주는 것은 주로 1번이다. 반복 호출의 비용은 JIT가 상당 부분 완화한다(MethodHandle과 invokedynamic은 더 낫다).
프레임워크가 리플렉션을 쓰는 이유는 명확하다. 컴파일 시점에 모르는 것을 런타임에 알아내기 위해서다. 애노테이션을 읽고, 생성자를 고르고, 필드를 주입한다. 그 유연성의 대가가 기동 시간이다.
AOT와 native-image가 바꾸는 것
Spring Boot 3의 AOT 처리는 빌드 시점에 결정을 미리 내린다. 어떤 빈이 필요한지, 어떤 자동 설정이 적용되는지를 컴파일 시점에 계산해 코드로 생성한다. 런타임의 조건 평가와 스캔이 사라진다.
GraalVM native-image는 더 나간다. 도달 가능한 코드만 골라 네이티브 실행 파일로 만든다. 기동이 수십 밀리초가 되고 메모리도 준다.
대가가 크다.
- 닫힌 세계 가정. 빌드 시점에 도달 가능한 것만 포함된다. 동적 리플렉션, 동적 프록시, 리소스 로딩은 힌트로 미리 알려 줘야 한다.
- 빌드가 느리고 무겁다. 몇 분 단위다.
- JIT의 실행 시 최적화를 포기한다. 오래 도는 서버는 JIT 쪽이 최종 처리량에서 유리한 경우가 많다(JIT 컴파일).
- 관측 도구 호환성을 확인해야 한다. 에이전트 기반 APM이 동작하지 않을 수 있다.
CDS(Class Data Sharing)는 중간 지점이다. 클래스 메타데이터를 미리 만들어 아카이브로 공유한다. 코드 변경 없이 기동을 일부 줄이고, AOT만큼의 제약이 없다. Java 21의 동적 CDS와 Project Leyden의 방향이 이쪽이다.
고르는 기준
| 상황 | 선택 |
|---|---|
| 오래 도는 서버, 기동은 가끔 | JVM 기본. 기동 최적화의 이득이 작다 |
| 오토스케일이 잦다 | AOT나 CDS로 기동 단축 |
| 서버리스, 스케일-투-제로 | native-image |
| 배치, 짧게 돌고 끝 | native-image 또는 CDS |
대부분의 상시 서버는 첫 줄이다. 기동 시간을 줄이는 작업은 그것이 실제 제약일 때 한다.
이 설명이 깨지는 곳
- 기동 시간과 워밍업은 다르다. 뜨는 데 3초, 최고 성능에 도달하는 데 수 분일 수 있다. 후자는 JIT의 영역이다.
- 컨테이너 CPU 제한이 기동을 늦춘다. 코어가 적으면 클래스 로딩과 컴파일이 직렬화된다(컨테이너 CPU 상한).
- 지연 초기화는 문제를 옮긴다. 기동 지표는 좋아지고 첫 요청 p99가 나빠진다. 어느 쪽이 중요한지는 서비스가 정한다.
- readiness 프로브가 기동 시간에 맞춰져 있어야 한다. 짧으면 재시작 루프가 돈다.
무엇을 재면 확인되는가
BufferingApplicationStartup으로 단계별 소요를 뽑는다. 스캔, 자동 설정, 빈 생성의 비중이 나온다.- 지연 초기화를 켜고 기동 시간과 첫 요청 지연을 함께 잰다. 둘을 함께 보지 않으면 옮긴 것을 줄인 것으로 착각한다.
- 컴포넌트 스캔 범위를 좁히기 전후를 비교한다.
- 컨테이너 CPU 제한을 바꿔 가며 기동 시간을 잰다.
실무와의 접점
Spring Bean 생명주기와 spring-internals-lab에서 빈이 만들어지는 과정을 따라갔다. 그 과정이 기동 시간의 실체다. 내부 동작을 아는 것이 성능 판단으로 이어지는 드문 사례이고, 반대로 말하면 그 구조를 모르면 “기동이 느리다”에 손댈 곳을 찾지 못한다.
정리
- 클래스는 최초 사용 시점에 게으르게 로드되고 초기화된다. 기동이 빨라도 첫 요청이 느릴 수 있다.
- Spring Boot 기동 시간의 큰 부분은 클래스패스 스캔과 자동 설정 조건 평가다.
- 지연 초기화는 시간을 옮기는 것이지 없애는 것이 아니다.
- 리플렉션의 기동 비용은 주로 탐색이고, 반복 호출 비용은 JIT가 완화한다.
- AOT는 결정을 빌드 시점으로 옮기고, native-image는 닫힌 세계를 가정하는 대신 JIT를 포기한다.
- 상시 서버는 대개 기동 최적화가 필요 없다. 실제 제약일 때 한다.
댓글
아직 댓글이 없습니다