본문 바로가기
Reactive Programming/Reactive Programming with Spring Boot

[WebFlux] 3. WebFlux로 Reactive Service Bean 구성하기

by kellis 2020. 8. 3.

먼저 WebFlux에 대한 전반적인 설명과 ImageService를 구성하는 과정에 대하여 다루도록 하겠습니다.

이전 글 2장과 현재 글 3장에서는 파일 기반으로 웹서비스를 만들고, 다음 글 4장. Reactive Spring Data에서 mongoDB를 사용하는 방식으로 수정하도록 하겠습니다.

 

 

1. Reactive Spring WebFlux? 무엇이 Spring MVC와 다를까요?

 

여러분은 Spring MVC라는 말에 익숙하실 것입니다. Spring MVC는 자바 커뮤니티에서 제공하는 웹 프레임워크 중 가장 유명한 것 중 하나이기 때문입니다. 하지만 이 글에서 사용하게 될 WebFlux는 생소하실 수도 있습니다.

Spring MVC는 Java EE의 Servlet spec에 기반하여 만들어져 있고, 이는 본질적으로 블럭킹이고 동기방식입니다. 비동기 처리 기능이 스프링 프레임워크 3에서 추가되어 지원된다고 하지만, 서블릿은 응답을 기다리는 동안 pool의 스레드들을 여전히 지연시킬 수 있기 때문에, 전체 stack이 reactive 해야 하는 우리의 요구를 충족시킬 수 없습니다. 이러한 요구사항에 맞추어 스프링 프레임워크 5에 도입된 대안적인 모듈이 바로 WebFlux로써, 웹 요청을 reactive 하게 다루는 데에 초점이 맞추어져 있습니다. 

(1) 구조

Spring MVC와 Spring WebFlux는 아래와 같은 구조로 되어 있습니다.

MVC는 서블릿 컨테이너와 서블릿을 기반으로 웹 추상화 계층을 제공하는데 반해, WebFlux는 서블릿 컨테이너 뿐만 아니라, Netty, Undertow와 같은 네트워크 애플리케이션 프레임워크도 지원하므로,  HTTP와 Reactive Stream 기반으로 웹 추상화 계층을 제공합니다.

그래서 WebFlux 모듈에는 HTTP abstractions, Reactive Stream apdater, Reactive codes 그리고 non-blocking servlet api를 지원하는 core web api가 포함되어 있습니다. 때문에 server-side WebFlux 상에서 아래와 같이 두 가지 프로그램 모델로 구성이 가능합니다. 

  • Annotated Controller : Spring MVC 모델 기반의 기존 spring-web 모듈과 같은 방식으로 구성하는 방법으로, Spring MVC에서 제공하는 어노테이션들을 그대로 사용 가능합니다. 
  • Functional Endpoints : Java 8 lambda style routing과 handling  방식입니다. 가벼운 routing기능과 request 처리 라이브러리라고 생각하면 쉽고, callback형태로써 요청이 있을 때만 호출된다는 점이 annotated controller방식과의 차이점입니다. 
더보기

우리는 Annotated Controller 프로그램 모델 방식으로 예제를 구성할 것입니다. Functional Endpoints은 아래와 같이 구성되는 프로그램 모델을 의미하며, 자세한 내용은 Spring Docs - Functional Endpoints 에서 보실 수 있습니다.

<SpringBootApplication>

@SpringBootApplication
@EnableWebFlux
public class ExampleApplication {
 
    @Bean
    HelloHandler helloHandler() {
        return new HelloHandler();
    }
 
    @Bean
    RouterFunction<ServerResponse> helloRouterFunction(HelloHandler helloHandler) {
        return RouterFunctions.route(RequestPredicates.path("/"), helloHandler::handleRequest);
    }
 
    public static void main(String[] args) throws Exception {
        SpringApplication.run(ExampleApplication.class);
    }
}

<Handler>

public class HelloHandler {
    public  Mono<ServerResponse> handleRequest(ServerRequest serverRequest) {
        return ServerResponse.ok().body(Mono.just("Hello World!"), String.class);
    }
}

 

(2) 동작 흐름

Spring MVC의 처리 흐름은 

   서블릿 컨테이너로 들어온 요청이 디스패처 서블릿으로 전달되면, 디스패처 서블릿은 순차적으로 HandlerMapping, HandlerAdapter에 요청에 대한 처리를 위임하고, ViewResolver에 응답에 대한 처리를 위임하는 방식입니다.

Spring WebFlux도 이와 크게 다르지는 않습니다.

 

웹 서버(Servlet Container나 Netty, Undertow 등)로 들어온 요청이 HttpHandler에서 전달되면 HttpHandler는 전처리 후 WebHandler에 처리를 위임하게 됩니다. 이 WebHandler 내부에서 HandlerMapping, HandlerAdapter, HandlerResultHandler 3개의 컴포넌트가 요청과 응답을 처리하게 됩니다. 처리가 끝나면 HttpHandler가 후처리 후 응답을 종료합니다. 여기에서 보듯 Spring MVC와 HandlerMapping, HandlerAdapter라는 컴포넌트가 동일하게 존재하는 것을 볼 수 있는데, 유의할 점은 컴포넌트의 이름과 역할은 동일하지만 동작 방식이 달라, 서로 다른 인터페이스를 사용한다는 것입니다.

 


