[Spring] WebClient 주의점

노유성·2024년 3월 13일
0
post-thumbnail

들어가며

스프링의 WebClient 그리고 Django에 대해서 알아보고 두 서버 사이의 데이터 전송에 대해서 알아보자.

시나리오


다음과 같은 같은 시나리오를 가지고 있었다.

  1. 스프링 서버에서는 서비스 레이어에서 WebClient를 이용해 외부 서버에 요청을 보낸다.
  2. 외부 서버는 분석에 대해 응답한다.
  3. 응답 결과를 저장한다.

하지만 문제가 있었다. 스프링 서버에서 아무리 요청을 보내도 장고 서버에서는 데이터를 읽을 수 없었다.

스프링 코드

@Transactional
@LogMethodParams
public AnalysisResult analysis(Long memberId, UserAnalysisRequestDto userAnalysisRequestDto) {
    // 분석 파일 로그 조회
    MemberFileLog memberFileLog = memberFileLogRepository.findByIdAuth(memberId, userAnalysisRequestDto.getMemberFileLodId())
            .orElseThrow(() -> new IllegalStateException("해당 파일을 찾을 수 없습니다."));
    AnalysisResult analysisResult = new AnalysisResult(memberFileLog, AnalysisStatus.CREATED);

    // 파일 상태 조회
    validateFileAnalysisStatus(analysisResult);

    // 요청 메타데이터 생성
    FileExtension extension = memberFileRepository.findExtensionByMemberFileLogId(
            userAnalysisRequestDto.getMemberFileLodId()
    ).orElseThrow(NoSuchMemberFileLogException::new);
    AnalysisRequestDto analysisRequestDto = new AnalysisRequestDto(extension,
            null,
            userAnalysisRequestDto.isAll());
    if (!userAnalysisRequestDto.isAll()) {
        analysisRequestDto.setColumns(fetchColumnDtos(userAnalysisRequestDto.getSelectedColumnIds()));
    }

    // 파일 내용 JSON 문자열로 파싱
    String json = null;
    try {
        json = objectMapper.writeValueAsString(analysisRequestDto);
    } catch (Exception e) {
        e.printStackTrace();
    }

    // 파일 가져오기
    String savedFilename = memberFileLog.getSavedName();
    String fullPath = fileUtils.getFullPath(savedFilename);
    File file = new File(fullPath);

    // body 작성
    MultipartBodyBuilder builder = new MultipartBodyBuilder();
    builder.part("file", new FileSystemResource(fullPath));
    builder.part("metadata", json);
    MultiValueMap<String, HttpEntity<?>> multipartBody = builder.build();

    // 비동기 요청 보내기
    // HTML 파일은 분석 서버에서 "문자열"로 반환함
    webClient.post()
            .uri(analysisServerUri + analysisUri)
            .contentType(MediaType.MULTIPART_FORM_DATA)
            .body(BodyInserters.fromMultipartData(multipartBody))
            .retrieve()
            .bodyToMono(String.class)
            .subscribe(result ->
                    analysisResultProcessor.processResult((String) result, (Long) analysisResult.getId()));

    analysisResult.changeAnalysisStatus(AnalysisStatus.PROCESSING);
    return analysisResult;
}

좀 길지만 요약해보자면

  1. 사용자가 분석 요청한 파일 존재 유무 확인
    2, File로 가져오기
  2. 메타데이터 json으로 변환
  3. 외부 서버에 전송
  4. Mono 타입으로 응답 결과를 받은 후 결과 저장 프로세스 실행

다음과 같다.

장고 코드

def analysis(request):
  # 파일 존재 여부 검사
  if 'file' not in request.FILES:
    print("파일이 업로드되지 않았습니다.")
    return HttpResponse("파일이 업로드되지 않았습니다.", status=400)

  # metadata 처리
  try:  
    metadata_str = request.POST.get('metadata', '{}')  # 기본값으로 빈 JSON 문자열 설정
    metadata = json.loads(metadata_str)
  except json.JSONDecodeError:
    print("metadata 형식이 잘못되었습니다.")
    return HttpResponse("metadata 형식이 잘못되었습니다.", status=400)
    
    ...

위처럼 데이터가 있는지 없는지를 체크한다.

문제점


문제점은 위 그림처럼 파일이 업로드가 되지 않았다. 아주 수상한 23이라는 syntax를 남긴채 말이다.
나중에 안 사실인데


