12월이 시작되었다 !
올해도 이렇게 시간이 빨리가는 구나
점점 더빨리가는 시간이 조금 야속하다.

그래도 내가 당장할 수 있는 일에
늘 집중할 수 있게 다시한번 마음을 다져보며
오늘 공부를 시작해보자


오늘은 WebFlux 기술을 이용한 애플리케이션을 구현해볼 생각이다.

Spring WebFulx ?

Spring WebFlux는 전통적인 Spring MVC방식의 애플리케이션보다
대량 클라이언트 요청을 좀 더 효율적으로 처리할 수 있는 현대적인
애플리케이션 구현을 위한 기술이라고 한다.

Spring 5에 Reactive 스택이라는 기술이 새롭게 추가되었고
해당 기술에서 지원하는 타입 MonoFlux를 사용 했던 걸
이전에 Project Reactor를 공부 하면서 배웠었다.

한마디로 저의를 하자면, Spring WebFlux는
리액티브 웹 애플리케이션을 위한 웹 프레임워크이다.

1
출처 - spring.io

Spring MVC와 Spring WebFlux를 벤다이어그램으로 비교한 사진이다.

이제 클라이언트의 요청을 적으로 받는다고 했었는데
어떻게 차이가나는지 한번 비교해보자


Spring MVC vs Spring WebFlux

두개의 프레임워크를 사용해 어떠한 차이가 있는지 확인해보려한다.
테스트 방식은 서버 2개를 띄워서 한쪽에서 다른쪽을 여러번 동시에 요청하였을 경우
어떻게 처리가 진행되어지는지 비교해보자


1). Spring MVC 테스트 하기

IntelliJ의 Spring Project를 두개를 만들고 각각 다른 서버로 만들 것이다.

  1. 호출하는 서버 - MAIN , PORT 8080
  2. 호출받는 서버 - OUT, PORT 7070

위와 같이 이름을 정해놓고 포트는

server:
  port: 7070

.yml 파일에 추가를 통해 변경이 가능하다.
우선 MAIN 서버 부터 코드를 작성해보자


  • Main 서버
@Slf4j
@RestController
@RequestMapping("/v11/coffees")
public class SpringMvcMainCoffeeController {
    private final RestTemplate restTemplate;

    String uri = "http://localhost:7070/v11/coffees/1";

    public SpringMvcMainCoffeeController(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @GetMapping("/{coffee-id}")
    public ResponseEntity getCoffee(@PathVariable("coffee-id") long coffeeId) {

        log.info("# call Spring MVC Main Controller: {}", LocalDateTime.now());
        ResponseEntity<CoffeeResponseDto> response = restTemplate.getForEntity(uri, CoffeeResponseDto.class);

        return ResponseEntity.ok(response.getBody());
    }
}

해당 Controller가 호출되었을 때, OUT서버를 호출하는 프로그램이다.
log로 현재 시간을 기록해 시간을 체크할 예정이다.
(Dto 코드는 간단하기 때문에 따로 올리지 않겠습니다!)

그리고 Spring 애플리케이션쪽에는

@Slf4j
@SpringBootApplication
public class SpringMvcMainSampleApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringMvcMainSampleApplication.class, args);
	}

	@Bean
	public CommandLineRunner run() {
		return (String... args) -> {
			log.info("# 요청 시작 시간: {}", LocalTime.now());

			for (int i = 1; i <= 5; i++) {
				CoffeeResponseDto response = this.getCoffee();
				log.info("{}: coffee name: {}", LocalTime.now(), response.getKorName());
			}
		};
	}

	// Postman처럼 아래 주소로 5번 요청
	// 그에따른 7070 서버에 5번 요청
	private CoffeeResponseDto getCoffee() {
		RestTemplate restTemplate = new RestTemplate();
		String uri = "http://localhost:8080/v11/coffees/1";
		ResponseEntity<CoffeeResponseDto> response = restTemplate.getForEntity(uri, CoffeeResponseDto.class);

		return response.getBody();
	}
}

CommandLineRunner를 반환타입으로한 메서드로
애플리케이션이 실행 시 5번 MAIN서버 API를 요청하도록 하였다.
5번을 실행하면서 OUT서버에서 응답받은 Coffee 이름과 현재 시간을 기록하게 작성했다.


