1. 개요

웹 서비스가 작업을 수행하기 위해 다른 웹 서비스를 사용해야 하는 경우가 종종 있습니다. 낮은 응답 시간을 유지하면서 사용자 요청을 처리하는 것은 어려울 수 있습니다. 느린 외부 서비스는 응답 시간을 늘리고 시스템이 더 많은 리소스를 사용하여 요청을 쌓이게 할 수 있습니다. 비 차단 접근 방식이 매우 도움이 될 수있는 곳입니다.

이 사용방법(예제)에서는 Play Framework 애플리케이션에서 서비스에 대한 여러 비동기 요청을 실행합니다. Java의 비차단 HTTP 기능을 활용하여 자체 기본 논리에 영향을 주지 않고 외부 리소스를 원활하게 쿼리할 수 있습니다.

이 예에서는 Play WebService 라이브러리 를 탐색합니다 .

2. Play WebService(WS) 라이브러리

WS는 Java Action 을 사용하여 비동기 HTTP 호출을 제공하는 강력한 라이브러리 입니다.

이 라이브러리를 사용하여 코드는 이러한 요청을 보내고 차단하지 않고 계속 진행합니다. 요청 결과를 처리하기 위해 소비 기능, 즉 Consumer 인터페이스 구현을 제공합니다.

이 패턴은 JavaScript의 콜백 구현, Promiseasync/await 패턴과 몇 가지 유사점을 공유합니다.

일부 응답 데이터를 기록 하는 간단한 소비자 를 빌드해 보겠습니다.

ws.url(url)
  .thenAccept(r -> 
    log.debug("Thread#" + Thread.currentThread().getId() 
      + " Request complete: Response code = " + r.getStatus() 
      + " | Response: " + r.getBody() 
      + " | Current Time:" + System.currentTimeMillis()))

우리의 소비자 는 이 예제에 로그인할 뿐입니다. 소비자는 결과를 데이터베이스에 저장하는 것과 같이 결과와 관련하여 필요한 모든 작업을 수행할 수 있습니다.

라이브러리의 구현을 더 자세히 살펴보면 WS가 표준 JDK의 일부이며 Play에 의존하지 않는 Java의 AsyncHttpClient 를 래핑하고 구성하는 것을 관찰할 수 있습니다.

3. 예제 프로젝트 준비

프레임워크를 실험하기 위해 몇 가지 단위 테스트를 생성하여 요청을 실행해 보겠습니다. 이에 응답하고 WS 프레임워크를 사용하여 HTTP 요청을 만드는 골격 웹 애플리케이션을 생성합니다.

3.1. 스켈레톤 웹 애플리케이션

먼저 sbt new 명령 을 사용하여 초기 프로젝트를 생성합니다 .

sbt new playframework/play-java-seed.g8

새 폴더 에서 build.sbt 파일을 편집 하고 WS 라이브러리 의존성을 추가합니다.

libraryDependencies += javaWs

이제 sbt run 명령으로 서버를 시작할 수 있습니다.

$ sbt run
...
--- (Running the application, auto-reloading is enabled) ---

