스프링 -WebClient 더 잘 사용하자.

이진우·2024년 2월 12일
0

스프링 학습

목록 보기
24/41

기록용임을 알림

지금부터 쓸 글은
제가 WebClient 를 비동기적으로 사용하려고 아둥바둥 거린 점을 기록함을 명시합니다. 솔직히 아직까지도 잘 모르겠습니다.

기존에는

프로그램 정보를 프론트에 한 api 에 반환하고 있습니다.
그 정보는 open api 를 통해 프로그램 상세 정보, 사람 상세 정보, Provider 상세 정보 을 요청한 이후 각각의 정보에서 가공 처리를 거친 후에(changeActorAndDirectorToKorea,및 밑에 주석처리한 부분) 사용자에게 반환합니다.

  //API 요청 첫번째: 프로그램 상세 정보
        OAProgramDetailsDto OAProgramDetailsDto = getProgramDetails(program.getTmDbProgramId(),
            program.getType());
        ProgramDetailResponse programDetailResponse = createProgramDetailResponse(
            OAProgramDetailsDto);

        //API 요청 두번째: 사람 상세 정보
        OAProgramCreditsDto oaProgramCreditsDto = getCreditsDto(program.getTmDbProgramId(),
            program.getType());
        changeActorAndDirectorToKorea(oaProgramCreditsDto);

        //API 요청 세번째: Provider 상세 정보
        OAProgramProviderDto oaProgramProviderDto = getProviderDto(program.getTmDbProgramId(),
            program.getType());
        Optional<OACountryDetailsDto> kr = Optional.ofNullable(
            oaProgramProviderDto.getResults().get("KR"));
            
               //사용자에게 보낼 DTO 를 만들고 OTT 의 이름을 한글로 변환시키는 작업을 수행합니다.
        Optional<ProgramProviderListResponseDto> programProviderListResponseDto = kr.map(
            oaCountryDetailsDto -> changeOTTtoKoreanAndMakeProviderResponseDto(
                oaCountryDetailsDto));


        // 사용자에게 보내줄 DTO
        ProgramResponseDto programResponseDto = new ProgramResponseDto(programDetailResponse,
            oaProgramCreditsDto, programProviderListResponseDto.orElse(null),
            program.getAverageRating());
        return programResponseDto;

또한 각각의 open api 를 통해 정보를 얻어오는 메서드는

<프로그램의 상세 정보를 얻어오는 부분>

 private OAProgramDetailsDto getProgramDetails(Long tmDbId, ProgramType programType) {
        if (programType == ProgramType.Movie) {
            OAMovieDetailsDto oaMovieDetailsDto = webClient.get()
                .uri("/movie/" + tmDbId + "?language=ko")
                .retrieve()
                .bodyToMono(OAMovieDetailsDto.class)
                .block();

            return oaMovieDetailsDto;
        } else {
            OATvDetailsDto oaTvDetailsDto = webClient.get()
                .uri("/tv/" + tmDbId + "?language=ko")
                .retrieve()
                .bodyToMono(OATvDetailsDto.class)
                .block();

            return oaTvDetailsDto;
        }
    }

<프로그램의 인물 정보를 얻어오는 부분>

  private OAProgramCreditsDto getCreditsDto(Long tmDbProgramId, ProgramType programType) {
        RequestHeadersUriSpec<?> requestHeadersUriSpec = webClient.get();

        if (programType == ProgramType.Movie) {
            requestHeadersUriSpec.uri("/movie/" + tmDbProgramId + "/credits");
        } else {
            requestHeadersUriSpec.uri("/tv/" + tmDbProgramId + "/credits");
        }

        return requestHeadersUriSpec
            .retrieve()
            .bodyToMono(OAProgramCreditsDto.class)
            .block();

    }

<프로그램의 OTT 제공 정보를 얻어오는 부분>

 private OAProgramProviderDto getProviderDto(Long tmDbProgramId, ProgramType programType) {

        RequestHeadersUriSpec<?> requestHeadersUriSpec = webClient.get();
        if (programType == ProgramType.Movie) {
            requestHeadersUriSpec.uri("/movie/" + tmDbProgramId + "/watch/providers");
        } else {
            requestHeadersUriSpec.uri("/tv/" + tmDbProgramId + "/watch/providers");
        }
        return requestHeadersUriSpec
            .retrieve()
            .bodyToMono(OAProgramProviderDto.class)
            .block();

    }