2. Domain 객체 생성 

Reactive Codes를 작성하기에 앞서 이미지를 위한 도메인 객체를 만들겠습니다. 

package com.wiki.reactivewithspringboot.reactivewithspringboot;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
 
@Data
@Document
@NoArgsConstructor
@AllArgsConstructor
public class Image {
    @Id private String id;
    private String name;
     
    public Image(String id) {
        this.id = id;
    }
}

id와 name이라는 프로퍼티를 가진 객체로, 몇 가지 lombok 어노테이션이 붙어 있습니다.

lombok은 에디터나 빌드 툴에 자동으로 연결되는 자바 라이브러리로, bean 객체에 setter/getter/toString/hashCode/equals 등과 같이 꼭 필요하지만 클래스 파일의 소스를 길어지게 만들고, 번거롭게 작성하도록 만드는 코드들을 없애주기 위해 만들어졌습니다. 모든 lombok 어노테이션을 다룰 순 없으므로, 위의 코드에서 사용한 lombok 어노테이션에 대해서만 간단하게 아래에서 설명하도록 하겠습니다.

  • @Data : 클래스 내의 모든 private 필드에 대해 @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor를 지정해주는 어노테이션입니다. 
    • @Getter, @Setter : 필드에 대한 getter/setter 메서드를 생성
    • @ToString : toString 메서드 생성
    • @EqualsAndHashCode : equals 메서드와 hashcode 메서드 생성
    • @RequiredArgsConstructor : 모든 초기화되지 않은 final 변수, @NotNull 어노테이션이 붙은 초기화되지 않은 필드를 파라미터로 받는 생성자를 생성해 주는 어노테이션으로, @NotNull 이 붙은 필드에 대해서는 명시적으로 null 체크에 대한 코드를 추가하여 줍니다.
  • @NoArgsConstructor : 파라미터가 없는 기본 생성자를 생성해 주는 어노테이션입니다.
  • @AllArgsConstructor :  모든 필드를 파라미터로 받는 생성자를 생성해 주는 어노테이션입니다.

여기에서 유의할 점은 @Document와 @Id는 lombok의 어노테이션이 아니라는 점입니다. 이 어노테이션은 Spring Data의 어노테이션으로 이에 대해서는 다음 글인 4장에서 다루도록 하겠습니다.

 


3. ImageService 생성하기

web apps를 만들 때 가장 중요한 규칙 중 하나는 컨트롤러들을 가능한 한 가볍게 유지하는 것입니다. 그렇게 하기 위해서 독립된 ImageService를 생성할 필요가 있습니다. 

 

ImageService 코드를 순서대로 찬찬히 살펴보도록 하겠습니다. 

 

@Service
public class ImageService {
    public static String UPLOAD_ROOT = "upload-dir";
     
    private final ResourceLoader resourceLoader;
    public ImageService(ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
    }
    ...
}
  • @Service :  이 객체가 Service로서 사용되는 Spring bean임을 가리킵니다. Spring boot는 자동으로 이 클래스를 스캔하고, 인스턴스를 생성합니다.
  • UPLOAD_ROOT : 이미지를 저장할 기본 폴더입니다.
  • ResourceLoader : 파일들을 관리하기 위해 사용되는 Spring utility class입니다. Spring Boot에 의해 자동으로 생성되고 생성자 주입을 통해 service로 주입됩니다.

 

이 장에서 사용하는 코드는 Spring Reactive Data를 사용하지 않는 상태이므로, 아래와 같이 테스트 데이터를 생성하는 코드를 추가해 두겠습니다.

 

@Bean
CommandLineRunner setUp() throws IOException {
    return (args) -> {
        FileSystemUtils.deleteRecursively(new File(UPLOAD_ROOT));
        Files.createDirectory(Paths.get(UPLOAD_ROOT));
        FileCopyUtils.copy("Test file1", new FileWriter(UPLOAD_ROOT + "/test1.jpg"));
        FileCopyUtils.copy("Test file2", new FileWriter(UPLOAD_ROOT + "/test2.jpg"));
        FileCopyUtils.copy("Test file3", new FileWriter(UPLOAD_ROOT + "/test3.png"));
    };
}
  • @Bean : ImageService가 생성될 때 Spring bean으로 등록되는 객체를 반환할 것임을 지시합니다.
  • 이 bean의 반환값은 CommandLineRunner으로서, 이는 Spring Application이 구동될 때 실행되는 특수한 인터페이스입니다. 그러므로 Spring Boot는 애플리케이션 컨텍스트가 완전히 realize 된 후에 모든 CommandLineRunner들을 run 합니다.
  • 이 메서드는 Java 8 lambda를 사용하였고, Java 8 SAM(Single Abstract Method) 규칙을 통해 CommandLineRunner로 자동으로 변환됩니다.

 

