Clickin Devlog

컴파일 타임의 마법 — Micronaut 내부 동작 심층 분석

· dev
#Java#Micronaut#AOT#AOP#DI#GraalVM#Internals
시리즈: micronaut-guide(9편)
  1. AOT의 시대를 열다 — Micronaut 소개와 역사적 맥락
  2. 첫 Micronaut 프로젝트 만들기
  3. 컴파일 타임의 마법 — Micronaut 내부 동작 심층 분석(현재)
  4. Spring과 Micronaut 비교 — 무엇을 선택할까
  5. HTTP 서버 모델과 Virtual Thread — Netty, EventLoop, 그리고 가상 스레드가 바꾼 선택 기준
  6. Micronaut Data JDBC와 Virtual Thread — 컴파일 타임 쿼리 생성과 데이터 접근 계층
  7. 선언적 HTTP Client와 Virtual Thread — @Client 인터페이스 완전 가이드
  8. Thymeleaf로 SSR 구현하기 — Micronaut Views 실전 가이드
  9. 세션 기반 인증 — 인메모리 & Redis | Micronaut Security 완전 가이드

“컴파일 타임에 DI를 처리한다”는 말은 쉽게 할 수 있지만, 실제로 어떤 일이 벌어지는지 확인해본 사람은 드뭅니다. 이번 편에서는 직접 빌드 디렉토리를 열어 Micronaut이 생성한 클래스를 분석합니다.

2편에서 만든 GreetingServiceHelloController를 예시로 사용합니다.


Annotation Processor가 하는 일

javac의 Annotation Processing 단계

Java 컴파일러(javac)는 소스를 컴파일할 때 별도의 Annotation Processing 단계를 거칩니다. 이 단계에서 javax.annotation.processing.Processor 구현체들이 실행되며, 소스 코드의 어노테이션을 분석하고 새로운 Java 소스나 바이트코드를 생성할 수 있습니다.

Gradle에서 annotationProcessor(...) 의존성으로 선언한 것들이 이 단계에서 실행됩니다.

소스 파일 (.java)

   javac 실행

  [Annotation Processing 단계]
  - 어노테이션 스캔
  - Processor 실행
  - 새 소스/클래스 생성

  바이트코드 생성 (.class)

Micronaut Annotation Processor 목록

build.gradle.kts에 선언하는 annotationProcessor 의존성은 각각 다른 처리를 담당합니다.

dependencies {
    // HTTP 관련 어노테이션 처리 (@Controller, @Get 등)
    annotationProcessor("io.micronaut:micronaut-http-validation")

    // Serialization 처리 (@Serdeable 등)
    annotationProcessor("io.micronaut.serde:micronaut-serde-processor")

    // Validation 처리 (@Valid, @NotNull 등)
    annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
}

핵심 DI 처리는 micronaut-inject-java에서 담당합니다. 이 processor가 @Singleton, @Inject, @Controller 등을 인식하고 BeanDefinition 클래스를 생성합니다. Micronaut Gradle 플러그인(io.micronaut.application)을 사용하면 micronaut-inject-java가 자동으로 annotationProcessor에 추가되지만, 플러그인 없이 직접 구성하는 경우 명시적으로 선언해야 합니다.1


생성되는 클래스 해부

직접 확인하기: build 디렉토리 탐험

프로젝트를 빌드한 후 생성된 클래스를 확인합니다.

./gradlew classes

build/classes/java/main/com/example/ 디렉토리를 열면 우리가 작성한 파일 외에 $로 시작하는 클래스들이 보입니다.

build/classes/java/main/com/example/
├── Application.class
├── GreetingService.class
├── $GreetingServiceDefinition.class          ← 자동 생성
├── $GreetingServiceDefinitionReference.class ← 자동 생성
├── HelloController.class
├── $HelloControllerDefinition.class          ← 자동 생성
└── $HelloControllerDefinitionReference.class ← 자동 생성

이 파일들이 Micronaut annotation processor가 생성한 결과입니다.