기존에 RestTemplate동기적으로 사용했던 것과 마찬가지
이런 식으로 .block()을 적어서 동기적으로 호출하도록 하였습니다.

문제점

postman을 통해서 program 정보를 호출하였을 때 너무 느렸습니다.

어벤져스 엔드 게임 기준(programID:84) 기준




이런 식으로 두번째 호출 이후 부터는 평균적으로 40ms 후반에서 최대 60 중후반까지 나오는게 보통이였습니다.

여기까지는 아 오케 불편한게 안느껴졌습니다.
하지만 처음호출하는 것은 얘기가 달랐습니다.

<처음 호출 시>

<두번째 호출 시>

이에 대한 원인을 따져보기 위해서 각각의 open api 호출 시작과 전에 시간을 찍어보았습니다.

이렇게 말입니다.

그래서 그 결과는

open api 를 호출하는데 드는 시간이 오래 걸리는 것이었고
이는 현재 사용하고 있는 tmdB 에 문제점이 있다고 생각했습니다.

확실하지 않으나 아마 tmdB내부에 자주 사용되거나 최근에 호출된 것은 캐시에 저장하거나 그런 성능상의 이점을 갖기 위해 된게 아닐까 추측합니다.

다른 영화도 처음 호출하는 것은

정말 오래걸렸습니다.

해결 방법 찾기

동기 비동기 blocking non-blocking 정보 모으기

WebClient의 차별화된 장점은 비동기 란 점을 알고 있었으나, 여러가지 한번에 호출하는 것 아냐?? 정도의 기초적인 지식만 가지고 있어서 이에 대한 정보를 먼저 모으고 정리하였습니다.

Blocking

호출 함수가 자신이 할 일을 모두 마칠 때까지 제어권을 계속 가지고서 호출된 함수에게 바로 돌려주지 않으면 Block

Application 이 Kernel 로 작업 요청을 할 때 kernel 에서는 요청에 대한 로직 실행
Application 은 요청에 대한 응답을 받을 때까지 대기
Application은 Kernel 이 작업을 끝낼 때까지 백그라운드에서 작업이 끝났는지 지속적 확인

Non-Blocking

호출된 함수가 자신이 할 일을 채 마치지 않았더라도 바로 제어권을 건내주어(return) 호출한 함수가 다른 일을 진행할 수 있도록 해주면 Non-Block =⇒ WebClient 의 특

Application이 요청을 하고 바로 제어권을 받음.

따라서 다른 로직을 실행할 수 있다.

동기

동기는 작업 결과값을 직접 받아 처리.

비동기

함수 호출 후 결과를 기다리지 않고 다음 코드를 실행하며, 나중에 결과가 완료되면 콜백 또는 이벤트를 통해 처리됩니다. 비동기는 결과값을 받으면 어떻게 할지의 콜백함수를 미리 정의

동기 비동기 Blocking Non Blocking 정리

동기 비동기는 요청한 작업에 대한 완료 여부를 신경 써서 작업을 순차적으로 수행할지 아닌지에 대한 관점.

동기 작업은 순서대로 요청한 작업을 수행하지만, 비동기 작업은 순서가 지켜지지 않을 수 있다.

블로킹 논블로킹 : 현재 작업이 block 되느냐 아니냐에 따라 다른 작업을 수행할 수 있는지에 대한 관점

다른 요청의 작업을 처리하기 위해 현재 작업을 block(차단,대기) 하냐 안하냐 의 유무를 나타내는 프로세스의 실행 방식

동기/비동기: 전체적인 작업에 대한 순차적인 흐름 유무

(주로 call back 함수사용)

블로킹/논블로킹: 전체적인 작업의 흐름 자체를 막냐 안막냐

예시: 자바 스크립트의 setTimeout 함수는 타이머 작업 완료 여부를 신경 쓰지 않고 다음 콘솔 작업을 수행하기 때문에 비동기

또한 setTimeout 함수는 자신의 타이머 작업을 수행 위해 메인 함수를 블락하지 않았으니 논블라킹

출처

https://gngsn.tistory.com/154 => 그림으로 이해 가능

https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-%EB%8F%99%EA%B8%B0%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%B8%94%EB%A1%9C%ED%82%B9%EB%85%BC%EB%B8%94%EB%A1%9C%ED%82%B9-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC#%EB%B9%84%EB%8F%99%EA%B8%B0%EC%9D%98_%EC%84%B1%EB%8A%A5_%EC%9D%B4%EC%A0%90