이제 MAIN서버에서 호출받는 OUT서버를 작성해보자

  • OUT 서버
    @RestController
    @RequestMapping("/v11/coffees")
    public class SpringMvcOutboundCoffeeController {
      @GetMapping("/{coffee-id}")
      public ResponseEntity getCoffee(@PathVariable("coffee-id") long coffeeId) throws InterruptedException {
          CoffeeResponseDto responseDto = new CoffeeResponseDto(coffeeId, "카페라떼", "CafeLattee", 4000);
    
          Thread.sleep(5000);
          return ResponseEntity.ok(responseDto);
      }
    }
    

    간단하다 MAIN서버에서 API호출 받는 서버로 Port번호는 7070으로 변경했다.
    쓰레드를 5초간 정지시켜놓고 응답을해주는 프로그램이다.

이제 OUT서버 애플리케이션을 먼저 실행시킨후
MAIN서버 애플리케이션을 동작시키면 작성한 코드에 의해 OUT서버 API가 5번 호출 될 것이다.

MAIN서버에서 출력된 로그를 확인해보면

2022-12-01 21:35:51.097  INFO 46801 --- [nio-8080-exec-1] c.c.c.c.SpringMvcMainCoffeeController    : # call Spring MVC Main Controller: 2022-12-01T21:35:51.097332
2022-12-01 21:35:56.325  INFO 46801 --- [           main] c.c.SpringMvcMainSampleApplication       : 21:35:56.325066: coffee name: 카페라떼
2022-12-01 21:35:56.330  INFO 46801 --- [nio-8080-exec-5] c.c.c.c.SpringMvcMainCoffeeController    : # call Spring MVC Main Controller: 2022-12-01T21:35:56.330589
2022-12-01 21:36:01.354  INFO 46801 --- [           main] c.c.SpringMvcMainSampleApplication       : 21:36:01.354035: coffee name: 카페라떼
2022-12-01 21:36:01.367  INFO 46801 --- [nio-8080-exec-3] c.c.c.c.SpringMvcMainCoffeeController    : # call Spring MVC Main Controller: 2022-12-01T21:36:01.367418
2022-12-01 21:36:06.388  INFO 46801 --- [           main] c.c.SpringMvcMainSampleApplication       : 21:36:06.388594: coffee name: 카페라떼
2022-12-01 21:36:06.399  INFO 46801 --- [nio-8080-exec-6] c.c.c.c.SpringMvcMainCoffeeController    : # call Spring MVC Main Controller: 2022-12-01T21:36:06.399304
2022-12-01 21:36:11.428  INFO 46801 --- [           main] c.c.SpringMvcMainSampleApplication       : 21:36:11.428414: coffee name: 카페라떼
2022-12-01 21:36:11.444  INFO 46801 --- [nio-8080-exec-2] c.c.c.c.SpringMvcMainCoffeeController    : # call Spring MVC Main Controller: 2022-12-01T21:36:11.444136
2022-12-01 21:36:16.466  INFO 46801 --- [           main] c.c.SpringMvcMainSampleApplication       : 21:36:16.466802: coffee name: 카페라떼

5초 간격으로 API가 호출된 것을 볼 수 있다.
총 25초 정도 소요되었음을 확인할 수 있다.

이유는 무었일까? Spring MVC는 Blocking 처리 방식이기 때문에
5번의 요청이 빠르게 들어와도 하나의 요청처리가 끝나고
다음 처리를 하지못하는 모습을 볼 수 있다.


2). Spring WebFlux 테스트 하기

그럼 우리가배운 Non-Blocking처리를 하는
Spring WebFlux를 사용해서 한번 작성해보자.
방식은 Spring MVC와 동일 방법으로 테스트를 진행해보려한다.

  • MAIN 서버
@Slf4j
@RestController
@RequestMapping("/v11/coffees")
public class SpringWebFluxMainCoffeeController {
    String uri = "http://localhost:7070/v11/coffees/1";

    @ResponseStatus(HttpStatus.OK)
    @GetMapping("/{coffee-id}")
    public Mono<CoffeeResponseDto> getCoffee(@PathVariable("coffee-id") long coffeeId) {
        log.info("# call Spring WebFlux Main Controller: {}", LocalDateTime.now());
        return WebClient.create()
                .get()
                .uri(uri)
                .retrieve()
                .bodyToMono(CoffeeResponseDto.class);
    }
}

마찬가지로 OUT서버를 호출하는 동작이다.

@Slf4j
@SpringBootApplication
public class SpringWebFluxMainSampleApplication {

	public static void main(String[] args) {
		System.setProperty("reactor.netty.ioWorkerCount", "1");
		SpringApplication.run(SpringWebFluxMainSampleApplication.class, args);
	}