BeanDefinition

$GreetingServiceDefinition.classBeanDefinition<GreetingService> 인터페이스를 구현합니다. 이 클래스가 Spring의 BeanFactory가 런타임에 reflection으로 하던 작업을 대체합니다.

javap으로 내용을 확인합니다.

javap -p build/classes/java/main/com/example/'$GreetingServiceDefinition.class'
public final class $GreetingServiceDefinition
    extends AbstractInitializableBeanDefinition<GreetingService>
    implements BeanDefinition<GreetingService> {

    private static final Class TYPE = GreetingService.class;

    public GreetingService build(BeanResolutionContext resolutionContext,
                                  BeanContext context,
                                  BeanDefinition<GreetingService> definition) {
        // 생성자 인수 0번: GreetingConfig를 컨텍스트에서 조회
        GreetingConfig config =
            (GreetingConfig) super.getBeanForConstructorArgument(
                resolutionContext, context, 0, null);
        // reflection 없이 생성자를 직접 호출
        return new GreetingService(config);
    }
}

2편에서 작성한 GreetingServiceGreetingConfig를 생성자로 주입받으므로, 생성된 $GreetingServiceDefinition도 동일하게 GreetingConfig를 컨텍스트에서 꺼내 new GreetingService(config)로 전달합니다. Class.forName(), getDeclaredConstructor(), newInstance() 같은 reflection API가 전혀 없습니다.

HelloController도 동일한 방식으로 처리됩니다.

public final class $HelloControllerDefinition
    extends AbstractInitializableBeanDefinition<HelloController> {

    public HelloController build(BeanResolutionContext resolutionContext,
                                  BeanContext context,
                                  BeanDefinition<HelloController> definition) {
        // 생성자 인수 0번: GreetingService를 컨텍스트에서 조회
        GreetingService greetingService =
            (GreetingService) super.getBeanForConstructorArgument(
                resolutionContext, context, 0, null);
        return new HelloController(greetingService);
    }
}

super.getBeanForConstructorArgument(index, ...)의 index가 생성자 파라미터 순서와 대응됩니다. 의존성이 여럿이라면 index 0, 1, 2…로 순서대로 조회한 뒤 한 번의 생성자 호출로 전달합니다.

BeanDefinitionReference

$GreetingServiceDefinitionReference.class는 경량화된 참조 클래스입니다.

public final class $GreetingServiceDefinitionReference
    implements BeanDefinitionReference<GreetingService> {

    @Override
    public String getBeanDefinitionName() {
        return "com.example.$GreetingServiceDefinition";
    }

    @Override
    public BeanDefinition<GreetingService> load() {
        return new $GreetingServiceDefinition();
    }

    @Override
    public boolean isPresent() {
        return true;
    }
}

이 클래스의 목적은 지연 로딩(lazy loading) 지원입니다. 애플리케이션이 시작될 때 모든 BeanDefinition을 즉시 로드하지 않고, Reference 클래스만 먼저 등록합니다. 실제 BeanDefinition은 해당 Bean이 처음 필요할 때 load()를 호출하여 로드합니다.

Micronaut은 META-INF/services/io.micronaut.inject.BeanDefinitionReference 파일에 모든 Reference 클래스를 나열합니다. 이 파일이 Micronaut의 “Bean 등록부”입니다. java.util.ServiceLoader 메커니즘을 활용합니다.2

cat build/resources/main/META-INF/services/io.micronaut.inject.BeanDefinitionReference
com.example.$GreetingServiceDefinitionReference
com.example.$HelloControllerDefinitionReference

AnnotationMetadata

Micronaut은 어노테이션 정보도 컴파일 타임에 클래스로 만들어둡니다. 런타임에 getAnnotation()을 호출하지 않아도 됩니다.

// Spring이 런타임에 하는 것
Method method = clazz.getMethod("greet", String.class);
GetMapping annotation = method.getAnnotation(GetMapping.class);
String path = annotation.value()[0]; // runtime reflection

