같은 자바 코드인데 Quarkus에서 실행하면 왜 더 빠를까? "빌드 타임에 처리한다"는 말은 쉽지만, 구체적으로 무엇을 어떻게 처리하는 걸까?

ArC란 무엇인가

ArC는 Quarkus의 빌드 타임 CDI(Contexts and Dependency Injection) 구현체 입니다. 이름은 "ArC"이며, CDI 표준의 경량 버전인 CDI Lite 를 빌드 시점에 처리하도록 설계되었습니다.

한 줄 정의: ArC는 의존성 주입을 런타임 리플렉션 대신 빌드 타임 코드 생성으로 처리하는 CDI 엔진입니다.

전통적인 CDI 구현체(Weld 등)는 애플리케이션이 시작할 때 빈을 스캔하고 프록시를 생성합니다. ArC는 이 모든 과정을 빌드 시 완료 합니다.


런타임 리플렉션 vs 빌드 타임 분석

Spring이나 전통적인 CDI가 DI를 처리하는 방식과 ArC의 방식을 비교하면, 차이가 명확해집니다.

Spring의 런타임 방식

PLAINTEXT
[애플리케이션 시작 시]
1. ClassPathBeanDefinitionScanner가 모든 클래스를 스캔
2. 각 클래스의 어노테이션을 리플렉션으로 읽기
3. BeanDefinition 생성 및 등록
4. 의존성 그래프 구성
5. CGLIB으로 프록시 바이트코드 동적 생성
6. 빈 인스턴스 생성 및 주입
→ 이 모든 과정이 매 시작마다 반복됨
JAVA
// 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의 빌드 타임 방식

PLAINTEXT
[빌드 시]
1. Jandex 인덱스로 모든 클래스 메타데이터 수집 (리플렉션 불필요)
2. 빈 디스커버리 및 의존성 그래프 구성
3. Gizmo로 바이트코드 직접 생성 (프록시, 빈 생성 코드)
4. 미사용 빈 자동 제거
5. 결과물을 바이트코드로 출력

[애플리케이션 시작 시]
1. 미리 생성된 코드 실행 → 끝

이 차이가 시작 시간의 핵심입니다.


Jandex: 리플렉션 없는 클래스 분석

ArC의 첫 번째 비밀 무기는 Jandex 입니다. Jandex는 클래스파일의 메타데이터(어노테이션, 타입 정보 등)를 빌드 시점에 인덱싱하는 라이브러리입니다.

PLAINTEXT
[전통 방식: 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

JAVA
// Spring: 런타임에 CGLIB으로 프록시 생성
// → 시작할 때마다 바이트코드를 동적으로 만듦
@Service
public class UserService {
    @Transactional
    public void createUser(User user) {
        // ...
    }
}
// Spring은 이 클래스의 CGLIB 프록시를 런타임에 생성

// ArC: 빌드 타임에 Gizmo로 바이트코드 생성
// → 빌드 결과물에 이미 프록시 클래스가 포함되어 있음

Gizmo가 생성하는 코드의 모습을 개념적으로 보면 이렇습니다.

JAVA
// 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는 빌드 시점에 의존성 그래프를 완전히 파악하므로, 실제로 사용되지 않는 빈을 자동으로 제거 할 수 있습니다.

JAVA
@ApplicationScoped
public class EmailService {
    // 어디에서도 @Inject하지 않으면 빌드 결과물에서 제거됨
}

@ApplicationScoped
public class UserService {
    @Inject
    UserRepository userRepository;  // 이것만 포함됨
}

전통적인 CDI에서는 런타임에 동적으로 빈을 룩업할 수 있기 때문에, 모든 빈을 등록해두어야 합니다. ArC는 빌드 시점에 "이 빈은 아무데서도 참조되지 않는다"를 확인할 수 있으므로 과감하게 제거합니다.

공부하다 보니 이 부분이 트리 쉐이킹(tree shaking)과 비슷하다는 걸 알게 되었습니다. 프론트엔드의 번들러가 미사용 코드를 제거하는 것처럼, ArC는 미사용 빈을 제거합니다.

제거하면 안 되는 빈 보호

동적으로 룩업해야 하는 빈은 @Unremovable 어노테이션으로 보호할 수 있습니다.

JAVA
@ApplicationScoped
@Unremovable  // ArC가 제거하지 않음
public class PluginService {
    // CDI.current().select()로 동적 룩업될 수 있음
}

빌드 시 검증 완료

ArC의 또 다른 장점은 빌드 시점에 DI 관련 오류를 잡아준다 는 것입니다.

Spring에서 흔히 겪는 런타임 에러

JAVA
// Spring에서는 애플리케이션을 시작해봐야 알 수 있는 에러들
@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;  // PaymentService 빈이 없으면?
    // → 시작할 때 NoSuchBeanDefinitionException
}

ArC에서는 빌드 시 실패

PLAINTEXT
[빌드 출력]
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 을 따릅니다. 빌드 시점에 도달 가능한 코드만 바이너리에 포함하고, 런타임에 새로운 클래스를 로딩하거나 리플렉션으로 접근하는 것을 원칙적으로 허용하지 않습니다.

PLAINTEXT
[Native Image가 싫어하는 것들]
- Class.forName("동적 클래스명")
- Constructor.newInstance()
- Field.set()
- Proxy.newProxyInstance()
- 동적 클래스 로딩

