[스프링 Web] 웹 API 호출 이야기 - RestTemplate을 사용하는 서비스 구조와 구현

웹 개발/스프링 프레임워크

2021. 12. 14.

RestTemplate을 사용하는 서비스 레이어 구조 및 구현

요약

서비스가 비즈니스 객체를 다른 곳에서 획득해 오는 경우가 있다. 그 출처는 DB가 될 수도 있고, 다른 웹 서비스가 되기도 한다. DB와 퍼시스턴스 레이어를 거쳐 객체를 뽑아오든, 웹 API를 호출하여 객체를 얻어오든 두 방식을 구현하는 코드 패턴은 스프링에서 거의 유사하다. 3티어 아키텍쳐로 책임을 나누게 되며. 티어 간 객체 변환이 공통적으로 요구되는 사항이다.

3티어 아키텍처 비교

DB에서 객체를 얻을 때

서비스는 레포지토리 레이어를 통해 DB 데이터에 접근한다. 레포지토리 단은 JPA 구현체 Hibernate 기술 등을 채용하여 ORM(객체-관계형 매핑)을 제공한다. ResultSet에 담긴 관계형 데이터를 자바 엔터티 객체로 변경하는 것은 중요한 요구사항이다.

서비스는 레포지토리 레이어를 통해 DB 데이터에 접근한다. 레포지토리 단은 JPA 구현체 Hibernate 기술 등을 채용하여 ORM(객체-관계형 매핑)을 제공한다. ResultSet에 담긴 관계형 데이터를 자바 엔터티 객체로 변경하는 것은 중요한 요구사항이다.

웹 서비스 호출로 객체를 얻을 때

가장 익숙하고 유명한 리모팅 프로토콜인 HTTP(S)를 사용하여 웹 서비스 API가 제공되었다고 가정하자. 서비스 단은 다시 웹 클라이언트에게 부탁하여 원격지 서비스의 API를 호출토록 한다. 그 결과로 돌아오는 HTTP(S) 응답 패킷의 Body에는 JSON 혹은 XML 포맷을 갖는 객체가 담겨있다. 따라서 JSON, XML 혹은 형식 없는 스트링을 자바 엔터티 객체로 변경하는 것은 중대한 요구사항이다.

가장 익숙하고 유명한 리모팅 프로토콜인 HTTP(S)를 사용하여 웹 서비스 API가 제공 되고 있다고 가정하자. 서비스 단은 웹 클라이언트 단에 부탁하여 해당 원격 서비스의 API를 호출토록 한다. 그 결과로 돌아오는 HTTP(S) 응답 패킷의 바디에는 JSON 혹은 XML 포맷을 갖는 객체가 담겨있다. 따라서 JSON, XML 혹은 형식 없는 스트링을 자바 엔터티 객체로 변경하는 것은 중대한 요구사항이다.

REST 클라이언트의 요구사항

  • 서비스는 클라이언트 레이어를 거쳐 비즈니스 엔터티를 획득한다.
    • 엔터티의 출처는 웹 서비스가 제공한 API이다. 엔터티를 DB에서 읽거나, 웹 서비스 API로 가져오거나 어찌됐건 서비스 단의 관심 사항이 아니다.
  • 클라이언트는 웹 API 스펙이 요구하는대로 HTTP 요청을 작성하고 이를 타겟 서비스에 전송해야 한다. 요청 완료 후 타겟 서비스로부터 HTTP 응답을 받아야한다.
    • HTTP 요청 패킷에 대응하여 추상화된 객체가 요청 엔터티이다.
    • HTTP 응답 패킷에 대응하여 추상화된 객체는 응답 엔터티이다.

엔터티 식별하기

비즈니스 엔터티

비즈니스에 중요한 정보와 로직을 캡슐화한 객체

