caffeine-library / pro-spring-5

🌱 전문가를 위한 스프링5를 읽는 스터디
5 stars 0 forks source link

[additional] 파일 업로드 - `Part` 타입으로 업로드 파일 다루기 #102

Closed binchoo closed 2 years ago

binchoo commented 2 years ago

MultipartFile 말고 Part 써보기

서블릿 3.0 부터 javax.servlet.http.Part 를 통해 파일 업로드를 처리할 수 있게 되었습니다.

하지만 스프링 5.1.8을 사용하는 중에 이 친구가 제대로 동작하는 걸 보지 못 했는데요.

[연관 이슈](https://github.com/spring-projects/spring-framework/issues/26501#issuecomment-772597270)

이슈가 수정돼 스프링 5.3.2로 버전 업하여 Part를 사용해 보니 묘하게 눈 여겨 볼 사항들이 있었습니다.

이런 미묘함으로 Part 사용이 망설여질 때, 테스트를 통과하는 유스케이스를 안다면

확신을 갖고Part를 써볼 수 있을 것입니다.

따라서 몇 가지 유스케이스를 준비하고 이를 커버하는 테스트를 진행해 보았습니다.

환경 구성

StandardServletMultipartResolver 빈 등록

이 멀티파트 리졸버는 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);
    }
}

8가지 업로드 유스케이스

image

테스트 케이스 소개

javax.servelet.http.Part이나 MultipartFile으로 업로드 파일 획득하는 것을 살펴봅니다. HTTP 요청의 형태와 핸들러 메서드의 시그니처에 의해 8가지로 확인해 보려합니다.

  1. params + Part -> params + Part
  2. params + File -> params + MultipartFile
  3. params + Part -> VO + Part
  4. params + File -> VO + MultipartFile
  5. params + Part -> VO Having Part (Fails)
  6. params + File -> VO Having MultipartFile
  7. json-part + Part -> VO + Part
  8. json-part + File -> VO + MultipartFile

TC 이름에서 화살표 왼편과 오른편이 나누어져 있으니 설명하겠습니다.

왼쪽은 Mock 멀티파트 요청의 모양을 의미합니다.

오른쪽은 요청을 처리하는 핸들러의 인자의 모습입니다.

수신 객체 정의

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 {
   //코드가 길어서 생략. 코멘트 첨부
}    

여기서 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 를 래핑하고 있습니다.

image

하필 StandardMultipartHttpServletRequest.StandardMultipartFile 클래스는 private 가시성을 갖기 때문에 컨버터를 작성할 수도 없네요. 프레임워크가 수정되어 이 녀석의 .getPart() 가 호출되어 멤버 주입이 일어나면 좋겠습니다.

아무튼 VO 객체에서 파일을 받을 멤버는 Part가 아닌, MultipartFile 타입이어야 하는 점을 알아야 합니다.

이렇게 5번째 TC의 의미에 대해 설명을 마칩니다.

테스트 결과 요약

테스트는 모두 통과합니다. 의미를 해석해 봅니다.

챕터

94 #100

binchoo commented 2 years ago

전체 코드 보기

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;
        }
    }
}