본문 바로가기
  • 장원익 기술블로그
더 좋은 개발자 되기/나의 개발론

[클라우드 기반 보안 실습 플랫폼 개발기] Spring-Boot 에서 OpenFeign 과 CompletableFuture 를 이용한 비동기적 HTTP 요청

by Wonit 2022. 1. 5.

 

오늘은 클라우드 기반 샌드박스 보안 실습 플랫폼-github 바로가기 개발을 하며 사용했던 OpenFeign 과 CompletableFuture의 사용 경험을 공유해보려 한다.

 

목차

  • 배경
  • 기술 선정
    • HTTP Client 의 선정
    • 비동기 처리 방법
  • 후기
    • 장단점
    • 발전 가능 사항

 

배경

 

클라우드 기반 보안 실습 플랫폼을 개발하며 내가 구현해야하는 feature 는 다음과 같았다.

 

  • 1. 강사는 클래스(수업)을 생성할 수 있고, 하나의 클래스(수업)이 생성되면 클래스에서 사용될 실습 Computing Engine 을 할당받는다.
  • 2. 학생은 클래스(수업)에 초대되면 실습 Computing Engine 을 할당받는다.
  • 3. 강사는 수업 도중 학생들의 컨테이너에 있는 웹서버와 통신을 수행해서 학생들의 실습 상황을 확인할 수 있어야 한다.

 

이중 3번째 기능인 학생 컨테이너와 통신인 상호작용 기능 개발에 있어서 했던 고민들에 대해서 설명해보도록 하겠다.

 

상호작용 기능?

 

우선 상호작용 기능이 무엇인지 알아보자.

 

 

상호작용 기능은 강사가 수업 도중 학생들의 실습 내용 파악을 위해서 컨테이너에 존재하는 상호작용 모듈 웹서버와 HTTP 통신을 통해서 위와 같은 기능들을 수행하는 것이다.

 

위의 그림처럼 강사는 학생들의 명령어 기록을 확인할 수 있고 원격 명령을 실행하거나 설치된 패키지를 확인하고 특정 파일을 컨테이너 내부로 주입하는 등 다양한 활동을 플랫폼 내부에서 수행할 수 있게 된다.

 

여기서 2가지의 기술적 고민을 했었다.

 

  1. HTTP Client 의 선정
  2. 비동기적 HTTP 통신 방법

 

각각의 구현 방법에 대한 고민들을 이야기해보도록 하겠다.

 

HTTP Client 의 선정

 

Spring-Boot 에서 사용할 수 있는 HTTP Client 모듈은 크게 2가지가 일반적이다.

 

  1. WebClient
  2. RestTemplate

 

WebClient

 

WebClient 는 Non-Blocking 방식으로 Spring WebFlux 에서 사용되는 HTTP Client 이다.

 

 

WebClient 는 요청을 Event Loog 내의 Job 으로 등록하고 Job 을 worker 에게 제공 후 결과를 기다리지 않고 다른 Job 을 처리하여 callback 응답이 있을 때 결과를 client 에게 반환하는 형태이다.

 

Spring 5 에서 소개된 내용으로 WebClient 를 이용하기 위해서는 Flux 구조의 application 이 준비되어야 한다.

 

하지만 서버의 스펙은 Servlet 구조였기 때문에 위의 기능만을 위해서 Flux 구조로 rewrite 할 수 없는 상황이었다.

 

RestTemplate

 

위의 HTTP Client 과 반대로 RestTemplate 은 가장 일반적인 HTTP Client 로 소개되곤 한다.

 

RestTemplate 은 Multi-Thread 와 Blocking 방식을 사용한다.

 

사실상 현재 서버 스펙으로는 RestTemplate 만을 선택할 수 없는 상황이었는데, 여기서 또 고민이 생겨난다.

 

String url = String.format("http://some-service/%s", id);

try {
  ResponseEntity<DTO> responseData = restTemplate.exchange(url,
        HttpMethod.GET,
        null,
        DTO.class);
} catch (Exception e) {
  throw new CommunicationNotProceedException();
}

DTO team = responseData.getBody();

 

위와 같이 exchange 메서드를 통해서 요청을 보내고 결과를 가져오는 형태로 주로 사용이 되는데, 단순히 위의 내용을 보기만 하더라도 뭔가 가독성이 매우 좋지 않고 error 처리를 위해서라면 try-catch 를 이용하여 더욱 읽기 힘든 코드가 나올 수 있다.

 

이 상황에서 나는 전에 Spring-Cloud 를 다루며 배웠던 OpenFeign 을 다시 살펴보았다.

 

Spring-Cloud-OpenFeign

 

Spring-Cloud의 OpenFeign 은 Netflix 에서 개발한 Microservice 용 HTTP Client 이다.

 

