Quarkus REST와 Hibernate Panache — API 개발의 기본
Spring Boot에서 REST API를 만들 때
@RestController와 Spring Data JPA를 쓰는 건 너무 익숙하다. 그런데 Quarkus에서는 같은 일을 어떻게 하고, 뭐가 다를까?
Quarkus REST — RESTEasy Reactive
Quarkus의 REST 레이어는 RESTEasy Reactive 라는 이름으로, Jakarta REST(구 JAX-RS) 표준 위에 빌드 타임 최적화와 Vert.x 이벤트 루프 통합을 더한 구현체입니다.
Spring MVC의 @RestController와 비슷한 역할을 하지만, 내부 동작 방식이 꽤 다릅니다.
Spring MVC와의 차이
| 구분 | Spring MVC | Quarkus REST |
|---|---|---|
| 표준 | Spring 자체 어노테이션 | Jakarta REST (JAX-RS) 표준 |
| 라우팅 처리 시점 | 런타임 리플렉션 | 빌드 타임에 라우팅 테이블 생성 |
| 기본 I/O 모델 | 서블릿 스레드 풀 | Vert.x 이벤트 루프 |
| 블로킹 처리 | 기본이 블로킹 | 논블로킹 기본, @Blocking으로 전환 |
빌드 타임에 라우팅을 미리 처리한다는 건, 애플리케이션이 시작될 때 리플렉션으로 어노테이션을 스캔하는 과정이 없다는 뜻입니다. 그래서 시작 속도가 빠르고 메모리도 적게 씁니다.
기본 엔드포인트 만들기
@Path("/hello")
public class GreetingResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "Hello from Quarkus";
}
}
Spring의 @GetMapping("/hello")와 거의 같은 느낌인데, Jakarta REST 표준 어노테이션(@Path, @GET, @Produces)을 사용합니다.
Spring 개발자라면 어노테이션 이름만 다르지 패턴은 동일하다고 느낄 수 있습니다. 실제로 Quarkus는
quarkus-spring-web확장을 통해 Spring MVC 어노테이션도 지원하지만, 네이티브하게 사용하려면 Jakarta REST가 권장됩니다.
논블로킹 vs 블로킹
Quarkus REST는 기본적으로 Vert.x 이벤트 루프 스레드 에서 요청을 처리합니다. 반환 타입에 따라 자동으로 실행 모델이 결정됩니다.
// 논블로킹 — 이벤트 루프에서 실행
@GET
@Path("/reactive")
public Uni<String> reactive() {
return Uni.createFrom().item("논블로킹 응답");
}
// 블로킹 — 워커 스레드로 오프로드
@GET
@Path("/blocking")
@Blocking
public String blocking() {
// DB 조회 등 블로킹 작업
return "블로킹 응답";
}
Uni<T>나Multi<T>를 반환하면 → 이벤트 루프에서 논블로킹 실행- 일반 타입(
String,List<T>등)을 반환하면 → 자동으로 워커 스레드에서 실행 @Blocking어노테이션으로 명시적으로 워커 스레드를 지정할 수도 있음
공부하다 보니 여기서 헷갈렸는데, Spring MVC는 기본이 블로킹이고 WebFlux가 논블로킹인 반면, Quarkus REST는 하나의 프레임워크 안에서 두 모델을 반환 타입으로 전환 합니다. 별도 모듈을 선택할 필요가 없다는 게 장점입니다.
Hibernate ORM with Panache
Panache는 Hibernate ORM 위에 올라가는 편의 레이어 입니다. JPA의 EntityManager를 직접 다루는 번거로움을 줄여주고, 두 가지 패턴을 제공합니다.
Active Record 패턴
엔티티 클래스 자체에 CRUD 메서드가 포함되는 방식입니다. Ruby on Rails의 Active Record와 같은 개념입니다.
@Entity
public class Person extends PanacheEntity {
// id 필드는 PanacheEntity가 제공
public String name;
public int age;
// getter/setter 불필요 — 빌드 타임에 자동 생성
// public 필드를 그대로 사용
// 커스텀 쿼리 메서드
public static List<Person> findByName(String name) {
return find("name", name).list();
}
public static List<Person> findAdults() {
return find("age >= ?1", 18).list();
}
}
사용하는 쪽에서는 이렇게 됩니다.
// 조회
Person person = Person.findById(1L);
List<Person> all = Person.listAll();
// 저장 (트랜잭션 안에서)
Person p = new Person();
p.name = "홍길동";
p.age = 25;
p.persist();
// 삭제
Person.deleteById(1L);
// 업데이트 — 변경 감지(dirty checking) 동작
person.name = "변경된 이름";
// flush 시 자동 반영
Spring Data JPA에 익숙하다면 "엔티티에 메서드가 붙어있다"는 게 낯설 수 있습니다. 하지만 간단한 CRUD에서는 Repository 클래스를 따로 만들 필요가 없어서 코드가 눈에 띄게 줄어듭니다.
Repository 패턴
Spring Data JPA처럼 엔티티와 데이터 접근 로직을 분리하고 싶다면 Repository 패턴을 사용합니다.
@Entity
public class Person extends PanacheEntity {
public String name;
public int age;
}
@ApplicationScoped
public class PersonRepository implements PanacheRepository<Person> {
public List<Person> findByName(String name) {
return find("name", name).list();
}
public List<Person> findAdults() {
return find("age >= ?1", 18).list();
}
}
두 패턴 모두 같은 기능을 제공합니다. 선택 기준은 다음과 같습니다.
- Active Record: 빠른 프로토타이핑, 간단한 도메인 모델
- Repository: 도메인 모델과 영속성 로직 분리가 필요할 때, 팀 컨벤션이 DDD/클린 아키텍처일 때
getter/setter가 필요 없는 이유
Panache의 특이한 점 중 하나는 public 필드를 그대로 사용해도 된다는 겁니다.
// 이렇게 써도
person.name = "홍길동";
String name = person.name;
// 빌드 타임에 자동으로 이렇게 변환됨
person.setName("홍길동");
String name = person.getName();
빌드 타임에 바이트코드 변환을 통해 public 필드 접근을 getter/setter 호출로 교체합니다. 그래서 캡슐화를 깨뜨리지 않으면서도 보일러플레이트를 없앨 수 있습니다. Lombok 없이도 깔끔한 코드가 가능한 이유입니다.
JSON 직렬화 — Reflection-free Jackson
Quarkus 3.20 이후부터는 ** 리플렉션 없이 JSON을 직렬화/역직렬화 **하는 Jackson 모드를 사용할 수 있습니다.
# application.properties
quarkus.jackson.reflection-free-serializers.enabled=true
기존 Jackson은 런타임 리플렉션으로 필드를 읽고 쓰는데, Quarkus에서는 빌드 타임에 직렬화 코드를 미리 생성합니다. 이것의 장점은 다음과 같습니다.
- ** 네이티브 이미지 호환성 향상 **: GraalVM에서 리플렉션 설정이 불필요
- ** 성능 향상 **: 리플렉션 오버헤드 제거
- ** 시작 시간 단축 **: 런타임 초기화 과정 최소화
별도로 @JsonProperty 같은 어노테이션은 그대로 동작합니다. 기존 Jackson 사용 경험이 있다면 투명하게 전환됩니다.
실전 CRUD API 예제
지금까지 배운 내용을 조합해서 간단한 CRUD API를 만들어보겠습니다.
엔티티 정의
@Entity
public class Todo extends PanacheEntity {
@Column(nullable = false)
public String title;
public String description;
@Column(nullable = false)
public boolean completed = false;
@Column(name = "created_at")
public LocalDateTime createdAt = LocalDateTime.now();
}
REST 리소스
@Path("/api/todos")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class TodoResource {
@GET
public List<Todo> list() {
return Todo.listAll(Sort.by("createdAt").descending());
}
@GET
@Path("/{id}")
public Todo get(@PathParam("id") Long id) {
Todo todo = Todo.findById(id);
if (todo == null) {
throw new WebApplicationException("Todo not found", 404);
}
return todo;
}
@POST
@Transactional
public Response create(Todo todo) {
todo.persist();
return Response.status(Response.Status.CREATED).entity(todo).build();
}
@PUT
@Path("/{id}")
@Transactional
public Todo update(@PathParam("id") Long id, Todo updated) {
Todo todo = Todo.findById(id);
if (todo == null) {
throw new WebApplicationException("Todo not found", 404);
}
todo.title = updated.title;
todo.description = updated.description;
todo.completed = updated.completed;
// dirty checking으로 자동 반영
return todo;
}
@DELETE
@Path("/{id}")
@Transactional
public Response delete(@PathParam("id") Long id) {
boolean deleted = Todo.deleteById(id);
if (!deleted) {
throw new WebApplicationException("Todo not found", 404);
}
return Response.noContent().build();
}
}
application.properties
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/tododb
quarkus.datasource.username=todo
quarkus.datasource.password=todo
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
Spring Boot 코드와 비교
같은 기능을 Spring Boot로 구현하면 이런 구조가 됩니다.
Spring Boot Quarkus
───────────────────────────── ─────────────────────────
@Entity + Lombok @Entity extends PanacheEntity
TodoRepository (interface) (Active Record면 불필요)
TodoService (선택) (리소스에서 직접 처리 가능)
TodoController TodoResource
Quarkus의 Active Record 패턴을 사용하면 Repository 인터페이스를 별도로 만들지 않아도 됩니다. 코드 파일 수가 줄어들고, 간단한 CRUD에서는 오히려 가독성이 좋아집니다.
물론 비즈니스 로직이 복잡해지면 서비스 레이어를 분리하는 것이 좋습니다. Active Record 패턴이 만능은 아닙니다.
실전에서 주의할 점
1. 트랜잭션 관리
Panache의 persist(), delete() 등 변경 작업은 반드시 @Transactional 안에서 호출해야 합니다. Spring과 동일하게 선언적 트랜잭션을 사용합니다.
@POST
@Transactional // 이게 없으면 TransactionRequiredException 발생
public Response create(Todo todo) {
todo.persist();
return Response.ok(todo).build();
}
2. 페이징 처리
@GET
public List<Todo> list(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
return Todo.findAll(Sort.by("createdAt").descending())
.page(Page.of(page, size))
.list();
}
Spring Data JPA의 Pageable과 비슷하지만, Panache는 PanacheQuery 객체에서 체이닝 방식으로 페이징을 처리합니다.
3. 커스텀 쿼리
// HQL 사용
public static List<Todo> findIncomplete() {
return find("completed = false order by createdAt desc").list();
}
// 네이티브 쿼리
public static List<Todo> findByNativeQuery() {
return find("#Todo.findRecent").list();
// @NamedNativeQuery로 등록된 쿼리 참조
}
정리
Quarkus에서 REST API를 만드는 과정은 Spring Boot와 크게 다르지 않습니다. 다만 핵심 차이는 다음과 같습니다.
- ** 빌드 타임 처리 **: 라우팅, JSON 직렬화, 바이트코드 변환이 모두 빌드 시점에 이루어짐
- ** 논블로킹 기본 **: Vert.x 이벤트 루프 위에서 동작하며, 필요 시
@Blocking으로 전환 - Panache의 Active Record: Repository 없이도 깔끔한 CRUD 가능
- **getter/setter 자동 생성 **: 보일러플레이트 최소화
Spring 개발자가 Quarkus로 넘어올 때 가장 빨리 적응하는 영역이 바로 REST + ORM입니다. 어노테이션 이름만 달라질 뿐, 전체적인 개발 패턴은 익숙하기 때문입니다.