	@Bean
	public CommandLineRunner run() {
		return (String... args) -> {
			log.info("# 요청 시작 시간: {}", LocalTime.now());

			for (int i = 1; i <= 5; i++) {
				this.getCoffee().subscribe(
                                        response -> {
                                            log.info("{}: coffee name: {}", LocalTime.now(), response.getKorName());
                                        }
                );
            }
        };
    }
    private Mono<CoffeeResponseDto> getCoffee() {
        String uri = "http://localhost:8080/v11/coffees/1";
        return WebClient.create()
                .get()
                .uri(uri)
                .retrieve()
                .bodyToMono(CoffeeResponseDto.class);
    }
}

Spring MVC와 동일하게 Spring WebFlux 방식으로
OUT서버를 호출하는 프로그램이다.

  • OUT 서버
    @RestController
    @RequestMapping("/v11/coffees")
    public class SpringWebFluxOutboundCoffeeController {
      @ResponseStatus(HttpStatus.OK)
      @GetMapping("/{coffee-id}")
      public Mono<CoffeeResponseDto> getCoffee(@PathVariable("coffee-id") long coffeeId) throws InterruptedException {
          CoffeeResponseDto responseDto = new CoffeeResponseDto(coffeeId, "카페라떼", "CafeLattee", 4000);
    
          Thread.sleep(5000);
          return Mono.just(responseDto);
      }
    }
    

    이제 OUT 서버도 동일하게 쓰레드에 5초의 딜레이를 걸어놓고
    OUT서버 애플리케이션부터 실행하고 MAIN서버를 실행하게되면

2022-12-01 21:41:38.540  INFO 47287 --- [ctor-http-nio-1] c.c.SpringWebFluxMainSampleApplication   : 21:41:38.540514: coffee name: 카페라떼
2022-12-01 21:41:38.541  INFO 47287 --- [ctor-http-nio-1] c.c.SpringWebFluxMainSampleApplication   : 21:41:38.541614: coffee name: 카페라떼
2022-12-01 21:41:38.543  INFO 47287 --- [ctor-http-nio-1] c.c.SpringWebFluxMainSampleApplication   : 21:41:38.543159: coffee name: 카페라떼
2022-12-01 21:41:38.544  INFO 47287 --- [ctor-http-nio-1] c.c.SpringWebFluxMainSampleApplication   : 21:41:38.544006: coffee name: 카페라떼
2022-12-01 21:41:38.544  INFO 47287 --- [ctor-http-nio-1] c.c.SpringWebFluxMainSampleApplication   : 21:41:38.544734: coffee name: 카페라떼

이렇게 콘솔에 로그가 발생할 것이다.
우리가 요청시간을 확인하려고 남긴로그고 실제로 로그 시간을 확인해보면
1초도 차이나지않게 5개의 요청이 전부처리가된 것을 볼 수 있다.

이 말은 즉, Non-Blocking 처리방식이다.
여러번의 요청이 들어와도 밀리지 않고 동작이 동시에 처리된 모습이다.

이렇게 Non-Blocking으로 처리되는 것을 눈으로 보았는데
실제 Spring WebFlux안에는 이렇게 처리해줄 수 있는
엄청난 기술과 코드가 들어가 있을 것이다.. 실제로 Spring MVC처럼
클래스를 모두 찾아보고 아키텍처를 그리고 상속관계를 파악해보고
깊게 탐구해보고 싶지만… 현실적으로 지금 공부하는 단계에서는 전부 확인하는 것은
무리가 있어보인다. 이렇게 사용을하면 일단.. Non-Blocking 처리가 되는구나! 하고
후에 Advance한 공부를 해야할 것 같다.


Spring WebFlux 적용

실제로 우리가 Controller 계층과 Service 계층에
어떻게 Spring WebFlux를 적용할 수 있는지 알아보자

애플리케이션 컨셉은
커피를 등록하면 해당 커피가 DB에 저장되는 컨셉이며
H2 Database를 사용하였다.

우선 resources로 활용할 데이터를 저장해야한다.
첫번째로 .sql 파일을 작성해야한다.

CREATE TABLE IF NOT EXISTS COFFEE (
    COFFEE_ID bigint NOT NULL AUTO_INCREMENT,
    KOR_NAME varchar(100) NOT NULL,
    ENG_NAME varchar(100) NOT NULL,
    PRICE number NOT NULL,
    COFFEE_CODE char(3) NOT NULL,
    COFFEE_STATUS varchar(100) NOT NULL,
    CREATED_AT datetime NOT NULL,
    LAST_MODIFIED_AT datetime NOT NULL,
    PRIMARY KEY (COFFEE_ID)
);

