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

[WebFlux] 4. WebFlux로 Reactive Controller Bean 작성하기

by kellis 2020. 8. 3.

이 글에서는 이전 글에서 작성한 ImageService와 연결될 ImageController를 작성하고, 또한 웹 상에서 보일 index.html 파일을 thymeleaf를 이용하여 작성하여 보도록 하겠습니다. 우리는 리액티브 프로그래밍에 대하여 공부하고 있으므로, thymeleaf는 화면을 그리기 위한 수단일 뿐, 내용을 다루지 않고 코드만 삽입하도록 하겠습니다.

 

1. ImageController 생성하기

 

ImageController에는 다음과 같은  필드와 생성자가 필요합니다.

@Controller
public class ImageController {
    private static final String BASE_PATH = "/images";
    private static final String FILENAME = "{filename:.+}";
 
    private final ImageService imageService;
 
    public ImageController(ImageService imageService) {
        this.imageService = imageService;
    }
  
    ...
  
}
  • @Controller : 앞의 장에서 언급했듯, WebFlux는 MVC와 동일한 어노테이션을 사용
  • BASE_PATH : imageService를 화면에 나타내기 위해 설정해 준 기본 경로
  • filename : 이미지를 가져오는 데 사용될 키 값
  • ImageService : 생성자 주입을 통해 ImageController에 ImageService가 주입

 

그럼 이제 하나씩 ImageService의 기능을 화면과 연결할 수 있도록, 요청별로 메서드를 정의해 보도록 하겠습니다.

 

첫 번째 메서드는 웹 페이지에 하나의 이미지를 출력하는 핸들러입니다.

@GetMapping(value = BASE_PATH + "/view/" + FILENAME, produces = MediaType.IMAGE_JPEG_VALUE)
@ResponseBody
public Mono<ResponseEntity<?>> oneImage(@PathVariable String filename) {
        return imageService.getOneImage(filename)
                    .map(resource -> {
                            try {
                                return ResponseEntity.ok()
                                        .contentLength(resource.contentLength())
                                        .body(new InputStreamResource(
                                                    resource.getInputStream()));
                            } catch (IOException e) {
                                return ResponseEntity.badRequest()
                                        .body("Couldn't find " + filename +
                                                 " => " + e.getMessage());
                            }
                    });
}
  • @GetMapping : Get 방식의 BASE_PATH/filename/raw 라는 경로로 들어오는 요청을 받아들이며, Content-Type header를 image에 맞게 적절히 렌더링
  • @ResponseBody : 이 메서드의 response가 바로 HTTP response body로 쓰일 것임을 지시
  • @PathVariable : filename이라는 input이 경로로 들어온 {filename} 속성으로부터 추출된다는 것을 의미
  • Mono<ResponseEntity<?>> : 하나의 response를 reactive 하게 반환하는 것을 의미하며, ResponseEntity<?>는 일반적으로 HTTP response를 의미
  • 이 메서드는 내부적으로 ImageService의 findOneImage(filename) 메서드를 호출.
  • findOneImage는 Mono<Resource>를 리턴하기 때문에, map을 통해서 Resource를 response header의 Content-Length 뿐만 아니라 body에 내장된 데이터까지 포함하는 ResponseEntity로 변환 
  • 예외가 발생하면 HTTP Bad Response를 반환

 

위의 짧은 코드에서 우리는 Reactive Spring이 제공하는 많은 특징들을 살펴볼 수 있습니다. route handling, 독립된 서비스에 위임하기, response를 클라이언트에 적합한 포맷으로 변환하기, 예외 처리까지.

위의 코드는 reactive하게 수행되었습니다. HTTP OK / HTTP BAD REQUEST response를 생성하는 것은 map()이 실행될 때까지 수행되지 않습니다. 이것은 파일을 디스크로부터 가져오는 ImageService의 메서드와 연결되어 있습니다. 클라이언트가 subscribe 하기 전까지는 그 어떤 것도 행해지지 않습니다. 이 경우에 subscribing은 요청이 들어왔을 때 프레임워크에 의해 다루어집니다. 

 

참고. 컨트롤러는 경량으로 유지되어야 한다고 언급했던 것과 반대되게, 위의 코드는 썩 경량인 것처럼 보이지 않습니다. 그러나, 컨트롤러를 더 가볍게 만들기 위해서 ResponseEntity를 래핑하여 imageService로 옮기는 것은 잘못된 선택입니다. ImageService는 web layer에 대해서는 그 어떤 것도 알 필요가 없기 때문입니다. 컨트롤러가 주시하는 것은 웹 클라이언트에게 보이는 데이터를 만드는 것이고, ResponseEntity를 만드는 것은 컨트롤러가 해야 할 일이지, ImageService가 해야 할 일이 아닙니다.

 

다음은 새로운 파일들을 업로드하는 메서드입니다.

