Spring Boot, WebClient 짧은 사용기

SpringBoot WebClient은 Spring 5.0에서 도입된 Reactive HTTP 클라이언트이다. RestTemplate의 대안으로, HTTP 요청을 보내기 위한 간단하고 직관적인 API를 제공하고 있다.

이 기록은 WebFlux 를 학습해가는 입장에서 BFF 를 구현한다는 가정하에 Member, Banner 의 목록을 제공하는 API 를 호출해서 하나의 API 로 묶어 FrontEnd 를 위한 API 를 작성 하는 것을 가정하였다. 상세한 부분은 이전에 작성된 글의 소스를 참고하면 그 소스를 기본으로 하고 있다는 걸 알 수 있다.

1. Backend API

API 별로 응답 결과가 다른 것을 가정하여 아래와 같이 구성 하였다.

1) Member API

데이터를 가져올 사용자 목록 부분이 data 속성에 있는 것이 특징이다.

{
    "code": 200,
    "page": 1,
    "limit": 3,
    "message": "ok",
    "data": [
        {
            "id": 6,
            "name": "Hong Gil Dong",
            "profile_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "age": 50,
            "created_at": "2023-10-03 20:48:54"
        },
        {
            "id": 5,
            "name": "Hong Gil Dong",
            "profile_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "age": 33,
            "created_at": "2023-10-03 20:48:51"
        },
        {
            "id": 4,
            "name": "Hong Gil Dong",
            "profile_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "age": 19,
            "created_at": "2023-10-03 20:48:49"
        }
    ]
}
Member API (localhost:8080/v1/api/member/list/1?limit=3) 의 응답

2) Banner API

해당 API 는 배너 목록 이외에는 상위에는 특별한 속성이 없다.

[
    {
        "id": 11,
        "banner_name": "샘플베너 11",
        "banner_image_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
        "created_at": "2023-10-03 20:33:30"
    },
    {
        "id": 10,
        "banner_name": "샘플베너 10",
        "banner_image_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
        "created_at": "2023-10-03 20:33:29"
    },
    {
        "id": 9,
        "banner_name": "샘플베너 9",
        "banner_image_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
        "created_at": "2023-10-03 20:33:27"
    }
]
Banner API (localhost:8080/v1/api/banner/list/flux/1?limit=3) 의 응답

2. Backend For Frontend API

편의상 BFF 서버도 동일한 로컬서버 안에 구현했다.

1) 응답 API 의 Model

WebClient 로 호출을 하고 응답을 받을때 다양한 구조의 데이터가 들어올거라 생각하고 bff/Response.java 라는 클래스에 inner class 로 먼저 Member 클래스를 만들어 주었다.

package com.example.webfluxapi.bff;

import com.example.webfluxapi.dto.MemberDTO;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

import java.util.List;

public class Response {

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public static class Member {
        @JsonProperty("code")
        private Integer code;

        @JsonProperty("message")
        private String message;
        
        @JsonProperty("data")
        private List<MemberDTO.Item> data;
    }
}
bff/Response.java

아래는 Member 와 Banner 의 Item 모델 속성을 정의한 DTO 이다.

package com.example.webfluxapi.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.hibernate.validator.constraints.Length;
import org.springframework.data.relational.core.mapping.Column;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;

public class MemberDTO {

    @Builder
    @Setter
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Item {
        @JsonProperty("id")
        private Integer id;
        @JsonProperty("name")
        private String name;
        @JsonProperty("profile_url")
        private String profileUrl;
        @JsonProperty("age")
        private Integer age;
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
        @JsonProperty("created_at")
        private LocalDateTime createdAt;
    }

}
dto/MemberDTO.java
package com.example.webfluxapi.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotEmpty;
import lombok.*;
import org.hibernate.validator.constraints.Length;
import org.springframework.data.relational.core.mapping.Column;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;

public class BannerDTO {