그리고 .yml 파일을 설정해주자

spring:
  r2dbc:
    url: r2dbc:h2:mem:///test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
  sql:
    init:
      schema-locations: classpath*:db/h2/schema.sql
logging:
  level:
    org:
      springframework:
        r2dbc: DEBUG

schema.sql은 위에서 설정한 .sql파일이름이다.
r2dbc를 설정해줘야 h2 데이터베이스를 웹으로 접근할 수 있다.
후에 아래에서 접근하기 위한 코드를 작성해야한다.

그리고 build.gradle 파일의 설정이다.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-webflux' // 추가
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' // 추가
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
	implementation 'org.mapstruct:mapstruct:1.5.1.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'
	runtimeOnly 'io.r2dbc:r2dbc-h2' // 추가
	implementation 'com.h2database:h2' 
}

여러가지가 있지만 우리가 WebFlux를 사용하기 위해
추가한 의존라이브러리 // 추가라고 되어있는 부분을 꼭 추가해주자

처음으로는 h2 DB를 접근할 수 있는 코드를 작성해보자

@Component
@Slf4j
public class H2Console {
    private Server webServer;

    @EventListener(ContextRefreshedEvent.class)
    public void start() throws java.sql.SQLException {
        log.info("starting h2 console at port 8078");
        this.webServer = org.h2.tools.Server.createWebServer("-webPort", "8078", "-tcpAllowOthers").start();
    }

    @EventListener(ContextClosedEvent.class)
    public void stop() {
        log.info("stopping h2 console at port 8078");
        this.webServer.stop();
    }
}

Port 8078로 설정하여
localhost:8078을 웹주소로 입력해 기존과 동일하게 접근이 가능하다.

이제 우리가 확인해야할 Controller가 어떻게
변경되었는지 확인해보자

@Slf4j
@RestController
@RequestMapping("/v12/coffees")
public class CoffeeController {

    private final CoffeeService coffeeService;
    private final CoffeeMapper mapper;

    public CoffeeController(CoffeeService coffeeService, CoffeeMapper mapper) {
        this.coffeeService = coffeeService;
        this.mapper = mapper;
    }

    @PostMapping()
    public ResponseEntity createCoffee(@RequestBody Mono<CoffeeDto.Post> requestBody){

        Mono<CoffeeDto.Response> result =
                requestBody
                        .flatMap(post -> coffeeService.createCoffee(mapper.coffeePostDtoToCoffee(post)))
                        .map(coffee -> mapper.coffeeToCoffeeResponseDto(coffee));

        return new ResponseEntity(result,HttpStatus.CREATED);
    }
}

정말 간단하게 되어있는 Controller 계층이다.
Post요청하나만 존재하는 클래스이고, Mono를 이용했다.
Mono<>로 감싼 객체를 받고, 응답을해주면 된다.

객체를 변환하기 위해서 flatMap() , map()을 사용했고
Mono에 감싸여있는 객체 CoffeeDto.Response를 꺼내서
우리가 만든 mapper 클래스를 이용해 Coffee 객체로 변환해주고 있다.

변환된 객체를 coffeeService.createCoffee();메서드에 매개변수로 넣어
Service 계층을 호출하는 모습이다. 이제 Service 계층에서는 데이터를 DB에 저장하고
다시 반환받은 객체를 map();메서드로 반환 타입에 맞는 형태로 변경하여

ResponseEntity 객체를 만들어 반환해주면 끝이다.
기존 Spring MVC와 달라진 점은 Mono를 사용한점만 변경되었다.

그럼 Service 계층을 살펴보자

@Service
public class CoffeeService {

    private final CoffeeRepository coffeeRepository;

    private final R2dbcEntityTemplate template;

    public CoffeeService(CoffeeRepository coffeeRepository, R2dbcEntityTemplate template) {
        this.coffeeRepository = coffeeRepository;
        this.template = template;
    }

    public Mono<Coffee> createCoffee(Coffee coffee) {

        Mono<Coffee> coffeeMono = findVerifiedCoffee(coffee.getCoffeeCode())
                .then(coffeeRepository.save(coffee));

        return coffeeMono;
    }

    private Mono<Void> findVerifiedCoffee(String coffeeCode) {
        return coffeeRepository.findByCoffeeCode(coffeeCode)
                .flatMap(findCoffee -> {
                    if (findCoffee != null) {
                        return Mono.error(new BusinessLogicException(ExceptionCode.COFFEE_CODE_EXISTS));
                    }
                    return Mono.empty();
                });
    }
}