@PostMapping(value = BASE_PATH)
public Mono<String> createFile(@RequestPart(name = "file") Flux<FilePart> files) {
    return imageService.createImage(files)
                       .then(Mono.just("redirect:/"));
}
  • 입력값으로 들어오는 FilePart 객체의 collection은 Flux로 표현됨
  • 파일들의 flux는 진행되는 중에 imageService로 즉시 전달(input으로 받아들임과 동시에 전달)
  • then() 은 메서드가 완료되면 redirect:/ 지시문을 반환(Mono로 래핑)하게 되고, HTML은 /로 redirect됨

 

여기서 꼭 기억해야 하는 것은 파일들의 flux에 대해서 then()을 하지 않는다는 점입니다. 그 대신 ImageService는 모든 파일의 처리가 완료되면 시그널을 보내는 Mono<Void>를 반환합니다. redirect로 추가적인 연결을 이어주는 것은 Mono입니다. 

 

다음으로 ImageController에 추가해 줄 메서드는 image를 삭제하는 요청을 처리하는 메서드입니다.

@DeleteMapping(BASE_PATH + "/delete/" + filename)
public Mono<String> deleteFile(@PathVariable String filename) {
    return imageService.deleteImage(filename)
                       .then(Mono.just("redirect:/"));
}
  • @DeleteMapping 어노테이션을 사용하고, 이는 HTTP DELETE 동작을 위한 준비가 되어 있음
  • BASE_PATH/filename 경로로 들어오는 요청을 받아들임
  • ImageService의 delteImage() 메서드를 호출.
  • then()은  mono로 래핑된 redirect:/가 직접적으로 돌아오기 전까지 delete를 수행하지 않고 기다림.

 

마지막으로 ImageController에 들어갈 메서드는 메인 페이지 역할을 할 root(/)로 연결해주는 메서드입니다.

@GetMapping("/")
public Mono<String> index(Model model){
    model.addAttribute("images", imageService.allImages());
    return Mono.just("index");
}
  • Get 요청을 받아들임. PATH는 /
  • Model 객체를 받아들이는 데 이는 우리가 데이터를 reactive 하게 로드할 수 있도록 함
  • addAttribute()는 ImageService의 findAllImages() 메서드가 반환하는 Flux를 템플릿 모델의 Image 속성으로 할당.
  • Mono로 래핑된 index를 반환. 

2. Thymeleaf template 작성

 

ImageController까지 구성하였으니 이제 실질적으로 화면에 출력되는 html 파일만 만든다면 우리는 실제 웹 페이지에서 지금껏 만들었던 로직이 제대로 동작하는지 확인할 수 있을 것입니다. 

/src/main/resources/templates 아래에 index.html 파일을 생성하고 아래와 같이 내용을 삽입하겠습니다.( templates 패키지 역시 생성합니다.)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>Reactive Programming with Spring Boot</title>
    <link rel="stylesheet" href="/index.css" />
</head>
<body>
<h1>Reactive Programming with Spring Boot</h1>
<div id="boardDiv">
    <form id="uploadForm" method="post" enctype="multipart/form-data" action="/images">
        <p>
            <input type="file" name="file"/>
            <input type="submit" value="Upload" />
        </p>
    </form>
    <table th:each="image : ${images}">
        <tr>
            <th>ID</th>
            <td id="imageTd" rowspan="2">
                 <a th:href="@{'/images/view/' + ${image.name}}">
                    <img th:src="@{'/images/view/'+${image.name}}" class="thumbnail" />
                </a> 
            </td>
            <td id="imageInfoTd" th:text="${image.id}" /></td>
            <td rowspan="2">
                 <form th:method="delete" th:action="@{'/images/delete/' + ${image.name}}">
                    <input type="submit" value="Delete" />
                </form>
            </td>
        </tr>
        <tr>
            <th>Name</th><td th:text="${image.name}" />
        </tr>
    </table>
</div>
</body>
</html>
  • 모든 Thymeleaf 지시자는 th 접두사로 표시되고, 이는 전체 템플릿을 HTML과 호환시킴
  • <tr th:each="image : ${images}" /> : Thymeleaf의 for-each 지시자. 템플릿 model로부터 images를 읽고, 반복하여 image마다 하나의 테이블 로우 요소로 생성
  • <a th:href="@{'/images/' + ${image.name} + '/raw'}"> : image의 이름 속성으로 link를 생성

 

html 파일을 만들 때 유의해야 할 점은 이 파일의 이름인 index와 / 경로에 대한 컨트롤러 메서드의 리턴 값, Mono.just("index")의 이름이 일치해야 한다는 점입니다. 

 

참고. Spring Boot는 우리가 선택하는 템플릿 솔루션에 기반하여 뷰 리졸버를 자동으로 구성합니다. Spring Boot는 Thymeleaf, Mustache, Grooby Templates, 심지어 Apache FreeMarker를 포함하여 많은 템플릿 엔진을 지원합니다. 기본적으로 이러한 템플릿 엔진을 사용할 때에는 관습적으로 src/main/resources/templates/<template name>.html 에 템플릿을 넣습니다. 

 

아래는 main.css 파일의 내용입니다. src/main/resources/static/main.css 위치에 넣어줍니다. (static 폴더 역시 생성합니다.)