그냥 아예 request body가 비어있었다.

그래서 다음과 같은 삽질을 했다.

1. Json으로 파싱하기

먼저 metadata라는 key값으로 넘기는 spring 서버의 객체가 직렬화가 안 되었던가? 라는 의문이 생겼다. 하지만 소용이 없었다.

2. 파일 경로의 문제

// body 작성
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("file", new FileSystemResource(fullPath));

위처럼 파일을 가져오는데에 있어서 fullPath의 경로가 잘못되었던 거일 수도 있지 않을까했다. 하지만 그것도 소용이 없었다.

3. Content-type

우리가 외부 서버에 전송하고자 하는 값은 Multipart/form-data이다. content-type이 맞지 않았던가 싶었다. 하지만 이것도 소용이 없었다.

4. Part 별로 Content-type 지정

이게 무슨 말이냐면

// body 작성
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("file", new FileSystemResource(fullPath))
        .contentType(MediaType.MULTIPART_FORM_DATA); // 추가
builder.part("metadata", json)
        .contentType(MediaType.APPLICATION_JSON); // 추가
MultiValueMap<String, HttpEntity<?>> multipartBody = builder.build();

위처럼 part별로 해야하나 싶었나. 하지만 스프링 공식문서를 확인해보면

  1. part별로 content-typeHttpMessageWriter에 의해서 알아서 지정된다.
  2. 심지어 body로 넘기는 MultiValueMapString이 아닌 값이 하나라도 존재하면 content-typeMultipart/form-data로 넘어간다.

정말 좌절스러웠다. 3일 내내 이것만 잡고 삽질을 하고 있었기 때문이다.

실마리

그러다가 장고 공식문서를 읽고 reqeuest.META 데이터를 읽어보았다. 왜냐하면 포스트맨에서 데이터를 보낼 때는 장고 서버에 데이터가 전달되었기 때문에 spring 서버와 postman에 무슨 차이가 있을까? 싶었다.

결과로 다음과 같은 차이가 있었다.

  1. spring webclient로 보낸 요청은 content-length가 없었다.
  2. spring webclient로 보낸 요청은 chunked로 압축이 되어있었다.

Postman은 정반대이다.

오잉? 압축이 청크로 되어있었다는 사실이 흥미로웠다.

장고 서버가 데이터를 못 읽은 이유

정답은 WebClientMultipart/form-data를 읽을 때에는 chunk해서 요청을 보낸다. 하지만 장고서버의 네트워크는 WSGI 인터페이스를 구현하고 있고, 해당 인터페이스는 chunk된 데이터를 받지 못 하는 기본 스펙을 가지고 있었다.

결론적으로, 스프링 서버는 chunk해서 보내는데 장고서버는 chunked된 데이터를 받을 수 없었기 때문에 발생한 문제였다.

해결

그럼 WebClient로 요청을 chunk를 안 하던가, 아니면 장고 서버에서 chunked된 데이터를 받던가 하면 된다.

하지만 서로 다른 인터페이스를 맞추기 위해서 한 인터페이스를 억지로 바꾸는 것은 옳지 않다고 생각했다.(다른 컴포넌트에 장애가 날 수도 있을 거 같아서...)

결론

결론은 Ngnix 서버를 장고 서버의 리버스 프록시 서버로 두었다. 스프링 서버에서 청킹된 데이터를 Ngnix 서버에 보내면, Ngnix 서버에서는 데이터를 전부 받아서 압축해서 장고 서버에 전달하는 것이다.

이렇게 했더니 문제가 말끔히 해결되었다!

Ngnix 설정 파일

http {
    upstream webflux {
        server localhost:8080; # 스프링 WebFlux 애플리케이션 주소
    }

    upstream django {
        server localhost:8000; # WSGI 기반 파이썬 서버 주소
    }

    server {
        listen 80;

        location / {
        }

        location /files/analyze/ {
            proxy_pass http://django;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_buffering off;
        }
    }
}

GPT가 써준거니까 알잘딱깔센으로 걸러서 보도록 하자!

마무리하며


이 경고의 수상한 syntax청킹된 데이터 중 첫번째 패킷이었고, 해당 패킷에 content-lenght가 없어서 발생한 에러였다.

긴 삽질이었다.

profile
풀스택개발자가되고싶습니다:)

0개의 댓글