- Today
- Total
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- Spring Security
- Kotlin
- 팀네이버 공채
- websocket
- 책
- 프로젝트
- 만들면서 배우는 클린 아키텍처
- container
- network
- chrome80
- SpringBoot
- 후기
- 캐싱전략
- 스프링
- JWT
- 젠킨스
- 브랜치전략
- 팀네이버
- spring
- redis
- LazyInitialization
- EntityTransaction
- 리뷰
- infra
- SPRING JWT
- docker
- jenkins
- Java
- Project
- JPA
PPAK
mulipart/form-data 다루기(feat. feign-client, restTemplate) 본문
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)
결과는 실패이고, 천천히 포함된 데이터를 살펴보자
- request content-type 이 있는가 → headers 변수에 담겨 있다. (o)
- text 의 name, filename 이 포함되어 있는가 → name 은 “text” 로 잘 설정되어 있고 json 이라서 filename 은 존재하지 않는다. (o)
- file 의 name, filename 이 포함되어 있는가 → name 은 “file” 로 설정되어 있는데 filename 이 없다 (문제 발생) (x)
filename 을 포함시키는 두 가지 방법
- (비추) 직접 file 헤더를 구성한다 → 번거롭고, 실수가 발생하기 쉬운 방법이다
- 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…) 가 의도대로 구현되어 있어 일반적으로 사용해도 된다고 판단했다.
'spring' 카테고리의 다른 글
Spring Rest Docs (2) | 2023.12.11 |
---|---|
[Spring/JWT] Access Token 과 Refresh Token 을 어디에 저장하고 어떻게 교환해야 할까? (2) | 2022.08.27 |
[Spring/Spring Boot] 서버 https 적용 (Certbot, Let's Encrypt) (0) | 2022.08.27 |
[Spring/SpringBoot] SpringBoot 로컬 서버 Https 적용 (0) | 2022.08.17 |
[Spring] Spring Security 에서 JWT 를 통한 인증/인가 수행하기 (7) | 2022.08.07 |