스프링 MVC 2 정리 - 11. 파일 업로드 (22.8.15)

피아노과 개발자도전?·2022년 8월 15일
0

Today I learned

목록 보기
34/75
post-thumbnail

김영한 개발자님의 스프링 MVC 2 강의를 수강하고 정리한 내용이다.

11. 파일 업로드

HTML 폼을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 두 가지 방식의 차이를 이해해야한다.

1. application/x-www-form-urlencoded

  • 가장 기본적인 방법이다.
  • 폼에 전송할 항목을 HTTP Body에 문자로 &로 구분해서 전송한다.
  • 다만 이 방식을 사용하면 문자와 바이너리 두 개를 동시에 저장해야 하기 때문에 아래의 방식을 사용한다.

2. multipart/form-data

  • 다른 종류의 여러 파일과 폼의 내용을 함께 전송할 수 있다.
  • 각각의 항목을 구분해서 한번에 전송하는 것이다.

11.1. 서블릿과 파일 업로드 1

다음과 같이 서블릿을 통해 파일 업로드를 한다.

@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {
    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }
    
    @PostMapping("/upload")
    public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
        log.info("request={}", request);
        String itemName = request.getParameter("itemName");
        log.info("itemName={}", itemName);
        Collection<Part> parts = request.getParts();
        // multipart/form-data방식에서 각각 나누어진 부분을 받아 확인할 수 있다. 
        log.info("parts={}", parts);
        return "upload-form";
    }
}

application.properties 옵션들

logging.level.org.apache.coyote.http11=debug
옵션을 통해 HTTP 요청 메시지를 확인할 수 있다.

spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
옵션을 통해 파일 하나의 최대 사이즈, 파일들의 총합 사이즈를 정의할 수 있다.

spring.servlet.multipart.enabled=false
이 옵션을 통해 스프링 부트는 서블릿 컨테이너에게 멀티파트 데이터를 처리하라고 설정한다.
복잡한 멀티파트 요청을 처리해서 사용할 수 있게 제공한다.


11.2. 서블릿과 파일 업로드 2

application.properties
file.dir=파일 업로드 경로 설정(예): /Users/kimyounghan/study/file/

주의
1. 꼭 해당 경로에 실제 폴더를 미리 만들어야한다.
2. application.properties 에서 설정할 때 마지막에 / 가 와야한다.

@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
    @Value("${file.dir}")
    private String fileDir;

    //spring의 value 사용해야 함
    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload")
    public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
        log.info("request={}", request);
        String itemName = request.getParameter("itemName");
        log.info("itemName={}", itemName);
        Collection<Part> parts = request.getParts();
        log.info("parts={}", parts);

        for (Part part : parts) {
            log.info("==== PART ====");
            log.info("name={}", part.getName());
            // Parts의 헤더와 바디 구분
            Collection<String> headerNames = part.getHeaderNames();
            for (String headerName : headerNames) {
                log.info(headerName, part.getHeader(headerName));
            }
            // 편의 메서드
            // content-dispositon, file-name
            log.info(part.getSubmittedFileName());
            log.info("size={}", part.getSize());
            // part body size

            // 데이터 읽기
            InputStream inputStream = part.getInputStream();
            String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            // 바디 읽은걸 String으로
            // 바이너리와 문자간의 변경에는 char 제공해줘야함
            log.info(body);
            if (StringUtils.hasText(part.getSubmittedFileName())) {
                String fullPath = fileDir + part.getSubmittedFileName();
                log.info("파일 저장 fullPath={}", fullPath);
                part.write(fullPath);
                // 편리하게 저장 가능
            }
        }

        return "upload-form";
    }
}

서블릿이 제공하는 Part 는 편하기는 하지만, HttpServletRequest 를 사용해야 하고, 추가로 파일 부분만 구분하려면 여러가지 코드를 넣어야 한다.


11.3. 스프링과 파일 업로드

MultipartFile 이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.

주요 메소드
file.getOriginalFilename() : 업로드 파일 명
file.transferTo(...) : 파일 저장

@RequestMapping("/spring")
public class SpringUploadController {
    @Value("${file.dir}")
    private String fileDir;
    
    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }
    
    @PostMapping("/upload")
    public String saveFile(@RequestParam String itemName, @RequestParam MultipartFile file, HttpServletRequest request) throws IOException {
        log.info("request={}", request);
        log.info("itemName={}", itemName);
        log.info("multipartFile={}", file);
        
        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 fullPath={}", fullPath);
            file.transferTo(new File(fullPath));
        }
        return "upload-form";
    }
}

