본문 바로가기

3.구현/Java or Kotlin

[Springboot] Spring boot WebFlux 사용하기

들어가기

Spring WebFlux은 Spring Framework의 모듈로서 비동기와 반응형 프로그래밍을 지원하며 작은 하드웨어 리소스에서 적은 수의 스레드로 동시성을 처리하는 웹 스택이다. 기존 Servlet API는 동기식 I/O형태로 처리되었지만 이를 비동기식으로 구성된 서버(예: Netty)에 의해 새로 구성된 API이다. 비동기 처리로 자원 사용 효율이 좋아졌지만 성능이 좋아진다는 보장이 없다. 다음과 장점이 있다.

  • 비동기 처리에 의한 다중 요청 처리
  • 반응형 프로그래밍으로 데이터 스트림 처리와 이벤트 기반 처리 유용
  • 함수형 라우팅으로 간결하게 코딩 작성 가능

WebFlux에서는 크게 두가지 방식으로 사용할 수 있는데, 하나가 어노테이션을 사용한 방법과 다른 하나는 직접 라우팅 설정을 통한 방법이 있다. 먼저 공통으로 사용할 환경 구성을 먼저 진행하고 각 방법에 대해 살펴보겠다.

작성자: ospace114@empal.com, http://ospace.tistory.com/

Note: 여기서는 WebFlux을 사용을 간단하게 보기위해 중간 Service에 해당하는 클래스가 없다.

환경구성

먼저 기본적인 spring boot 프로젝트를 구성한다. 다른 모듈 선택없이 기본만 선택해서 생성하면 된다.

프로젝트 구성

WebFux을 사용하기 위해 아래 dependency을 추가해야 한다. 주의할 부분은 기존 Spring MVC와 같이 사용하면 안된다. 같이 사용하게 되면 실행할 때에 에러가 발생할 수 있다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

이외에 테스트나 Spring Security을 위해서 다른 추가 dependency가 필요하지만, 여기서는 단순한 예를 다룰 부분이라서 생략했다.

WebFluxConfig 클래스

다음으로 자동으로 WebFlux을 설정할 클래스를 생성한다. 별다른 내용은 없고 EnableWebFlux 어노테이션을 추가한다.

package com.tistory.ospace;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.config.EnableWebFlux;

@EnableWebFlux
@Configuration
public class WebFluxConfig {
}

User 클래스

사용자 정보를 저장할 User 클래스이다. 단순히 id와 name 속성을 가진다.

package com.tistory.ospace;

public class User {
    private String id;
    private String name;
    //getter, setter
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
}

UserRepository

데이터 저장하고 관리할 저장소이다. 별도 DB을 사용하지 않고 메모리를 사용했다.

해시맵을 사용해서 User 데이터를 저장관리한다.

package com.tistory.ospace;

import java.util.HashMap;
import java.util.Map;

import org.springframework.stereotype.Repository;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public class UserRepository {
    @SuppressWarnings("serial")
    private Map<String, User> users = new HashMap<>() {{
        put("1", new User("1", "foo1"));
        put("2", new User("2", "foo2"));
    }};

    private Integer id = 10;

  public Mono<User> findById(String id) {
      return Mono.just(users.get(id));
  }

    public Flux<User> findAll() {
        return Flux.fromIterable(users.values());
    }

    public Mono<User> save(User user) {
        User res = null;
        if (null == user.getId()) {
            user.setId((++id).toString());
            users.put(user.getId(), user);
            res = user;
        } else {
            res = users.get(user.getId());
            if (null != res) {
                res.setName(user.getName());
            }
        }

        return Mono.just(res);
    }

    public void delete(String id) {
        users.remove(id);
    }
}

findById()은 User의 id로 사용자 정보를 찾고, findAll()은 모든 사용자 정보를 반환한다. 그리고 save()에서 User의 id가 null인 경우는 새로운 사용자로 간주해서 새로 저장을 하며, null이 아닌 경우 해당 사용자 정보를 찾아서 변경한다.

이것으로 공통으로 사용할 환경구성이 끝났다.

Annotaion 방법

먼저살펴볼 방법은 어노테이션을 활용한 방법으로 가장 단순하고 쉽다. 기존에 Spring MVC와도 유사해서 사용하는데는 어렵지 않다. UserController 클래스를 보자.

package com.tistory.ospace;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserRepository userRepo;

    @GetMapping("/{id}")
    public Mono<User> getUserById(@PathVariable String id) {
        return userRepo.findById(id);
    }

    @GetMapping
    public Flux<User> getAllUsers() {
        return userRepo.findAll();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<User> saveUser(@RequestBody User user) {
        return userRepo.save(user);
    }

    @PutMapping("/{id}")
    public Mono<User> updateUser(@PathVariable String id, @RequestBody User user) {
        user.setId(id);
        return userRepo.save(user);
    }

    @DeleteMapping
    public void deleteUser(@PathVariable String id) {
        userRepo.delete(id);
    }
}

기본적인 RESTfull 형태의 API이다. 메소드 매핑도 GetMapping, PostMapping, PutMapping, DeleteMapping을 사용한다. 물론 RequestMapping으로 세부 설정으로도 가능하다. 사용하는 방법은 기존 SpringMVC와 거의 유사하다. 단지 주의할 부분은 리턴할 때에 Mono, Flux을 사용한다.

