첫 Micronaut 프로젝트 만들기
1편에서는 왜 Micronaut이 만들어졌는지를 살펴봤습니다. 이번에는 직접 프로젝트를 만들어 봅니다. Spring Boot의 @RestController, @Service, @Autowired에 익숙하다면 대부분의 개념이 자연스럽게 연결됩니다.
Micronaut Launch 소개
launch.micronaut.io 사용법
Spring Initializr처럼 Micronaut도 웹 기반 프로젝트 생성기를 제공합니다. launch.micronaut.io에서 옵션을 선택하고 ZIP을 내려받으면 됩니다.
기본 설정으로 시작합니다.
- Application Type: Micronaut Application
- Java Version: 21
- Language: Java
- Build Tool: Gradle (Kotlin)
- Test Framework: JUnit
- Base Package:
com.example
필요한 Feature를 추가합니다. 이번 실습에서는 추가 없이 기본 상태로 진행합니다.
CLI를 선호한다면 Micronaut CLI를 설치하거나, curl로 직접 생성할 수 있습니다.
# Micronaut CLI 설치 (macOS/Linux, SDKMAN 사용)
sdk install micronaut
# 프로젝트 생성
mn create-app com.example.demo --build=gradle --lang=java
IntelliJ IDEA 통합
IntelliJ IDEA Ultimate 2020.2 이상에서는 Micronaut 프로젝트 생성 기능이 기본 내장되어 있습니다.1 New Project → Micronaut에서 바로 생성할 수 있습니다. VS Code는 Micronaut Tools 확장을 설치하면 됩니다.
프로젝트 구조 살펴보기
생성된 프로젝트의 주요 파일을 확인합니다.
demo/
├── build.gradle.kts
├── gradle/
│ └── wrapper/
├── settings.gradle.kts
└── src/
├── main/
│ ├── java/com/example/
│ │ └── Application.java
│ └── resources/
│ ├── application.yml
│ └── logback.xml
└── test/
└── java/com/example/
└── DemoTest.java
build.gradle.kts에서 Annotation Processor 설정 확인
가장 먼저 확인해야 할 부분이 build.gradle.kts입니다.
dependencies {
// HTTP 관련 어노테이션 처리 (@Controller, @Get 등)
annotationProcessor("io.micronaut:micronaut-http-validation")
// @Serdeable 처리 — 컴파일 타임에 직렬화/역직렬화 코드를 생성
annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
// Jackson Annotations bridge — @JsonProperty 등 사용 가능.
// 실제 직렬화 로직은 micronaut-serde-processor가 컴파일 타임에 생성한 코드가 수행.
// jackson-databind(리플렉션 기반 ObjectMapper)는 포함되지 않는다.
implementation("io.micronaut.serde:micronaut-serde-jackson")
runtimeOnly("ch.qos.logback:logback-classic")
testImplementation("io.micronaut:micronaut-http-client")
}
annotationProcessor가 핵심입니다. 이 의존성들이 컴파일 시점에 실행되어 BeanDefinition 등의 클래스를 생성합니다. Spring Boot의 spring-boot-starter와 달리, 어떤 작업이 빌드 시 일어나는지 명시적으로 드러납니다.
직렬화 방식도 다릅니다. Spring은 jackson-databind가 자동 구성되어 ObjectMapper가 런타임에 리플렉션으로 클래스를 탐색합니다. Micronaut은 micronaut-serde-processor가 빌드 시 직렬화/역직렬화 코드를 생성하고, 런타임에는 그 코드가 실행됩니다. micronaut-serde-jackson은 Jackson 자체가 아니라 @JsonProperty 등 Jackson Annotations를 코드 모델로 쓸 수 있게 해주는 bridge입니다. DTO에 @Serdeable을 붙이면 컴파일 타임에 해당 클래스의 직렬화 구현체가 생성됩니다.
Lombok을 함께 사용할 경우 선언 순서에 주의해야 합니다. Micronaut annotation processor는 소스 코드를 분석해 BeanDefinition을 생성하는데, Lombok이 @Getter·@AllArgsConstructor 등으로 생성한 메서드와 생성자를 그 대상으로 삼아야 하는 경우가 많습니다. Lombok processor가 먼저 실행되어 코드를 생성해두지 않으면, Micronaut processor가 생성자나 getter를 찾지 못해 빌드가 실패합니다.
annotationProcessor 선언 순서는 원칙적으로 실행 순서와 일치하지만, Gradle 의존성 해석 방식에 따라 보장되지 않는 경우도 보고된 바 있습니다2. 일반적인 프로젝트에서는 선언 순서를 맞추는 것으로 충분하므로, Lombok을 Micronaut보다 앞에 선언합니다.
dependencies {
// Lombok을 반드시 먼저 선언
annotationProcessor("org.projectlombok:lombok")
compileOnly("org.projectlombok:lombok")
// Micronaut processor는 그 다음
annotationProcessor("io.micronaut:micronaut-http-validation")
annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
// Jackson Annotations bridge — @JsonProperty 등 사용 가능.
// 실제 직렬화 로직은 micronaut-serde-processor가 컴파일 타임에 생성한 코드가 수행.
// jackson-databind(리플렉션 기반 ObjectMapper)는 포함되지 않는다.
implementation("io.micronaut.serde:micronaut-serde-jackson")
runtimeOnly("ch.qos.logback:logback-classic")
testImplementation("io.micronaut:micronaut-http-client")
}
Maven을 사용한다면, annotationProcessorPaths 내 선언 순서가 실행 순서입니다. 마찬가지로 Lombok을 먼저 선언합니다.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<!-- Lombok 먼저 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<!-- Micronaut processor는 그 다음 -->
<path>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-validation</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
application.yml
micronaut:
application:
name: demo
Spring Boot의 application.properties에 해당하지만 YAML을 기본으로 사용합니다. 설정 구조는 Spring Boot와 유사하며, micronaut.* 네임스페이스 하위에 프레임워크 설정이 위치합니다.
Application.java
package com.example;
import io.micronaut.runtime.Micronaut;
public class Application {
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
Spring Boot의 @SpringBootApplication과 거의 동일한 역할입니다. Micronaut.run()이 애플리케이션 컨텍스트를 초기화하고 서버를 시작합니다.
첫 번째 REST API 만들기
@Controller, @Get, @Post
src/main/java/com/example/HelloController.java를 생성합니다.
package com.example;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import jakarta.inject.Inject;
@Controller("/hello")
public class HelloController {
private final GreetingService greetingService;
@Inject
public HelloController(GreetingService greetingService) {
this.greetingService = greetingService;
}
@Get("/{name}")
@Produces(MediaType.TEXT_PLAIN)
public String greet(String name) {
return greetingService.greet(name);
}
}
Spring과의 대응 관계:
| Micronaut | Spring |
|---|---|
@Controller | @RestController |
@Get | @GetMapping |
@Post | @PostMapping |
@Body | @RequestBody |
@PathVariable 없어도 경로변수 자동 바인딩 | @PathVariable 명시 필요 |
@Produces는 Spring의 produces = "text/plain" 속성에 해당합니다. JSON 응답이 기본이므로 텍스트를 반환할 때만 명시합니다.
@Singleton, @Inject
package com.example;
import jakarta.inject.Singleton;
@Singleton
public class GreetingService {
public String greet(String name) {
return "안녕하세요, " + name + "!";
}
}
@Singleton은 Spring의 @Service 또는 @Component + 기본 scope(singleton)에 해당합니다. 주목할 점은 jakarta.inject.Singleton을 사용한다는 것입니다. JSR-330 표준 어노테이션이기 때문에, Spring과 Micronaut 모두 동일한 어노테이션을 이해합니다.
@Inject도 마찬가지입니다. Spring의 @Autowired 대신 JSR-330 표준인 @Inject를 사용합니다. @Autowired는 Micronaut core에서 기본적으로 인식하지 않으며3, JSR-330 표준 어노테이션을 쓰는 편이 이식성도 높습니다.
@Value, @Property
설정값을 주입받는 방법입니다.
package com.example;
import io.micronaut.context.annotation.Value;
import jakarta.inject.Singleton;
@Singleton
public class GreetingService {
@Value("${greeting.prefix:안녕하세요}")
private String prefix;
public String greet(String name) {
return prefix + ", " + name + "!";
}
}
${greeting.prefix:안녕하세요}에서 : 뒤가 기본값입니다. Spring의 @Value("${greeting.prefix:안녕하세요}")와 동일한 문법입니다.
더 구조적인 방식으로는 @ConfigurationProperties를 사용합니다.
package com.example;
import io.micronaut.context.annotation.ConfigurationProperties;
@ConfigurationProperties("greeting")
public class GreetingConfig {
private String prefix = "안녕하세요";
private String suffix = "!";
// getter/setter
public String getPrefix() { return prefix; }
public void setPrefix(String prefix) { this.prefix = prefix; }
public String getSuffix() { return suffix; }
public void setSuffix(String suffix) { this.suffix = suffix; }
}
# application.yml
greeting:
prefix: "반갑습니다"
suffix: "님"
@Singleton
public class GreetingService {
private final GreetingConfig config;
@Inject
public GreetingService(GreetingConfig config) {
this.config = config;
}
public String greet(String name) {
return config.getPrefix() + ", " + name + config.getSuffix();
}
}
Spring의 @ConfigurationProperties와 거의 동일한 패턴입니다.
Bean Scope 이해하기
@Singleton vs @Prototype vs @RequestScope
Micronaut의 Bean Scope는 Spring과 유사하지만 몇 가지 차이가 있습니다.
import jakarta.inject.Singleton;
@Singleton
public class MySingleton {
// 애플리케이션 전체에서 단 하나의 인스턴스
}
import io.micronaut.context.annotation.Prototype;
@Prototype
public class MyPrototype {
// 주입될 때마다 새 인스턴스 생성
}
import io.micronaut.runtime.http.scope.RequestScope;
@RequestScope
public class MyRequestScoped {
// HTTP 요청 당 하나의 인스턴스
// 요청이 끝나면 소멸
}
Spring 개발자를 위한 대응표
| Spring | Micronaut | 설명 |
|---|---|---|
@Component / @Service / @Repository + 기본 | @Singleton | 앱 전체 단일 인스턴스 |
@Scope("prototype") | @Prototype | 주입마다 새 인스턴스 |
@RequestScope | @RequestScope | HTTP 요청 범위 |
@SessionScope | @SessionScope | HTTP 세션 범위 |
@Bean (설정 클래스) | @Bean (설정 클래스) | 동일 |
@Primary | @Primary | 동일 |
@Qualifier | @Named | JSR-330 표준 |
@Lazy | @Lazy | 지연 초기화 |
한 가지 중요한 차이점이 있습니다. Spring은 애플리케이션 시작 시 classpath를 훑으며 @Component, @Service 등이 붙은 클래스를 직접 탐색하여 Bean으로 등록합니다. Micronaut은 annotation processor가 컴파일 타임에 이미 META-INF/micronaut/io.micronaut.context.BeanDefinitionReference 파일에 모든 Bean 목록을 기록해두기 때문에, Bean 등록을 위한 런타임 classpath scanning이 없습니다. JVM의 클래스 로딩 자체는 동일하게 일어나지만, “classpath 전체를 어노테이션 기준으로 뒤지는” 탐색 비용이 없는 것입니다. 이 덕분에 시작이 빠릅니다.
실행과 테스트
Gradle로 실행
# 개발 모드 실행
./gradlew run
# 빌드 후 실행
./gradlew build
java -jar build/libs/demo-0.1-all.jar
Maven을 사용한다면:
./mvnw mn:run
서버가 시작되면 기본적으로 8080 포트에서 수신합니다. Spring Boot와 동일합니다.
curl로 API 테스트
curl http://localhost:8080/hello/클로드
# 안녕하세요, 클로드!
@MicronautTest로 통합 테스트
Micronaut의 테스트 어노테이션은 @MicronautTest입니다.
package com.example;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@MicronautTest
class HelloControllerTest {
@Inject
@Client("/")
HttpClient client;
@Test
void greetReturnsKoreanMessage() {
String response = client.toBlocking()
.retrieve("/hello/세계");
assertThat(response).isEqualTo("안녕하세요, 세계!");
}
}
Spring의 @SpringBootTest + TestRestTemplate에 해당하는 패턴입니다. @MicronautTest는 전체 애플리케이션 컨텍스트를 로드하고 실제 HTTP 서버를 시작합니다. test 환경에서는 micronaut.server.port를 별도 지정하지 않으면 랜덤 포트로 기동되며4, @Client("/")는 EmbeddedServer가 선택한 포트를 자동으로 참조합니다.
단위 테스트에서는 Micronaut 컨텍스트 없이 순수하게 테스트할 수 있습니다.
package com.example;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GreetingServiceTest {
@Test
void greetIncludesName() {
GreetingConfig config = new GreetingConfig("prefix_", "_suffix");
GreetingService service = new GreetingService(config);
String result = service.greet("자바");
assertThat(result).contains("자바");
}
}
@MicronautTest 없이 순수 JUnit 테스트입니다. 컨텍스트 로드 비용이 없으므로 매우 빠릅니다.
테스트를 실행합니다.
./gradlew test
첫 프로젝트 완성
지금까지 만든 파일 구조입니다.
src/main/java/com/example/
├── Application.java
├── HelloController.java
├── GreetingService.java
└── GreetingConfig.java
src/main/resources/
├── application.yml
└── logback.xml
src/test/java/com/example/
├── HelloControllerTest.java
└── GreetingServiceTest.java
Spring Boot와 비교하면 어노테이션 이름만 다를 뿐 구조와 흐름은 거의 동일합니다. @SpringBootApplication → Micronaut.run(), @RestController → @Controller, @Service → @Singleton, @Autowired → @Inject.
다음 편에서는 이 간단한 프로젝트를 빌드했을 때 내부에서 무슨 일이 일어나는지를 살펴봅니다. 생성된 클래스 파일을 직접 열어보면 Micronaut이 얼마나 정교한 작업을 컴파일 타임에 처리하는지 확인할 수 있습니다.
이전 편: AOT의 시대를 열다 — Micronaut 소개와 역사적 맥락
다음 편: 컴파일 타임의 마법 — 내부 동작 심층 분석
Footnotes
-
Gradle annotation processor 순서가 보장되지 않는 사례: gradle/gradle#33136. 문제가 발생하면
configurations.annotationProcessortask에서 명시적으로 재정렬하는 workaround를 적용한다. ↩ -
@Autowired지원은 별도의micronaut-spring모듈(Micronaut for Spring)을 추가해야 동작한다. ↩ -
Micronaut 공식 문서: “By default if the configuration property micronaut.server.port is not specified a Micronaut application will run on port 8080 and tests will run on a random port.”
application.yml에 포트를 명시하면 테스트도 그 포트를 사용하므로 병렬 테스트 시 충돌 가능성이 있다. ↩
댓글 영역에 가까워지면 자동으로 불러옵니다.
Preparing comments...