블로그
2023년 7월 1일

Spring으로 HTTP 클라이언트 만들기

Spring HTTP 인터페이스를 사용해서 HTTP 클라이언트를 간단히 만들어보자
serverToServer
HTTP 통신을 통해 서버와 서버 간에 데이터를 주고 받을 수 있다.

고객에게 정보를 제공해주는 서비스를 운영한다고 생각해보자. 고객에게 제공할 정보를 우리 서비스가 모두 가지고 있다면 DB를 조회해서 결과를 보여주면된다. 하지만 고객에게 제공할 정보중 일부는 외부에서 가져와야한다면 외부 서버와 통신을 해야 한다. 웹 환경에서 통신은 대부분 HTTP API 방식을 사용한다. 이 때 우리 서비스는 외부 서비스를 호출하는 클라이언트이기 때문에 서비스 내부에 HTTP 클라이언트를 구성해야 한다. 스프링에서 HTTP 클라이언트를 구성하는 방법을 여러가지가 있지만 그 중에 최근에 나온 HTTP Interface를 활용하여 클라이언트를 만들어보자.

이 글에서 설명하는 예제는 Den Vega의 유튜브 Spring HTTP Interface Clients: Consuming HTTP services in Spring Boot에서 따라해볼 수 있고 소스코드는 Github http-interfaces에서 확인할 수 있다.

환경

HTTP Interface는 스프링 6 버전 이후부터 사용이 가능하다. 여기에서는 스프링부트 3.1 버전(스프링 6)을 사용하였다.

데모 구성도

외부에 서비스를 요청하는 서버인 article-service와 요청에 대한 응답을 주는 서버인 content-service로 총 두 개의 서버로 구성할 것이다. 각각의 서버는 한 프로젝트 내의 모듈로 구성하여 한 소스 내에서 관리할 수 있도록 했다.

projectMap
데모를 위한 프로젝트 구성도

article service 만들기

content-service에 정보를 제공해주는 article-servicearticle이라는 데이터 클래스(IDtitle 그리고 body로 구성)로 정보를 제공한다.

package com.llighter.articleservice.model;

public record Article(Integer id, String title, String body) {
}

컨트롤러에서는 @PostConstuct 어노테이션으로 init() 메소드에서 인메모리에 article 리스트를 저장한다. 그리고 기본적인 조회, 수정, 생성, 삭제 API를 제공하도록 구성하였다.

article controller

@RestController
@RequestMapping("/api/articles")
public class ArticleController {

    private final List<Article> articles = new ArrayList<>();

    @GetMapping
    public List<Article> findAll() {
        return articles;
    }

    @GetMapping("/{id}")
    public Optional<Article> findById(@PathVariable Integer id) {
        return articles.stream().filter(article -> article.id().equals(id)).findFirst();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void create(@RequestBody Article article) {
        articles.add(article);
    }

    @PutMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void update(@RequestBody Article article, @PathVariable Integer id) {
        Optional<Article> currentArticle = articles.stream().filter(a -> a.id().equals(id)).findFirst();
        currentArticle.ifPresent(value -> articles.set(articles.indexOf(value), article));
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Integer id) {
        articles.removeIf(article -> article.id().equals(id));
    }


    @PostConstruct
    private void init() {
        articles.add(new Article(1, "Hello, World!", "This is my first blog post"));
    }
}

추가적으로 이후에 구성할 content-service와 겹치지 않도록 서버 기본 포트를 8081으로 바꿔두자.

server.port=8081

article service 테스트

article-service가 잘 동작하는지 확인하기 위해 HTTP 요청을 해보자. HTTP 요청을 하기 위해서는 포스트맨과 같은 별도 프로그램을 사용해도 되고 cURL이나 http-pie 같은 터미널 프로그램을 사용해도 된다. 여기에서는 intellij 플러그인으로 설치할 수 있는 HTTP Clinet를 사용하였다.

### 모든 article 조회
GET http://localhost:8081/api/articles

### 특정 article 조회
GET http://localhost:8081/api/articles/1

### 새로운 article 생성
POST http://localhost:8081/api/articles
Content-Type: application/json

{
  "id": 2,
  "title": "Article 2",
  "body": "My Second blog post"
}

### 기존 article 수정
PUT http://localhost:8081/api/articles/2
Content-Type: application/json

{
  "id": 2,
  "title": "Article 2",
  "body": "I have updated my 2nd blog post!"
}

서버를 올리고 테스트 콜을 호출해보면 아래와 같이 정상적으로 호출이 되는 것을 확인할 수 있다. 아래 로그는 전체 article 목록을 조회하는 요청이고 기본적으로 한 개의 값만을 등록해두었기 때문에 한 건이 조회된 것을 볼 수 있다.

GET http://localhost:8081/api/articles

HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 01 Jul 2023 11:53:49 GMT
Keep-Alive: timeout=60
Connection: keep-alive

[
  {
    "id": 1,
    "title": "Hello, World!",
    "body": "This is my first blog post"
  }
]

content service 만들기

자, 이제 호출할 aricle-service를 만들고 테스트까지 해봤으니 content service로 HTTP 클라이언트를 작성해보자.

article-service에서도 article이라는 데이터 클래스(IDtitle 그리고 body로 구성)로 정보를 제공하는것과 컨트롤러로 조회, 수정, 생성, 삭제 API를 제공하는 것은 동일하다. 다만, article-service는 사용자가 직접 사용하는 서비스이고 데이터는 외부 서비스인 article-service에서 가져온다는 점이 다르다.

content controller

@RestController
@RequestMapping("/api/content")
public class ContentController {