// Micronaut이 컴파일 타임에 생성한 것 (단순화)
AnnotationMetadata metadata = $HelloControllerDefinition.ANNOTATION_METADATA;
String path = metadata.stringValue(Get.class, "value").orElse(""); // 상수 조회

AnnotationMetadata는 어노테이션 정보를 상수와 배열로 보관하는 클래스입니다. 런타임 reflection 없이 어노테이션 정보를 조회할 수 있습니다.

ExecutableMethod

컨트롤러의 각 메서드에 대해서도 ExecutableMethod 클래스가 생성됩니다.

// 단순화된 예시
public final class $HelloControllerDefinition$Exec
    implements ExecutableMethod<HelloController, String> {

    @Override
    public String invoke(HelloController bean, Object... args) {
        return bean.greet((String) args[0]); // reflection 없이 직접 호출
    }
}

HTTP 요청이 들어와서 greet() 메서드를 호출할 때, Micronaut은 이 ExecutableMethod를 통해 reflection 없이 직접 메서드를 호출합니다.


AOP 동작 방식

AOP가 있을 때 생성되는 클래스를 살펴봅니다. 먼저 간단한 타이밍 인터셉터를 만듭니다.

package com.example;

import io.micronaut.aop.Around;
import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@Around
public @interface Timed {
}
package com.example;

import io.micronaut.aop.InterceptorBean;
import io.micronaut.aop.MethodInterceptor;
import io.micronaut.aop.MethodInvocationContext;
import jakarta.inject.Singleton;

@Singleton
@InterceptorBean(Timed.class)
public class TimedInterceptor implements MethodInterceptor<Object, Object> {

    @Override
    public Object intercept(MethodInvocationContext<Object, Object> context) {
        long start = System.currentTimeMillis();
        try {
            return context.proceed();
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            System.out.println(context.getMethodName() + ": " + elapsed + "ms");
        }
    }
}

GreetingService에 어노테이션을 붙입니다.

@Singleton
public class GreetingService {

    @Timed  // ← AOP 어노테이션 추가
    public String greet(String name) {
        return "안녕하세요, " + name + "!";
    }
}

빌드하면 추가 클래스가 생성됩니다.

build/classes/java/main/com/example/
├── GreetingService.class
├── $GreetingServiceDefinition.class
├── $GreetingServiceDefinitionReference.class
└── $GreetingService$Intercepted.class  ← 자동 생성 (AOP 적용 시)

컴파일 타임 프록시 생성

$GreetingService$Intercepted.class가 AOP 프록시입니다. GreetingService를 상속합니다.

// 단순화된 디컴파일 결과
public final class $GreetingService$Intercepted extends GreetingService
    implements Intercepted {

    private final InterceptorChain[] chains;

    @Override
    public String greet(String name) {
        // 인터셉터 체인을 통해 실행
        return (String) this.chains[0].proceed(name);
    }
}

이 클래스가 실제로 주입됩니다. HelloControllerGreetingService를 주입받을 때, 실제로는 $GreetingService$Intercepted 인스턴스를 받습니다. GreetingService의 하위 클래스이므로 타입 호환성이 있습니다.

Spring의 Runtime CGLIB Proxy와 비교

Spring의 AOP는 런타임에 CGLIB 또는 JDK Dynamic Proxy를 사용합니다.

Spring AOP (런타임):
  앱 시작 → BeanFactory 초기화 → @Aspect 분석 →
  CGLIB으로 프록시 바이트코드 생성 → 클래스 로드 → Bean 등록

Micronaut AOP (컴파일 타임):
  javac + AP → 프록시 클래스 생성 → .class 저장 →
  앱 시작 → 이미 만들어진 .class 로드 → Bean 등록
항목Spring AOPMicronaut AOP
프록시 생성 시점런타임 (시작 시)컴파일 타임
생성 방법CGLIB / JDK ProxyAnnotation Processor
런타임 오버헤드있음없음 (빌드 시 생성 완료)
GraalVM 호환추가 설정 필요기본 동작
스택 트레이스CGLIB 클래스 포함명시적 클래스명