OpenFeign 과 RestTemplate 에 대한 비교는 해당 블로그의 마이크로서비스에서 서비스간 통신을 위한 2가지 방법 비교 에서 확인할 수 있습니다.

 

OpenFeign 에 대한 특징을 간단히 나열하면 다음과 같다.

 

  • 인터페이스만으로 클라이언트를 구현할 수 있는 어노테이션 지원
  • Blocking 방식의 HTTP Client
  • Spring MVC 의 HttpMessageConverters 를 제공
  • ErrorDecoder 를 통한 통합 예외 처리

 

이 모든 특징들이 현재 상황과 가장 잘 맞아 보여 결국 OpenFeign 으로 기술을 선정하게 되었다.

 

OpenFeign 의 사용

 

OpenFeign 을 사용하기 위해서 우선 FeignClient 인터페이스를 작성해야 한다.

 

FeignClient 인터페이스 작성

 

FeignClient 인터페이스는 메서드를 정의해놓고 Spring MVC 어노테이션으로 해당 메서드의 간단한 Request Spec 를 명시한다.

 

그럼 Runtime 에 OpenFeign 이 해당 어노테이션들을 분석하여 적절한 Request 의 구현을 통해 우리가 원하는 API 호출을 날릴 수 있게 되는 것이다.

 

@FeignClient(name = "student-container", url="url-placeholder")
public interface ContainerClient {

    @GetMapping(value = "/mouse_keyboard/keyboard")
    FeignActivationResData detectKeyboardHit(URI uri);

    @GetMapping(value = "/mouse_keyboard/mouse")
    FeignStatusResData detectMouseMove(URI uri);

    @PostMapping(value = "/command/execute/")
    FeignBashResponseData executeRemoteCommand(URI uri, @RequestBody FeignCommandReqData body);

    @PostMapping(value = "/command/execute_script/",  consumes = "multipart/form-data")
    FeignBashResponseData executeRemoteScript(URI uri, @RequestPart MultipartFile scriptFile);

    @GetMapping(value = "/filesystem/find_install")
    FeignInstallResData detectInstallation(URI uri, @RequestParam String programName);

    @GetMapping(value = "/filesystem/file_view")
    FeignFileResData getFileContent(URI uri, @RequestParam String filePath);

    @PostMapping(value = "/bash_history/non_realtime/")
    FeignHistoryResData getBashHistory(URI uri, @RequestBody FeignHistoryReqData body);

    @PostMapping(value = "/filesystem/file_insert/", consumes = "multipart/form-data")
    FeignInsertFileResData insertFile(URI uri,
                    @RequestParam String filePath,
                    @RequestParam Integer insertOption,
                    @RequestPart MultipartFile inputFile);
}

 

Feign Client 는 Spring MVC 어노테이션을 그대로 이용하기 때문에 더 가독성이 좋고 쉬운 Request 를 구성할 수 있다.

 

여기서 눈여겨볼 것이 @FeignClient(name = "", url ="url-placeholder") 로 직접 URL 명시를 하지 않았다는 것을 볼 수 있다.

 

이는 하나의 Feign 요청이더라도 다양한 컨테이너의 URL 을 포함할 수 있도록 place-holder 로 명시하고 Dynamic URL Request 가 가능하도록 메서드의 Parameter 로 URI 객체를 넣어줬다.

 

이렇게 한다면 Feign 의 메서드가 호출될 때 받는 URI로 요청을 날릴 수 있게 되므로 하나의 method spec 으로 다양한 URL 에게 요청을 날릴 수 있게 된다.

 

예외 처리

 

FeignClient 를 이용하며 컨테이너에게 요청을 보낸다면 당연히 Exception 이 떨어지는 상황이 발생한다.

 

이를테면 Internal Server Error 라던지 Not Found, Forbidden 등등

 

이를 위해서 ErrorDecoder 를 구현해주었다.

 

public class FeignError implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        switch(response.status()) {
            case 404:
                if(methodKey.contains("existsFile")) {
                    return new OpenFeignException("ip 혹은 url 이 존재하지 않습니다.");
                }
        }

        return null;
    }
}

 

decode 에서는 methodKey 부분에서 앞서 명시한 FeignClient 의 메서드 name 을 넣어주어 메서드 단위로 예외를 처리할 수 있다.

 

하지만 예외 상황을 모두 catch 하지 않고 현재는 하나의 메서드에 404 에러가 잘 동작하는지만 확인하도록 하였다.

 


비동기 처리 방법

 

이제 HTTP Client 를 선정했으니 어떻게 Blocking 에서 잘 풀어나갈지를 생각해야 한다.

 

서버는 Front-End 에서 한 번 요청의 오게되면 평균 40 대의 컨테이너에게 동시다발적인 요청을 보내야 한다.

 