@ToString
@EqualsAndHashCode
@Setter
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class Movie {

    Long id;

    String title;

    String titleEng;

    @JsonFormat(pattern = "yyyy-MM-dd")
    DateTime date;

    Float userRating;

    Float audienceRating;

    Float reviewerRating;

    Float reservationRate;

    Long reservationGrade;

    Long grade;

    String thumb;

    String image;

    String photos;

    String videos;

    String outlinks;

    String genre;

    Long duration;

    Long audience;

    String synopsis;

    String director;

    String actor;

    Long like;

    Long dislike;
}

HTTP 엔터티

  • HttpEntity

    HttpHeaders + 비즈니스 엔터티(HTTP Body가 됨)

    HTTP 요청 패킷과 HTTP 응답 패킷의 추상화.

  • RequestEntity extends HttpEntity

    HTTP Method + HttpHeaders + 비즈니스 엔터티(HTTP Body)

    요청 메서드도 함께 캡슐화 함.

    스태틱 메서드들은 빌더 패턴으로 쉽게 HTTP 헤더 및 바디를 작성하는 편의 기능 제공.

    RestTemplate API 중 exchange의 인자로 사용 가능.

RequestEntity requestEntity = RequestEntity.post(URI.create(URL_READ_MOVIE_LIST))
                .header(HttpHeaders.COOKIE, "PHPSESSIONID=123456", "token=8855")
                .header(HttpHeaders.AUTHORIZATION, "Basic YWxhZGRpbjpvcGVuc2VzYW1l")
                .body(new Movie());
ResponseEntity<Movie> response = restTemplate.exchange(requestEntity, Movie.class);
  • ResponseEntity extends HttpEntity

    HTTP Status Code + HttpHeaders + 비즈니스 엔터티(HTTP Body)

    응답 상태 코드도 함께 캡슐화 함.

    스태틱 메서드들은 빌더 패턴으로 각 응답 코드에 대응하는 HTTP 헤더 및 바디를 작성하도록 편의 기능 제공.

HTTP 요청하고 응답하기

[spring-web] RestTemplate API

요청 엔터티 작성 & 전송 & 응답 엔터티 수신 & 비즈니스 객체로 매핑

헬퍼 클래스들의 도움으로 HTTP 요청 패킷은 직관적으로 작성 가능하다.

RestTemplate API를 사용하면 요청 패킷 전송 & 응답 수신 & 비즈니스 객체 매핑이 진행된다.

  • exchange 예시
RequestEntity requestEntity = RequestEntity.post(URI.create(URL_READ_MOVIE_LIST))
                .header(HttpHeaders.COOKIE, "PHPSESSIONID=123456", "token=8855")
                .header(HttpHeaders.AUTHORIZATION, "Basic YWxhZGRpbjpvcGVuc2VzYW1l")
                .body(new Movie());
ResponseEntity<Movie> response = restTemplate.exchange(requestEntity, Movie.class);
  • getForObject + 요청 파라미터 예시
@Override
public List<Movie> readMovie(Long id, String name) {
    UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(URL_READ_MOVIE);
    Optional.ofNullable(id).ifPresent(it-> uriBuilder.queryParam("id", it));
    Optional.ofNullable(name).ifPresent(it-> uriBuilder.queryParam("name", name));

    String url = uriBuilder.build().toUriString(); // build string without encoding
    MovieResponse response = restTemplate.getForObject(url, MovieResponse.class); // because restTemplate will encodes the url
    return Optional.of(response.getResult())
            .orElseThrow(InternalError::new);
}

UriComponentsBuilder로 요청 파라미터 삽입 시 주의사항

UriComponentsBuilderRestTemplate 과 같이 사용할 때 인코딩 쪽에 주의가 필요하다.

restTemplate의 API는 String URL 혹은 URI을 요구한다.

문자열 URL을 전달하게 되면, restTemplate의 내부에서 UTF-8로 인코딩을 진행하므로, 굳이 UriComponentsBuilder 로 URL 생성시 인코딩을 적용할 필요가 없다.

인코딩이 2번 진행되면 한글 표현 등 문제가 발생하니 유의.

String url = uriBuilder.toUriString(); // bad - 인코딩 진행함
String url = uriBuilder.build().encode().toUriString(); // bad - 인코딩 진행함
String url = uriBuilder.build().toUriString(); // good - 인코딩 진행 안 함