Spring의 프록시 클래스 이름은 OrderService$$SpringCGLIB$$0처럼 생성됩니다. Micronaut은 $OrderService$Intercepted처럼 더 읽기 쉬운 이름을 사용합니다.


왜 이게 중요한가

Reflection이 없으면 뭐가 달라지나

시작 시간: Spring은 시작 시 classpath의 모든 클래스를 탐색하여 @Component, @Service 등의 어노테이션을 reflection으로 확인하고 Bean을 등록합니다. Micronaut은 annotation processor가 컴파일 타임에 이미 작성해둔 BeanDefinitionReference 목록을 읽어 Bean을 로드합니다. JVM의 클래스 로딩 자체는 동일하게 일어나지만, Bean 등록을 위해 classpath 전체를 탐색하는 비용이 없습니다.

Spring 시작:
  1. classpath의 모든 .class 파일 스캔
  2. @Component, @Service 등 어노테이션 reflection으로 탐색
  3. BeanDefinition 동적 생성
  4. 의존성 그래프 계산
  5. Bean 초기화

Micronaut 시작:
  1. META-INF의 BeanDefinitionReference 목록 로드 (ServiceLoader)
  2. 필요한 BeanDefinition 로드 (이미 .class로 존재)
  3. Bean 초기화

메모리: Spring은 reflection 메타데이터, CGLIB 생성 클래스, classpath 스캔 결과 등을 메모리에 유지합니다. Micronaut은 컴파일된 클래스만 사용합니다.

타입 안전성: 의존성 오류가 컴파일 타임에 발견됩니다.

// Micronaut: 컴파일 오류 발생
@Singleton
public class OrderService {
    @Inject
    public OrderService(NonExistentRepository repo) { ... }
    // 컴파일 시 "NonExistentRepository에 대한 Bean이 없음" 오류
}

Spring에서는 이 오류가 애플리케이션을 시작하고 컨텍스트가 로드되는 시점에야 발견됩니다.

GraalVM Native Image와의 시너지

GraalVM Native Image는 실행에 필요한 모든 코드를 AOT 컴파일합니다. 이를 위해 Closed World Assumption을 전제합니다: 실행 중에 동적으로 로드되는 클래스가 없어야 합니다.

동적 reflection은 이 가정을 깨트립니다. Class.forName("com.example.Foo")처럼 문자열로 클래스를 로드하는 코드는 Native Image 빌드 시 별도의 reflection 설정 파일(reflect-config.json)에 명시해야 합니다.

// Spring Native에서 필요한 reflect-config.json 예시
[
  {
    "name": "com.example.OrderService",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true
  },
  ...수백 개의 항목...
]

Micronaut은 런타임 reflection을 사용하지 않으므로 이 파일이 거의 필요하지 않습니다. ./gradlew nativeCompile 한 번이면 Native Image가 빌드됩니다.

# Native Image 빌드 (GraalVM 설치 필요)
./gradlew nativeCompile

# 실행
./build/native/nativeCompile/demo

실행 결과:

 __  __ _                                  _
