PPAK

mulipart/form-data 다루기(feat. feign-client, restTemplate) 본문

spring

mulipart/form-data 다루기(feat. feign-client, restTemplate)

PPakSang 2024. 4. 15. 15:26

multipart/form-data

multipart/form-data 는 http 를 사용해 데이터를 주고 받는 상황에서 하나의 body 에 여러 데이터를 넣어야 하는 경우를 구현하기 위해 만들어진 Content-Type 이다. (e.g 사진을 전송하는데 이에 대한 설명을 함께 포함해서 전송하고 싶은 경우에 사진은 image/jpeg 타입이지만, 설명은 text/plain 으로 전송해야 하는 경우, 웹브라우저 관점에서는 폼 데이터를 전송할 때 사용하는 Content-Type 이다)

 

위 사진은 HTTP Request 의 구조이다

multipart/form-data type의 데이터를 전송하겠다는 것의 의미는

  • ContentType 헤더 값으로 multipart/form-data 를 사용하는 것이고
  • body 에 multipart-data 가 담긴다는 것이다.

 

그렇다면, multipart -data 대해서 자세히 알아보자

  • 각각의 Multipart 데이터(text, file, mp3 …) 는 boundary 라는 구분문자(delimiter) 에 의해 나뉘어지며, 데이터의 시작과 끝 부분을 나타내기도 한다
  • 첫번째 Boundary 가 나오기 전 데이터는 MIME을 지원하지 않는 클라이언트를 위해 사용된다.
  • boundary 는 본문 내용과 충돌이 발생하지 않도록 임의의 문자열을 사용한다
  • 하나의 multipart 는 크게 Content-Disposition, Content-Type Header, 데이터 본문(보통 binary) 로 나뉜다

 

아래는 multipart/form-data request 중 핵심 요소를 추출한 예시다. 빨간 박스(엄청 많지만) 를 자세히 보면

  • 메시지의 시작과 끝, 그리고 데이터를 구분하는 구분 문자열인 boundary 가 정의되어 있고 사용되는 것을 확인할 수 있다.
  • multipart 각각의 ContentDisposition(form-data; name; filename 형식) ContentType 이 존재한다.
  • ContentDisposition 의 name 은 일종의 multipart 데이터의 key 역할을 담당하고 filename 은 실제 전송한 파일의 이름을 명시한다.
  • 마지막으로 실제 데이터 본문이 담긴다

 

다음은 본문의 주제이기도 한 코틀린으로 multipart/form-data 전송하는 방법에 대해서 알아보자

ISSUE1

처음 RestTemplate 을 통해 multipart/form-data 요청을 구성해서 전송하고자 아래와 같은 코드를 작성했다.

val textHeaders = HttpHeaders().also { it.contentType = MediaType.APPLICATION_JSON }
val textEntity = HttpEntity<String>(objectMapper.writeValueAsString(uploadRequest), textHeaders)

val fileHeaders = HttpHeaders().also { it.set("Content-Type", "audio/mp3") }
val file = ResourceUtils.getFile("classpath:epidemic/MUSIC_Under_the_sunshine.mp3")
val fileEntity = HttpEntity<ByteArray>(file.readBytes(), fileHeaders)

val body: MultiValueMap<String, Any> = LinkedMultiValueMap()
body.add("text", textEntity)
body.add("file", fileEntity)

val headers = HttpHeaders().also { it.contentType = MediaType.MULTIPART_FORM_DATA }
val requestEntity: HttpEntity<MultiValueMap<String, Any>> =
    HttpEntity<MultiValueMap<String, Any>>(body, headers)

val aodUpload: ResponseEntity<UploadResponse> = restTemplate.exchange(
    "$host",
    HttpMethod.POST,
    requestEntity,
    UploadResponse::class.java)

 

결과는 실패이고, 천천히 포함된 데이터를 살펴보자

  1. request content-type 이 있는가 → headers 변수에 담겨 있다. (o)
  2. text 의 name, filename 이 포함되어 있는가 → name 은 “text” 로 잘 설정되어 있고 json 이라서 filename 은 존재하지 않는다. (o)
  3. file 의 name, filename 이 포함되어 있는가 → name 은 “file” 로 설정되어 있는데 filename 이 없다 (문제 발생) (x)

filename 을 포함시키는 두 가지 방법

  1. (비추) 직접 file 헤더를 구성한다 → 번거롭고, 실수가 발생하기 쉬운 방법이다

  1. spring 에서 제공하는 FileSystemResource 클래스를 사용한다

https://www.baeldung.com/spring-rest-template-multipart-upload#uploading-a-single-file

 

위 코드에서 file.readBytes() 부분을 FileSystemResource(file) 로 변경하기만 하면 된다.

이는 RestTemplate 내부 MessageConverter 에서 FileSystemResource 의 getFileName() 을 통해 filename 을 자동으로 세팅해주기 때문이다.

 

하지만, 개발 중인 프로젝트에서는 외부 API 를 open-feign 을 사용해 호출하고 있기 때문에 feign-client 를 사용하는 방식으로 변경해야 한다.

 

ISSUE2

RestTemplate → feign-client 로 변경 하는 것은 크게 어렵지 않아서 생략하겠다.

val audioUploadFeignClient: AudioUploadFeignClient
... // HttpEntity 객체 구성 코드
audioUploadFeignClient.upload(body) // body: HttpEntity<MultiValueMap<String,Any>>

 

SpringEncoder 에 이러한 LinkedMultiValueMap 을 파싱하는 로직이 존재하지 않아 빈 요청이 만들어지는 것을 알 수 있다.

해결 방법으로는 LinkedMultiValueMap 을 파싱하는 로직을 포함하는 Encoder(Writer) 를 구현해서 추가하는 방법도 있다. 아래 링크에 좋은 예시가 있다.

https://github.com/spring-cloud/spring-cloud-openfeign/pull/314/files

 

하지만, 기본적으로 주어진 스펙을 사용해서 해결하면 좋을 것 같고 아래에 그 방법이 나와 있다.

File Upload With Open Feign | Baeldung

 

feign-client

마지막으로, multipart/form-data 요청을 전송하는 FeignClient 코드를 살펴보자

@FeignClient(
    name = "audio-upload-client",
    url = "\${url}"
)
interface AudioUploadFeignClient {

    @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
    fun upload(
        @RequestPart("text") text: MultipartFile,
        @RequestPart("file") file: MultipartFile,
    ) : UploadResponse
}

consumes 은 일종의 Request Content-Type(multipart/form-data) 로 볼 수 있다.

그리고 각각의 파라미터를 통해 multipart 데이터를 정의할 수 있다

(text: MultipartFile / file: MultipartFile)

  • MultipartFile 이 아닌 primitive 타입을 넘기면 text/plain type 의 데이터가 들어간다

MultipartFile 은 쉽게 생각하면 Body의 boundary 사이에 들어갈 데이터를 표현한다고 볼 수 있다.

 

파라미터로 MultipartFile 의 구현체를 넘겨야 하는데, Spring 에서 구현 해놓은 MockMultipartFile 을 사용했다.

Spring 에서는 테스팅에 사용하는 것이 유용하다고 하지만, 내부 구현을 살펴보면 필요한 함수(getName, getOriginalFilename, getContentType, getBytes…) 가 의도대로 구현되어 있어 일반적으로 사용해도 된다고 판단했다.

 

MockMultipartFile (Spring Framework 6.1.6 API)

getContentType Return the content type of the file. Specified by: getContentType in interface MultipartFile Returns: the content type, or null if not defined (or no file has been chosen in the multipart form)

docs.spring.io

Comments