    @Builder
    @Setter
    @Getter
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Item {
        @JsonProperty("id")
        private Integer id;
        @JsonProperty("banner_name")
        private String bannerName;
        @JsonProperty("banner_image_url")
        private String bannerImageUrl;
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
        @JsonProperty("created_at")
        private LocalDateTime createdAt;
    }

}
dto/BannerDTO.java

2) BFF Controller 의 구현

package com.example.webfluxapi.controller;

import com.example.webfluxapi.bff.Response;
import com.example.webfluxapi.common.ApiResponse;
import com.example.webfluxapi.dto.BannerDTO;
import com.example.webfluxapi.dto.MemberDTO;
import com.example.webfluxapi.dto.ProductDTO;
import com.example.webfluxapi.mapper.MemberMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.List;

@Slf4j
@RestController
@RequestMapping("/bff")
public class BFFController {

    private final MemberMapper memberMapper;

    @Autowired
    public BFFController(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }

	// member api webclient 인스턴스
    WebClient.Builder memberApi = WebClient.builder()
            .baseUrl("http://localhost:8080/v1/api/member/list/1?limit=3")
            .defaultHeader("Content-Type", "application/json");

	// banner api webclient 인스턴스
    WebClient.Builder bannerApi = WebClient.builder()
            .baseUrl("http://localhost:8080/v1/api/banner/list/flux/1?limit=3")
            .defaultHeader("Content-Type", "application/json");


    @GetMapping("")
    public Mono<ApiResponse> sample() {

		// Response.Member 로 가져와서 data 속성에서 목록을 가져오는 부분
        Flux<MemberDTO.Item> memList = memberApi.build().get()
                .retrieve()
                .bodyToFlux(Response.Member.class)
                // Flux 에 포함된 Member 객체의 getData 를 호출해서 List 를 Flux로 변경함
                .flatMapIterable(Response.Member::getData);

        Flux<BannerDTO.Item> bannerList = bannerApi.build().get()
                .retrieve()
                .bodyToFlux(BannerDTO.Item.class);

		// 두개의 Flux 를 하나의 Mono로 결합하고 각 Flux의 데이터가 튜플로 변경됨
        return Mono.zip(
                memList.collectList(),
                bannerList.collectList()
        ).map(tuple -> {
            List<MemberDTO.Item> mem = tuple.getT1();
            List<BannerDTO.Item> banner = tuple.getT2();
            
            return ApiResponse.builder()
                    .code(200)
                    .message("ok")
                    .member(mem)
                    .banner(banner)
                    .build();
        }).switchIfEmpty(Mono.just(ApiResponse.builder()
                .code(500)
                .message("error")
                .build()));

    }


}
controller/BFFController.java

3) BFF API 응답 결과

{
    "code": 200,
    "message": "ok",
    "member": [
        {
            "id": 6,
            "name": "Hong Gil Dong",
            "profile_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "age": 50,
            "created_at": "2023-10-03 20:48:54"
        },
        {
            "id": 5,
            "name": "Hong Gil Dong",
            "profile_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "age": 33,
            "created_at": "2023-10-03 20:48:51"
        },
        {
            "id": 4,
            "name": "Hong Gil Dong",
            "profile_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "age": 19,
            "created_at": "2023-10-03 20:48:49"
        }
    ],
    "banner": [
        {
            "id": 11,
            "banner_name": "샘플베너 11",
            "banner_image_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "created_at": "2023-10-03 20:33:30"
        },
        {
            "id": 10,
            "banner_name": "샘플베너 10",
            "banner_image_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "created_at": "2023-10-03 20:33:29"
        },
        {
            "id": 9,
            "banner_name": "샘플베너 9",
            "banner_image_url": "https://pbs.twimg.com/profile_images/1159314102679793664/p7AMiPpV_400x400.jpg",
            "created_at": "2023-10-03 20:33:27"
        }
    ]
}
Web on Reactive Stack

https://www.stacktips.com/articles/what-is-webclient-how-to-use-webclient-in-java-springboot