Java

[HTTP Client] 개요; HttpURLConnection, Apache HttpClient, RestTemplate, OpenFeign, WebClient, RestClient

ride-dev 2024. 6. 14. 16:37

[HTTP Client] 개요HttpURLConnection, Apache HttpClient, RestTemplate, OpenFeign, WebClient, RestClient

0. HTTP Client

HTTP Client는 서버와 HTTP 통신을 담당하는 라이브러리 또는 도구를 의미합니다.

이를 통해 웹 애플리케이션에서 데이터를 조회 할 수 있습니다.

(MSA에서)다른 서버와 통합 작업을 가능하게 하고,

(공공데이터와 같은)외부 API와 데이터를 주고 받을 수 있습니다.

0.1 Spring Boot의 HTTP Client

Spring Boot는 다양한 HTTP Client를 사용할 수 있습니다.

HttpURLConnection, Apache HttpClient, RestTemplate, Feign, WebClient, RestClient

각각의 HTTP Client는 장점과 단점이 있기에 프로젝트의 요구사항에 따라 선택할 수 있습니다.

등장 시기는 아래와 같습니다.

1. 특성
(예제 코드와 함께, 예제의 전체적인 흐름은 다음 게시글에 작성했습니다)

아래 service에서 메서드를 호출하는 예제를 기반으로 특성을 보겠습니다.

@Service
@RequiredArgsConstructor
public class SchoolService {
    private final SchoolRepository repository;
    private final 각각의HttpClient client; // 구현한 Http Client를 주입합니다.

    public Integer saveSchool(School school) {
        return repository.save(school).getId();
    }

    public List<School> findAllSchools() {
        return repository.findAll();
    }

    public FullSchoolResponse findSchoolsWithStudents(Integer schoolId) {
        var school = repository.findById(schoolId)
                .orElse(School.builder()
                        .name("NOT_FOUND")
                        .email("NOT_FOUND")
                        .build());
        var students = client.findAllStudentsBySchool(매개변수:schoolId);
        // 구현하고자 하는 방향에 따라 매개변수가 달라질 수 있습니다.
        return FullSchoolResponse.builder()
                .name(school.getName())
                .email(school.getEmail())
                .students(students)
                .build();
    }
}

HttpURLConnection

HttpURLConnection은 JDK에 내장되어 있어 별도의 라이브러리 없이 사용 가능합니다.

간단하게 HTTP request, response 를 처리할 수 있지만,

복잡한 처리는 부족합니다. 확장성과 직관성이 낮습니다.

기본적인 처리는 유용하지만, 후술되는 HTTP Client 라이브러리들로 대체되는 추세입니다.

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.List;

@Component
@RequiredArgsConstructor
public class HttpUrlConnectionClient {
    @Value("${application.config.student-url}")
    private String studentUrl;
    // application.yml에 application.config.student-url을
    // 다음과 같이 설정했습니다. http://localhost:8222/api/v1/students/school
    private final ObjectMapper objectMapper;

    public List<Student> findAllStudentBySchool(Integer schoolId) {
        try {
            URL url = new URL(studentUrl + "/" + schoolId);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();

            conn.setRequestMethod("GET"); // Http Method

            conn.setRequestProperty("Accept", "application/json"); // Header

            BufferedReader br = new BufferedReader(new InputStreamReader((conn.getInputStream())));
            StringBuilder response = new StringBuilder();
            String output;
            while ((output = br.readLine()) != null) {
                response.append(output);
            }

            conn.disconnect();

            return objectMapper
                    .readValue(
                            response.toString(),
                            objectMapper.getTypeFactory()
                                    .constructCollectionType(
                                            List.class,
                                            Student.class
                                    )
                    );
        } catch (Exception e) {
            throw new RuntimeException("Exception in findAllStudentBySchool", e);
        }
    }
}

Apache HttpClient

Apache HttpClient는 HttpURLConnection의 한계를 극복하기 위해 개발되었습니다.

웹소켓, 동기-비동기처리를 지원하는 등 다양한 기능을 유연하게 지원합니다.

Spring과 통합하는 것이 불편하고 학습 곡선이 높은 것이 단점이지만,

깃허브를 보면 지속적으로 업데이트 되고 있음을 확인할 수 있습니다.

(게시글 작성일인 2024-06-14 기준 최근 업데이트 3days ago)

https://github.com/apache/httpcomponents-client

package com.ride.http_client.config;

