이전 글: [스프링MVC] 파일 업로드 이야기 - multipart/form-data 요청 다루기
MultipartFile 말고 Part 써보기
서블릿 3.0 부터 javax.servlet.http.Part
를 통해 파일 업로드를 처리할 수 있게 되었습니다.
하지만 스프링 5.1.8을 사용하는 중에 이 친구가 제대로 동작하는 걸 보지 못 했는데요.
이슈를 참조해서 스프링 5.3.2로 버전 업하였습니다.
MockMultipartHttpServletRequestBuilder
를 사용해 MockMVC 테스트를 해 보니 참 묘했습니다. 인터페이스를 보고 예측한 행동들과, 실제 벌어진 행동들에 간극이 있어서 정말 묘해요.
목 요청을 만들 때 file()
과 part()
의 차이점을 모르겠음
- 그들로 주입한 컨텐츠는 모두
multiPartFileMap
이 아닌parts
에게 저장됨. - 그들로 주입한 컨텐츠는 컨트롤러 메서드의 인자인
MultipartFile
및Part
객체로 바인딩 가능함. - 그들로 주입한 컨텐츠는 포조 속성 중
MutlipartFile
타입에게 바인딩 가능하나,Part
타입에게는 불가능함.
1, 2번 내용을 보면 이번 Part
지원이, MultipartFile
와의 완전한 호환성을 갖춘 것처럼 느껴지는데요. 다시 3번을 보면 또 아닙니다. 왜 하필 포조 속성 매핑할 때만 안 되는 건지...??
(저는 일관성을 지적하며 스프링 프레임워크에 문의하였고, 이슈의 결론을 여기서 확인 가능하십니다.)
이런 점 때문에 Part
사용이 망설여질 때 테스트를 통과하는 유스케이스를 안다면 확신을 갖고Part
를 써볼 수 있을 것입니다. 따라서 몇 가지 유스케이스를 준비하고 이를 커버하는 테스트를 진행해 보았습니다.
환경 구성
StandardServletMultipartResolver
빈 등록
이 멀티파트 리졸버는 Part
사양을 지원하는 구현체입니다. 디스패처 서블릿에 이 빈을 등록합시다.
아까 첨부드린 이슈 결론을 보셨다면 깨달으셨을 겁니다. Part
호환 멀티파트 리졸버를 등록함으로 인해서 Part
를 컨트롤러 메서드 인자에 바인딩 할 수 있게 되지만, Part
타입의 포조 속성에게는 바인딩 할 수 없게 된다는 사실을요. 그리고 이것이 현재로써는 의도된 스펙이라는 것을...
@EnableWebMvc
@ComponentScan(basePackages = {"org.binchoo.study.spring.multipart.profileservice"})
@Configuration
@Import(MustacheAutoConfiguration.class)
public class WebConfig implements WebMvcConfigurer {
...
...
@Bean
public StandardServletMultipartResolver multipartResolver() {
return new StandardServletMultipartResolver();
}
}
Object Mapper 등록
오브젝트 매퍼가 컨텍스트에 추가되어 있지 않다면 직접 등록합니다. JSON을 VO(Value Object)로 변경할 때 이용하겠습니다.
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
멀티파트 설정 추가
MultipartConfigElement
객체에 멀티파트 관련 설정을 생성하고 registration
에 추가합니다.
public class WebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
...
...
@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
registration.setMultipartConfig(multipartConfigElement());
}
@Bean
private MultipartConfigElement multipartConfigElement() {
return new MultipartConfigElement(null, 500000, 700000, 0);
}
}
- 첫 번째 인자: 저장할 디렉토리를 설정합니다. 기본값은 ""입니다.
- 두 번째 인자: 업로드 되는 파일의 최대 크기.
- 세 번째 인자: 요청의 최대 크기를 나타냅니다. (요청의 크기 > 이미지의 크기)일 것이므로 이를 고려하여 설정합니다.
- 마지막 인자: 파일을 디스크에 쓰도록 하는 임계 크기를 설정합니다. 0으로 설정하면 모든 업로드된 파일들을 디스크에 쓸 것입니다.
8가지 업로드 유스케이스
테스트 케이스 소개
javax.servelet.http.Part
이나 MultipartFile
으로 업로드 파일 획득하는 것을 살펴봅니다. HTTP 요청의 형태와 핸들러 메서드의 시그니처에 의해 8가지로 확인해 보려합니다.
- params + Part -> params + Part
- params + File -> params + MultipartFile
- params + Part -> VO + Part
- params + File -> VO + MultipartFile
- params + Part -> VO Having Part (Fails)
- params + File -> VO Having MultipartFile
- json-part + Part -> VO + Part
- json-part + File -> VO + MultipartFile
TC 이름에서 화살표 왼편과 오른편이 나누어져 있으니 설명하겠습니다.
왼쪽은 Mock 멀티파트 요청의 모양을 의미합니다.
- params:
param()
으로 객체 멤버를 넣어준다는 의미 - json-part:
part()
로 객체의 JSON 문자열 표현을 넣어준다는 의미 - File:
file()
로 업로드 파일을 넣겠다는 의미 - Part:
part()
로 업로드 파일을 넣겠다는 의미
오른쪽은 요청을 처리하는 핸들러의 인자의 모습입니다.
- params: 프리미티브 자료형으로 객체 멤버를 주입 받습니다.
- VO: 객체를 주입받습니다.
- MultipartFile: 업로드 파일을
MultipartFile
로 주입 받습니다. - Part: 업로드 파일을
Part
로 주입 받습니다.
수신 객체 정의
HTTP 요청에 담긴 자료를 뽑아 바인딩 해 두는 객체입니다.
UserVO
는 이름과 나이를 저장하며, UserHaving...
는 업로드된 파일도 저장하고자 합니다.
이 때 Part
멤버는 스프링이 주입하지 못 한다는 점을 UserHavingPartVO
로 확인할 것입니다.
@Setter
@Getter
@NoArgsConstructor // jackson-databind objectMapper 사용을 위해
@AllArgsConstructor
private static class UserVO {
private String name;
private int age;
}
@Setter
@Getter
private static class UserHavingPartVO extends UserVO {
private Part file;
public UserHavingPartVO(String name, int age, Part file) {
super(name, age);
this.file = file;
}
}
@Setter
@Getter
private static class UserHavingFileVO extends UserVO {
private MultipartFile file;
public UserHavingFileVO(String name, int age, MultipartFile file) {
super(name, age);
this.file = file;
}
}
테스트 자료 준비
HTTP 요청에 실을 자료들을 준비합니다.
MockMVC
를 사용하여 multipart()
빌더로 리퀘스트를 작성할 것이며, 테스트 자료을 담아 핸들러에 보냅니다.
이 때, MockMultipartHttpServletRequest
타입의 요청이 생성됩니다.
MockPart mockPart = new MockPart("file", "filename.png", "file".getBytes());
MockPart mockJsonPart = new MockPart("user", "{\"name\": \"jaebin-joo\", \"age\":\"11\"}".getBytes());
MockMultipartFile mockFile = new MockMultipartFile("file", "filename.png", "image/png", "file".getBytes());
컨트롤러 작성
TC에 대응하는 8개의 컨트롤러 메서드를 작성합니다.
테스트 목적은 핸들러 인자에 값이 잘 바인딩 되는 지를 보는 것이니 메서드 내용은 별 것 없습니다.
바인딩이 성공하면 테스트는 성공이므로 메서드는 OK를 반환합니다.
@Controller
private class FileUploadController {
@PostMapping("/param/part")
public ResponseEntity bindParamsAndPart(@RequestParam("name") String name,
@RequestParam("age") int age, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/param/multipartfile")
public ResponseEntity bindParamsAndFile(@RequestParam("name") String name,
@RequestParam("age") int age, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
@PostMapping("/vo/part")
public ResponseEntity bindVOAndPart(UserVO userVo, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/vo/multipartfile")
public ResponseEntity bindVOAndFile(UserVO userVo, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
@PostMapping("/vopart")
public ResponseEntity bindVOHavingPart(UserHavingPartVO userVo){
if (userVo.getFile() == null)
return ResponseEntity.noContent().build();
return ResponseEntity.ok().build();
}
@PostMapping("/vomultipartfile")
public ResponseEntity bindVOHavingFile(UserHavingFileVO userVo){
if (userVo.getFile() == null)
return ResponseEntity.noContent().build();
return ResponseEntity.ok().build();
}
@PostMapping("/json-part/part")
public ResponseEntity bindJsonAndPart(@RequestPart(value = "user") UserVO user, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/json-part/multipartfile")
public ResponseEntity bindJsonAndFile(@RequestPart(value = "user") UserVO user, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
}
테스트 작성
앞서 설명한 8가지 유스케이스를 확인합시다.
이들은 통과하므로 스프링 5.3.2에서 통과하는 유스케이스 세트가 생겼습니다. 그러므로 Part
를 사용한 파일 업로드 구현에 참고할 수 있습니다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringJUnitWebConfig(classes = {WebConfig.class})
public class FileUploadControllerTests {
MockMvc mvc;
FileUploadController controller = new FileUploadController();
MockPart mockPart = new MockPart("file", "filename.png", "file".getBytes());
MockPart mockJsonPart = new MockPart("user", "{\"name\": \"jaebin-joo\", \"age\":\"11\"}".getBytes());
MockMultipartFile mockFile = new MockMultipartFile("file", "filename.png", "image/png", "file".getBytes());
@BeforeEach
public void setup() {
this.mvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Order(1)
@DisplayName("params + Part -> params + Part")
@Test
void bindParamsAndPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/param/part")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(2)
@DisplayName("params + File -> params + MultipartFile")
@Test
void bindParamsAndFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/param/multipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(3)
@DisplayName("params + Part -> VO + Part")
@Test
void bindVOAndPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vo/part")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(4)
@DisplayName("params + File -> VO + MultipartFile")
@Test
void bindVOAndFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vo/multipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(5)
@DisplayName("params + Part -> VO Having Part (Fails)")
@Test
void bindVOHavingPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vopart")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().is4xxClientError()) // StandardMultipartFile 타입을 Part로 변환할 수 없기 때문에
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(6)
@DisplayName("params + File -> VO Having MultipartFile")
@Test
void bindVOHavingFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vomultipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(7)
@DisplayName("json-part + Part -> VO + Part")
@Test
void bindJsonAndPart() throws Exception {
mockJsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/json-part/part")
.part(mockPart)
.part(mockJsonPart))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(2);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(8)
@DisplayName("json-part + File -> VO + MultipartFile")
@Test
void bindJsonAndFile() throws Exception {
mockJsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/json-part/multipartfile")
.file(mockFile)
.part(mockJsonPart))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(2);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
}
여기서 5번째 TC와 메서드는 주의해야 합니다.
다른 핸들러들은 인자 값들을 제대로 전달 받길 원하지만, 컨트롤러 5번째 bindVOHavingPart(UserHavingPartVO userVo)
메서드는 실패하길 기대합니다.
요청 작성시 컨텐츠를 part()
로 전달했는데요. 객체 멤버의 타입이 Part
인데도 스프링은 업로드 된 파일을 전달해 주지 못해 당황스럽습니다.
@Setter
@Getter
private static class UserHavingPartVO extends UserVO {
private Part file; // 스프링이 값 못 넣어 줍니다 ...
public UserHavingPartVO(String name, int age, Part file) {
super(name, age);
this.file = file;
}
}
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vopart")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().is4xxClientError()) // StandardMultipartFile 타입을 Part로 변환할 수 없기 때문에
.andReturn().getRequest();
왜 그런가요?
멀티파트 요청 StandardMultipartHttpServletRequest
이 객체 멤버에게 값을 주입할 땐
그 타입이 StandardMultipartHttpServletRequest.StandardMultipartFile
입니다.
이 클래스는 MultipartFile
을 상속합니다. 그러고선 Part
를 래핑하고 있습니다.
얘는 private 가시성을 갖기 때문에 우리가 커스텀 컨버터를 작성할 수도 없어요.
이 부분이 Part
의 일관적이지 못한 부분이라고 생각하였습니다. 마치 MutlipartFile
을 완전 대체할 것 같지만 그렇지 못 하는군요. 프레임워크가 수정되어 StandardMultipartFile.getPart()
가 호출되며 멤버 주입이 진행되면 좋을 것 같습니다.
관련하여 스프링 프레임워크에 이슈를 열었습니다. 이 이슈에 대한 팔로업은 글 맨 마지막에 추가하겠습니다.
결론: MultipartResolver가 있는 상태라면, 포조에서 멀티파트 파일을 받아줄 속성의 타입은 Part
가 아닌, MultipartFile
타입이어야 한다는 것!
이렇게 5번째 TC의 의미를 설명하였습니다.
테스트 결과 요약
테스트는 모두 통과합니다. 의미를 해석해 봅니다.
- 파일을 담을 때
multiPartFileMap
가 사용되지 않고parts
가 사용된다.- 목 요청 생성에
file()
를 쓰나part()
를 쓰나 관계 없다. assertThat(request.getMultiFileMap().size()).isEqualTo(0);
코드가 확인하는 사실이다.
- 목 요청 생성에
- 목 요청에
file()
로 파일을 담아도Part
객체가 받을 수 있다.- 반대로
part()
로 파일을 담아도MultipartFile
객체가 받아줄 수 있다.
- 반대로
- 멤버 타입이
Part
이면 업로드 파일을 바인딩 해줄 수 없다!!!! - JSON 문자열이 Content-Type: application/json인 파트에 담기면,
@RequestPart
로 바인딩 가능함. 단,ObjectMapper
필요.
전체 코드 보기
package org.binchoo.study.spring.multipart.profileservice.controller;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.binchoo.study.spring.multipart.profileservice.config.WebConfig;
import org.junit.jupiter.api.*;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.mock.web.MockPart;
import org.springframework.stereotype.Controller;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import javax.servlet.http.Part;
import static org.assertj.core.api.Java6Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringJUnitWebConfig(classes = {WebConfig.class})
public class FileUploadControllerTests {
MockMvc mvc;
FileUploadController controller = new FileUploadController();
MockPart mockPart = new MockPart("file", "filename.png", "file".getBytes());
MockPart mockJsonPart = new MockPart("user", "{\"name\": \"jaebin-joo\", \"age\":\"11\"}".getBytes());
MockMultipartFile mockFile = new MockMultipartFile("file", "filename.png", "image/png", "file".getBytes());
@BeforeEach
public void setup() {
this.mvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Order(1)
@DisplayName("params + Part -> params + Part")
@Test
void bindParamsAndPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/param/part")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(2)
@DisplayName("params + File -> params + MultipartFile")
@Test
void bindParamsAndFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/param/multipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(3)
@DisplayName("params + Part -> VO + Part")
@Test
void bindVOAndPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vo/part")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(4)
@DisplayName("params + File -> VO + MultipartFile")
@Test
void bindVOAndFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vo/multipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(5)
@DisplayName("params + Part -> VO Having Part (Fails)")
@Test
void bindVOHavingPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vopart")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().is4xxClientError()) // StandardMultipartFile 타입을 Part로 변환할 수 없기 때문에
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(6)
@DisplayName("params + File -> VO Having MultipartFile")
@Test
void bindVOHavingFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vomultipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(7)
@DisplayName("json-part + Part -> VO + Part")
@Test
void bindJsonAndPart() throws Exception {
mockJsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/json-part/part")
.part(mockPart)
.part(mockJsonPart))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(2);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(8)
@DisplayName("json-part + File -> VO + MultipartFile")
@Test
void bindJsonAndFile() throws Exception {
mockJsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/json-part/multipartfile")
.file(mockFile)
.part(mockJsonPart))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(2);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Controller
private class FileUploadController {
@PostMapping("/param/part")
public ResponseEntity bindParamsAndPart(@RequestParam("name") String name,
@RequestParam("age") int age, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/param/multipartfile")
public ResponseEntity bindParamsAndFile(@RequestParam("name") String name,
@RequestParam("age") int age, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
@PostMapping("/vo/part")
public ResponseEntity bindVOAndPart(UserVO userVo, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/vo/multipartfile")
public ResponseEntity bindVOAndFile(UserVO userVo, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
@PostMapping("/vopart")
public ResponseEntity bindVOHavingPart(UserHavingPartVO userVo){
if (userVo.getFile() == null)
return ResponseEntity.noContent().build();
return ResponseEntity.ok().build();
}
@PostMapping("/vomultipartfile")
public ResponseEntity bindVOHavingFile(UserHavingFileVO userVo){
if (userVo.getFile() == null)
return ResponseEntity.noContent().build();
return ResponseEntity.ok().build();
}
@PostMapping("/json-part/part")
public ResponseEntity bindJsonAndPart(@RequestPart(value = "user") UserVO user, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/json-part/multipartfile")
public ResponseEntity bindJsonAndFile(@RequestPart(value = "user") UserVO user, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
}
@Setter
@Getter
@NoArgsConstructor // jackson-databind objectMapper 사용을 위해
@AllArgsConstructor
private static class UserVO {
private String name;
private int age;
}
@Setter
@Getter
private static class UserHavingPartVO extends UserVO {
private Part file;
public UserHavingPartVO(String name, int age, Part file) {
super(name, age);
this.file = file;
}
}
@Setter
@Getter
private static class UserHavingFileVO extends UserVO {
private MultipartFile file;
public UserHavingFileVO(String name, int age, MultipartFile file) {
super(name, age);
this.file = file;
}
}
}
이슈 팔로업
Part 타입 속성 바인딩은 MultipartResolver가 없는 경우의 폴백 루틴으로 동작한다
이슈가 아닌 스펙
스프링 컨트리뷰터 Rossen Stoyanchev 님의 설명에 따르면, Part
타입 속성 바인딩은 멀티파트 리졸버가 없는 경우에 동작합니다.
그 분께서 주신 코멘트를 보고 저는 DispatcherServlet::checkMultipart
와 ServletRequestDataBinder::bind
를 검토하여 이 내용이 사실임을 확인했습니다. 두 코드를 추적해 보는 과정은 Part 타입 속성에 Part를 바인딩 할 수 없는 이유에 정리했습니다.
다만, 이런 스펙에 관해 스프링이 문서와 테스트 코드를 충분히 갖추지 못했으며, 똑같은 고생을 하실 분들이 추후 나오게 될 것이라 판단했습니다. 그래서 Rossen Stoyanchev님은 자바독을 강화하셨고, 저는 몇 가지 테스트 메서드를 추가하는 것으로 이슈가 마무리 되었습니다.
이슈 요약
여러분이 이 이슈로부터 가져가실 내용은 아래 두 가지입니다.
서블릿 요청이 MultipartResolver를 통해 MultipartRequest가 되었다면, 이 요청이 보유한 멀티파트 파일은 (애초에 Part 타입입니다)
Part
타입 인자에 바인딩 가능하다.Part
타입 속성에 바인딩이 불가하다. 하지만MultipartFile
타입 속성에 바인딩 가능하다.
spring.servlet.multipart.enabled=false 를 설정해 MultipartResolver를 사용하지 않음으로써 서블릿 리퀘스트가 보유한 멀티파트 파일은 (애초에 Part 타입입니다)
Part
타입 인자에 바인딩 가능하다.Part
타입 속성에 바인딩이 가능하다. 하지만MultipartFile
타입 속성에 바인딩 불가하다.
이를 검증하는 테스트 코드는 아래를 참고합니다.
MultipartResolver 존재여부에 따른 Part 바인딩 행동 검증
'웹 개발 > 스프링 프레임워크' 카테고리의 다른 글
[스프링 Cloud] Spring Cloud AWS + RDS Read Replica 연동 (0) | 2022.01.13 |
---|---|
[스프링MVC] javax.servlet.http.Part가 파일을 받지 못할 때 (0) | 2021.12.15 |
[스프링 Web] RestTemplate이 쏘는 패킷을 보고싶다 - Fiddler4로 JVM 패킷 디버깅하기 (2) | 2021.12.14 |
[스프링 Web] 웹 API 호출 이야기 - RestTemplate을 사용하는 서비스 구조와 구현 (0) | 2021.12.14 |
[스프링MVC] 어노테이션 이야기 - REST와 @ResponseBody (0) | 2021.12.14 |