이때 40대의 요청을 synchronous 하게 보낸다면 40대의 요청이 모두 성공될 때 까지 대기해야 한다.

 

하지만 요청들을 asynchronous 하게 보낸다면 시스템 자원을 이용해 쓰레드로 한 번에 40대의 요청 모두가 날아가게 되고 그만큼 빠른 응답을 Front-End 에게 전달할 수 있게 된다.

 

 

이를 위한 방법으로 멀티 쓰레딩을 이용해야 했는데, 이를 CompletableFuture 로써 구현하였다.

 

CompletableFuture 란?

 

CompletableFuture 는 Java 5에서 소개된 Future 와 Java8 에서 소개된 CompletionStage 를 구현한 클래스로 명시적으로 쓰레드를 생성하지 않고 asynchronous 한 작업을 병렬로 처리하거나 병합하여 처리할 수 있다.

 

또한 Cancle 과 Error 과 같은 처리 또한 제공하므로 현재 상황에서 가장 잘 맞는 기술이라고 판단하였다.

 

실제 사용 코드를 확인해보자

 

/**
* 컨테이너 내부의 path 에 위치한 파일 내용을 반환한다.
*
* @param courseId 클래스 id
* @param requestData 파일 확인을 위한 dto
* @param tokenUserId 요청을 보낸 강사의 id
* @return
*/
public List<ContainerFileResData> getFileContent(Long courseId,
                                                ContainerFileReqData requestData,
                                                Long tokenUserId) {

  validateCourse(courseId, tokenUserId);
  List<CourseUser> courseUsers = getCourseUserFromIds(courseId, requestData.getStudentIds());

  return courseUsers.stream()
          .map(courseUser ->
                  CompletableFuture.supplyAsync(
                          () -> {
                              URI uri = URI.create("http://" + courseUser.getContainerIp() + ":8080");
                              FeignFileResData feignResponse = containerClient.getFileContent(
                                      uri,
                                      requestData.getFilePath());

                              return ContainerFileResData.builder()
                                      .studentId(courseUser.getUser().getId())
                                      .status(feignResponse.getStatus())
                                      .fileContent(feignResponse.getFileContent())
                                      .build();
                          })
                          .orTimeout(2L, TimeUnit.SECONDS)
                          .exceptionally(e ->
                                  ContainerFileResData.builder()
                                          .studentId(courseUser.getUser().getId())
                                          .error(true)
                                          .build()))
          .collect(Collectors.toList())
          .stream()
          .map(CompletableFuture::join)
          .collect(Collectors.toUnmodifiableList());
}

 

위의 코드는 컨테이너 내부에 존재하는 파일의 내용을 확인하는 코드이다.

 

우선 validateCourse(courseId, tokenUserId) 를 통해서 강사가 해당 클래스의 주인인지 먼저 확인하고

getCourseUserFroomIds() 를 통해 특정 course 에 소속된 모든 학생의 Container IP 정보들을 받아온다.

 

받아온 List 를 stream iteration 을 통해 각각의 user마다 비동기 작업을 수행하는데, 컨테이너에게 보내는 각각의 요청에는 반환 값이 존재하기 때문에 runAsync() 가 아닌 supplyAsync() 를 이용했다.

 

supplyAsync() 를 사용하면 작업들을 여러개의 Sub Task 로 Fork 하여 각각 처리하며 이들을 최종적으로 Join 하는 ForkJoinPool 이 사용된다.

 

또한 2초간 아무 반응이 없다면 exceptionally() 를 통해서 예외를 전달하게 된다.

 

그리고 각각의 결과들을 collect() 로 리스트로 만들어준 뒤 join 을 통해 해당 Future 가 끝나기를 Blocking 하여 결과를 불변객체로 반환하게 된다.

 

결국..

 

결국 위의 내용들을 모두 적용한다면 다음과 같은 형태로 response 를 만들어서 Front-End 에게 전달할 수 있게 된다.

 

 

비동기적으로 동작하여 시스템 성능을 조금 많이 사용하지만 각각의 작업을 독립적으로 수행할 수 있게 되었다.

 

후기

 

저번 게시글과 마찬가지로 위의 기술들을 사용했다고 해서 모두 제대로된 구성은 아니라고 생각한다.

 

ErrorDecoder 기능도 구현만 해놓았지 사실상 돌아가는 기능도 아닐 뿐더러 exceptionally() 또한 제대로 구현하지 않았다.

 

ErrorDecoder 기능들이 제대로 동작하기 위해서는 상호작용 모듈 서버에서도 적절한 status code 반환이 있어야 가능한데 현재로써는 불가능하다고 생각된다.

 

또한 비동기 처리 로직이 계속해서 중복이 발생하게 된다.

 

댓글