import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ApacheHttpClientConfig {

    @Bean
    public CloseableHttpClient httpClient() {
        return HttpClients.createDefault();
    }
}
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.core5.http.ParseException;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.List;

@Component
@RequiredArgsConstructor
public class ApacheHttpClient {

    @Value("${application.config.student-url}")
    private String studentUrl;
    private final CloseableHttpClient client;
    private final ObjectMapper objectMapper;

    public List<Student> findAllStudentsBySchool(Integer schoolId) {
        HttpGet request = new HttpGet(studentUrl + "/" + schoolId);
        // Http Method 별로 다른 객체가 있습니다.
        // 예: Post는 HttpPost를 사용합니다.

        request.addHeader("Accept", "application/json"); // header

        try (CloseableHttpResponse response = client.execute(request)) {
            String responseBody = EntityUtils.toString(response.getEntity());
            return objectMapper.readValue(
                    responseBody,
                    objectMapper
                            .getTypeFactory()
                            .constructCollectionType(
                                    List.class,
                                    Student.class
                            )
            );
        } catch (IOException | ParseException e) {
            throw new RuntimeException("Failed to fetch students", e);
        }
    }
}

RestTemplate

Spring 에서 HTTP 통신을 쉽게 하기 위해 개발되었습니다.

간단하고 직관적이지만 비동기 요청을 지원하지 않습니다.

최근 RestClient가 RestTemplate의 상위 호환으로 릴리즈되었습니다.

@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.List;

@Component
@RequiredArgsConstructor
public class RestTemplateClient {

    @Value("${application.config.student-url}")
    private String studentUrl;
    private final RestTemplate restTemplate;
    // 위에 생성한 RestTemplate Spring Bean을 주입합니다.

    public List<Student> findAllStudentsBySchool(Integer schoolId) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Accept", "application/json");
        HttpEntity<String> requestEntity = new HttpEntity<>(headers);
	// HttpEntity에 HttpStatusCode를 더하면 ResponseEntity입니다.
        // Controller에 ResponseBody를 더하면 RestController인 것과 유사합니다.
        try {
            ResponseEntity<List> response = restTemplate.exchange(
                    studentUrl + "/" + schoolId,
                    HttpMethod.GET,
                    requestEntity,
                    List.class
            );
            return response.getBody();
        } catch (Exception e) {
            throw new RuntimeException("Failed to fetch students", e);
        }
    }
}

(Feign)OpenFeign

Netflix가 MSA간 통신을 간소화하기 위해 개발했습니다.

OpenFeign은 Feign을 오픈소스로 릴리즈한 프로젝트입니다.

선언형 기반이며 간결합니다. Spring Cloud와 통합이 쉽습니다.

복잡한 기능 구현이 어렵고 유연성이 낮습니다.

Spring Cloud 기반 MSA에서 주로 활용됩니다.

implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients // main class에 추가합니다
public class OpenFeignSchoolApplication {

	public static void main(String[] args) {
		SpringApplication.run(OpenFeignSchoolApplication.class, args);
	}
}
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;

import java.util.List;

@FeignClient(name = "student-service", url = "${application.config.student-url}")
public interface OpenFeignClient {

    @GetMapping("/{school-id}")
    List<Student> findAllStudentsBySchool(
            @PathVariable("school-id") Integer schoolId,
            @RequestHeader("Accept") String acceptHeader
    );
}
# school-service입니다.
# application.yml 에 아래 설정을 추가해야 위에 작성한 것처럼 사용할 수 있습니다.
spring:
  application:
    name: school-service
application:
  config:
    student-url: http://localhost:8222/api/v1/students/school

WebClient

RestTemplate에 없는 비동기 및 반응형 프로그래밍을 위해 개발되었습니다.

초기 학습 곡선이 높을 수 있으며, 간단한 사용에 오버헤드가 있을 수 있습니다.

지속적으로 업데이트되고 있습니다.

implementation 'org.springframework.boot:spring-boot-starter-webflux'
@Component
@RequiredArgsConstructor
public class WebClientClient {

    @Value("${application.config.student-url}")
    private String studentUrl;
    private final WebClient.Builder webClientBuilder;
    // RestTemplate처럼 WebClientConfig 파일을 만들어서 Bean으로 관리하는 것으로
    // 결합도를 낮출 수 있습니다.