[info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000

애플리케이션이 시작되면 http://localhost:9000 을 탐색하여 모든 것이 정상인지 확인할 수 있습니다 . 그러면 Play의 시작 페이지가 열립니다.

3.2. 테스트 환경

애플리케이션을 테스트하기 위해 단위 테스트 클래스인 HomeControllerTest 를 사용합니다 .

먼저 서버 수명 주기를 제공 할 WithServer 를 확장해야 합니다.

public class HomeControllerTest extends WithServer {

부모 덕분에 이 클래스는 이제 테스트 를 실행하기 전에 테스트 모드와 랜덤의 포트에서 스켈레톤 웹 서버를 시작합니다 . 테스트가 완료 되면 WithServer 클래스  도 애플리케이션을 중지합니다.

다음으로 실행할 애플리케이션을 제공해야 합니다.

GuiceGuiceApplicationBuilder 로 만들 수 있습니다 .

@Override
protected Application provideApplication() {
    return new GuiceApplicationBuilder().build();
}

마지막으로 테스트 서버에서 제공하는 포트 번호를 사용하여 테스트에 사용할 서버 URL을 설정합니다.

@Override
@Before
public void setup() {
    OptionalInt optHttpsPort = testServer.getRunningHttpsPort();
    if (optHttpsPort.isPresent()) {
        port = optHttpsPort.getAsInt();
        url = "https://localhost:" + port;
    } else {
        port = testServer.getRunningHttpPort()
          .getAsInt();
        url = "http://localhost:" + port;
    }
}

이제 테스트를 작성할 준비가 되었습니다. 포괄적인 테스트 프레임워크를 통해 테스트 요청 코딩에 집중할 수 있습니다.

4. WSRequest 준비

GET 또는 POST와 같은 기본 유형의 요청과 파일 업로드를 위한 멀티파트 요청을 실행하는 방법을 살펴보겠습니다.

4.1. WSRequest 개체 초기화

우선 요청을 구성하고 초기화하려면 WSClient 인스턴스를 가져와야 합니다.

실제 애플리케이션에서는 의존성 주입을 통해 기본 설정으로 자동 구성된 클라이언트를 얻을 수 있습니다.

@Autowired
WSClient ws;

그러나 테스트 클래스에서는 Play 테스트 프레임워크 에서 사용할 수 있는  WSTestClient 를 사용합니다 .

WSClient ws = play.test.WSTestClient.newClient(port);

클라이언트가 있으면 url 메소드 를 호출하여 WSRequest 객체를 초기화할 수 있습니다.

ws.url(url)

url 메서드 는  요청을 실행하기에 충분합니다. 그러나 몇 가지 사용자 지정 설정을 추가하여 추가로 사용자 지정할 수 있습니다.

ws.url(url)
  .addHeader("key", "value")
  .addQueryParameter("num", "" + num);

보시다시피 헤더와 쿼리 매개변수를 추가하는 것은 매우 쉽습니다.

요청을 완전히 구성한 후 메서드를 호출하여 이를 시작할 수 있습니다.

4.2. 일반 GET 요청

GET 요청을 트리거하려면 WSRequest 개체 에서 get 메서드 를 호출하기만 하면 됩니다.

ws.url(url)
  ...
  .get();

이것은 비 차단 코드이므로 요청을 시작한 다음 함수의 다음 줄에서 실행을 계속합니다.

get의해 반환된 개체 는 CompletableFuture API 의 일부인 CompletionStage 인스턴스 입니다.

HTTP 호출이 완료되면 이 단계에서는 몇 가지 명령만 실행합니다. 응답을 WSResponse 개체에 래핑합니다.

일반적으로 이 결과는 실행 체인의 다음 단계로 전달됩니다. 이 예제에서는 소비 기능을 제공하지 않았으므로 결과가 손실됩니다.

이러한 이유로 이 요청은 "fire-and-forget" 유형입니다.

4.3. 양식 제출

양식을 제출하는 것은 get 예제 와 크게 다르지 않습니다  .

요청을 트리거하기 위해 우리는 post 메소드를 호출하기만 하면 됩니다.

ws.url(url)
  ...
  .setContentType("application/x-www-form-urlencoded")
  .post("key1=value1&key2=value2");

이 시나리오에서는 본문을 매개 변수로 전달해야 합니다. 이것은 파일, json 또는 xml 문서, BodyWritable 또는 Source 와 같은 간단한 문자열일 수 있습니다 .

4.4. 멀티파트/양식 데이터 제출

멀티파트 양식을 사용하려면 첨부 파일이나 스트림에서 입력 필드와 데이터를 모두 보내야 합니다.

프레임워크에서 이를 구현하기 위해 Source 와 함께 post 메서드를 사용합니다 .

소스 내에서 양식에 필요한 모든 다양한 데이터 유형을 래핑할 수 있습니다.

Source<ByteString, ?> file = FileIO.fromPath(Paths.get("hello.txt"));
FilePart<Source<ByteString, ?>> file = 
  new FilePart<>("fileParam", "myfile.txt", "text/plain", file);
DataPart data = new DataPart("key", "value");

ws.url(url)
...
  .post(Source.from(Arrays.asList(file, data)));

이 접근 방식은 구성을 더 추가하지만 여전히 다른 유형의 요청과 매우 유사합니다.

5. 비동기 응답 처리

지금까지 우리는 코드가 응답 데이터로 아무 작업도 수행하지 않는 실행 후 삭제 요청만 트리거했습니다.

이제 비동기 응답을 처리하는 두 가지 기술을 살펴보겠습니다.

메인 스레드를 차단 하거나 CompletableFuture를 기다리거나 Consumer 와 비동기적으로 소비할 수 있습니다.

5.1. CompletableFuture 로 차단하여 처리 응답

비동기 프레임워크를 사용하는 경우에도 코드 실행을 차단하고 응답을 기다릴 수 있습니다.

CompletableFuture API를 사용하여 이 시나리오를 구현하려면 코드에서 몇 가지만 변경하면 됩니다.

WSResponse response = ws.url(url)
  .get()
  .toCompletableFuture()
  .get();

예를 들어 이는 다른 방법으로는 달성할 수 없는 강력한 데이터 일관성을 제공하는 데 유용할 수 있습니다.

5.2. 응답을 비동기적으로 처리

차단하지 않고 비동기 응답을 처리하기 위해 응답을 사용할 수 있을 때 비동기 프레임워크에 의해 실행되는 소비자 또는 함수 를 제공 합니다.

예를 들어 이전 예제에 소비자 를 추가하여 응답을 기록해 보겠습니다.

ws.url(url)
  .addHeader("key", "value")
  .addQueryParameter("num", "" + 1)
  .get()
  .thenAccept(r -> 
    log.debug("Thread#" + Thread.currentThread().getId() 
      + " Request complete: Response code = " + r.getStatus() 
      + " | Response: " + r.getBody() 
      + " | Current Time:" + System.currentTimeMillis()));

그런 다음 로그에서 응답을 확인합니다.

[debug] c.HomeControllerTest - Thread#30 Request complete: Response code = 200 | Response: {
  "Result" : "ok",
  "Params" : {
    "num" : [ "1" ]
  },
  "Headers" : {
    "accept" : [ "*/*" ],
    "host" : [ "localhost:19001" ],
    "key" : [ "value" ],
    "user-agent" : [ "AHC/2.1" ]
  }
} | Current Time:1579303109613

로깅 후 아무 것도 반환할 필요가 없기 때문에 Consumer 함수 가 필요한 thenAccept 를 사용했다는 점은 주목할 가치가 있습니다.

현재 단계에서 무언가를 반환하여 다음 단계에서 사용할 수 있도록 하려면 대신 Function 을 사용하는 thenApply 가 필요합니다 .

이들은 표준 Java 기능 인터페이스 의 규칙을 사용합니다 .

5.3. 대형 Response body

지금까지 구현한 코드는 작은 응답과 대부분의 사용 사례에 적합한 솔루션입니다. 그러나 수백 메가바이트의 데이터를 처리해야 하는 경우 더 나은 전략이 필요합니다.

참고: getpost 와 같은 요청 메서드  는 전체 응답을 메모리에 로드합니다.

가능한 OutOfMemoryError 를 피하기 위해 Akka Streams 를 사용 하여 메모리를 채우지 않고 응답을 처리할 수 있습니다.

예를 들어 파일에 본문을 작성할 수 있습니다.

ws.url(url)
  .stream()
  .thenAccept(
    response -> {
        try {
            OutputStream outputStream = Files.newOutputStream(path);
            Sink<ByteString, CompletionStage<Done>> outputWriter =
              Sink.foreach(bytes -> outputStream.write(bytes.toArray()));
            response.getBodyAsSource().runWith(outputWriter, materializer);
        } catch (IOException e) {
            log.error("An error happened while opening the output stream", e);
        }
    });

스트림 메서드는 WSResponse Source<ByteString, ?> 를 제공하는 getBodyAsStream 메서드 있는 CompletionStage 를 반환합니다 .

Akka의 Sink 를 사용하여 이러한 유형의 본문을 처리하는 방법을 코드에 알릴 수 있습니다. 이 예제에서는 단순히 OutputStream 을 통과하는 모든 데이터를 씁니다 .

5.4. 타임아웃

요청을 작성할 때 특정 시간 제한을 설정할 수도 있으므로 시간 내에 완전한 응답을 받지 못하면 요청이 중단됩니다.

이것은 우리가 쿼리하는 서비스가 특히 느리고 응답을 기다리는 열린 연결이 쌓일 수 있는 경우에 특히 유용한 기능입니다.

튜닝 매개변수를 사용하여 모든 요청에 ​​대해 전역 제한 시간을 설정할 수 있습니다. 요청별 제한 시간의 경우 setRequestTimeout 을 사용하여 요청에 추가할 수 있습니다 .

ws.url(url)
  .setRequestTimeout(Duration.of(1, SECONDS));

그래도 처리해야 할 한 가지 경우가 있습니다. 모든 데이터를 수신했지만 소비자 가 처리 속도가 매우 느릴 수 있습니다. 이는 데이터 크런칭, 데이터베이스 호출 등이 많은 경우에 발생할 수 있습니다.

처리량이 낮은 시스템에서는 코드가 완료될 때까지 실행되도록 할 수 있습니다. 그러나 장기 실행 활동을 중단할 수 있습니다.

이를 달성하기 위해 일부 퓨처 처리로 코드를 래핑해야 합니다.

코드에서 매우 긴 프로세스를 시뮬레이션해 보겠습니다.

ws.url(url)
  .get()
  .thenApply(
    result -> { 
        try { 
            Thread.sleep(10000L); 
            return Results.ok(); 
        } catch (InterruptedException e) { 
            return Results.status(SERVICE_UNAVAILABLE); 
        } 
    });

그러면  10초 후에 OK 응답이 반환되지만 그렇게 오래 기다리지는 않습니다.

대신 시간 초과 래퍼를 사용하여 코드에 1초 이상 기다리지 않도록 지시합니다.

CompletionStage<Result> f = futures.timeout(
  ws.url(url)
    .get()
    .thenApply(result -> {
        try {
            Thread.sleep(10000L);
            return Results.ok();
        } catch (InterruptedException e) {
            return Results.status(SERVICE_UNAVAILABLE);
        }
    }), 1L, TimeUnit.SECONDS);

이제 Future는 어느 쪽이든 결과를 반환할 것입니다. Consumer 가 제 시간에 완료한 경우 계산 결과 또는 Futures 시간 초과 로 인한 예외  입니다.

5.5. 예외 처리

이전 예제에서는 결과를 반환하거나 예외로 인해 실패하는 함수를 만들었습니다. 따라서 이제 두 가지 시나리오를 모두 처리해야 합니다.

handleAsync 메서드 를 사용하여 성공 및 실패 시나리오를 모두 처리할 수 있습니다 .

결과가 있으면 결과를 반환하거나 오류를 기록하고 추가 처리를 위해 예외를 반환한다고 가정해 보겠습니다.

CompletionStage<Object> res = f.handleAsync((result, e) -> {
    if (e != null) {
        log.error("Exception thrown", e);
        return e.getCause();
    } else {
        return result;
    }
});

이제 코드 에서 발생한 TimeoutException 이 포함 된 CompletionStage 를 반환해야 합니다 .

반환된 예외 객체의 클래스에 대해 assertEquals 를 호출하여 간단히 확인할 수 있습니다 .

Class<?> clazz = res.toCompletableFuture().get().getClass();
assertEquals(TimeoutException.class, clazz);

테스트를 실행할 때 수신한 예외도 기록합니다.

[error] c.HomeControllerTest - Exception thrown
java.util.concurrent.TimeoutException: Timeout after 1 second
...

6. 요청 필터

요청이 트리거되기 전에 일부 로직을 실행해야 하는 경우가 있습니다.

일단 초기화되면 WSRequest 개체 를 조작할 수 있지만 더 우아한 기술은 WSRequestFilter 를 설정하는 것 입니다.

트리거 메서드를 호출하기 전에 초기화 중에 필터를 설정할 수 있으며 요청 논리에 연결됩니다.

WSRequestFilter 인터페이스 를 구현하여 자체 필터를 정의 하거나 미리 만들어진 필터를 추가할 수 있습니다.

일반적인 시나리오는 요청을 실행하기 전에 요청이 어떻게 보이는지 기록하는 것입니다.

이 경우 AccCurlRequestLogger 를 설정하기만 하면 됩니다 .

ws.url(url)
  ...
  .setRequestFilter(new AhcCurlRequestLogger())
  ...
  .get();

결과 로그는 curl 과 같은 형식을 갖습니다.

[info] p.l.w.a.AhcCurlRequestLogger - curl \
  --verbose \
  --request GET \
  --header 'key: value' \
  'http://localhost:19001'

logback.xml 구성 을 변경하여 원하는 로그 수준을 설정할 수 있습니다.

7. 캐싱 응답

WSClient 는 응답 캐싱도 지원합니다.

이 기능은 동일한 요청이 여러 번 트리거되고 매번 최신 데이터가 필요하지 않을 때 특히 유용합니다.

또한 우리가 호출하는 서비스가 일시적으로 중단된 경우에도 도움이 됩니다.

7.1. 캐싱 의존성 추가

캐싱을 구성하려면 먼저 build.sbt 에 의존성을 추가해야 합니다 .

libraryDependencies += ehcache

이렇게 하면 Ehcache 가 캐싱 계층으로 구성됩니다.

특별히 Ehcache를 원하지 않는 경우 다른 JSR-107 캐시 구현을 사용할 수 있습니다.

7.2. 강제 캐싱 휴리스틱

기본적으로 Play WS는 서버가 캐싱 구성을 반환하지 않는 경우 HTTP 응답을 캐시하지 않습니다.

이를 피하기 위해 application.conf 에 설정을 추가하여 휴리스틱 캐싱을 강제할 수 있습니다 .

play.ws.cache.heuristics.enabled=true

이렇게 하면 원격 서비스의 알려진 캐싱에 관계없이 HTTP 응답을 캐시하는 것이 유용한 시기를 결정하도록 시스템이 구성됩니다.

8. 추가 튜닝

외부 서비스에 요청하려면 일부 클라이언트 구성이 필요할 수 있습니다. 사용자 에이전트 헤더에 따라 리디렉션, 느린 서버 또는 일부 필터링을 처리해야 할 수 있습니다.

이를 해결하기 위해 application.conf 의 속성을 사용하여 WS 클라이언트를 조정할 수 있습니다 .

play.ws.followRedirects=false
play.ws.useragent=MyPlayApplication
play.ws.compressionEnabled=true
# time to wait for the connection to be established
play.ws.timeout.connection=30
# time to wait for data after the connection is open
play.ws.timeout.idle=30
# max time available to complete the request
play.ws.timeout.request=300

기본 AsyncHttpClient 를 직접 구성하는 것도 가능합니다.

사용 가능한 속성의 전체 List은 AhcConfig 의 소스 코드에서 확인할 수 있습니다 .

9. 결론

이 기사에서는 Play WS 라이브러리와 주요 기능을 살펴보았습니다. 우리는 프로젝트를 구성하고 일반적인 요청을 실행하고 응답을 동기식 및 비동기식으로 처리하는 방법을 배웠습니다.

우리는 대용량 데이터 다운로드로 작업하고 단기 장기 실행 활동을 줄이는 방법을 확인했습니다.

마지막으로 성능 향상을 위한 캐싱과 클라이언트 조정 방법을 살펴보았습니다.

항상 그렇듯이 이 예제의 소스 코드는 GitHub에서 사용할 수 있습니다 .

REST footer banner