엔터티 간 매핑하기

HTTP 패킷은 문자열이다.

고로 자바 객체 - 문자열 포맷간 직렬화/역직렬화가 꼭 필요하지만, 엔터티 마다 이런 로직을 구현하기는 매우 까다롭다. 다행히도 RestTemplate에겐 MessageConverter가 존재하여 그런 역할을 수행해 주고 있다.

  • 비즈니스 엔터티 ➡️ 요청 엔터티

    비즈니스 엔터티 Movie 객체는 HTTP 요청의 바디에 들어갈 때 적절한 문자열 형태가 되어야 한다. RestTemplate을 사용하는 개발자는 직렬화 로직을 작성하지 않고 RestTemplate의 MessageConverter에게 맡기면 된다.

      RequestEntity requestEntity = RequestEntity.post(URI.create(URL_READ_MOVIE_LIST))
                      .header(HttpHeaders.COOKIE, "PHPSESSIONID=123456", "token=8855")
                      .header(HttpHeaders.AUTHORIZATION, "Basic YWxhZGRpbjpvcGVuc2VzYW1l")
                      .body(new Movie());
  • 응답 엔터티 ➡️ 비즈니스 엔터티

    HTTP 응답의 바디에서 문자열로 표현된 객체는 비즈니스 객체 MovieResponse로 매핑이 되어야 한다. RestTemplate을 사용하는 개발자는 역직렬화 로직을 작성하지 않고 RestTemplate의 MessageConverter에게 맡기면 된다.

      @Override
      public List<Movie> readMovie(Long id, String name) {
          UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(URL_READ_MOVIE);
          if (id != null)
              uriBuilder.queryParam("id", id);
          if (name != null)
              uriBuilder.queryParam("name", name);
          MovieResponse response = restTemplate.getForObject(uriBuilder.toUriString(), MovieResponse.class);
          return Optional.of(response.getResult())
                  .orElseThrow(InternalError::new);
      }

[jaskson-databind] Object Mapper 구성 예시

  • 자바 객체 - JSON 간 매핑

    MappingJackson2HttpMessageConverter 빈을 구성하고 ObjectMapper 빈을 주입하여 사용하는 것이 다수이다.

      @ComponentScan(basePackages = "org.binchoo.study.spring.resttemplate.movieservice")
      @Configuration
      public class ClientConfig {
    
          @Bean
          public RestTemplate restTemplate() {
              RestTemplate restTemplate = new RestTemplate();
              restTemplate.setMessageConverters(Arrays.asList(messageConverter()));
              return restTemplate;
          }
    
          @Bean
          public MappingJackson2HttpMessageConverter messageConverter() {
              MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
              converter.setObjectMapper(objectMapper());
              return converter;
          }
    
          @Bean
          public ObjectMapper objectMapper() {
              ObjectMapper objectMapper = new ObjectMapper();
              objectMapper.registerModule(jodaModule());
              return objectMapper;
          }
    
          @Bean
          public JodaModule jodaModule() {
              return new JodaModule();
          }
      }
  • 자바 객체 - XML 간 매핑

    pro spring 5 예제에서는 자바 객체와 XML간 매핑도 다루고 있다.

    이 경우 MarshallingHttpMessageConverter빈을 구성하고 마샬/언마샬러로 Castor XML 라이브러리를 사용할 수 있다.

[jaskson-databind] 참고할 어노테이션

@JsonNaming(PropertyNamingStrategy.SankeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonProperty("name")
@JsonFormat("yyyy-MM-dd")

[jaskon-datatype-joda] 간편하게 JodaTime 매핑 설정하기

@Bean
public ObjectMapper objectMapper() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(jodaModule());
    return objectMapper;
}

@Bean
public JodaModule jodaModule() {
    return new JodaModule();
}

---
@ToString
@EqualsAndHashCode
@Setter
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class Movie {
    Long id;
    @JsonFormat(pattern = "yyyy-MM-dd")
    DateTime date; //joda의 DateTime
    ...
}