#boardDiv {
    margin-top: 50px;
    width: 700px;
}
#uploadForm{   
    float: right;
    width: 100%;
}
#imageTd{
    width: 120px;
    height: 120px; 
    max-height: 120px;
    max-width: 120px;
    text-align: center;
}
#imageInfoTd{
    width: 500px;
}
table {
    margin: 2px 0px 2px 0px;
    border-collapse: collapse;
    border: 2px solid black;
}
td, th {
    text-align: left;
    border: 1px solid #999;
    padding: 2px;
    height: 60px;
    white-space: nowrap;
}
.thumbnail {
    max-width: 118px;
    max-height: 118px;
}
.flash {
    background-color: lightcoral;
    padding: 1em;
}
  • 테이블의 border를 겹치고, 테이블 엔트리 간에 작은 여백을 줌. 또한 이미지를 입력했을 때 작은 썸네일 사이즈로 렌더하여 출력하도록 함.

 

마찬가지로, 우리는 CSS에 초점을 맞추고 있지 않기 때문에 이상의 설명 없이 다음으로 넘어가겠습니다. 

마지막으로 해야 할 일은, LearningSpringBootApplication에 작은 코드를 추가해 주는 일입니다. 

@Bean
HiddenHttpMethodFilter hiddenHttpMethodFilter() {
    return new HiddenHttpMethodFilter();
}

이 코드를 추가하지 않고 Delete 동작을 수행하면 아래와 같은 에러가 발생하게 됩니다.

이는 DELETE가 HTML5 FORM에 유효한 동작이 아니기 때문입니다. 그래서 위의 코드 없이 동작하게 되면, Thymeleaf는 원하는 verb가 포함된 hidden input field를 생성하는데, 이는 HTML5 POST를 사용합니다. 결국 웹 호출 중에 Spring에 의해 변형되어 @DeleteMapping 메서드가 적절히 호출되지 않게 되는 것입니다. 

 


3. 결과 화면

 

프로젝트 Run As > Spring Boot App을 통해 프로젝트를 동작시키고, localhost:8080으로 접속하게 되면 아래와 같은 화면이 출력되는 것을 확인할 수 있습니다.

테스트 데이터는 존재하지 않기 때문에 깨진 이미지가 나타나며, ID의 값은 파일의 해쉬코드값을 이용해 생성하기 때문에 위와 같이 출력됩니다.

 


4. 왜 Reactive Programming을 사용하는가?

 

우리는 여기까지 가벼운 Reactive 파일 업로드 서비스를 구축해보았습니다. 그렇다면 왜 이렇게 reactive 하게 만들 필요가 있는 것일까요? 

명령형 프로그래밍을 사용할 때에는 input들을 받아들이고, 중간 collections와 기타 단계들을 작성하는 과정에서 종종 많은 중간 상태(intermediate states)가 발생합니다. 그중 일부는 잠재적으로 나쁜 위치에서 블럭킹됩니다. 

지금까지 살펴본 functional style을 사용하면 비효율적인 이러한 중간 상태를 만드는 위험으로부터 벗어나, 데이터 스트림을 생성하는 방식으로 전환됩니다. 또한 Reactor의 operation이 하나의 스트림이 여러 다른 방식으로 주입될 수 있도록 합니다.  스트림을 병합할 수 있고, 필터링할 수 있고, 변환할 수 있습니다. 

Reactive programming을 사용할 때 추상화 수준은 한 단계 올라갑니다. 다양한 작업을 수행할 수 있는 작은 함수를 만드는 데 초점을 두고, 더 낮은 수준의 세부 구현보다는 스트림 항목들의 통합에 대해 더 많은 것을 생각합니다. 

입력을 출력에 연결하는 이러한 연계된 동작 흐름을 구축하기 위해서, Reactor는 필요할 때 코드를 호출하고, 가능한 한 효율적으로 리소스들을 요청하고 해제할 수 있습니다. 또한  본질적으로 비동기, 논블럭킹을 가짐으로써, Reactor 프레임워크는 스케쥴러와의 대화를 관리할 수 있습니다. 프레임워크가 언제 발생하는지에 대해 다루는 동안에 우리는 무엇이 발생했는지에 초점을 맞출 수 있습니다.

 

요약하자면 다음과 같습니다.

  • 비효율적인 중간 상태(intermediate states) 회피
  • 데이터 스트림 구축에 중점
  • 데이터 스트림을 병합, 필터링, 변환하는 기능 제공
  • Reactor가 언제 동작할 것인지 결정하는 것이 아니라, 각 스텝에서 무엇을 할 것인지에 초점

 

더보기

왜 filename으로 이미지에 대한 처리를 수행합니까?

 

- 여기까지 작성된 코드는 데이터베이스 없이 파일 기반으로 작성되었습니다. 때문에 로컬에 이미지를 저장할 때, 파일의 이름으로 저장하든, ID값으로 이름을 변경하여 저장하든 하나의 값을 선택할 수밖에 없습니다. 다음 장부터 다루게 될 Reactive Spring Data에서 데이터베이스를 사용하게 되면, 파일 이름에 대한 코드는 ID를 사용하는 것으로 변경될 수 있을 것입니다.

댓글