    public List<Student> findAllStudentsBySchool(Integer schoolId) {

        WebClient webClient = webClientBuilder.build();
        try {
            return webClient.get()
                    .uri(studentUrl + "/" + schoolId)
                    .header("Accept", "application/json")
                    .retrieve()
                    .bodyToFlux(Student.class)
                    .collectList()
                    .block(); // 동기통신
        } catch (Exception e) {
            throw new RuntimeException("Failed to fetch students", e);
        }
    }
}

Bean으로 관리하는 것은 아래 RestClient를 참조합니다.

RestClient

RestTemplate과 WebClient의 장점을 결합하여 개발되었습니다.

RestTemplate 객체를 주입하여 생성할 수 있습니다.

동기-비동기 요청을 지원하고 간결합니다.

직관적이고 유연합니다.

최근에 릴리즈되었기에 버그가 있을 수 있고, 참조할 자료가 상대적으로 부족합니다.

@Configuration
public class RestClientConfig {
    @Bean
    public RestClient restClient() {
        return RestClient.builder().build();
    }
}

(WebClient도 RestClient처럼 구현할 수 있습니다)

@Component
@RequiredArgsConstructor
public class RestClientClient {

    @Value("${application.config.student-url}")
    private String studentUrl;
    private final RestClient restClient;

    public List<Student> findAllStudentsBySchool(Integer schoolId) {

        try {
            return restClient.get()
                    .uri(studentUrl + "/" + schoolId)
                    .header("Accept", "application/json")
                    .retrieve()
                    .body(new ParameterizedTypeReference<List<Student>>() {});
        } catch (Exception e) {
            throw new RuntimeException("Failed to fetch students", e);
        }
    }
}

 

특성 정리

HttpURLConnection은 기본적인 HTTP 통신에 유용하지만, 복잡한 기능을 구현하는 데 한계가 있습니다.

Apache HttpClient는 확정성이 뛰어나지만, 학습 곡선이 가파릅니다.

OpenFeign은 선언형 인터페이스로 REST API 호출이 간편하여 MSA에 최적화되어 있습니다.

RestTemplate 은 직관적이고 단순한 동기식 요청에 적합합니다.

WebClient는 비동기, 반응형 프로그래밍에 적합합니다.

RestClient는 WebClient의 기능을 구현할 수 있으며 RestTemplate의 직관성, 인프라를 갖췄습니다.

(RestTemplate을 주입하여 객체를 생성할 수 있습니다)

다만 최근 기술이라 참조 문서가 상대적으로 적습니다.

2. Spring Cloud 기반 예제 작성을 위한 준비

간단한 예제를 만들어 각각의 HTTP Client를 비교해보겠습니다.

각 클라이언트를 독립적으로 관리할 수 있기 때문에,

spring cloud를 활용하여 예제를 만들겠습니다.

의존성, 설정 등을 분리해서 보도록 하겠습니다.

Spring Cloud 예제의 기본적인 구조

Spring Cloud 예제를 만들기 위해 초기 설정을 하도록 하겠습니다.

각 Http Client에 해당하는 프로젝트를 생성할 예정입니다.

IDE는 인텔리제이, 프로젝트 생성은 https://start.spring.io/ 에서 진행했습니다.

구성관리는 application.yml 형식을 사용했습니다.

DB는 postgresql을 사용하고, Student DB, School DB 이렇게 2 개의 DB를 사용합니다.

discovery-server, config-server, gateway, student-service + 각 Http Client 프로젝트(6),

총 10개의 프로젝트를 생성합니다.

(Feign 프로젝트는 생성하지 않고 OpenFeign만 생성합니다)

각 프로젝트는 DB를 공유합니다.

1. School에서 Student서비스로 요청을 보냅니다.

2. Student가 School에 응답을 보냅니다.

3. School이 응답을 받아 데이터를 가공합니다.

Spring Cloud 준비(config server, discovery, gateway)

0. 상위 폴더 생성

프로젝트를 총괄할 폴더를 생성하고 인텔리제이로 열어줍니다.

1. config-server

config-server는 Spring Cloud의 구성관리를 담당합니다.

각 서버의 공통된(혹은 각 서버별) application.properties(또는 yml)를 관리합니다.

다운로드한 config-server를 상위 폴더에 넣습니다.

(필수)build.gradle파일을 우클릭하여 Link Gradle Project를 진행합니다.

Gradle이 프로젝트를 관리하게 됩니다.