직접 라우팅과 핸들링

새로운 예제로 직접 라우팅 처리하는 예를 보자. 어노테이션을 사용할 경우 매핑 정보를 어노테이션을 지정했지만, 이번에는 라우팅 처리할 때에 직접 입력해야 하다. 라우팅 처리하기 전에 먼저 요청을 처리할 핸들러는 먼저 정의해보자.

package com.tistory.ospace;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;

import reactor.core.publisher.Mono;

@Component
public class UserHandler {
    @Autowired
    private UserRepository userRepo;

    public Mono<ServerResponse> getUserById(ServerRequest req) {
        String id = req.pathVariable("userId");
        return userRepo
                .findById(id)
                .flatMap(user->
                    ServerResponse
                    .ok()
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Mono.just(user), User.class)
                )
                .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> getAllUsers(ServerRequest req) {
        return ServerResponse
                .ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(userRepo.findAll(), User.class);
    }

    public Mono<ServerResponse> saveUser(ServerRequest req) {
        Mono<User> user = req.bodyToMono(User.class);
        return user
                .single()
                .flatMap(u->{
                    return ServerResponse
                        .ok()
                        .contentType(MediaType.APPLICATION_JSON)
                        .body(userRepo.save(u), User.class);
                });
    }

    public Mono<ServerResponse> updateUser(ServerRequest req) {
        String id = req.pathVariable("userId");
        Mono<User> user = req.bodyToMono(User.class);
        return user.flatMap(u->{
            if (null != id && !id.isEmpty()) u.setId(id);
            return ServerResponse
                .ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(userRepo.save(u), User.class);
        });
    }

    public Mono<ServerResponse> deleteUser(ServerRequest req) {
        String id = req.pathVariable("userId");
        userRepo.delete(id);
        return ServerResponse
                .ok()
                .build();
    }
}

앞에 UserController와 같은 역할이다. 여기서는 관용적으로 Handler라는 이름을 사용한다. 아직 요청 메시지와 매핑하지 않았고, 기본적인 CRUD을 처리하는 메소드를 정의해놓았다. 주의할 부분은 입력받는 인자는 ServerRequest로 넘겨받아야하고, 리턴할 때에는 Mono로 리턴해야한다. 그래서 ServerRequest에서 쿼리 스트링이나 body에서 직접 데이터를 추출해야한다.

그리고 UserHander를 이용해서 라우팅 처리하는 RoutingConfig 클래스를 보자.

package com.tistory.ospace;

import static org.springframework.web.reactive.function.server.RequestPredicates.DELETE;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.POST;
import static org.springframework.web.reactive.function.server.RequestPredicates.PUT;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;

@Configuration
public class RouterConfig {

    @Bean
    RouterFunction<ServerResponse> routes(UserHandler handler) {
        return route(GET("/user").and(accept(MediaType.APPLICATION_JSON)), handler::getAllUsers)
            .andRoute(GET("/user/{userId}").and(accept(MediaType.APPLICATION_JSON)), handler::getUserById)
            .andRoute(POST("/user").and(accept(MediaType.APPLICATION_JSON)), handler::saveUser)
            .andRoute(PUT("/user/{userId}").and(accept(MediaType.APPLICATION_JSON)), handler::updateUser)
            .andRoute(DELETE("/user/{userId}").and(accept(MediaType.APPLICATION_JSON)), handler::deleteUser);
    }
}

route()을 사용해서 json 유형의 메시지를 처리하고 있다. UserHandler 인자로 앞에 Component로 등록한 UserHandler 빈 객체가 입력된다. route()에 첫번째 인자는 RequestPredicates가 오고, 두번째 인자는 HandlerFunction이 입력된다. RequestPredicates가 GET, POST, PUT, DELETE 메소드 종류와 url가 포함된 매핑 정보이고 이에 호출할 핸들러 메소드를 HandlerFunction으로 등록한다. 매핑된 정보를 기준으로 핸들러의 메소드를 호출하게 된다.

github

결론

간단하게 WebFlux을 사용하는 방법을 살펴보았다. 이외에 다양한 여러 방법이 있다. 큰 틀에서 살표보았기 때문에 위의 방식이 정답은 아니다. 어노테이션을 사용한 방법은 크게 변화는 없을 것 같지만, 직접 라우팅을 설정하는 경우 매우 다양한 구조가 나올 수 있다. 예를 들어 위의 예제에서는 핸들러와 라우팅 매핑 정보가 분리되었지만, 다른 구조로 각 핸들러 메소드와 매핑정보를 하나의 클래스로 묶어서 관리할 수 있다. 생각보다 구조가 복잡해질 수도 있다. 또한 그만큼 더 유연하게 설계할 수도 있다는 의미이다.
부족한 글이지만 WebFlux 사용에 도움이 되었으면 합니다. 즐프하세요. ospace.

참고

[1] Arpendu Kumar Garai, Getting Started with Spring WebFlux, https://reflectoring.io/getting-started-with-spring-webflux/, 2022-03-10

[2] Spring WebFlux, https://docs.spring.io/spring-framework/reference/web/webflux.html

반응형