|  \/  (_) ___ _ __ ___  _ __   __ _ _   _| |_
| |\/| | |/ __| '__/ _ \| '_ \ / _` | | | | __|
| |  | | | (__| | | (_) | | | | (_| | |_| | |_
|_|  |_|_|\___|_|  \___/|_| |_|\__,_|\__,_|\__|
  Micronaut (v4.x.x)

08:15:23.001 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 8ms.

위 출력은 예시입니다. 실제 수치는 애플리케이션 규모와 환경에 따라 다릅니다. 공개된 벤치마크에서 단순 Micronaut 앱 기준으로 JVM 모드 약 300~700ms, Native Image 수십 ms 수준이 보고됩니다.3

예측 가능성 (Predictability)

Micronaut의 컴파일 타임 처리는 “예측 가능성”을 높입니다. 빌드가 성공했다면 실행 시 DI 관련 오류가 발생하지 않습니다. 의존성 그래프가 이미 검증되었습니다.

Spring에서는 가끔 이런 상황이 발생합니다.

애플리케이션이 로컬에서는 정상 동작하지만,
특정 profile이 활성화된 운영 환경에서만
NoSuchBeanDefinitionException이 발생한다.

Micronaut에서 profile(@Requires)을 잘못 설정한 경우에도, 컴파일 타임에 가능한 범위에서 오류를 먼저 감지합니다.

순환 참조(Circular Dependency)도 마찬가지입니다. Bean A가 Bean B를 의존하고, Bean B가 다시 Bean A를 의존하는 구조입니다.

@Service
public class ServiceA {
    @Autowired ServiceB serviceB; // ServiceB가 필요
}

@Service
public class ServiceB {
    @Autowired ServiceA serviceA; // ServiceA가 필요 → 순환
}

Spring은 의존성 그래프를 런타임에 구성하므로, 이 문제가 애플리케이션 시작 시점에야 드러납니다.

The dependencies of some of the beans in the application context
form a cycle:
serviceA → serviceB → serviceA

Spring Boot 2.6부터는 기본적으로 순환 참조를 금지하고 시작 시 위 오류를 던집니다. 그 이전 버전에서는 field injection + @Lazy 조합으로 우회할 수 있었기 때문에, 잠재적인 순환 참조가 코드베이스에 숨어있다가 특정 조건에서만 드러나는 경우도 있었습니다.

Micronaut은 annotation processor가 컴파일 타임에 의존성 그래프 전체를 분석하므로, 순환 참조가 있으면 빌드 자체가 실패합니다. “배포하고 나서야 알게 되는” 상황이 원천적으로 차단됩니다.


정리: 컴파일 타임에 무엇이 일어나는가

프로젝트를 빌드할 때 Micronaut annotation processor가 수행하는 작업 목록입니다.

  1. Bean 탐색: @Singleton, @Controller, @Repository 등이 붙은 클래스 탐색
  2. 의존성 분석: 생성자 파라미터 타입으로 의존성 그래프 구성
  3. 타입 검증: 각 의존성이 충족 가능한지 확인 (오류 시 컴파일 실패)
  4. BeanDefinition 생성: 각 Bean에 대한 $XxxDefinition.class 생성
  5. BeanDefinitionReference 생성: $XxxDefinitionReference.class 생성
  6. ServiceLoader 파일 갱신: META-INF/services/io.micronaut.inject.BeanDefinitionReference 파일에 등록
  7. AOP 프록시 생성: @Around가 있으면 $Xxx$Intercepted.class 생성
  8. AnnotationMetadata 생성: 어노테이션 정보를 상수로 변환
  9. ExecutableMethod 생성: 각 메서드의 직접 호출 래퍼 생성
  10. Configuration 검증: @ConfigurationProperties 바인딩 타입 검증

이 모든 과정이 ./gradlew build 한 번에 완료됩니다. JVM이 시작하면 Bean 등록을 위한 classpath 스캔 없이 이미 생성된 클래스들을 로드합니다.


4편에서는 이 내부 동작이 실제 성능과 개발 경험에 어떤 차이를 만드는지, Spring과 직접 비교합니다.


이전 편: 첫 Micronaut 프로젝트 만들기

다음 편: Spring과 Micronaut 비교 — 무엇을 선택할까

Footnotes

  1. Micronaut Gradle 플러그인의 자동 구성 동작은 공식 플러그인 문서에서 확인할 수 있습니다.

  2. BeanDefinitionReference 인터페이스는 io.micronaut.inject 패키지에 위치합니다. Micronaut 4.x API 문서 참조.

  3. Micronaut 공식 블로그의 Performance ComparisonJava Code Geeks 2025 벤치마크 참조. 측정 환경(Bean 수, 하드웨어 등)에 따라 편차가 큽니다.

댓글 영역에 가까워지면 자동으로 불러옵니다.

Preparing comments...