업로드하는 HTML Formname에 맞추어 @RequestParam을 적용하면 된다.
@ModelAttribute 에서도 MultipartFile 을 동일하게 사용할 수 있다.


11.4. 예제로 구현하는 파일 업로드, 다운로드

요구사항

  • 상품을 관리
  • 상품 이름
  • 첨부파일 하나
  • 이미지 파일 여러개
  • 첨부파일을 업로드 다운로드 할 수 있다.
  • 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.
@Data
public class Item {
    private Long id;
    private String itemName;
    private UploadFile attachFile;
    private List<UploadFile> imageFiles;
    //여러개의 파일을 업로드 할 수 있어야 함
}

uploadFileName : 고객이 업로드한 파일명
storeFileName : 서버 내부에서 관리하는 파일명

고객이 업로드한 파일명으로 서버 내부에 파일을 저장하면 안된다.
서로 다른 고객이 같은 파일이름을 업로드 하는 경우 기존 파일 이름과 충돌이 날 수 있다.

@Data
public class UploadFile {
    private String uploadFileName;
    private String storeFileName;
    // 내부에서의 파일이름은 안겹치게 만들어야 함
    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}

저장과 관련된 코드는 아래와 같다.

@Component
public class FileStore {
    @Value("${file.dir}")
    private String fileDir;

    public String getFullPath(String fileName){
        return fileDir + fileName;
    }
    // 여러개를 업로드
    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
        List<UploadFile> storeFileResult = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if (!multipartFile.isEmpty()) {
                storeFileResult.add(storeFile(multipartFile));
                // storeFile을 loop를 돌며서 시행한다.
            }
        }
        return storeFileResult;
    }
    // 하나를 업로드
    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        // 멀티파트 파일을 받아서 uploadfile로 변환해줌
        if(multipartFile.isEmpty()){
            return null;
        }
        String originalFileName = multipartFile.getOriginalFilename();
        String storeFileName = createStoreFileName(originalFileName);
        // image.png가 들어오면 서버에 저장하는 파일명을 UUID로 만들어준다. 다만 확장자는 가져오고 싶다
        // 서버에 저장하는 파일명
        multipartFile.transferTo(new File(getFullPath(storeFileName)));
        return new UploadFile(originalFileName, storeFileName);

    }
    private String createStoreFileName(String originalFilename) {
        String ext = extractExt(originalFilename);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;
    }

    private String extractExt(String originalFileName) {
        // 확장자 추출을 위한 메소드
        int pos = originalFileName.lastIndexOf(".");
        return originalFileName.substring(pos + 1);
    }
}

컨트롤러의 코드는 아래와 같다.

@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
    private final ItemRepository itemRepository;
    private final FileStore fileStore;
    // 등록 폼을 보여준다.
    @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }
    
    // 폼의 데이터를 저장하고 보여주는 화면으로 리다이렉트한다.
    @PostMapping("/items/new")
    public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
        UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
        List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());
        
        //데이터베이스에 저장
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setAttachFile(attachFile);
        item.setImageFiles(storeImageFiles);
        itemRepository.save(item);
        
        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:/items/{itemId}";
    }
    // 상품을 보여준다.
    @GetMapping("/items/{id}")
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);
        return "item-view";
    }
    
    @ResponseBody
    @GetMapping("/images/{filename}")
    // <img> 태그로 이미지를 조회할 때 사용된다. UrlResurce로 읽고, @ResponseBody로 이미지 바이너리를 반환한다.
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        return new UrlResource("file:" + fileStore.getFullPath(filename));
        // 파일에 직접 접근해서 리소스 가져옴 
    }

    @GetMapping("/attach/{itemId}")
    // 파일 다운로드시 권한체크를 한다. 고객이 업로드한 파일 이름으로 다운로드 한다.
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        Item item = itemRepository.findById(itemId);
        
        String storeFileName = item.getAttachFile().getStoreFileName();
        String uploadFileName = item.getAttachFile().getUploadFileName();
        UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
        log.info("uploadFileName={}", uploadFileName);
        
        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
        
        // 다운로드 받게 하기 위함
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(resource);
    }
}

하나의 첨부파일을 업로드 다운로드 하고 여러개의 이미지를 업로드 할 수 있다.

주의

  • 이미지 업로드와 다운로드시 파일명을 다르게 해서 관리
  • 이미지 보여주는 경로도 별도로 관리.

결과

파일들을 등록하고 제출하면

파일을 조회할 수 있다.
첨부파일 링크를 누르면 파일을 다운받을 수 있다.

profile
공부한 내용 정리

0개의 댓글