🚀   70세 이전에 한 모든 일은 신경 쓸 가치가 없다. - 호쿠사이

[Quarkus] 프로젝트 구조 설정 및 메모리 기반 REST API 구현

2024.08.08
15분


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 엔티티 클래스를 작성합니다.

src/main/java/com/nalutbae/example/domain/enumeration/Genre.java
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
}
src/main/java/com/nalutbae/example/domain/Book.java
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 클래스를 작성합니다.

src/main/java/com/nalutbae/example/service/BookService.java
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 EEJakarta EE에서 사용되는 CDI(Contexts and Dependency Injection) 표준의 일부로, Quarkus에서도 이 어노테이션을 통해 애플리케이션 스코프를 관리할 수 있습니다. Quarkus는 개발자가 종속성 주입을 통해 객체 생성을 관리하고, 객체의 라이프사이클을 제어할 수 있게 해줍니다.

@ApplicationScoped의 주요 개념
애플리케이션 스코프: @ApplicationScoped가 적용된 빈(Bean)은 애플리케이션이 시작될 때 생성되고, 애플리케이션이 종료될 때까지 유지됩니다. 즉, 애플리케이션 전반에 걸쳐 하나의 인스턴스만 존재하며, 모든 요청에서 동일한 인스턴스를 사용합니다. 이는 Spring 프레임워크의 싱글톤(Singleton) 패턴과 유사하지만, CDI 컨테이너가 객체의 생성을 관리한다는 점에서 차이가 있습니다.

  • 멀티스레드 환경에서 안전성: @ApplicationScoped 빈은 여러 스레드에서 동시에 접근할 수 있기 때문에 멀티스레드 환경에서도 안전하게 사용할 수 있도록 설계되어야 합니다. 일반적으로 상태를 가지지 않거나, 스레드 안전한 상태만을 유지하는 것이 좋습니다.
  • 상태 관리: 이 빈이 애플리케이션 전체에서 공유되기 때문에, 상태를 관리하는 경우 주의가 필요합니다. 예를 들어, 특정 요청에 따라 상태가 변경될 수 있는 데이터는 보관하지 않는 것이 좋습니다.

3.4. REST 리소스 작성

REST API를 처리하는 BookResource 클래스를 작성합니다.

src/main/java/com/nalutbae/example/rest/BookResource.java
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를 테스트하는 클래스를 추가합니다.

src/test/java/com/nalutbae/example/rest/BookResourceTest.java
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. UniMulti

위의 예제에서 UniMulti가 생소한 분들이 있을 수 있어 그에 대한 추가적인 설명을 하고자 합니다.
Quarkus에서 사용하는 UniMulti반응형 프로그래밍을 지원하는 기본 요소로, 비동기 작업을 처리하는 데 사용됩니다. 이 두 가지는 Quarkus에서 비동기 및 반응형 API를 작성할 때 매우 유용하며, 특히 I/O 작업이나 대규모 데이터를 처리할 때 효과적입니다.

1. Uni

  • Uni는 단일 값을 비동기적으로 제공하는 타입입니다. JavaCompletableFuture와 유사하지만, 더 강력한 비동기 작업을 처리할 수 있도록 설계되었습니다.
  • 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. UniMulti의 비교

UniMultiQuarkus에서 비동기 및 반응형 프로그래밍을 지원하는 핵심 요소입니다. Uni는 단일 결과를 처리하는데 사용되며, Multi는 여러 개의 결과나 스트리밍 데이터를 처리할 때 사용됩니다. 이를 통해 Quarkus 애플리케이션에서 비동기 작업을 효율적으로 구현할 수 있습니다.

  • 반환할 수 있는 값의 수:

    • Uni: 단일 값 또는 결과가 없음을 나타냄.
    • Multi: 0개 이상의 여러 값을 스트리밍 방식으로 반환.
  • 적용 시나리오:

    • Uni: 단일 결과가 필요한 비동기 작업에 적합.
    • Multi: 연속적인 데이터 스트림이나 다수의 결과를 처리할 때 적합.
  • 중첩 처리:

    • Uni는 단일 작업의 완료 또는 실패를 기다리는 비동기 작업에서 사용되며, Multi는 스트림의 각 요소를 처리하는 데 사용됩니다.

여기에서는 Quarkus 프로젝트에서 메모리 기반으로 동작하는 REST API를 구축해 보았습니다. 데이터베이스 연동이 필요할 경우, 이후 단계에서 Panache와 데이터베이스 설정을 추가하여 확장할 수 있습니다.


프로필 사진
Nanutbae
A small boat sailing freely on the sea of curiosity ⊹ ࣪ ﹏𓊝﹏𓂁﹏⊹ ࣪ ˖

Quarkus를 이용한 REST API 개발

3 / 3
24.08.05
0분
Quarkus와 Panache를 활용한 REST API 개발에 관한 블로그 시리즈입니다. Java와 Spring에 익숙한 개발자들이 Quarkus를 빠르게 익히고 적용할 수 있도록 구성되었습니다.