빌드 타임 처리와 ArC — Quarkus가 빠른 진짜 이유
같은 자바 코드인데 Quarkus에서 실행하면 왜 더 빠를까? "빌드 타임에 처리한다"는 말은 쉽지만, 구체적으로 무엇을 어떻게 처리하는 걸까?
ArC란 무엇인가
ArC는 Quarkus의 빌드 타임 CDI(Contexts and Dependency Injection) 구현체 입니다. 이름은 "ArC"이며, CDI 표준의 경량 버전인 CDI Lite 를 빌드 시점에 처리하도록 설계되었습니다.
한 줄 정의: ArC는 의존성 주입을 런타임 리플렉션 대신 빌드 타임 코드 생성으로 처리하는 CDI 엔진입니다.
전통적인 CDI 구현체(Weld 등)는 애플리케이션이 시작할 때 빈을 스캔하고 프록시를 생성합니다. ArC는 이 모든 과정을 빌드 시 완료 합니다.
런타임 리플렉션 vs 빌드 타임 분석
Spring이나 전통적인 CDI가 DI를 처리하는 방식과 ArC의 방식을 비교하면, 차이가 명확해집니다.
Spring의 런타임 방식
[애플리케이션 시작 시]
1. ClassPathBeanDefinitionScanner가 모든 클래스를 스캔
2. 각 클래스의 어노테이션을 리플렉션으로 읽기
3. BeanDefinition 생성 및 등록
4. 의존성 그래프 구성
5. CGLIB으로 프록시 바이트코드 동적 생성
6. 빈 인스턴스 생성 및 주입
→ 이 모든 과정이 매 시작마다 반복됨
// Spring에서 런타임에 일어나는 일 (의사 코드)
for (Class<?> clazz : classPathScanner.scan("com.example")) {
// 리플렉션으로 어노테이션 확인
if (clazz.isAnnotationPresent(Component.class)) {
// BeanDefinition 생성
BeanDefinition bd = new GenericBeanDefinition();
bd.setBeanClass(clazz);
// 의존성 분석 (생성자/필드/세터의 파라미터 타입 확인)
for (Constructor<?> ctor : clazz.getDeclaredConstructors()) {
// 리플렉션으로 파라미터 타입 확인
}
registry.registerBeanDefinition(name, bd);
}
}
ArC의 빌드 타임 방식
[빌드 시]
1. Jandex 인덱스로 모든 클래스 메타데이터 수집 (리플렉션 불필요)
2. 빈 디스커버리 및 의존성 그래프 구성
3. Gizmo로 바이트코드 직접 생성 (프록시, 빈 생성 코드)
4. 미사용 빈 자동 제거
5. 결과물을 바이트코드로 출력
[애플리케이션 시작 시]
1. 미리 생성된 코드 실행 → 끝
이 차이가 시작 시간의 핵심입니다.
Jandex: 리플렉션 없는 클래스 분석
ArC의 첫 번째 비밀 무기는 Jandex 입니다. Jandex는 클래스파일의 메타데이터(어노테이션, 타입 정보 등)를 빌드 시점에 인덱싱하는 라이브러리입니다.
[전통 방식: java.lang.reflect]
Class.forName("com.example.UserService") ← 클래스 로딩 필요
→ class.getAnnotations() ← 리플렉션 호출
→ class.getDeclaredFields() ← 리플렉션 호출
→ field.getType() ← 리플렉션 호출
[ArC 방식: Jandex]
jandexIndex.getClassByName("com.example.UserService") ← 파일에서 직접 읽음
→ classInfo.annotations() ← 인덱스 조회
→ classInfo.fields() ← 인덱스 조회
Jandex는 .class 파일의 바이트코드를 직접 파싱하여 메타데이터를 추출합니다. JVM에 클래스를 로딩할 필요가 없으므로, 빌드 도구(Maven/Gradle)에서 실행할 수 있습니다.
Gizmo: 바이트코드 직접 생성
ArC의 두 번째 비밀 무기는 Gizmo 입니다. Gizmo는 Quarkus 팀이 만든 바이트코드 생성 라이브러리로, 빈의 생성 코드와 프록시를 빌드 시점에 만들어냅니다.
Spring의 CGLIB vs ArC의 Gizmo
// Spring: 런타임에 CGLIB으로 프록시 생성
// → 시작할 때마다 바이트코드를 동적으로 만듦
@Service
public class UserService {
@Transactional
public void createUser(User user) {
// ...
}
}
// Spring은 이 클래스의 CGLIB 프록시를 런타임에 생성
// ArC: 빌드 타임에 Gizmo로 바이트코드 생성
// → 빌드 결과물에 이미 프록시 클래스가 포함되어 있음
Gizmo가 생성하는 코드의 모습을 개념적으로 보면 이렇습니다.
// ArC가 빌드 시 생성하는 코드 (개념적 표현)
public class UserService_Bean implements InjectableBean<UserService> {
@Override
public UserService create(CreationalContext<UserService> ctx) {
// 리플렉션 없이 직접 생성자 호출
UserRepository repo = Arc.container()
.instance(UserRepository.class).get();
return new UserService(repo);
}
@Override
public void destroy(UserService instance, CreationalContext<UserService> ctx) {
// 정리 코드
}
}
핵심은 new UserService(repo)로 직접 생성 한다는 것입니다. Constructor.newInstance()같은 리플렉션 호출이 없습니다.
미사용 빈 자동 제거
ArC는 빌드 시점에 의존성 그래프를 완전히 파악하므로, 실제로 사용되지 않는 빈을 자동으로 제거 할 수 있습니다.
@ApplicationScoped
public class EmailService {
// 어디에서도 @Inject하지 않으면 빌드 결과물에서 제거됨
}
@ApplicationScoped
public class UserService {
@Inject
UserRepository userRepository; // 이것만 포함됨
}
전통적인 CDI에서는 런타임에 동적으로 빈을 룩업할 수 있기 때문에, 모든 빈을 등록해두어야 합니다. ArC는 빌드 시점에 "이 빈은 아무데서도 참조되지 않는다"를 확인할 수 있으므로 과감하게 제거합니다.
공부하다 보니 이 부분이 트리 쉐이킹(tree shaking)과 비슷하다는 걸 알게 되었습니다. 프론트엔드의 번들러가 미사용 코드를 제거하는 것처럼, ArC는 미사용 빈을 제거합니다.
제거하면 안 되는 빈 보호
동적으로 룩업해야 하는 빈은 @Unremovable 어노테이션으로 보호할 수 있습니다.
@ApplicationScoped
@Unremovable // ArC가 제거하지 않음
public class PluginService {
// CDI.current().select()로 동적 룩업될 수 있음
}
빌드 시 검증 완료
ArC의 또 다른 장점은 빌드 시점에 DI 관련 오류를 잡아준다 는 것입니다.
Spring에서 흔히 겪는 런타임 에러
// Spring에서는 애플리케이션을 시작해봐야 알 수 있는 에러들
@Service
public class OrderService {
@Autowired
private PaymentService paymentService; // PaymentService 빈이 없으면?
// → 시작할 때 NoSuchBeanDefinitionException
}
ArC에서는 빌드 시 실패
[빌드 출력]
BUILD FAILURE
Unsatisfied dependency for type PaymentService and qualifiers [@Default]
- injection target: OrderService#paymentService
- declared on: com.example.OrderService
Build step io.quarkus.arc.deployment.ArcProcessor#validate threw an exception
빌드가 실패하므로, **배포 자체가 되지 않습니다 **. "시작해보니 빈이 없어서 터졌다"는 상황을 원천 차단할 수 있습니다.
ArC가 빌드 시 검증하는 항목들:
- 미충족 의존성 (Unsatisfied dependency)
- 모호한 의존성 (Ambiguous dependency)
- 순환 의존성 (Circular dependency)
- 잘못된 스코프 조합
- 관측자(Observer) 메서드의 시그니처 오류
GraalVM Native Image와의 시너지
ArC의 빌드 타임 처리가 GraalVM Native Image와 만나면 진짜 힘을 발휘합니다.
Native Image의 제약과 ArC의 해결
GraalVM Native Image는 Closed World Assumption 을 따릅니다. 빌드 시점에 도달 가능한 코드만 바이너리에 포함하고, 런타임에 새로운 클래스를 로딩하거나 리플렉션으로 접근하는 것을 원칙적으로 허용하지 않습니다.
[Native Image가 싫어하는 것들]
- Class.forName("동적 클래스명")
- Constructor.newInstance()
- Field.set()
- Proxy.newProxyInstance()
- 동적 클래스 로딩
[ArC가 이것들을 빌드 타임에 제거]
- 리플렉션 → Gizmo 바이트코드로 대체
- 동적 프록시 → 빌드 시 생성된 서브클래스로 대체
- 클래스 스캐닝 → Jandex 인덱스로 대체
성능 수치
| 항목 | JVM 모드 | Native 모드 |
|---|---|---|
| 시작 시간 | 1~2초 | 15~80ms |
| 메모리 (RSS) | 100~200MB | 30~50MB |
| 바이너리 크기 | JRE + JAR | ** 단일 바이너리 50~100MB** |
| 빌드 시간 | 5~15초 | 2~5분 |
Native 모드의 시작 시간 15~80ms는 Go나 Rust로 작성한 애플리케이션과 비교해도 경쟁력 있는 수치입니다.
빌드 타임 처리의 구조: Extension 아키텍처
Quarkus의 빌드 타임 처리는 Extension의 Build Step 시스템으로 구현됩니다. 각 확장은 빌드 시 실행되는 @BuildStep 메서드를 포함합니다.
Build Step의 개념
// Quarkus 확장의 Build Step (프레임워크 개발자가 작성)
public class MyExtensionProcessor {
@BuildStep
BeanDefiningAnnotationBuildItem registerBeans() {
// 빌드 시: 어떤 어노테이션을 빈으로 인식할지 등록
return new BeanDefiningAnnotationBuildItem(
DotName.createSimple("com.example.MyBean"));
}
@BuildStep
void configureRuntime(BuildProducer<GeneratedClassBuildItem> generatedClasses) {
// 빌드 시: 런타임에 필요한 클래스를 Gizmo로 생성
}
@BuildStep
@Record(ExecutionTime.RUNTIME_INIT)
void setupAtRuntime(MyRecorder recorder) {
// 빌드 시: 런타임 초기화 코드를 레코딩
recorder.initialize();
}
}
빌드 프로세스의 흐름은 다음과 같습니다.
[Quarkus 빌드 파이프라인]
소스 코드 + 의존성
│
▼
① Jandex 인덱싱 (모든 클래스 메타데이터 수집)
│
▼
② Build Step 실행 (각 확장의 @BuildStep 순서대로)
│
├── ArC: 빈 디스커버리, 의존성 그래프 구성, 프록시 생성
├── RESTEasy: 엔드포인트 스캔, 라우팅 테이블 구성
├── Hibernate: 엔티티 스캔, 메타모델 생성
└── ...
│
▼
③ Gizmo 바이트코드 생성 (런타임용 코드 출력)
│
▼
④ 결과물: 최적화된 JAR 또는 Native 바이너리
실제 ArC 동작 확인하기
개발 모드에서 ArC가 어떻게 빈을 관리하는지 확인할 수 있습니다.
Dev UI에서 빈 목록 확인
quarkus dev
# 브라우저에서 http://localhost:8080/q/dev-ui/arc/beans 접속
Dev UI에서 등록된 모든 빈, 스코프, 인터셉터, 미사용 빈 등을 확인할 수 있습니다.
빌드 로그로 확인
# application.properties
quarkus.arc.remove-unused-beans=true
quarkus.log.category."io.quarkus.arc".level=DEBUG
빌드 로그에서 ArC가 제거한 빈, 생성한 프록시 등을 확인할 수 있습니다.
CDI 스코프와 ArC
ArC는 CDI 표준 스코프를 지원하지만, 빌드 타임 특성상 알아둬야 할 차이가 있습니다.
// 지원하는 스코프
@ApplicationScoped // 앱 전체에서 하나 (가장 많이 사용)
@RequestScoped // HTTP 요청마다 하나
@SessionScoped // HTTP 세션마다 하나
@Dependent // 주입 지점마다 새로 생성
@Singleton // @ApplicationScoped와 비슷하지만 프록시 없음
@ApplicationScoped vs @Singleton
이 둘의 차이가 면접에서 나올 수 있는 포인트입니다.
@ApplicationScoped
public class ServiceA {
// 프록시 객체가 생성됨 → 지연 초기화 가능
// 실제 인스턴스는 첫 호출 시 생성
}
@Singleton
public class ServiceB {
// 프록시 없이 실제 인스턴스가 직접 주입됨
// 시작 시점에 생성
}
@ApplicationScoped는 프록시를 통해 지연 초기화됩니다.@Singleton은 프록시 없이 직접 주입되어 미세하게 더 빠르지만, 순환 의존성 해결이 불가능합니다.
트레이드오프: 빌드 타임 처리의 비용
빌드 타임 처리가 장점만 있는 것은 아닙니다. 공부하면서 느낀 현실적인 단점을 정리합니다.
1. 빌드 시간 증가
[JVM JAR 빌드]
Spring Boot: ~10초
Quarkus: ~15초 (빌드 스텝 처리 때문에 약간 더 김)
[Native 빌드]
Spring Boot Native: 3~8분
Quarkus Native: 2~5분 (조금 더 빠르지만 여전히 오래 걸림)
Native 빌드는 어느 쪽이든 시간이 오래 걸립니다. CI/CD 파이프라인에서 빌드 시간이 중요한 팀이라면 이 점을 고려해야 합니다.
2. 리플렉션 제약
// 이런 코드는 Native 모드에서 문제가 됨
Object service = Class.forName(className).getDeclaredConstructor().newInstance();
// 해결: @RegisterForReflection 어노테이션 사용
@RegisterForReflection
public class MyDto {
private String name;
private int age;
}
JSON 직렬화/역직렬화에 사용되는 DTO 클래스는 리플렉션이 필요한 경우가 많습니다. Quarkus는 Jackson/JSON-B 관련 클래스를 자동으로 등록해주지만, 커스텀 직렬화를 사용하면 수동 등록이 필요할 수 있습니다.
3. 디버깅의 어려움
빌드 타임에 생성된 코드는 소스 코드에 없으므로, 디버깅이 직관적이지 않을 수 있습니다.
[문제 발생 시 디버깅 흐름]
Spring: 스택 트레이스 → 소스 코드에서 직접 확인 가능
ArC: 스택 트레이스 → 생성된 바이트코드 → 원래 의도를 역추적
다만 Quarkus 팀은 이 문제를 인지하고 있어서, Dev UI와 빌드 로그를 통해 생성된 빈과 프록시를 확인할 수 있는 도구를 제공합니다.
4. 런타임 동적 변경 불가
// Spring에서는 가능한 패턴
@ConditionalOnProperty(name = "feature.enabled", havingValue = "true")
@Service
public class FeatureService {
// 프로퍼티 값에 따라 런타임에 빈 등록 여부 결정
}
// Quarkus에서는 빌드 시 결정됨
// 런타임에 프로퍼티를 바꿔도 빈 등록은 변하지 않음
Quarkus도 빌드 타임 조건부 빈 등록을 지원하지만(@IfBuildProfile, @UnlessBuildProfile), 이는 ** 빌드 프로파일 **에 따른 것이지 런타임 설정에 따른 것이 아닙니다.
정리
ArC와 빌드 타임 처리를 요약하면 다음과 같습니다.
- ArC: CDI Lite의 빌드 타임 구현체. 리플렉션 대신 Jandex + Gizmo 사용
- Jandex: 클래스 로딩 없이 메타데이터를 읽는 인덱싱 라이브러리
- Gizmo: 프록시와 빈 생성 코드를 빌드 시 바이트코드로 출력
- ** 미사용 빈 제거 **: 빌드 시 의존성 그래프를 분석하여 불필요한 빈 제거
- ** 빌드 시 검증 **: DI 오류를 배포 전에 잡아줌
- **GraalVM 시너지 **: 리플렉션 제거로 네이티브 이미지 변환이 자연스러움
기억할 핵심: Quarkus가 빠른 이유는 "더 빠른 코드를 실행해서"가 아니라 "시작할 때 할 일 자체를 없앴기 때문"입니다. 런타임 최적화가 아니라 런타임 작업량 제거입니다.