    public final ArticleClient articleClient;

    public ContentController(ArticleClient articleClient) {
        this.articleClient = articleClient;
    }

    @GetMapping("/articles")
    public List<Article> findAllArticles() {
        return articleClient.findAll();
    }

    @GetMapping("/articles/{id}")
    public Optional<Article> findById(@PathVariable Integer id) {
        return articleClient.findOne(id);
    }

    @PostMapping("/articles")
    public void create(@RequestBody Article article) {
        articleClient.create(article);
    }

    @PutMapping("/articles/{id}")
    public void update(@RequestBody Article article, @PathVariable Integer id) {
        articleClient.update(article, id);
    }

    @DeleteMapping("/articles/{id}")
    public void delete(@PathVariable Integer id) {
        articleClient.delete(id);
    }
}

article client

위에서 컨트롤러에서 외부 서비스를 호출할 때 필요한 클라이언트를 주입받아 사용했는데 이 클라이언트는 어노테이션 메소드(예. @GetExchange)를 사용하여 자바 인터페이스로 HTTP 요청을 정의할 수 있다.

public interface ArticleClient {

    @GetExchange("/articles")
    List<Article> findAll();

    @GetExchange("/articles/{id}")
    Optional<Article> findOne(@PathVariable Integer id);

    @PostExchange("/articles")
    void create(@RequestBody Article article);

    @PutExchange("/articles/{id}")
    void update(@RequestBody Article article, @PathVariable Integer id);

    @DeleteExchange("/articles/{id}")
    void delete(@PathVariable Integer id);
}

client config

이렇게 @HttpExchange 메소드들로 인터페이스를 선언하고 config에서 프록시를 만들어서 빈으로 등록하면 컨트롤러에서 주입을 받아 사용할 수 있다.

@Configuration
public class ClientConfig {

    @Bean
    ArticleClient articleClient() {
        WebClient client = WebClient.builder()
                .baseUrl("http://localhost:8081/api")
                .build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder(WebClientAdapter.forClient(client)).build();
        return factory.createClient(ArticleClient.class);
    }
}

content service 테스트

자 이제 content-service도 실행해서 잘 동작하는지 확인해보자.

get all articles
content-service에서 article-service로부터 article 목록을 조회하는 요청
### 모든 article 조회
GET http://localhost:8080/api/content/articles

### 특정 article 조회
GET http://localhost:8080/api/content/articles/1

### 새로운 article 생성
POST http://localhost:8080/api/content/articles
Content-Type: application/json

{
  "id": 2,
  "title": "My 2nd Post",
  "body": "My Second blog post"
}

### 기존 article 수정
PUT http://localhost:8080/api/content/articles/2
Content-Type: application/json

{
  "id": 2,
  "title": "My 2nd Post",
  "body": "I have updated my 2nd blog post!"
}

### 기존 article 삭제
DELETE http://localhost:8080/api/content/articles/2

아래 로그는 특정 article 목록을 조회하는 요청이다.

GET http://localhost:8080/api/content/articles/1

HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 01 Jul 2023 12:08:02 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "id": 1,
  "title": "Hello, World!",
  "body": "This is my first blog post"
}

정리

외부 서비스 연동을 위해 HTTP API(REST endpoints) 를 호출하기위해서 스프링 프레임워크에서는 3가지 방법이 있는데 그 중 HTTP Interface를 사용하여 서비스를 구성해보았다.

요즘은 고객에게 어떤 서비스를 제공하더라도 자체적으로 모든 것을 제공하는 경우는 거의 없고 대부분 외부 서비스를 연동하여 제공하는 경우가 일반적이다. 금융에서도 대출, 렌탈, 보험 등 다양한 서비스를 여러 금융사와 연계하여 한 곳에서 서비스를 제공하는 형태가 일반화 되었다. 추후에는 이번에 작성한 HTTP Interface를 활용하여 외부 대출 서비스로 부터 고객의 대출 가능여부를 조회하는 서비스를 만들어보자. 👋🏻

스프링 프레임워크에서 HTTP API를 호출하는 3가지 방법: WebClient, RestRemplate, HTTP Interface