스프링

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

dodo4723 2024. 2. 13. 17:44
728x90
반응형

사용자가 특정 차트를 고르면, 전 종목의 과거(10년) 차트들을 모두 탐색하여 가장 유사한 차트 10개를 골라 사용자에게 보여줍니다.

 

웹프로젝트 링크

 

비슷한 차트 검색기

전 종목의 최근 10년간 모든 차트를 탐색합니다. 내 종목의 차트는 과연 상승하는 차트일까요?

www.similarchart.com

 

 

 

 

 

 

 

 

김영한 개발자님의 스프링 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);
    }
}

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

주의

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

 

결과

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


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

반응형