[ArC가 이것들을 빌드 타임에 제거]
- 리플렉션 → Gizmo 바이트코드로 대체
- 동적 프록시 → 빌드 시 생성된 서브클래스로 대체
- 클래스 스캐닝 → Jandex 인덱스로 대체

성능 수치

항목JVM 모드Native 모드
시작 시간1~2초15~80ms
메모리 (RSS)100~200MB30~50MB
바이너리 크기JRE + JAR** 단일 바이너리 50~100MB**
빌드 시간5~15초2~5분

Native 모드의 시작 시간 15~80ms는 Go나 Rust로 작성한 애플리케이션과 비교해도 경쟁력 있는 수치입니다.


빌드 타임 처리의 구조: Extension 아키텍처

Quarkus의 빌드 타임 처리는 Extension의 Build Step 시스템으로 구현됩니다. 각 확장은 빌드 시 실행되는 @BuildStep 메서드를 포함합니다.

Build Step의 개념

JAVA
// 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();
    }
}

빌드 프로세스의 흐름은 다음과 같습니다.

PLAINTEXT
[Quarkus 빌드 파이프라인]

소스 코드 + 의존성


① Jandex 인덱싱 (모든 클래스 메타데이터 수집)


② Build Step 실행 (각 확장의 @BuildStep 순서대로)

     ├── ArC: 빈 디스커버리, 의존성 그래프 구성, 프록시 생성
     ├── RESTEasy: 엔드포인트 스캔, 라우팅 테이블 구성
     ├── Hibernate: 엔티티 스캔, 메타모델 생성
     └── ...


③ Gizmo 바이트코드 생성 (런타임용 코드 출력)


④ 결과물: 최적화된 JAR 또는 Native 바이너리

실제 ArC 동작 확인하기

개발 모드에서 ArC가 어떻게 빈을 관리하는지 확인할 수 있습니다.

Dev UI에서 빈 목록 확인

BASH
quarkus dev
# 브라우저에서 http://localhost:8080/q/dev-ui/arc/beans 접속

Dev UI에서 등록된 모든 빈, 스코프, 인터셉터, 미사용 빈 등을 확인할 수 있습니다.

빌드 로그로 확인

PROPERTIES
# application.properties
quarkus.arc.remove-unused-beans=true
quarkus.log.category."io.quarkus.arc".level=DEBUG

빌드 로그에서 ArC가 제거한 빈, 생성한 프록시 등을 확인할 수 있습니다.


CDI 스코프와 ArC

ArC는 CDI 표준 스코프를 지원하지만, 빌드 타임 특성상 알아둬야 할 차이가 있습니다.

JAVA
// 지원하는 스코프
@ApplicationScoped  // 앱 전체에서 하나 (가장 많이 사용)
@RequestScoped      // HTTP 요청마다 하나
@SessionScoped      // HTTP 세션마다 하나
@Dependent          // 주입 지점마다 새로 생성
@Singleton          // @ApplicationScoped와 비슷하지만 프록시 없음

@ApplicationScoped vs @Singleton

이 둘의 차이가 면접에서 나올 수 있는 포인트입니다.

JAVA
@ApplicationScoped
public class ServiceA {
    // 프록시 객체가 생성됨 → 지연 초기화 가능
    // 실제 인스턴스는 첫 호출 시 생성
}

@Singleton
public class ServiceB {
    // 프록시 없이 실제 인스턴스가 직접 주입됨
    // 시작 시점에 생성
}

@ApplicationScoped는 프록시를 통해 지연 초기화됩니다. @Singleton은 프록시 없이 직접 주입되어 미세하게 더 빠르지만, 순환 의존성 해결이 불가능합니다.


트레이드오프: 빌드 타임 처리의 비용

빌드 타임 처리가 장점만 있는 것은 아닙니다. 공부하면서 느낀 현실적인 단점을 정리합니다.

1. 빌드 시간 증가

PLAINTEXT
[JVM JAR 빌드]
Spring Boot: ~10초
Quarkus:     ~15초  (빌드 스텝 처리 때문에 약간 더 김)

[Native 빌드]
Spring Boot Native: 3~8분
Quarkus Native:     2~5분  (조금 더 빠르지만 여전히 오래 걸림)

Native 빌드는 어느 쪽이든 시간이 오래 걸립니다. CI/CD 파이프라인에서 빌드 시간이 중요한 팀이라면 이 점을 고려해야 합니다.

2. 리플렉션 제약

JAVA
// 이런 코드는 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. 디버깅의 어려움

빌드 타임에 생성된 코드는 소스 코드에 없으므로, 디버깅이 직관적이지 않을 수 있습니다.

PLAINTEXT
[문제 발생 시 디버깅 흐름]
Spring: 스택 트레이스 → 소스 코드에서 직접 확인 가능
ArC:    스택 트레이스 → 생성된 바이트코드 → 원래 의도를 역추적

다만 Quarkus 팀은 이 문제를 인지하고 있어서, Dev UI와 빌드 로그를 통해 생성된 빈과 프록시를 확인할 수 있는 도구를 제공합니다.

4. 런타임 동적 변경 불가

JAVA
// 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가 빠른 이유는 "더 빠른 코드를 실행해서"가 아니라 "시작할 때 할 일 자체를 없앴기 때문"입니다. 런타임 최적화가 아니라 런타임 작업량 제거입니다.

댓글 로딩 중...