3. Quarkus 프로젝트 구조 설정 및 메모리 기반 REST API 구현
이 글에서는 Quarkus 프로젝트의 패키지 구조를 설정하고, 간단한 메모리 기반 REST API를 구현하는 방법을 예제를 통해 안내합니다.
이 예제에서는 책 정보를 관리하는 API를 작성하게 되며, 데이터베이스 대신 메모리에 데이터를 저장하고 관리합니다.
다음 단계에 따라 프로젝트를 설정하고 예제를 작성할 수 있습니다.
전체 소스코드는 Git Repository에서 확인할 수 있습니다.
3.1. 프로젝트 구조 설정
앞서 진행된 과정에서 생성된 패키지 구조는 com.example
하나만 존재합니다. 하나의 패키지 내에 모든 클래스를 포함시켜 개발하는 것도 가능하지만 기능과 목적에 맞게 구조화하여 분리하는 것이 일반적인 관례이기에 이 예제에서도 domain
, rest
, service
로 구분하고자 합니다.
최종적으로 만들어질 전체 구조는 아래와 같습니다. 저는 com.example
패키지 대신 com.nalutbae.example
패키지를 기본 패키지로 변경했습니다. 패키지명은 자유롭게 구성할 수 있지만 보통 서비스 도메인의 역순을 사용합니다.
.
├── README.md
├── mvnw
├── mvnw.cmd
├── pom.xml
├── qodana.yaml
└── src
├── main
│ ├── docker
│ ├── java
│ │ └── com
│ │ └── nalutbae
│ │ └── example
│ │ ├── domain
│ │ │ ├── Book.java
│ │ │ ├── CustomRuntimeException.java
│ │ │ └── enumeration
│ │ │ └── Genre.java
│ │ ├── rest
│ │ │ ├── BookResource.java
│ │ │ ├── CustomError.java
│ │ │ └── GlobalErrorHandler.java
│ │ └── service
│ │ └── BookService.java
│ └── resources
│ └── application.properties
└── test
├── http
│ └── book.http
└── java
└── com
└── nalutbae
└── example
├── rest
│ └── BookResourceTest.java
└── service
└── BookServiceTest.java
이 구조를 기반으로 프로젝트를 설정하고, 각 파일을 단계별로 작성합니다.
3.2. 도메인 클래스 작성
먼저, 책의 장르에 대한 정보를 나타내는 Genre
열거형 클래스와 책 정보에 대한 Book
엔티티 클래스를 작성합니다.
package com.nalutbae.example.domain.enumeration;
public enum Genre {
FICTION,
NON_FICTION,
MYSTERY,
HORROR,
ROMANCE,
SCIENCE_FICTION,
FANTASY,
THRILLER,
BIOGRAPHY,
HISTORY,
SELF_HELP,
POETRY,
DRAMA,
COMEDY,
CHILDREN,
YOUNG_ADULT
}
package com.nalutbae.example.domain;
import com.nalutbae.example.domain.enumeration.Genre;
import jakarta.validation.constraints.NotBlank;
public class Book {
private String title;
private String author;
private String isbn;
private Genre genre;
private String publisher;
private int yearPublished;
public Book(String title, String author, String isbn, Genre genre, String publisher, int yearPublished) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.genre = genre;
this.publisher = publisher;
this.yearPublished = yearPublished;
}
@NotBlank
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
// Another Getters and Setters...
}
3.3. 서비스 클래스 작성
이제 비즈니스 로직을 처리하는 BookService
클래스를 작성합니다.
package com.nalutbae.example.service;
import com.nalutbae.example.domain.Book;
import com.nalutbae.example.domain.enumeration.Genre;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.Multi;
import javax.enterprise.context.ApplicationScoped;
import java.util.Collection;
import java.util.Comparator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.time.Duration;
@ApplicationScoped
public class BookService {
private final ConcurrentMap<String, Book> books = new ConcurrentHashMap<>();
public BookService() {
// ISBN을 키로 하여 메모리 상에 데이터를 입력
books.put("9780747532743", new Book("Harry Potter and the Philosopher's Stone", "J.K. Rowling", "9780747532743", Genre.FANTASY, "Bloomsbury Publishing", 1997));
books.put("9780061120084", new Book("To Kill a Mockingbird", "Harper Lee", "9780061120084", Genre.FICTION, "Harper Perennial Modern Classics", 1960));
books.put("9780451524935", new Book("1984", "George Orwell", "9780451524935", Genre.SCIENCE_FICTION, "Signet Classics", 1949));
}
public Collection<Book> getBooks() {
return this.books.values();
}
public Uni<Book> getBook(String bookId) {
return Uni.createFrom().item(this.books.get(bookId));
}
public Uni<Book> addOrUpdateBook(Book book) {
this.books.put(book.getIsbn(), book);
return Uni.createFrom().item(book);
}
public Uni<Void> deleteBook(String bookId) {
this.books.remove(bookId);
return Uni.createFrom().voidItem();
}
public Multi<Book> streamBooks() {
return Multi.createFrom()
.ticks()
.every(Duration.ofSeconds(1))
.map(tick ->
this.books.values()
.stream()
.sorted(Comparator.comparing(Book::getTitle))
.toList()
.get(tick.intValue())
)
.select().first(this.books.size());
}
}
이 클래스는 메모리에 데이터를 저장하며, CRUD 및 스트리밍 작업을 제공합니다.
Service 클래스에 선언된 @ApplicationScoped
어노테이션은 Java EE
와 Jakarta EE
에서 사용되는 CDI
(Contexts and Dependency Injection
) 표준의 일부로, Quarkus
에서도 이 어노테이션을 통해 애플리케이션 스코프를 관리할 수 있습니다. Quarkus
는 개발자가 종속성 주입을 통해 객체 생성을 관리하고, 객체의 라이프사이클을 제어할 수 있게 해줍니다.
@ApplicationScoped
의 주요 개념
애플리케이션 스코프:@ApplicationScoped
가 적용된 빈(Bean)은 애플리케이션이 시작될 때 생성되고, 애플리케이션이 종료될 때까지 유지됩니다. 즉, 애플리케이션 전반에 걸쳐 하나의 인스턴스만 존재하며, 모든 요청에서 동일한 인스턴스를 사용합니다. 이는Spring
프레임워크의 싱글톤(Singleton
) 패턴과 유사하지만,CDI
컨테이너가 객체의 생성을 관리한다는 점에서 차이가 있습니다.
- 멀티스레드 환경에서 안전성:
@ApplicationScoped
빈은 여러 스레드에서 동시에 접근할 수 있기 때문에 멀티스레드 환경에서도 안전하게 사용할 수 있도록 설계되어야 합니다. 일반적으로 상태를 가지지 않거나, 스레드 안전한 상태만을 유지하는 것이 좋습니다.- 상태 관리: 이 빈이 애플리케이션 전체에서 공유되기 때문에, 상태를 관리하는 경우 주의가 필요합니다. 예를 들어, 특정 요청에 따라 상태가 변경될 수 있는 데이터는 보관하지 않는 것이 좋습니다.
3.4. REST 리소스 작성
REST API를 처리하는 BookResource
클래스를 작성합니다.
package com.nalutbae.example.rest;
import com.nalutbae.example.domain.Book;
import com.nalutbae.example.service.BookService;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.Multi;
import javax.inject.Inject;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
@Path("/books")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class BookResource {
@Inject
BookService bookService;
@GET
public Uni<Collection<Book>> getAllBooks() {
return Uni.createFrom().item(bookService.getBooks());
}
@GET
@Path("/{isbn}")
public Uni<Response> getBookByIsbn(@PathParam("isbn") String isbn) {
return bookService.getBook(isbn)
.onItem().ifNotNull().transform(book -> Response.ok(book).build())
.onItem().ifNull().continueWith(Response.status(Response.Status.NOT_FOUND).build());
}
@POST
public Uni<Response> createOrUpdateBook(Book book) {
return bookService.addOrUpdateBook(book)
.onItem().transform(savedBook -> Response.status(Response.Status.CREATED).entity(savedBook).build());
}
@DELETE
@Path("/{isbn}")
public Uni<Response> deleteBook(@PathParam("isbn") String isbn) {
return bookService.deleteBook(isbn)
.onItem().transform(ignored -> Response.noContent().build());
}
@GET
@Path("/stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
public Multi<Book> streamBooks() {
return bookService.streamBooks();
}
}
이 클래스는 HTTP 요청을 처리하며, BookService
와 연동하여 메모리에서 데이터를 관리합니다.
3.5. 테스트 작성
마지막으로, 작성한 API를 테스트하는 클래스를 추가합니다.
package com.nalutbae.example.rest;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
@QuarkusTest
public class BookResourceTest {
@Test
public void testGetAllBooks() {
given()
.when().get("/books")
.then()
.statusCode(200);
}
@Test
public void testGetBookByIsbn() {
given()
.when().get("/books/9780747532743")
.then()
.statusCode(200)
.body("title", is("Harry Potter and the Philosopher's Stone"));
}
}
이 테스트 클래스는 기본적인 API 호출 테스트를 포함하고 있으며, RestAssured
를 사용하여 API의 동작을 검증합니다.
3.6. 프로젝트 실행 및 테스트
모든 파일이 준비되면 Quarkus
애플리케이션을 실행하여 REST API가 올바르게 동작하는지 확인합니다.
./mvnw quarkus:dev
브라우저에서 http://localhost:8080/books
로 접속하여 결과를 확인하거나, API 클라이언트(예: Postman) 또는 curl
을 사용해 테스트할 수 있습니다.
3.7. Uni
와 Multi
위의 예제에서 Uni
와 Multi
가 생소한 분들이 있을 수 있어 그에 대한 추가적인 설명을 하고자 합니다.
Quarkus
에서 사용하는 Uni
와 Multi
는 반응형 프로그래밍을 지원하는 기본 요소로, 비동기 작업을 처리하는 데 사용됩니다. 이 두 가지는 Quarkus
에서 비동기 및 반응형 API를 작성할 때 매우 유용하며, 특히 I/O
작업이나 대규모 데이터를 처리할 때 효과적입니다.
1. Uni
Uni
는 단일 값을 비동기적으로 제공하는 타입입니다.Java
의CompletableFuture
와 유사하지만, 더 강력한 비동기 작업을 처리할 수 있도록 설계되었습니다.Uni
는 한 번에 하나의 결과를 반환하거나, 결과가 없을 수 있습니다. 결과를 반환하거나 실패하거나 둘 중 하나의 상태만 가질 수 있습니다.
사용 사례
- 비동기 작업에서 단일 결과를 반환하는 경우에 사용됩니다. 예를 들어, 데이터베이스에서 단일 항목을 조회하거나, 외부 API 호출의 결과를 기다릴 때
Uni
를 사용할 수 있습니다.
예시
import io.smallrye.mutiny.Uni;
public class MyService {
public Uni<String> getGreeting() {
return Uni.createFrom().item("Hello, Quarkus!");
}
}
위 예시에서 getGreeting
메서드는 비동기적으로 문자열을 반환하는 Uni
타입을 사용하고 있습니다. 이 메서드는 결과를 즉시 반환하지 않고, 나중에 사용할 수 있도록 Uni
에 담아 반환합니다.
Uni
사용법
- 데이터 생성:
Uni.createFrom().item(item)
- 단일 항목을 생성합니다.Uni.createFrom().failure(exception)
- 예외를 발생시키는Uni
를 생성합니다.
- 데이터 처리:
onItem().transform()
- 항목을 변환합니다.onFailure().recoverWithItem()
- 실패 시 대체 항목으로 복구합니다.
- 구독 (Subscription):
subscribe().with()
- 결과를 사용하거나 실패 시 작업을 정의합니다.
Uni<String> greeting = service.getGreeting();
greeting
.onItem().transform(item -> item.toUpperCase())
.subscribe().with(
item -> System.out.println("Received: " + item),
failure -> System.err.println("Failed with " + failure)
);
2. Multi
Multi
는 여러 개의 값을 비동기적으로 스트리밍하는 타입입니다. 이는 Java의Flux
(Reactive Streams API
)와 유사합니다.Multi
는 0개 이상의 결과를 순차적으로 전달할 수 있으며, 스트리밍 방식으로 데이터를 처리할 때 유용합니다.
사용 사례
- 대량의 데이터를 스트리밍하거나, 이벤트 스트림을 처리할 때 사용됩니다. 예를 들어, 데이터베이스에서 다수의 레코드를 스트리밍하거나, WebSocket 연결을 통해 연속적인 데이터를 수신할 때
Multi
를 사용할 수 있습니다.
예시
import io.smallrye.mutiny.Multi;
public class MyService {
public Multi<String> getGreetings() {
return Multi.createFrom().items("Hello", "Hi", "Greetings", "Salutations");
}
}
위 예시에서 getGreetings
메서드는 여러 개의 문자열을 스트리밍 방식으로 반환하는 Multi
타입을 사용하고 있습니다.
Multi
사용법
- 데이터 생성:
Multi.createFrom().items(item1, item2, ...)
- 여러 항목을 생성합니다.Multi.createFrom().ticks()
- 일정 간격으로 항목을 생성할 수 있습니다.
- 데이터 처리:
onItem().transform()
- 각각의 항목을 변환합니다.select().first(n)
- 첫 n개의 항목을 선택합니다.
- 구독 (Subscription):
subscribe().with()
- 각 항목을 처리할 작업을 정의합니다.
Multi<String> greetings = service.getGreetings();
greetings
.onItem().transform(String::toUpperCase)
.subscribe().with(
item -> System.out.println("Received: " + item),
failure -> System.err.println("Failed with " + failure),
() -> System.out.println("Completed!")
);
3. Uni
와 Multi
의 비교
Uni
와 Multi
는 Quarkus
에서 비동기 및 반응형 프로그래밍을 지원하는 핵심 요소입니다. Uni
는 단일 결과를 처리하는데 사용되며, Multi
는 여러 개의 결과나 스트리밍 데이터를 처리할 때 사용됩니다. 이를 통해 Quarkus
애플리케이션에서 비동기 작업을 효율적으로 구현할 수 있습니다.
-
반환할 수 있는 값의 수:
Uni
: 단일 값 또는 결과가 없음을 나타냄.Multi
: 0개 이상의 여러 값을 스트리밍 방식으로 반환.
-
적용 시나리오:
Uni
: 단일 결과가 필요한 비동기 작업에 적합.Multi
: 연속적인 데이터 스트림이나 다수의 결과를 처리할 때 적합.
-
중첩 처리:
Uni
는 단일 작업의 완료 또는 실패를 기다리는 비동기 작업에서 사용되며,Multi
는 스트림의 각 요소를 처리하는 데 사용됩니다.
여기에서는 Quarkus 프로젝트에서 메모리 기반으로 동작하는 REST API를 구축해 보았습니다. 데이터베이스 연동이 필요할 경우, 이후 단계에서 Panache와 데이터베이스 설정을 추가하여 확장할 수 있습니다.
Quarkus를 이용한 REST API 개발