정보를 모은 후 결론

현재 .block()을 사용하여 동기식으로 차례대로 호출하고 있으니 비동기적으로 적용할 방법을 찾으려고 했습니다.

Tuple3 로 한번에 block() 처리

그러던 중 저와 비슷한 상황을 가진 블로그 글을 찾았습니다.

https://suyeonchoi.tistory.com/61

위 글을 요약하면
A.block() , B.block() , c.block() 대신 Mono.zip(A,B,C) 이런 식으로 바꾼다면
Mono.zip을 통해 최종적인 결과는 동기적으로 처리되지만, 외부 API를 호출하는 각각의 Mono는 스케줄러에 의해 비동기적으로 수행된다.

라는게 블로그의 내용요약입니다.

또한 스프링 공식 문서의

https://docs.spring.io/spring-framework/reference/web/webflux-webclient/client-synchronous.html

이 부분을 보면

여러 번 multiple calls 가 필요할 때 각각을 따로 blocking 하는 것보다 결합된 결과를 기다리는 것이 더 효율적이다 라는 문구를 볼 수 있습니다.

어쨌든 성능이 개선된다는 이야기가 됩니다!!

적용 과정 문제점: 기존 상속 구조

private OAProgramDetailsDto getProgramDetails(Long tmDbId, ProgramType programType) {
        if (programType == ProgramType.Movie) {
            OAMovieDetailsDto oaMovieDetailsDto = webClient.get()
                .uri("/movie/" + tmDbId + "?language=ko")
                .retrieve()
                .bodyToMono(OAMovieDetailsDto.class)
                .block();

            return oaMovieDetailsDto;
        } else {
            OATvDetailsDto oaTvDetailsDto = webClient.get()
                .uri("/tv/" + tmDbId + "?language=ko")
                .retrieve()
                .bodyToMono(OATvDetailsDto.class)
                .block();

            return oaTvDetailsDto;
        }
    }

기존에 이 코드에서 OAMovieDetailsDto,OATvDetailsDtoOAProgramDetailsDto 를 상속받아서 사용하고 있었습니다.

이를 Mono 형식으로 반환하려 하였는데 ...

이런 식으로 오류가 발생하였습니다.

따라서

아래와 같이

private Mono<?> getProgramDetails(Long tmDbId, ProgramType programType) {
        if (programType == ProgramType.Movie) {
            Mono<OAMovieDetailsDto> oaMovieDetailsDto = webClient.get()
                .uri("/movie/" + tmDbId + "?language=ko")
                .retrieve()
                .bodyToMono(OAMovieDetailsDto.class);

            return oaMovieDetailsDto;
        } else {
            Mono<OATvDetailsDto> oaTvDetailsDto = webClient.get()
                .uri("/tv/" + tmDbId + "?language=ko")
                .retrieve()
                .bodyToMono(OATvDetailsDto.class);

            return oaTvDetailsDto;
        }
    }

로 수정하였습니다.

적용 과정 문제점: Java 11.0.2 에서의 문제점

SSLException: No PSK available. Unable to resume.

계속 이 오류가 Tuple 을 사용했을 때마다 발생하였는데

이는 Java 11.0.2 버젼을 사용했을 때 이럴 수 있다고 한다.

현재 프로젝트에 배포되어 있는 최신 자바 버젼으로 바꾸었다.

https://stackoverflow.com/questions/55128398/jdk-11-javax-net-ssl-sslpeerunverifiedexception-peer-not-authenticated

https://bugs.openjdk.org/browse/JDK-8213202