테스트 데이터를 만들었으니 이제 실제로 CRUD 메서드를 작성해 보도록 하겠습니다.

먼저, 모든 이미지들을 가져와 반환하는 findAllImages 메서드입니다.

public Flux<Image> allImages() {
    try {
        return Flux.fromIterable(
            Files.newDirectoryStream(Paths.get(UPLOAD_ROOT)))
            .map(path ->
                new Image(Integer.toString(path.hashCode()),
                          path.getFileName().toString()));
    } catch (IOException e) {
        return Flux.empty();
    }
}
  • 이 메서드는 Flux<Image>를 반환하므로, image들의 컨테이너는 consumer가 subscribe 할 때 생성되어 얻어지게 됩니다.
  • Files는 nio 패키지의 reactive 파일 처리 클래스입니다. Files.newDirectoryStream 메서드는 UPLOAD_ROOT 경로에  lazy 한 DirectoryStream을 열기 위해 사용됩니다. DirectoryStream은 next() 메서드가 호출될 때까지 해당 디렉토리(recursive하지 않으므로 자식 파일만 가져옴) 내 파일을 가져오지 않아, Reactor Flux에 가장 적합합니다.
  • Flux.fromIterable은 이 lazy iterable을 래핑 하는 데 사용되고, 래핑함으로써 reactive streams client가 각각 item을 요구했을 때 가져오도록 만들어줍니다. 
  • 만약 Image가 존재하지 않는 등의 이유로 Exception이 발생하면 이 메서드는 빈 Flux를 반환합니다.

 

 

이미지 전체를 가져오는 것뿐만 아니라, 각 각 이미지를 가져오는 코드는 아래와 같습니다.

public Mono<Resource> getOneImage(String filename) {
    return Mono.fromSupplier(() ->
        resourceLoader.getResource(
            "file:" + UPLOAD_ROOT + "/" + filename));
}
  • 하나의 이미지만을 다루기 때문에 이 메서드는 Mono<Resource>를 반환합니다. 여기서 Resource는 파일을 위한 스프링의 추상 타입입니다.
  • 클라이언트가 subscribe 할 때까지 파일을 가져오는 것을 지연시키기 위해서, Mono.fromSupplier메서드로 전체 코드를 래핑하고, getResource 메서드는 lambda 내부에 구현하였습니다.

 

다음으로 볼 부분은 이미지를 생성하는 부분입니다.

public Mono<Void> uploadImage(Flux<FilePart> files) {
    return files.flatMap(file -> file.transferTo(
        Paths.get(UPLOAD_ROOT, file.filename()).toFile())).then();
}
  • 이미지를 생성하기만 하는 메서드이므로, 아무것도 반환하지 않는 Mono<Void>를 return value로 지정하였습니다. 
  •  FilePart 객체의 Flux를 인자로 받아 flatMap을 사용하여 각각에 대해 처리를 합니다.
  • 각 각의 파일은 비어있지 않다는 것을 보장하기 위해 테스트됩니다.
  • FilePart의 content는 UPLOAD_ROOT에 새로운 파일로서 저장됩니다. 
  • then() 메서드는 전체 Flux가 끝날 때까지 기다렸다가 Mono<Void>를 생산하도록 만들어줍니다.

 

마지막으로 이미지를 삭제하는 메서드를 살펴보겠습니다.

public Mono<Void> deleteImage(String filename) {
    return Mono.fromRunnable(() -> {
        try {
            Files.deleteIfExists(Paths.get(UPLOAD_ROOT, filename));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    });
}
  • 생성과 마찬가지로 return value에 관심이 없는 메서드이므로 Mono<Void>를 반환합니다.
  • subscribe 될 때까지 기다리기 위해, Mono.fromRunnable 메서드를 이용하여 래핑할 필요가 있으며, lambda expression을 사용하여 Runnable을 강제 실행시킵니다. 
  • Java.NIO의 Files.deleteIfExists메서드를 사용하여 간단하게 삭제할 수 있습니다.

Before Next Step..

이 글에서 ImageService를 작성해보았습니다. 이 글은 Spring Data를 사용하기 전 단계이므로 4장에서 일부 코드가 수정될 수 있음을 미리 말씀드립니다.

 

이 프로그래밍 스타일은 아직 낯설고 익숙해지기까지 시간이 조금 걸릴 수 있습니다만, 그리 오래 걸리지 않을 것입니다. 일단 익숙해지면 모든 곳에서 블럭킹되지 않는 코드를 작성할 수 있습니다. 그렇게 된다면 여러분은 callback hell에 빠지지 않고 reactive 한 프로그램을 만들 수 있게 될 것입니다.

 

 

[ Reference ] 

 

Web on Reactive Stack

 

Web on Reactive Stack

The original web framework included in the Spring Framework, Spring Web MVC, was purpose-built for the Servlet API and Servlet containers. The reactive-stack web framework, Spring WebFlux, was added later in version 5.0. It is fully non-blocking, supports

docs.spring.io

 

댓글