Micronaut Data JDBC와 Virtual Thread — 컴파일 타임 쿼리 생성과 데이터 접근 계층
5편에서 Virtual Thread와 JDBC의 관계, 그리고 Micronaut이 EventLoop 기반 서버에서 blocking I/O를 처리하는 방식을 살펴봤습니다. 이번 편에서는 그 토대 위에서 실제 데이터 접근 계층을 구현합니다. Micronaut Data JDBC를 사용해 엔티티, 리포지토리, 서비스, 컨트롤러를 만들고, Virtual Thread 환경에서의 커넥션 풀 전략까지 다룹니다.
Micronaut Data JDBC란 무엇인가
Micronaut Data는 Micronaut 생태계의 데이터 접근 계층 프레임워크입니다. JPA(Hibernate), JDBC, MongoDB, R2DBC를 지원하며, 이 편에서 다루는 micronaut-data-jdbc는 순수 JDBC 위에서 동작하는 경량 구현체입니다.
Spring Data JPA와 가장 크게 다른 점은 쿼리 생성 시점입니다.
Spring Data JPA는 애플리케이션 시작 시점에 리포지토리 인터페이스의 메서드 이름을 파싱하여 JPQL을 생성하고, 동적 프록시를 통해 메서드 호출을 가로챕니다. 이 분석은 런타임 reflection으로 이루어지며, 잘못된 메서드 이름은 대부분 시작 시에 오류로 드러나지만 일부 경우에는 실제 호출 시점까지 감지되지 않기도 합니다.1
Spring Data JPA — 런타임 쿼리 생성:
애플리케이션 시작
↓
리포지토리 인터페이스 분석 (reflection)
↓
메서드 이름 파싱 → JPQL 생성
↓
JDK 동적 프록시 생성
↓
첫 호출 시 쿼리 실행
Micronaut Data JDBC는 다릅니다. Annotation Processor가 컴파일 타임에 리포지토리 인터페이스를 분석하고, 각 메서드에 대응하는 SQL을 생성하며, 구현 클래스를 바이트코드로 직접 만들어냅니다. 런타임에는 프록시도 reflection도 없습니다.
Micronaut Data JDBC — 컴파일 타임 쿼리 생성:
javac + micronaut-data-processor 실행
↓
리포지토리 인터페이스 분석
↓
SQL 생성 + 구현 클래스 바이트코드 생성
↓
.class 파일로 출력
런타임:
↓
생성된 구현 클래스를 직접 실행 (reflection 없음)
이 차이는 세 가지 실질적인 이점을 만듭니다.
첫째, 오류 조기 발견입니다. 잘못된 쿼리 메서드 이름은 빌드 시 컴파일 에러로 즉시 드러납니다. 프로덕션에서 처음 호출될 때까지 숨어있지 않습니다.
둘째, 빠른 시작 시간입니다. 런타임에 메서드 분석, 프록시 생성, JPQL 파싱이 없으므로 애플리케이션 시작이 빠릅니다. 4편에서 살펴본 Spring Boot 대비 시작 시간 이점이 데이터 계층에서도 그대로 적용됩니다.
셋째, GraalVM Native Image 호환성입니다. reflection이 없으므로 GraalVM의 정적 분석이 데이터 접근 계층을 완전히 추적할 수 있습니다. Spring Data JPA의 런타임 프록시는 GraalVM Native Image에서 별도의 AOT 힌트를 필요로 합니다.
의존성 설정
build.gradle.kts (Kotlin DSL)로 의존성을 선언합니다.
plugins {
id("io.micronaut.application") version "4.4.4"
}
micronaut {
version = "4.8.2"
runtime("netty")
testRuntime("junit5")
processing {
incremental(true)
annotations("com.example.*")
}
}
dependencies {
// Micronaut Data JDBC 핵심
implementation("io.micronaut.data:micronaut-data-jdbc")
// HikariCP 커넥션 풀 (micronaut-jdbc-hikari에 포함)
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
// Jackson Annotations bridge — @JsonProperty 등 사용 가능.
// 실제 직렬화 로직은 micronaut-serde-processor가 컴파일 타임에 생성한 코드가 수행.
// jackson-databind(리플렉션 기반 ObjectMapper)는 포함되지 않는다.
implementation("io.micronaut.serde:micronaut-serde-jackson")
// Validation
implementation("io.micronaut.validation:micronaut-validation")
// 프로덕션 DB: PostgreSQL 드라이버
runtimeOnly("org.postgresql:postgresql")
// 테스트용 H2 인메모리 DB
testRuntimeOnly("com.h2database:h2")
// Annotation Processor — 컴파일 타임 쿼리 생성의 핵심
annotationProcessor("io.micronaut.data:micronaut-data-processor")
annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
}
micronaut-data-processor가 핵심입니다. 이 annotation processor가 컴파일 타임에 리포지토리 인터페이스를 분석하고 구현 클래스를 생성합니다. annotationProcessor 스코프에 선언해야 하며, implementation으로 선언하면 동작하지 않습니다.
micronaut.processing.incremental = true는 Gradle의 증분 컴파일을 annotation processor와 연동하여 변경된 파일만 재처리하도록 합니다. annotations 설정은 어떤 패키지의 어노테이션을 처리할지 지정합니다.
DataSource와 HikariCP 설정
src/main/resources/application.yml에서 데이터소스와 커넥션 풀을 설정합니다.
datasources:
default:
url: jdbc:postgresql://localhost:5432/shop
username: ${DB_USERNAME:shopuser}
password: ${DB_PASSWORD:shoppass}
driver-class-name: org.postgresql.Driver
db-type: postgres
# HikariCP 커넥션 풀 설정
hikari:
# 풀에서 유지할 최대 커넥션 수
maximum-pool-size: 10
# 풀에서 유지할 최소 유휴 커넥션 수
minimum-idle: 5
# 커넥션을 빌릴 때 대기하는 최대 시간 (ms)
connection-timeout: 30000
# 커넥션 유효성 검사 쿼리
connection-test-query: SELECT 1
# 커넥션이 풀에서 idle 상태로 머무를 수 있는 최대 시간 (ms)
idle-timeout: 600000
# 커넥션의 최대 수명 (ms)
max-lifetime: 1800000
# 풀 이름 (모니터링, 로그에 표시됨)
pool-name: ShopHikariPool
micronaut:
application:
name: shop-service
# BLOCKING executor를 Virtual Thread로 구성 (JDK 21+에서는 자동 적용되지만 명시적 선언도 가능)
executors:
blocking:
virtual: true
${DB_USERNAME:shopuser} 문법은 환경변수 DB_USERNAME이 있으면 그 값을, 없으면 shopuser를 기본값으로 사용합니다. 로컬 개발 환경과 프로덕션 환경에서 동일한 설정 파일을 사용하면서 민감 정보를 환경변수로 주입하는 패턴입니다.
micronaut.executors.blocking.virtual: true는 5편에서 소개한 설정입니다. JDBC 쿼리를 처리하는 BLOCKING executor를 Virtual Thread executor로 교체합니다. 이 설정 하나로 @ExecuteOn(TaskExecutors.BLOCKING)이 붙은 모든 작업이 Virtual Thread에서 실행됩니다.
테스트 환경용 application-test.yml은 H2를 사용합니다.
# src/test/resources/application-test.yml
datasources:
default:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password: ''
driver-class-name: org.h2.Driver
db-type: h2
schema-generate: CREATE_DROP # 테스트 시작 시 스키마 생성, 종료 시 삭제
dialect: H2
Entity 정의
Micronaut Data JDBC의 엔티티는 JPA의 @Entity와 다른 어노테이션을 사용합니다. JPA 어노테이션을 그대로 쓰는 것이 아니라 io.micronaut.data.annotation 패키지의 어노테이션을 사용합니다.
package com.example.domain;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.annotation.MappedProperty;
import io.micronaut.serde.annotation.Serdeable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Serdeable // JSON 직렬화/역직렬화 활성화
@MappedEntity("products") // 매핑할 테이블 이름
public class Product {
@Id
@GeneratedValue(GeneratedValue.Type.AUTO) // DB auto-increment
private Long id;
@MappedProperty("product_name") // 컬럼명이 필드명과 다를 때 명시
private String name;
private String description;
private BigDecimal price;
@MappedProperty("stock_quantity")
private Integer stockQuantity;
@MappedProperty("created_at")
private LocalDateTime createdAt;
@MappedProperty("updated_at")
private LocalDateTime updatedAt;
// 기본 생성자 (Micronaut Data가 ResultSet에서 객체를 생성할 때 사용)
public Product() {}
public Product(String name, String description, BigDecimal price, Integer stockQuantity) {
this.name = name;
this.description = description;
this.price = price;
this.stockQuantity = stockQuantity;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public Integer getStockQuantity() { return stockQuantity; }
public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
주문 엔티티도 정의합니다.
package com.example.domain;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.annotation.MappedProperty;
import io.micronaut.data.annotation.Relation;
import io.micronaut.serde.annotation.Serdeable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Serdeable
@MappedEntity("orders")
public class Order {
@Id
@GeneratedValue(GeneratedValue.Type.AUTO)
private Long id;
@MappedProperty("customer_id")
private Long customerId;
// 연관 엔티티: Product와 N:1 관계
@Relation(value = Relation.Kind.MANY_TO_ONE)
private Product product;
private Integer quantity;
@MappedProperty("total_price")
private BigDecimal totalPrice;
private String status; // PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
@MappedProperty("created_at")
private LocalDateTime createdAt;
public Order() {}
public Order(Long customerId, Product product, Integer quantity) {
this.customerId = customerId;
this.product = product;
this.quantity = quantity;
this.totalPrice = product.getPrice().multiply(BigDecimal.valueOf(quantity));
this.status = "PENDING";
this.createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public Product getProduct() { return product; }
public void setProduct(Product product) { this.product = product; }
public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
public BigDecimal getTotalPrice() { return totalPrice; }
public void setTotalPrice(BigDecimal totalPrice) { this.totalPrice = totalPrice; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Spring Data JPA와의 어노테이션 비교입니다.
| 항목 | Spring Data JPA | Micronaut Data JDBC |
|---|---|---|
| 엔티티 선언 | @Entity (jakarta.persistence) | @MappedEntity (io.micronaut.data.annotation) |
| 기본 키 | @Id (jakarta.persistence) | @Id (io.micronaut.data.annotation) |
| 자동 생성 | @GeneratedValue (jakarta.persistence) | @GeneratedValue (io.micronaut.data.annotation) |
| 컬럼 매핑 | @Column(name = "...") | @MappedProperty("...") |
| 연관 관계 | @ManyToOne, @OneToMany 등 | @Relation(Kind.MANY_TO_ONE) 등 |
| JSON 직렬화 | Jackson 자동 감지 | @Serdeable 명시 필요 |
| 변경 감지 | Hibernate Dirty Checking | 없음 — 명시적 update 호출 필요 |
| 지연 로딩 | FetchType.LAZY (프록시) | 지원하지 않음 — 명시적 JOIN 필요 |
중요한 차이점은 변경 감지(Dirty Checking)와 지연 로딩입니다. JPA/Hibernate는 영속성 컨텍스트가 엔티티 상태 변화를 추적하여 자동으로 UPDATE 쿼리를 생성합니다. Micronaut Data JDBC에는 영속성 컨텍스트가 없습니다. 엔티티를 수정하고 저장하려면 반드시 repository.update(entity) 또는 repository.save(entity)를 명시적으로 호출해야 합니다.
이 차이는 단점이 아닙니다. 동작이 명확해지고, “언제 UPDATE가 나가는가”를 예측하기 쉬워집니다.
Repository 인터페이스
리포지토리 인터페이스를 정의합니다. @JdbcRepository는 이 인터페이스가 JDBC를 사용하는 Micronaut Data 리포지토리임을 나타내고, dialect는 사용할 SQL 방언을 지정합니다.
package com.example.repository;
import com.example.domain.Product;
import io.micronaut.data.annotation.Query;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import io.micronaut.data.repository.PageableRepository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@JdbcRepository(dialect = Dialect.POSTGRES)
public interface ProductRepository extends PageableRepository<Product, Long> {
// 메서드 이름으로 쿼리 생성: findBy + 필드명 + 조건
Optional<Product> findByName(String name);
List<Product> findByPriceLessThan(BigDecimal price);
List<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice);
List<Product> findByStockQuantityGreaterThan(Integer quantity);
// 정렬 조건 포함
List<Product> findByPriceLessThanOrderByPriceAsc(BigDecimal maxPrice);
// 페이징 — Pageable 파라미터를 받으면 Page<T> 반환
Page<Product> findByPriceLessThan(BigDecimal maxPrice, Pageable pageable);
// @Query — 네이티브 SQL 직접 작성
@Query("SELECT * FROM products WHERE LOWER(product_name) LIKE LOWER(:keyword)")
List<Product> searchByName(String keyword);
// UPDATE 쿼리
@Query("UPDATE products SET stock_quantity = stock_quantity - :quantity WHERE id = :id AND stock_quantity >= :quantity")
int decreaseStock(Long id, int quantity);
// 집계 쿼리
@Query("SELECT COUNT(*) FROM products WHERE price < :price")
long countCheaperThan(BigDecimal price);
// 존재 여부 확인
boolean existsByName(String name);
}
주문 리포지토리도 정의합니다.
package com.example.repository;
import com.example.domain.Order;
import io.micronaut.data.annotation.Join;
import io.micronaut.data.annotation.Query;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import java.util.List;
import java.util.Optional;
@JdbcRepository(dialect = Dialect.POSTGRES)
public interface OrderRepository extends CrudRepository<Order, Long> {
// JOIN을 명시적으로 지정 — 지연 로딩이 없으므로 필요한 JOIN을 선언
@Join(value = "product", type = Join.Type.LEFT_FETCH)
Optional<Order> findById(Long id);
@Join(value = "product", type = Join.Type.LEFT_FETCH)
List<Order> findByCustomerId(Long customerId);
@Join(value = "product", type = Join.Type.LEFT_FETCH)
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
List<Order> findByStatus(String status);
@Query("UPDATE orders SET status = :status WHERE id = :id")
int updateStatus(Long id, String status);
}
Spring Data JPA와의 리포지토리 비교입니다.
| 항목 | Spring Data JPA | Micronaut Data JDBC |
|---|---|---|
| 기반 어노테이션 | @Repository (자동 감지) | @JdbcRepository(dialect = ...) |
| 쿼리 생성 시점 | 런타임 (애플리케이션 시작 시) | 컴파일 타임 |
| 기본 인터페이스 | JpaRepository<T, ID> | CrudRepository<T, ID> 또는 PageableRepository<T, ID> |
| 커스텀 쿼리 | @Query (JPQL 또는 nativeQuery=true) | @Query (네이티브 SQL) |
| 지연 로딩 | @ManyToOne(fetch = LAZY) 자동 | @Join 명시 필요 |
| 영속성 컨텍스트 | 있음 (Hibernate) | 없음 |
| 잘못된 메서드 이름 | 런타임 오류 | 컴파일 오류 |
| 반환 타입 | Optional, List, Page | Optional, List, Page |
PageableRepository는 CrudRepository를 확장하며 페이징 관련 메서드를 추가로 제공합니다. findAll(Pageable pageable)이 자동으로 제공되어 별도 선언 없이 페이징을 사용할 수 있습니다.
컴파일 타임 쿼리 생성 원리
micronaut-data-processor가 컴파일 타임에 실제로 무슨 일을 하는지 살펴봅니다.
ProductRepository 인터페이스를 컴파일하면 build/classes/java/main/com/example/repository/ 디렉토리에 다음 클래스들이 생성됩니다.
build/classes/java/main/com/example/repository/
├── ProductRepository.class ← 원본 인터페이스
├── $ProductRepositoryDefinition.class ← Bean 정의
├── $ProductRepositoryDefinitionReference.class
└── ProductRepositoryImpl.class ← 실제 구현 클래스 (자동 생성)
ProductRepositoryImpl은 processor가 생성한 구체 클래스입니다. 내부를 javap로 역컴파일해보면 각 메서드가 이미 SQL 문자열을 포함하고 있음을 확인할 수 있습니다.
// 생성된 코드를 단순화하여 표현 — 실제 생성 코드는 더 복잡합니다
public class ProductRepositoryImpl extends AbstractJdbcRepository implements ProductRepository {
// findByName 메서드에 대응하는 미리 준비된 SQL
private static final String QUERY_FIND_BY_NAME =
"SELECT product_.id, product_.product_name, product_.description, " +
"product_.price, product_.stock_quantity, product_.created_at, product_.updated_at " +
"FROM products product_ WHERE product_.product_name = ?";
// findByPriceLessThan 메서드에 대응하는 SQL
private static final String QUERY_FIND_BY_PRICE_LESS_THAN =
"SELECT product_.id, product_.product_name, product_.description, " +
"product_.price, product_.stock_quantity, product_.created_at, product_.updated_at " +
"FROM products product_ WHERE product_.price < ?";
@Override
public Optional<Product> findByName(String name) {
// 생성된 SQL을 PreparedStatement로 실행
return queryOne(QUERY_FIND_BY_NAME, name);
}
@Override
public List<Product> findByPriceLessThan(BigDecimal price) {
return queryMany(QUERY_FIND_BY_PRICE_LESS_THAN, price);
}
// ... 나머지 메서드들
}
실제 생성 코드는 추상 기반 클래스의 헬퍼 메서드를 사용하고, ResultSet 매핑 로직도 포함하지만, 핵심은 명확합니다. SQL이 이미 문자열 상수로 결정되어 있습니다.
만약 리포지토리에 오타가 있다면 어떻게 될까요.
// 잘못된 메서드 이름 — 'ProductName' 이라는 필드가 없음
List<Product> findByProductName(String name);
이 코드를 빌드하면 ./gradlew build 실행 시 다음과 같은 컴파일 에러가 발생합니다.
error: Cannot automatically determine the query for method: findByProductName
com.example.repository.ProductRepository.findByProductName(String)
런타임에 첫 호출 시 발생할 오류가 빌드 단계에서 잡힙니다. 이는 CI/CD 파이프라인에서 빌드 실패로 자동 차단되어, 잘못된 데이터 접근 코드가 프로덕션에 배포되는 것을 막습니다.
Service 계층과 @Transactional
서비스 계층에서 트랜잭션을 관리합니다.
package com.example.service;
import com.example.domain.Order;
import com.example.domain.Product;
import com.example.repository.OrderRepository;
import com.example.repository.ProductRepository;
import io.micronaut.transaction.annotation.Transactional;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
@Singleton
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
// 생성자 주입 — @Inject 생략 가능 (Micronaut이 유일한 생성자를 자동 인식)
public OrderService(OrderRepository orderRepository,
ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}
// @Transactional: 메서드 실행 전 트랜잭션 시작, 정상 종료 시 커밋, 예외 시 롤백
@Transactional
public Order createOrder(Long customerId, Long productId, Integer quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다: " + productId));
if (product.getStockQuantity() < quantity) {
throw new IllegalStateException(
"재고가 부족합니다. 요청: " + quantity + ", 현재 재고: " + product.getStockQuantity()
);
}
// 재고 감소 — 재고 부족 시 0행 업데이트 반환
int updated = productRepository.decreaseStock(productId, quantity);
if (updated == 0) {
throw new IllegalStateException("재고 감소에 실패했습니다. 동시 요청을 확인하세요.");
}
// 주문 생성
Order order = new Order(customerId, product, quantity);
return orderRepository.save(order);
// 이 메서드가 정상 반환되면 트랜잭션 커밋
// RuntimeException이 던져지면 트랜잭션 롤백
}
// readOnly = true: 읽기 전용 트랜잭션 — 일부 DB에서 성능 최적화
@Transactional(readOnly = true)
public Optional<Order> findOrder(Long orderId) {
return orderRepository.findById(orderId);
}
@Transactional(readOnly = true)
public List<Order> findCustomerOrders(Long customerId) {
return orderRepository.findByCustomerId(customerId);
}
// rollbackOn: 특정 체크드 예외에서도 롤백
@Transactional(rollbackOn = {Exception.class})
public Order confirmOrder(Long orderId) throws Exception {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new Exception("주문을 찾을 수 없습니다: " + orderId));
if (!"PENDING".equals(order.getStatus())) {
throw new Exception("PENDING 상태의 주문만 확정할 수 있습니다. 현재 상태: " + order.getStatus());
}
int updated = orderRepository.updateStatus(orderId, "CONFIRMED");
if (updated == 0) {
throw new Exception("주문 상태 변경에 실패했습니다.");
}
order.setStatus("CONFIRMED");
return order;
}
// 중첩 트랜잭션 — 기본값은 REQUIRED: 기존 트랜잭션이 있으면 참여, 없으면 새로 시작
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다: " + orderId));
orderRepository.updateStatus(orderId, "CANCELLED");
// 재고 복원 — 같은 트랜잭션에서 실행됨
restoreStock(order.getProduct().getId(), order.getQuantity());
}
// 이 메서드는 cancelOrder에서 호출될 때 기존 트랜잭션에 참여
@Transactional
public void restoreStock(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다: " + productId));
product.setStockQuantity(product.getStockQuantity() + quantity);
productRepository.update(product);
}
}
@Transactional은 io.micronaut.transaction.annotation.Transactional을 사용합니다. Jakarta EE의 jakarta.transaction.Transactional도 지원하지만, Micronaut 네이티브 어노테이션이 컴파일 타임 AOP 처리를 통해 동작합니다.
Spring의 @Transactional과 동작 방식의 차이가 있습니다. Spring은 런타임 동적 프록시로 트랜잭션을 처리하므로, 같은 클래스 내부의 메서드 호출은 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다. Micronaut은 기본적으로(proxyTarget=false) 컴파일 타임에 대상 클래스의 서브클래스를 생성하고, 각 메서드 호출을 super.method()를 통해 인터셉터 체인으로 연결합니다. 공식 문서에 따르면 이 방식은 “클래스 내부 호출에도 프록시 메서드가 동작하도록” 허용합니다.2 위 예제의 cancelOrder에서 restoreStock을 호출할 때도 @Transactional 의미론이 정상적으로 동작합니다.
Controller와 연동
HTTP 요청을 받아 서비스를 호출하는 컨트롤러입니다.
package com.example.controller;
import com.example.domain.Order;
import com.example.domain.Product;
import com.example.repository.ProductRepository;
import com.example.service.OrderService;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Delete;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.QueryValue;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.util.List;
@Controller("/api")
@ExecuteOn(TaskExecutors.BLOCKING) // 이 컨트롤러의 모든 메서드를 BLOCKING executor에서 실행
public class ShopController {
private final ProductRepository productRepository;
private final OrderService orderService;
public ShopController(ProductRepository productRepository,
OrderService orderService) {
this.productRepository = productRepository;
this.orderService = orderService;
}
// 상품 목록 — 페이징 지원
@Get("/products")
public Page<Product> listProducts(
@Nullable @QueryValue Integer page,
@Nullable @QueryValue Integer size) {
int pageNum = page != null ? page : 0;
int pageSize = size != null ? size : 20;
return productRepository.findAll(Pageable.from(pageNum, pageSize));
}
// 상품 단건 조회
@Get("/products/{id}")
public HttpResponse<Product> getProduct(@PathVariable Long id) {
return productRepository.findById(id)
.map(HttpResponse::ok)
.orElse(HttpResponse.notFound());
}
// 가격 범위로 상품 검색
@Get("/products/search")
public List<Product> searchProducts(@QueryValue String keyword) {
return productRepository.searchByName("%" + keyword + "%");
}
// 상품 등록
@Post("/products")
public HttpResponse<Product> createProduct(@Body @Valid ProductCreateRequest request) {
if (productRepository.existsByName(request.name())) {
return HttpResponse.unprocessableEntity();
}
Product product = new Product(
request.name(),
request.description(),
request.price(),
request.stockQuantity()
);
Product saved = productRepository.save(product);
return HttpResponse.created(saved, URI.create("/api/products/" + saved.getId()));
}
// 주문 생성
@Post("/orders")
public HttpResponse<Order> createOrder(@Body @Valid OrderCreateRequest request) {
try {
Order order = orderService.createOrder(
request.customerId(),
request.productId(),
request.quantity()
);
return HttpResponse.created(order, URI.create("/api/orders/" + order.getId()));
} catch (IllegalStateException e) {
return HttpResponse.unprocessableEntity();
}
}
// 주문 조회
@Get("/orders/{id}")
public HttpResponse<Order> getOrder(@PathVariable Long id) {
return orderService.findOrder(id)
.map(HttpResponse::ok)
.orElse(HttpResponse.notFound());
}
// 주문 확정
@Post("/orders/{id}/confirm")
public HttpResponse<Order> confirmOrder(@PathVariable Long id) {
try {
Order order = orderService.confirmOrder(id);
return HttpResponse.ok(order);
} catch (Exception e) {
return HttpResponse.unprocessableEntity();
}
}
// 주문 취소
@Delete("/orders/{id}")
public HttpResponse<Void> cancelOrder(@PathVariable Long id) {
try {
orderService.cancelOrder(id);
return HttpResponse.noContent();
} catch (IllegalArgumentException e) {
return HttpResponse.notFound();
}
}
}
요청 바인딩을 위한 레코드 타입입니다.
package com.example.controller;
import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
@Serdeable
public record ProductCreateRequest(
@NotBlank String name,
String description,
@NotNull @Positive BigDecimal price,
@NotNull @Min(0) Integer stockQuantity
) {}
@Serdeable
public record OrderCreateRequest(
@NotNull Long customerId,
@NotNull Long productId,
@NotNull @Min(1) Integer quantity
) {}
Virtual Thread와 JDBC의 궁합
이번 편의 핵심 섹션입니다. 5편에서 소개한 개념들이 실제 데이터 접근 계층과 어떻게 연결되는지 구체적으로 살펴봅니다.
기본 흐름
Micronaut의 HTTP 서버는 Netty EventLoop 기반입니다. EventLoop thread에서 blocking I/O(JDBC 쿼리)를 직접 실행하면 해당 EventLoop가 담당하는 모든 커넥션이 DB 응답을 기다리는 동안 멈춥니다.
[잘못된 방법] EventLoop에서 직접 JDBC 실행:
EventLoop Thread
─────────────────────────────────────────────────────────────────
요청 수신 → Product.findById() → [=========DB 대기=========] → 응답
↑ JDBC blocking
이 동안 같은 EventLoop의 모든 커넥션이 대기
@ExecuteOn(TaskExecutors.BLOCKING)을 사용하면 JDBC 호출을 별도 executor로 위임합니다.
[올바른 방법] BLOCKING executor로 offload:
EventLoop Thread
─────────────────────────────────────────────────────────────────
요청 수신 → offload 요청 → (다른 커넥션 처리 계속) → 결과 수신 → 응답
↓
BLOCKING executor (Virtual Thread)
─────────────────────────────────────────────────────────────────
→ Product.findById() → [DB 대기] → 완료 → EventLoop 반환
Virtual Thread가 더하는 것
application.yml에서 BLOCKING executor를 Virtual Thread로 구성하면 (virtual: true), JDBC를 실행하는 스레드가 Virtual Thread가 됩니다.
Virtual Thread가 JDBC를 실행할 때:
VThread-1: [쿼리 준비] → [executeQuery 호출] → park → [결과 수신] → [결과 처리]
Carrier: [=VT-1 실행=] → [=VT-2 실행=] → [=VT-3 실행=] → [=VT-1 재개=]
↑
DB 대기 중 carrier thread가 다른 VT 처리
전체 요청 처리 흐름 (Virtual Thread 환경):
클라이언트
│
↓ HTTP 요청
┌─────────────────┐
│ Netty │
│ EventLoop │ (CPU 코어 수 × 2 스레드)
│ Thread │
└────────┬────────┘
│ @ExecuteOn(BLOCKING) → Virtual Thread executor로 offload
↓
┌─────────────────────────────────────────────┐
│ Virtual Thread (VT) │
│ (수천 개 동시 실행 가능, OS thread는 소수) │
│ │
│ @Transactional 시작 │
│ │ │
│ ↓ │
│ HikariCP.getConnection() │
│ │ │
│ ↓ [커넥션 획득] │
│ productRepository.findById(id) │
│ │ │
│ ↓ park (DB I/O 대기) │
│ ↑ unpark (DB 응답 도착) │
│ │ │
│ orderRepository.save(order) │
│ │ │
│ ↓ park → unpark │
│ │ │
│ @Transactional 커밋 │
│ │ │
│ ↓ │
│ 커넥션 반환 (HikariCP) │
└────────┬────────────────────────────────────┘
│ 결과 반환 → EventLoop thread로 전달
↓
┌─────────────────┐
│ Netty │
│ EventLoop │ HTTP 응답 전송
└─────────────────┘
│
↓ HTTP 응답
클라이언트
JDK 버전과 HikariCP Pinning 문제
5편에서 설명한 pinning 문제가 JDBC + Virtual Thread 조합에서 구체적으로 어떻게 나타나는지 살펴봅니다.
HikariCP는 커넥션 풀 관리 내부에서 synchronized를 사용합니다. JDK 21~23 환경에서 Virtual Thread가 HikariCP.getConnection() 중 synchronized 블록 안에서 대기하게 되면 carrier thread가 묶입니다(pinning). 동시에 많은 Virtual Thread가 커넥션을 기다리면 carrier thread들이 모두 pinning되어 처리량이 급격히 떨어질 수 있습니다.
JDK 24에서 JEP 491: Synchronize Virtual Threads without Pinning이 도입되어 이 문제가 해결되었습니다. synchronized 블록 안에서 blocking 대기가 발생해도 carrier thread를 다른 Virtual Thread에 내어줄 수 있게 됩니다.
따라서 JDBC + Virtual Thread 조합을 프로덕션에 사용할 때는 JEP 491이 포함된 JDK 24 이상을 강력히 권장합니다. JDK 21~23에서도 -Djdk.tracePinnedThreads=full JVM 옵션으로 pinning 발생 여부를 모니터링할 수 있습니다.
Virtual Thread 환경에서의 커넥션 풀 사이즈 전략
일반적인 커넥션 풀 사이즈 설정 조언은 connections = ((core_count * 2) + effective_spindle_count) 공식이나3, 또는 서비스의 동시 사용자 수에 비례하여 늘리는 방식이었습니다. 그러나 Virtual Thread 환경에서는 이 접근이 바뀝니다.
커넥션 풀의 병목은 스레드 수가 아니라 DB 서버의 처리 능력입니다. DB가 동시에 처리할 수 있는 쿼리 수는 DB 서버의 CPU와 I/O에 의존하며, Java 애플리케이션에서 수천 개의 Virtual Thread를 만들어도 DB가 처리할 수 있는 쿼리 수가 늘어나지는 않습니다.
오히려 너무 큰 커넥션 풀은 역효과를 냅니다. 많은 커넥션이 동시에 쿼리를 던지면 DB 서버 내부에서 경합이 증가하고, 커넥션 유지 비용(메모리, TCP 소켓)도 증가합니다.
| 환경 | 참고 maximum-pool-size |
|---|---|
| OS 스레드 기반, 동시 요청 200개 | ~200 (스레드 수에 맞춰 증가) |
| Virtual Thread, DB 서버 8코어 | 20-40 (DB 처리 능력 기준 추정)4 |
| Virtual Thread, DB 서버 32코어 | 40~804 |
핵심은 풀 사이즈가 스레드 수 기준이 아닌 DB 서버 처리 능력 기준이 된다는 것입니다. Virtual Thread 환경에서는 커넥션 획득이 block되어도 OS thread를 낭비하지 않으므로(JDK 24+에서는 pinning도 없음), 커넥션이 사용 가능해질 때까지 Virtual Thread가 park 상태로 기다리면 됩니다.
테스트
@MicronautTest와 H2 인메모리 DB를 사용한 통합 테스트입니다.
package com.example.service;
import com.example.domain.Order;
import com.example.domain.Product;
import com.example.repository.OrderRepository;
import com.example.repository.ProductRepository;
import io.micronaut.test.annotation.Sql;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@MicronautTest // Micronaut 컨텍스트 시작 (H2 사용)
@Sql(scripts = "classpath:sql/schema.sql", // 스키마 생성
executionPhase = Sql.Phase.BEFORE_ALL)
@Sql(scripts = "classpath:sql/cleanup.sql", // 테스트 후 데이터 정리
executionPhase = Sql.Phase.AFTER_EACH)
class OrderServiceTest {
@Inject
OrderService orderService;
@Inject
ProductRepository productRepository;
@Inject
OrderRepository orderRepository;
@Test
@DisplayName("재고가 충분할 때 주문이 생성되고 재고가 감소한다")
@Sql(scripts = "classpath:sql/product-fixture.sql",
executionPhase = Sql.Phase.BEFORE_EACH)
void createOrder_withSufficientStock_savesOrderAndDecreasesStock() {
// given
Long productId = 1L;
Long customerId = 100L;
int orderQuantity = 3;
Product before = productRepository.findById(productId).orElseThrow();
int stockBefore = before.getStockQuantity();
// when
Order order = orderService.createOrder(customerId, productId, orderQuantity);
// then
assertNotNull(order.getId());
assertEquals("PENDING", order.getStatus());
assertEquals(customerId, order.getCustomerId());
assertEquals(orderQuantity, order.getQuantity());
Product after = productRepository.findById(productId).orElseThrow();
assertEquals(stockBefore - orderQuantity, after.getStockQuantity());
}
@Test
@DisplayName("재고 부족 시 예외가 발생하고 주문이 생성되지 않는다")
@Sql(scripts = "classpath:sql/product-fixture.sql",
executionPhase = Sql.Phase.BEFORE_EACH)
void createOrder_withInsufficientStock_throwsAndDoesNotSaveOrder() {
// given
Long productId = 1L;
int excessiveQuantity = 9999;
// when & then
assertThrows(IllegalStateException.class, () ->
orderService.createOrder(100L, productId, excessiveQuantity)
);
// 트랜잭션 롤백으로 주문이 저장되지 않았음을 확인
assertEquals(0, orderRepository.count());
}
@Test
@DisplayName("PENDING 주문을 CONFIRMED로 상태 변경할 수 있다")
@Sql(scripts = {"classpath:sql/product-fixture.sql", "classpath:sql/order-fixture.sql"},
executionPhase = Sql.Phase.BEFORE_EACH)
void confirmOrder_whenStatusIsPending_changesStatusToConfirmed() throws Exception {
// given — fixture의 주문 ID 1번은 PENDING 상태
Long orderId = 1L;
// when
Order confirmed = orderService.confirmOrder(orderId);
// then
assertEquals("CONFIRMED", confirmed.getStatus());
Optional<Order> fromDb = orderService.findOrder(orderId);
assertTrue(fromDb.isPresent());
assertEquals("CONFIRMED", fromDb.get().getStatus());
}
}
픽스처 SQL 파일들입니다.
-- src/test/resources/sql/schema.sql
CREATE TABLE IF NOT EXISTS products (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(19, 2) NOT NULL,
stock_quantity INTEGER NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INTEGER NOT NULL,
total_price DECIMAL(19, 2) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id)
);
-- src/test/resources/sql/product-fixture.sql
INSERT INTO products (id, product_name, description, price, stock_quantity, created_at, updated_at)
VALUES (1, '테스트 상품 A', '테스트용 상품입니다', 15000.00, 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- src/test/resources/sql/order-fixture.sql
INSERT INTO orders (id, customer_id, product_id, quantity, total_price, status, created_at)
VALUES (1, 100, 1, 2, 30000.00, 'PENDING', CURRENT_TIMESTAMP);
-- src/test/resources/sql/cleanup.sql
DELETE FROM orders;
DELETE FROM products;
@Sql의 executionPhase로 픽스처 실행 시점을 세밀하게 제어합니다. BEFORE_ALL은 테스트 클래스 전체에서 한 번 실행되고, BEFORE_EACH는 각 테스트 메서드 직전에 실행됩니다. AFTER_EACH로 테스트 후 데이터를 정리하여 테스트 간 독립성을 유지합니다.
정리
이 편에서 다룬 내용을 정리합니다.
| 주제 | 핵심 포인트 |
|---|---|
| 컴파일 타임 쿼리 생성 | micronaut-data-processor가 빌드 시 SQL을 생성하고 구현 클래스를 만든다 |
| 오류 조기 발견 | 잘못된 리포지토리 메서드 이름은 컴파일 에러로 즉시 드러난다 |
| 엔티티 어노테이션 | @MappedEntity, @Id, @GeneratedValue, @MappedProperty |
| 변경 감지 없음 | JPA의 Dirty Checking이 없으므로 명시적 repository.update() 필요 |
| JOIN 명시 | 지연 로딩이 없으므로 @Join으로 필요한 연관 관계를 선언 |
| @Transactional | 컴파일 타임 AOP — Spring과 달리 내부 메서드 호출에도 적용됨 |
| Virtual Thread | micronaut.executors.blocking.virtual: true 설정 하나로 BLOCKING executor를 VT로 전환 |
| 커넥션 풀 사이즈 | VT 환경에서는 스레드 수 기준이 아닌 DB 처리 능력 기준으로 설정 |
| JDK 버전 | JEP 491(JDK 24+)로 HikariCP pinning 문제가 해결됨 |
다음 편에서는 선언적 HTTP Client를 다룹니다. @Client 인터페이스로 외부 API를 호출하는 방법, Retry, Circuit Breaker, 인터셉터 패턴, 그리고 Virtual Thread 환경에서의 blocking client 활용을 살펴봅니다.
이전 편: HTTP 서버 모델과 Virtual Thread
다음 편: 선언적 HTTP Client와 Virtual Thread
Footnotes
-
Spring Data는 6.0(Spring Boot 3.0)부터 AOT 처리를 지원하기 시작했으며, GraalVM Native Image 빌드 시 컴파일 타임 처리를 활용할 수 있습니다. 일반 JVM 실행 환경에서는 여전히 런타임 reflection 기반 초기화가 기본입니다. ↩
-
Micronaut AOP 공식 문서 — Around Advice: “by default Micronaut will compile subclasses of the target class and call super.foo(..) to invoke the original method since this is more efficient and allows proxied methods to work for calls from within the class.” (https://docs.micronaut.io/latest/guide/#aroundAdvice) ↩
-
HikariCP의 About Pool Sizing 문서에서 유래한 공식입니다.
effective_spindle_count는 HDD의 물리 스핀들 수이며, SSD 환경에서는 보통 0으로 두고core_count * 2만 사용합니다. 자세한 설명은 [번역] 커넥션 풀 사이즈에 대하여를 참고하세요. ↩ -
위 수치는 개념 설명을 위한 추정치입니다. 실제 최적값은 DB 서버 사양, 쿼리 특성, 네트워크 지연에 따라 크게 달라집니다. 부하 테스트를 통해 DB CPU 사용률이 80% 수준에서 포화되는 커넥션 수를 측정하는 것이 가장 정확합니다. ↩ ↩2
댓글 영역에 가까워지면 자동으로 불러옵니다.
Preparing comments...