코드 작성

 @Override
    public ProgramResponseDto showDetails(Long programId) {
        long firstPart = System.currentTimeMillis();
        Program program = programRepository.findById(programId)
            .orElseThrow(() -> new NotFoundException(ErrorCode.PROGRAM_NOT_FOUND));

        //API 요청 첫번째: 프로그램 상세 정보
        Mono<?> programDetailsMono = getProgramDetails(
            program.getTmDbProgramId(),
            program.getType()).subscribeOn(Schedulers.boundedElastic());


        /*ProgramDetailResponse programDetailResponse = createProgramDetailResponse(
            (OAProgramDetailsDto) programDetailsMono.block());
*/
        //API 요청 두번째: 사람 상세 정보
        Mono<OAProgramCreditsDto> oaProgramCreditsDtoMono = getCreditsDto(
            program.getTmDbProgramId(),
            program.getType()).subscribeOn(Schedulers.boundedElastic());

        //API 요청 세번째: Provider 상세 정보
        Mono<OAProgramProviderDto> oaProgramProviderDtoMono = getProviderDto(
            program.getTmDbProgramId(),
            program.getType()).subscribeOn(Schedulers.boundedElastic());

        System.out.println("Mono 부분 전 시간:" + (System.currentTimeMillis() - firstPart));

        long openApiPart = System.currentTimeMillis();
        Tuple3<?, OAProgramCreditsDto, OAProgramProviderDto> tuple3 = Mono.zip(programDetailsMono,
            oaProgramCreditsDtoMono, oaProgramProviderDtoMono).block();
        System.out.println("open api 호출 부분:" + (System.currentTimeMillis() - openApiPart));

        long restPart = System.currentTimeMillis();

        ProgramDetailResponse programDetailResponse = createProgramDetailResponse(
            (OAProgramDetailsDto) tuple3.getT1());
        changeActorAndDirectorToKorea(tuple3.getT2());

        Optional<OACountryDetailsDto> kr = Optional.ofNullable(
            tuple3.getT3().getResults().get("KR"));

        //사용자에게 보낼 DTO 를 만들고 OTT 의 이름을 한글로 변환시키는 작업을 수행합니다.
        Optional<ProgramProviderListResponseDto> programProviderListResponseDto = kr.map(
            oaCountryDetailsDto -> changeOTTtoKoreanAndMakeProviderResponseDto(
                oaCountryDetailsDto));

        // 사용자에게 보내줄 DTO
        ProgramResponseDto programResponseDto = new ProgramResponseDto(programDetailResponse,
            tuple3.getT2(), programProviderListResponseDto.orElse(null),
            program.getAverageRating());

        System.out.println("나머지 부분: " + (System.currentTimeMillis() - restPart));

        return programResponseDto;

    }

이렇게 수정하였습니다 .

나머지 메서드는 반환 타입을 Mono<Dto> 로 바꾸고 .block()을 제외하였습니다.

  • Schedulers.boundedElastic(): https://projectreactor.io/docs/core/release/api/ 에서 사용 이유에 따르면 " Optimized for longer executions, an alternative for blocking tasks where the number of active tasks (and threads) is capped" 쓰레드 수가 제한된 블로킹 작업에 효율적인 대안이라고 하네요
  • 참고:
    https://devfunny.tistory.com/915
    https://projectreactor.io/docs/core/release/reference/#schedulers

    테스트

    이제는 새로운 프로그램을 검색해도 500ms 대로 속도가 줄어들었습니다.

    어떤 새로운 영화 및 TV 의 상세 정보를 검색해도 0.7 초의 속도는 나오지 않습니다!

    이제는 같은 프로그램(초기:programId 84) 를 두번 세번 계속 검색해보겠습니다.





    기존 같은 데이터를 조회했을 때 50~60 이 이제는 30~40 으로 바뀐 것을 볼 수 있습니다.

    정리

    여러 open api 의 데이터를 한번에 block() 처리 하였는데 이를 통해 처음에 호출된 open api 의 정보는 대략 600~700 ms 의 속도가 나오던게 4,500ms 단위로 줄었고 반복적으로 open api 를 호출하면 그 속도가 빨라지는데 그 속도는 50~60 이 30~40 정도로 빨라졌습니다.

    아쉬운점

  • 테스트 해볼 수단이 현재 이런 수단 밖에 없는게 아쉬웠습니다. 조금더 직관적으로 알 수 있는 방법이 있을 것 같긴 한데 더 공부가 필요함
  • 네트워크 통신 같은 IO 작업은 시스템 콜을 사용한다. 그러면 여기서 open api 호출할 때 시스템 콜 횟수를 3번에서 1번으로 줄이는가? 이런 개념이 아닌가? 헷갈린다... 제대로 알아봐야 겠다.
  • 다음에 해볼 것

    완전히 비동기로 한번 작성해보고 싶었다.

    subscribeflatMap 등을 활용하고 싶었다.

    하지만 제대로 된 이해 없이 마구잡이로 사용해봤자 의미가 없어 보인다.

    쓰레드에 대한 개념과 연계해서

    어떻게 해야 잘 사용할 수 있을지 배경지식이 더욱 필요해 보인다. 꼭 다시 하자!

    profile
    기록을 통해 실력을 쌓아가자

    0개의 댓글