Service 계층도 기존과 동일한 코드들이고
CoffeeCode를 통해 유무를 확인하는 findVerifiedCoffee();메서드와
실제로 DB에 저장을하기위한 createCoffee();메서드가 존재한다.

여기도 안에있는 내용들이 Mono를 이용해 처리한점만 다르다.

그럼 이제 Repository 계층을 살펴보자

public interface CoffeeRepository extends R2dbcRepository<Coffee, Long> {
    Mono<Coffee> findByCoffeeCode(String coffeeCode);
}

기존에 Spring MVC를 사용할 때는 JDBC,JPA를 사용했지만
Spring WebFlux에서는 R2dbc를 사용하고있다.

형식은 기존에 설정하는 것과 비슷하다.
여기서도 다른점은 Mono를 사용했다는 점

이렇게 작성을 완료하고 Postman을 통해 API를 호출해보면

2

Spring MVC와 동일하게 요청과 응답이 오는 것을 볼 수 있다.
실제로 Postman을 통해서 여러개 요청하는 작업은 못해봤지만

처음에 테스트해보았던 동시에 호출하였을 경우처럼
동시에 많은 요청처리가 이을 경우 확연히 다른 차이를 보일 것 같다.


기타로 정상적으로 테스트하기 위해 필요한 코드인
Coffee, CoffeeDto, CoffeeMapper,
BusinessLogicException, ExceptionCode를 아래에 적어본다.

@NoArgsConstructor
@Getter
@Setter
@Table
public class Coffee {
    @Id
    private Long coffeeId;
    private String korName;
    private String engName;
    private Integer price;
    private String coffeeCode;
    private CoffeeStatus coffeeStatus = CoffeeStatus.COFFEE_FOR_SALE;

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column("last_modified_at")
    private LocalDateTime modifiedAt;

    public enum CoffeeStatus {
        COFFEE_FOR_SALE("판매중"),
        COFFEE_SOLD_OUT("판매 중지");

        @Getter
        private String status;

        CoffeeStatus(String status) {
            this.status = status;
        }
    }
}
public class CoffeeDto {
    @Getter
    @AllArgsConstructor
    public static class Post {
        @NotBlank
        private String korName;

        @NotBlank
        @Pattern(regexp = "^([A-Za-z])(\\s?[A-Za-z])*$", message = "커피명(영문)은 영문이어야 합니다(단어 사이 공백 한 칸 포함). 예) Cafe Latte")
        private String engName;

        @Range(min= 100, max= 50000)
        private int price;

        @NotBlank
        @Pattern(regexp = "^([A-Za-z]){3}$", message = "커피 코드는 3자리 영문이어야 합니다.")
        private String coffeeCode;
    }
}
@Mapper(componentModel = "spring")
public interface CoffeeMapper {
    Coffee coffeePostDtoToCoffee(CoffeeDto.Post coffeePostDto);
    CoffeeDto.Response coffeeToCoffeeResponseDto(Coffee coffee);
}
public class BusinessLogicException extends RuntimeException {
    @Getter
    private ExceptionCode exceptionCode;

    public BusinessLogicException(ExceptionCode exceptionCode) {
        super(exceptionCode.getMessage());
        this.exceptionCode = exceptionCode;
    }
}
public enum ExceptionCode {
    COFFEE_NOT_FOUND(404, "Coffee not found"),
    COFFEE_CODE_EXISTS(409, "Coffee Code exists");

    @Getter
    private int status;

    @Getter
    private String message;

    ExceptionCode(int code, String message) {
        this.status = code;
        this.message = message;
    }
}



오늘은 이렇게 SpringWebFlux를 실제 우리가
애플리케이션 계층에 적용하는 방식과 동일하게 적용해보았다.

확실히 Spring MVC보다 사용하기 어려운 느낌이든다.
아무래도 익숙해지려면 실제 내부 프로그램도 많이 봐야할 것 같고
Backpressure라든가 Non-Blocking처리 라든가
정확히 어떻게 코드로 동작하는지 개념과 흐름 파악이 필요할 것 같다.
또한 Operator들도 많이 알아야할 것같다..ㅠㅠ

우선 아직 Spring MVC도 애플리케이션 구현 경험이 거의 없다보니까
눈앞에 있는 기술부터 체득과 습득을하고 이후로 나아가보려한다.

이렇게 수박 겉 핥기인 WebFlux는 여기서 끝 !


오늘의 커피량: ☕️ ☕️
오늘의 점심: 짜장밥