์คํ๋ง API ๋น๋๊ธฐ ๋ ผ๋ธ๋กํน ๋ฐฉ์ ํธ์ถ
์คํ๋ง API ๋น๋๊ธฐ ๋ ผ๋ธ๋กํน ๋ฐฉ์ ํธ์ถ (How to call API with async-nonblocking method)
RestTemplate To WebClient
๋์ ๊ณ๊ธฐ
๐ก ์ธ๋ถ์ ๋๋๋ฐ์ดํฐ๋ฅผ API๋ฅผ ์ด์ฉํ์ฌ ๋ด๋ถ DB์ ์ ์ฅํด์ผ๋๋ ๋์ฆ๊ฐ ์์๋ค.
[์ด๊ธฐ ์ ์ฌ์ ํ์ํ ์๊ฐ ๋น์ฉ ์ธก์ ]
- ์ฝํ ์ธ ๊ฐ์ : 98,250
- ์ด๋ฏธ์ง ๊ฐ์ : 306,654
์ฝํ ์ธ ๋ชฉ๋ก ์กฐํ : 1๋ฒ ์กฐํ์ ์ต๋ 100๊ฑด (ํธ์ถ ๋น 5์ด ์ ๋ ์์) โ 983(98,250 / 100)๋ฒ ์กฐํ ํ์
- 983(ํธ์ถ์) x 5(์ด) = 4915์ด = ์ฝ 82๋ถ
์ฝํ ์ธ ์์ธ ์กฐํ : 98,250๋ฒ (ํธ์ถ ๋น 1์ด ๋ฏธ๋ง)
- 98,250(ํธ์ถ์) x 1(์ด) = 98,250์ด = 1,638๋ถ = ์ฝ 27์๊ฐ = ์ฝ 1์ผ 3์๊ฐ์์
์ฝํ ์ธ ์ด๋ฏธ์ง ์กฐํ : 306,654๋ฒ (ํธ์ถ ๋น 1์ด ๋ฏธ๋ง)
- 306,654(ํธ์ถ์) x 1(์ด) = 306,654์ด = 5,111๋ถ = ์ฝ 86์๊ฐ = ์ฝ 3์ผ 14์๊ฐ์์
โ ์ด ์๊ฐ ์์ : 5์ผ์ ๋ ์์
์ฌ์ง์ด, ๋จ์ ํธ์ถ๋ง ํ๋๋ฐ ๊ฑธ๋ฆฌ๋ ์๊ฐ ๋น์ฉ์ด 5์ผ์ด๋ผ๋ ๊ฒ์ด๋ค.
๋น๋๊ธฐ API ํธ์ถ ๋ฐฉ๋ฒ์ด ์์๊น?
๋์ ์ฌ์์ธ? ๊ตฌ๊ธ์๊ฒ Spring Async RestTemplate
**์ ํค์๋๋ก ๊ฒ์์ ํ๋ค.
์ฐ๊ด ๊ฒ์์ด๋ฅผ ํตํด WebClient
์ ์กด์ฌ๋ฅผ ์ ์ ์์๋ค.
๋๋ ๋น์ทํ ์๊ฐ์ ๊ฐ์ง ์ฌ๋์ด ๋ง๊ตฌ๋ ์๊ฐํ๋ค. ใ
WebClient
์ ๊ธฐ๋ณธ ๊ฐ๋
์ ๊ฒ์ ๊ฒฐ๊ณผ๋ก ์ตํ๊ณ ์ผ์ํ ๊ฐ๋ฐ์๋ต๊ฒ? ๋ฐ๋ก ์์ ์ฝ๋๋ฅผ ๋ง๋ค์๋ค. (๋ฐฑ๋ฌธ์ด ๋ถ์ฌ์ผํ.. )
๊ณต์๋ฌธ์ ๋งํฌ ๋ค์๊ณผ ๊ฐ๋ค..
[๋งํฌ]
https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client
Gradle ์ค์
dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' }
ํ ์คํธ ์ฝ๋
ํ ์คํธ๋ฅผ ์ํด, ์ธ๋ถ API ๋์ฒด์ฉ์ผ๋ก ๊ฐ๋จํ ์ปจํธ๋กค๋ฌ ์์ ๋ฅผ ์์ฑํ์๋ค.
@RestController public class HelloController { @GetMapping("/hello") public String hello() { return "Hello World"; } }
๋ง๋ ์ปจํธ๋กค๋ฌ๋ฅผ ํธ์ถํ๋ ํ ์คํธ ์ฝ๋ ์์ฑ
@AutoConfigureWebClient @SpringBootTest(webEnvironment = RANDOM_PORT) class ApiWebClientTest { @Autowired WebTestClient webTestClient; @Test void helloWebClient() { webTestClient.method(HttpMethod.GET) .uri("/hello") .exchange() .expectStatus().isOk() // ์๋ต ์ฝ๋ ๊ธฐ๋๊ฐ .expectBody(String.class) // ์๋ต body ํด๋์ค ํ์
๊ธฐ๋๊ฐ .value(response -> { // ์๋ต ๋ฐ๋ response System.out.println("response = " + response); assertThat(response).isEqualToIgnoringCase("Hello World"); }); } }
ํ ์คํธ ์ฑ๊ณต ํ๋ฉด
ํ๋ก๋์ ์ฝ๋
๋์ผํ ํ๋ก์ ํธ ๋ด์์ ํจํค์ง ๋ช
์ external
(์ธ๋ถ API ์ญํ ํ๋ ํจํค์ง) / internal
(์ค์ ๋ด๋ถ ํ๋ก์ ํธ ํจํค์ง) ๊ตฌ๋ถํ์ฌ ์ฝ๋๋ฅผ ์์ฑํ ์์ ์ด๋ค.
๋จผ์ , ์ธ๋ถ์ API๋ฅผ ๋ง๋ค์ด ๋ณผ๊ฒ์ด๋ค. ํจํค์ง๋ .external
์ด๋ค.
๐ก ์ํ๋ช ๊ณผ ์ํ๊ฐ๊ฒฉ์ ๋ฑ๋กํ๋ ๊ฐ์ API๋ก ๊ฒฐ๊ณผ๊ฐ์ ์์ฑ์ผ์๊ฐ ๋ค์ด๊ฐ ๋ฐํํ๋ API๋ค.
ItemController
- ์ธ๋ถ API๋ฅผ ๊ฐ์ํ ์ปจํธ๋กค๋ฌ
@Slf4j @RestController @RequestMapping("/external/api/v1/item") public class ItemController { @PostMapping public ItemResponseDto create(@RequestBody ItemRequestDto request) { log.info("Call create item : {}", request); // ๊ฒฐ๊ณผ๊ฐ ํ์ธ์ ์ํ ๋ก๊ทธ return ItemResponseDto.of(request); } }
ItemRequestDto
- ItemController
์์ฒญ ๋ฐ๋
@Data public class ItemRequestDto { private String name; private Integer price; }
ItemResponseDto
- ItemController
์๋ต ๋ฐ๋
@Data public class ItemResponseDto { private String name; private Integer price; private LocalDateTime createdDate; @Builder public ItemResponseDto(String name, Integer price) { this.name = name; this.price = price; this.createdDate = LocalDateTime.now(); } public static ItemResponseDto of(ItemRequestDto request) { return ItemResponseDto.builder() .name(request.getName()) .price(request.getPrice()) .build(); } }
๋ค์์, .internal.api
์ ์ํ๋ ํจํค์ง๋ก API๋ฅผ ํธ์ถํ๋๋ฐ ํ์ํ ์ฝ๋๋ค.
ApiRequestDto<T>
- API ํธ์ถํ๊ธฐ ์ํ DTO
@Getter public class ApiRequestDto<T> { private final String url; // API ํธ์ถ uri private final Object body; // API ์์ฒญ ๋ฐ๋ private final HttpMethod method; // API ์์ฒญ ๋ฉ์๋ private final Class<T> returnType; // API ์๋ต ํด๋์ค ํ์
private final Consumer<T> callback; // API ์๋ต ์ฝ๋ฐฑ ์ฒ๋ฆฌ @Builder public ApiRequestDto(String url, Object body, HttpMethod method, Class<T> returnType, Consumer<T> callback) { this.url = url; this.body = body; this.method = method; this.returnType = returnType; this.callback = callback; } }
Class<T> returnType
: API๋ง๋ค ์๋ต ํด๋์ค ํ์ ์ด ๋ฐ๋ ์ ์์ผ๋ฏ๋ก OCP(๊ฐ๋ฐฉ-ํ์ ์์น)๋ฅผ ์์น์ ์ค์ํ์ฌ ์์ฑํ ๋ณ์Consumer<T> callback
: ๋น๋๊ธฐ API ํธ์ถ์ ํ๊ธฐ ๋๋ฌธ์ API ์๋ต๊ฐ์ ์ฒ๋ฆฌํ๋ ์ฝ๋ฐฑ ๋ณ์
ApiWebClient
- WebClient
: API ํธ์ถ ํด๋์ค
@Component public class ApiWebClient { private final WebClient webClient; public ApiWebClient(WebClient.Builder builder) { this.webClient = builder .baseUrl("http://localhost:8080") // (1) ์ธ๋ถ API Base URl .defaultHeader(CONTENT_TYPE, APPLICATION_JSON.toString()) // (2) DEFAULT HTTP ํค๋ .build(); } public <T> void call(ApiRequestDto<T> request) { execute(request).subscribe(request.getCallback()); // (3) } private <T> Mono<T> execute(ApiRequestDto<T> request) { return webClient.method(request.getMethod()) .uri(request.getUrl()) .bodyValue(request.getBody()) .retrieve() // (4) .bodyToMono(request.getReturnType()); // (5) } }
external
ํจํค์ง์ ์ธ๋ถ API๋ฅผ ์์ฑํ๊ธฐ ๋๋ฌธ์http://localhost:8080
๋ก ์ค์ - ๊ธฐ๋ณธ ํค๋๋ฅผ ์ค์ ํ๋ ๋ถ๋ถ์ด๋ฉฐ
Content-Type : application/json
๋ฅผ ์ค์ ํ ๋ถ๋ถ์ด๋ค.
1, 2๋ฒ ํ์ ์ฌํญ์ ์๋๊ณ ์ต์ ์ด๋ค. ๋ง์ฝ API ์คํ์ด ํญ์ ๋ฌ๋ผ์ง๋ค๋ฉด
builder.build()
๋ก ์ค์ ํด๋ ๋ฌด๋ฐฉํ๋ค.
WebClient
API ํธ์ถ ํ ๋ ผ๋ธ๋กํน ๋ฐฉ์(Non-blocking)์ผ๋กrequest
์ ์ง์ ํcallback
์ฒ๋ฆฌ์ด๋ค.[์ฐธ๊ณ ]- https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html#subscribe-java.util.function.Consumer-
- ๋์ ๋ฐฉ์์ ์๋ ์ด๋ฏธ์ง์ ๊ฐ๊ณ ์์ธํ ๋ด์ฉ์ ์ฐธ๊ณ ์ ๋งํฌ๋ฅผ ํ์์ผ๋ ์ฐธ๊ณ ๋ฐ๋๋ค.
- ์๋ต์ ์ถ์ถํ๋๋ฐ ์ฌ์ฉํ๋ ๋ฉ์๋์ด๋ค.[์ฐธ๊ณ ]
- https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client-retrieve
- ๋น์ทํ ๋ฉ์๋๋ก ๋ง์ ์ ์ด๊ฐ ํ์ํ ๋ ์ฌ์ฉํ๋
exchangeToMono()
,exchangeToFlux()
๊ฐ ์์ง๋ง, ๋ฉ๋ชจ๋ฆฌ ๋์์ ๊ฐ๋ฅ์ฑ์ด ์์ดretrieve()
๋ฅผ ๊ถ์ฅํ๋ค. - ์๋ต ๋ฐ๋๋ง
return
ํ๋ฉฐ ํด๋์ค ํ์ ์request
์ ์ง์ ํreturnType
์ผ๋ก ์ค์ ํ๋ค.
๋ง์ง๋ง์ผ๋ก, ์ธ๋ถ ๊ฐ์ API๋ฅผ ํธ์ถํ๊ธฐ ์ํ ์ฝ๋์ด๋ค.
ExternalApiCallController
- ์ธ๋ถ APIํธ์ถ ํ๋ ์ปจํธ๋กค๋ฌ
@RestController @RequiredArgsConstructor @RequestMapping("/external/api/call") public class ExternalApiCallController { private final ExternalApiCallService callService; @GetMapping("/create-item/count/{count}") // {count}๋ ๋น๋๊ธฐ๋ก ํธ์ถํ ๊ฐ์๋ฅผ ๋งํ๋ค. public String createItemOne(@PathVariable int count) { callService.execute(count); return "OK"; } }
ExternalApiCallService
- ์ธ๋ถ APIํธ์ถ์ ์๋น์ค ํด๋์ค
@Slf4j @Service @RequiredArgsConstructor public class ExternalApiCallService { private final ApiWebClient webClient; public void execute(int count) { validate(count); // count ๊ฒ์ฆ getTargets(count).boxed() .map(this::createRequest) // API ํธ์ถ์ ์ฌ์ฉํ ์์ฒญ dto ์ธํ
.forEach(webClient::call); // API ํธ์ถ } private void validate(int count) { if (count < 1) throw new IllegalArgumentException("Greater than 1"); } private ApiRequestDto<ItemCallResponseDto> createRequest(Integer index) { return ApiRequestDto.<ItemCallResponseDto>builder() .returnType(ItemCallResponseDto.class) // ์๋ต ๋ฐ๋ ํ์
์ง์ .url("/external/api/v1/item") // ์ธ๋ถ ๊ฐ์ API URL .method(POST) // ์ธ๋ถ ๊ฐ์ API ๋ฉ์๋ .body(createBody(index)) // ์์ฒญ ๋ฐ๋ ์์ฑ .callback(this::callback) .build(); } private void callback(ItemCallResponseDto response) { log.info("Success create item : {}", response); // ๊ฒฐ๊ณผ๊ฐ ํ์ธ ์ํด ๋ก๊ทธ } private ItemCallRequestDto createBody(Integer index) { // ์์ฒญ ๋ฐ๋ ์์ฑ return ItemCallRequestDto.builder() .name("product" + index) .price(index * 10000) .build(); } private IntStream getTargets(int count) { return IntStream.range(1, count + 1); // 1๋ถํฐ ํ๋ผ๋ฏธํฐ๋ก ๋ค์ด์จ count๋งํผ API ํธ์ถ ํ๋ ํ๋ก์ธ์ค } }
Dto
๋ ์์์ ์ธ๋ถ API์ ๊ฑฐ์ ๋น์ทํ๊ฒ ์์ฑํ์๋ค.
ItemCallRequestDto
- ์ธ๋ถ API ์์ฒญ ๋ฐ๋
public class ItemCallRequestDto { private String name; private Integer price; }
ItemCallResponseDto
- ์ธ๋ถ API ์๋ต ๋ฐ๋
@Data public class ItemCallResponseDto { private String name; private Integer price; private LocalDateTime createdDate; }
๊ทธ๋ผ, ์ด์ ํ๋ก๋์ ์ฝ๋๋ ๋ค ์์ฑํ์ผ๋ ๊ฒฐ๊ณผ๋ฅผ ํ์ธํด๋ณด์.
ํ๋ก๋์ ์ฝ๋ ๊ฒฐ๊ณผ
์ผ๋จ ํ๋ฒ๋ง ํธ์ถํ๊ฒ ํ
์คํธ ํด๋ณด์. (ํธ์ถ์ IntelliJ์์ ์ ๊ณตํ๋ http
๋ก ์งํํ์๋ค.)
GET http://localhost:8080/external/api/call/create-item/count/1
์๋ต์ ์ ์์ ์ด๋ค. ๊ทธ๋ผ ๊ฒฐ๊ณผ๊ฐ ํ์ธ์ ์ํด ๋ก๊ทธ ์ฒ๋ฆฌ๋ฅผ ํด๋จ๋ ๋ถ๋ถ์ด ์ฐํ๋์ง ํ์ธํด๋ณด์. ๐คฃ
์ค.. callback
์ฒ๋ฆฌ๋ ์ ์์ ์ผ๋ก ๋ ๊ฒ์ ํ์ธ ํ ์ ์๋ค.
๊ทธ๋ฌ๋ฉด ์ฐ๋ฆฌ๊ฐ ๊ถ๊ทน์ ์ผ๋ก ํ๊ณ ์ ํ๋ ๋น๋๊ธฐ - ๋ ผ๋ธ๋กํน๋ฐฉ์์ผ๋ก ์ฌ๋ฌ๊ฐ์ API๋ฅผ ํธ์ถํด๋ณด์
10๋ฒ์ ํธ์ถํ๋ ์๋ ํฌ์ธํธ์ด๋ค.
GET http://localhost:8080/external/api/call/create-item/count/10
์๋ต์ ์ ์์ ์ด๋ค. ๊ทธ๋ผ ๋ง์ฐฌ๊ฐ์ง๋ก ๋ก๊ทธ ํ์ธ..
๋ก๊ทธ๋ฅผ ๋ณด๋ฉด ๋ค์ฃฝ๋ฐ์ฃฝ์ด๋ค. ใ ใ
๊ฒฐ๊ณผ๋ถํฐ ๋งํ๋ฉด ์ํ๋ ๋ก๊ทธ์ ๊ทธ๋ฆผ์ด๋ค.
๋นจ๊ฐ์ ๋ฐ์ค
๋ถ๋ถ์ ๋ณด๋ฉด ํธ์ถ๋ก๊ทธ์ธ โCall create itemโ ์ฌ์ด์ ์ฝ๋ฐฑ ๋ก๊ทธ์ธ โSuccess create itemโ ์ด ์ฐํ์๋๊ฑธ ๋ณด๋ ๋น๋๊ธฐ ํธ์ถ์ด ์ ์์ ์ผ๋ก ๋์ด์์ ์ ์ ์๋ค.- ๊ทธ๋ฆฌ๊ณ , ์ฝ๋ฐฑ๋ก๊ทธ์
ItemCallResponseDto
์craeteDate
์ ์๊ฐ์ ๋ณด๋ฉด ๊ฑฐ์ ๋์์ ํธ์ถ ๋์์์ ์ ์ ์๋ค.
๊ทธ๋ผ ์ด์ ์ค์ ์๋น์ค์ ์ ์ฉ์ ํด๋ณด๋๋ก ํ์. ์ฝ๋๋ ์ ๋ฌด๊ด๋ จ๋๊ฑฐ๋ผ ์คํ์ด ๋ถ๊ฐ๋ฅํ๊ณ ๊ฒฐ๊ณผ๋ฅผ ๊ณต์ ํ๊ฒ ๋ค. ๐
์ค์ ์
๋ฌด ์๋น์ค์์์ WebClient
๋์
๊ฒฐ๊ณผ
์ฝํ ์ธ ๋ชฉ๋ก ์กฐํ : 10๊ฐ ๋น๋๊ธฐ ํธ์ถ 1๋ฒ ์กฐํ์ ์ต๋ 1000๊ฑด (ํธ์ถ ๋น 5์ด ์ ๋ ์์) โ 99(98,250 / 1000)๋ฒ ์กฐํ ํ์
- 99(ํธ์ถ์) x 5(์ด) = 495์ด = ์ฝ 9๋ถ
์ฝํ ์ธ ์์ธ ์กฐํ : 100๊ฐ ๋น๋๊ธฐ ํธ์ถ โ 983(98,250 / 100)๋ฒ (ํธ์ถ ๋น 1์ด ๋ฏธ๋ง)
- 983(ํธ์ถ์) x 1(์ด) = 983์ด = 17๋ถ ์์
์ฝํ ์ธ ์ด๋ฏธ์ง ์กฐํ : 100๊ฐ ๋น๋๊ธฐ ํธ์ถ โ 3,067(306,654 / 100)๋ฒ (ํธ์ถ ๋น 1์ด ๋ฏธ๋ง)
- 3,067(ํธ์ถ์) x 1(์ด) = 3,067์ด = 52๋ถ ์์
โ ์ด ์๊ฐ ์์ : 1์๊ฐ 18๋ถ
๐คฉ ์๊ฐ์, **๋์ ์ 5์ผ**์์ **๋์ ํ 1์๊ฐ 18๋ถ**์ผ๋ก ๋จ์ถํ์๋คโฆ
์.. ํผํฌ๋จผ์ค๊ฐ ์ฝ 92%์ฆ๊ฐ ํ์๋ค.
์์ผ๋ก๋ ๋์ฉ๋์ผ๋ก ๋ฐ์ดํฐ ์ด๊ดํ ๋ API๋ฅผ ์ฌ์ฉํ๋ค๋ฉด,,
๊ธฐ์กด์ ๋ง์ด ์ฌ์ฉํ๋ RestTemplate
๋ณด๋ค WebClient
๋ฅผ ์ฌ์ฉํ ๊ฒ ๊ฐ๋ค.
์ฝ๋๋ Github์ ์ฌ๋ฆด ์์ ์ด๋ผ ์ฐธ๊ณ ๋ฐ๋๋ค.
Github Repository : https://github.com/discphy/webclient-example