config-server 애플리케이션이 ConfigServer임을 어노테이션으로 선언합니다.

package com.ride.http_client;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;

@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {

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

config-server의 application.yml을 작성합니다.

server:
  port: 8888
spring:
  profiles:
    active: native
  application:
    name: config-server
  cloud:
    config:
      server:
        native:
          search-locations: classpath:/configurations

스크립트를 분해해보겠습니다.

 

먼저 server.port입니다.

config-server가 클라이언트 요청을 수신할 포트를 지정합니다.

server:
  port: 8888

spring.profiles.active 를 native로 설정하여,

로컬 파일 시스템에서 다른 설정 파일(각 클라이언트 별 yml, properties 파일)을 읽어올 수 있도록 합니다.

spring.application.name을 config-server로 하여 이 프로젝트를 식별하기 쉽게 합니다.

spring.cloud.config.server.native.search-locations 설정을 통해,

config-server가 configurations디렉터리에서 설정 파일을 읽어오도록 합니다.

spring:
  profiles:
    active: native
  application:
    name: config-server
  cloud:
    config:
      server:
        native:
          search-locations: classpath:/configurations

이제 각 서버에서 공통으로 사용하는 설정파일을 configurations에 작성합니다.

eureka:
  instance:
    hostname: localhost
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka
spring:
  cloud:
    config:
      override-system-properties: false

management:
  tracing:
    sampling:
      probability: 1.0

eureka.instance.hostname 설정을 통해 Eureka 서버 인스턴스의 호스트 이름을 설정합니다.

Eureka 클라이언트는 이렇게 설정된 localhost를 통해 Eureka 서버에 접근합니다.

eureka.client.service-url.defaultZone 설정을 통해,

Eureka 클라이언트가 Eureka 서버에 접근하기 위한 기본 URL을 설정합니다.

등록된 URL을 통해 각 클라이언트가 Eureka 서버에 등록됩니다.

spring.cloud.config.override-system-properties 설정을 통해,

Spring Cloud Config가 시스템 프로퍼티를 덮어쓰지 않도록 합니다.

https://cloud.spring.io/spring-cloud-static/spring-cloud-commons/2.2.2.RELEASE/reference/html/#overriding-bootstrap-properties

management.tracing.sampling.probability 는 분산 추적에 대한 설정입니다.

0.0에서 1.0까지 설정할 수 있으며, 요청 중 몇 퍼센트를 추적할지 설정합니다.

개발 환경에서는 샘플링 확률을 높게 설정하고,

운영 환경에서는 낮게 설정하여 성능에 미치는 영향을 최소화할 수 있습니다.

저는 개발 환경이기 때문에 1.0, 모든 요청을 샘플링하도록 설정했습니다.

2. discovery-service

이제 Eureka(discovery)프로젝트를 생성합니다.

discovery는 config-server의 설정을 받고, 각 서비스를 등록합니다.

마찬가지로 다운로드한 프로젝트를 최상위 디렉토리에 넣습니다.

프로젝트가 보이지 않는다면, 최상단 디렉토리를 우클릭하여 Reload from Disk를 진행합니다.

그리고 Link Gradle Project를 잊지 않도록 합니다.

어노테이션을 선언하는 것으로, Discovery를 Eureka Server로 설정합니다.

package com.ride.discovery;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryApplication {

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

discovery의 application.yml을 작성합니다.

spring:
  application:
    name: discovery-service
  config:
    import: optional:configserver:http://localhost:8888

spring.application.name을 disvoery-service로 식별할 수 있도록 합니다.

이제 config-server에 작성하는 discovery의 yml 파일을 이와 동일하게 명명하도록 합니다.

spring.config.import 설정을 통해 외부 서버(config-server)로부터 설정을 가져옵니다.

optional: 을 통해 configserver에 문제가 있어도 애플리케이션이 동작되도록 합니다.

configserver 를 통해 Spring Cloud Config 서버에서 설정을 가져도록 합니다.

8888은 config-server의 port입니다.

이는 discovery의 설정파일을 config-server로부터 가져오겠다는 것을 의미합니다.

config-server에 discovery의 설정 파일을 생성합니다.

server:
  port: 8761
eureka:
  instance:
    hostname: localhost
  client:
    registerWithEureka: false
    fetchRegistry: false
    serviceUrl:
      defaultZone: http://${eureka.instance.hostname}/${server.port}/eureka/

server.port 를 통해 Eureka 애플리케이션의 port를 8761로 설정합니다.

eureka.instance.hostname 설정을 통해 로컬 호스트에서 실행되도록 합니다.

eureka.client.registerWithEureka 를 통해 Eureka에 등록할지 설정합니다.

eureka.client.fetchRegistry 를 통해, 이 애플리케이션이 Eureka 서버로부터 레지스트리를 가져올지 설정합니다.

Eureka 서버 자체의 설정에서 주로 위 두 옵션은 false로 합니다.

eureka.client.serviceUrl.defaultZone 을 통해 Eureka 클라이언트가 Eureka 서버에 접근하기 위한 URL을 설정합니다.

이제 다른 클라이언트 애플리케이션이 http://localhost:8761/eureka/ URL을 통해 Eureka 서버에 접근할 수 있습니다.

3. gateway

gateway를 통해 discovery에 등록된 다른 서비스에 접근할 수 있습니다.

gateway는 Eureka Server에 등록된 서비스로 리디렉션합니다.

프로젝트를 생성하고,

디렉토리에 넣고, Link Gradle Project를 진행합니다.

gateway에 application.yml을 작성합니다.

config-server에 resources/configurations/gateway-service.yml을 작성합니다.

각 클라이언트 별 서비스가 둘(school, student)이지만, 이를 동일한 이름으로 받고,
다른 포트를 사용할 것입니다.

로드 밸런싱을 통해 gateway 설정을 단순하게 만들었습니다.

물론, 위에 있는 사진처럼 각 서비스별로 이름을 따로 만들어도 됩니다.

다만, 더 단순하게 관리하기 위해 아래처럼 스크립트를 작성했습니다.

(세부적인 경로는 각 클라이언트에 속한 RestController에서 @RequestMapping으로 나눌 예정입니다)

server:
  port: 8222
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
        - id: school-service
          uri: lb:http://SCHOOL-SERVICE
          predicates:
            - Path=/api/v1/schools/**
        - id: student-service
          uri: lb:http://STUDENT-SERVICE
          predicates:
            - Path=/api/v1/students/**

 

server.port 설정을 통해 gateway가 8222 port에서 실행되도록 합니다.

spring.cloud.gateway.discovery.locator.enabled 설정을 통해,

gateway가  Eureka 서비스 레지스트리에서 경로를 동적으로 찾도록 합니다.

만약 false로 한다면, 아래에 routes에서 선언할 uri를 명시적으로 설정합니다.

spring.cloud.gateway.routes 에서 개별 서비스에 대한 라우팅 규칙을 정의합니다.

id 는 각 route의 식별자입니다.

uri 는 요청을 전달할 대상 서비스의 URI입니다.

lb를 통해 로드 밸런싱을 가능하게 합니다.

predicates 특정 조건에 따라 route를 적용할지를 결정하는 조건자(Path, Method, Header 등)를 정의합니다.

docker-compose 작성

DB로 사용할 postgresql과 분산 추적을 위한 ZIPPKIN을 도커 컨테이너로 가동시킵니다.

프로젝트 최상단 디렉토리에 docker-compose.yml 파일을 생성합니다.

secrets디렉토리 및 하위 파일을 생성합니다.

school DB와 student DB를 생성하는 초기화 파일도 생성합니다.

services:
  postgresql:
    container_name: http_client_postgresql
    image: postgres
    secrets:
      - postgres_user
      - postgres_password
    environment:
      - POSTGRES_USER_FILE=/run/secrets/postgres_user
      - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
      - PGDATA=/var/lib/postgresql/data
    volumes:
      - postgres:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"
    networks:
      - http-client-net
    restart: unless-stopped

  zipkin:
    container_name: http_client_zipkin
    image: openzipkin/zipkin
    ports:
      - "9411:9411"
    networks:
      - http-client-net

networks:
  http-client-net:
    driver: bridge

volumes:
  postgres:

secrets:
  postgres_user:
    file: ./secrets/postgres_user.txt
  postgres_password:
    file: ./secrets/postgres_password.txt

user와 password 파일 내용은 자유롭게 설정합니다.

아래는 init.sql 내용입니다.

-- Create the 'students' database
CREATE DATABASE students;

-- Create the 'schools' database
CREATE DATABASE schools;

 

이렇게 초기 준비는 끝마쳤습니다.

다음 게시글에서 각기 다른 HTTP Client 서비스를 구현하겠습니다.

728x90