포스트

클래스 로딩과 기동 시간 - 리플렉션의 비용과 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)으로 단계별 시간을 뽑을 수 있다. 어디가 느린지 모른 채 최적화하면 대개 엉뚱한 곳을 건드린다.

리플렉션의 비용

리플렉션은 세 가지 비용을 낸다.

  1. 탐색: getDeclaredMethod 같은 조회. 문자열 비교와 배열 순회다.
  2. 호출: 접근 검사와 박싱. JIT가 인라이닝하기 어렵다.
  3. 메타데이터 메모리: 리플렉션 데이터가 캐시된다.

기동 시간에 영향을 주는 것은 주로 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 프로브가 기동 시간에 맞춰져 있어야 한다. 짧으면 재시작 루프가 돈다.

무엇을 재면 확인되는가

  1. BufferingApplicationStartup으로 단계별 소요를 뽑는다. 스캔, 자동 설정, 빈 생성의 비중이 나온다.
  2. 지연 초기화를 켜고 기동 시간과 첫 요청 지연을 함께 잰다. 둘을 함께 보지 않으면 옮긴 것을 줄인 것으로 착각한다.
  3. 컴포넌트 스캔 범위를 좁히기 전후를 비교한다.
  4. 컨테이너 CPU 제한을 바꿔 가며 기동 시간을 잰다.

실무와의 접점

Spring Bean 생명주기와 spring-internals-lab에서 빈이 만들어지는 과정을 따라갔다. 그 과정이 기동 시간의 실체다. 내부 동작을 아는 것이 성능 판단으로 이어지는 드문 사례이고, 반대로 말하면 그 구조를 모르면 “기동이 느리다”에 손댈 곳을 찾지 못한다.

정리

  • 클래스는 최초 사용 시점에 게으르게 로드되고 초기화된다. 기동이 빨라도 첫 요청이 느릴 수 있다.
  • Spring Boot 기동 시간의 큰 부분은 클래스패스 스캔과 자동 설정 조건 평가다.
  • 지연 초기화는 시간을 옮기는 것이지 없애는 것이 아니다.
  • 리플렉션의 기동 비용은 주로 탐색이고, 반복 호출 비용은 JIT가 완화한다.
  • AOT는 결정을 빌드 시점으로 옮기고, native-image는 닫힌 세계를 가정하는 대신 JIT를 포기한다.
  • 상시 서버는 대개 기동 최적화가 필요 없다. 실제 제약일 때 한다.

참고

Spring과 JVM 백엔드
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다