[Spring] 27. MultipartFile로 파일 업로드 처리하기

[Spring] 27. MultipartFile로 파일 업로드 처리하기

이전 글에서는 @ExceptionHandler@ControllerAdvice를 사용해 공통 예외 처리를 구성하는 방법을 살펴봤습니다. 이번 글에서는 Spring MVC에서 파일 업로드를 처리하는 방법인 MultipartFile에 대해 정리하겠습니다.

게시판 첨부파일, 프로필 이미지, 상품 이미지, 리뷰 이미지처럼 사용자가 파일을 서버로 보내야 하는 기능에서는 파일 업로드 처리가 필요합니다. Spring에서는 이런 업로드 파일을 MultipartFile 객체로 받을 수 있습니다.

핵심은 간단합니다. HTML form에서 enctype="multipart/form-data"를 설정하고, Controller에서 MultipartFile로 파일을 받으면 됩니다.


1. 파일 업로드란?

파일 업로드는 사용자의 컴퓨터나 휴대폰에 있는 파일을 서버로 전송하는 기능입니다. 일반 텍스트 입력값과 달리 파일은 용량이 있고, 이름과 확장자, 타입 같은 정보도 함께 다뤄야 합니다.

사용자
 → 이미지 선택
 → 업로드 버튼 클릭
 → 서버로 파일 전송
 → 서버가 파일 저장
 → DB에 파일 정보 저장

게시판 첨부파일을 예로 들면, 사용자가 글 제목과 내용을 입력하고 파일을 선택한 뒤 등록 버튼을 누릅니다. 서버는 글 정보와 파일을 함께 받아 처리합니다.


2. MultipartFile이란?

MultipartFile은 Spring에서 업로드된 파일을 표현하는 인터페이스입니다. 사용자가 전송한 파일 이름, 파일 크기, 파일 타입, 실제 파일 데이터 등을 확인할 수 있습니다.

import org.springframework.web.multipart.MultipartFile;

Controller에서는 다음처럼 받을 수 있습니다.

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
    System.out.println(file.getOriginalFilename());
    return "redirect:/";
}

여기서 file은 사용자가 업로드한 파일입니다. HTML form의 input name과 Controller의 @RequestParam 이름이 같아야 합니다.


3. HTML form 설정

파일 업로드 form에서는 반드시 enctype="multipart/form-data"를 설정해야 합니다. 이 설정이 없으면 파일 데이터가 제대로 전송되지 않을 수 있습니다.

<form action="/board/write" method="post" enctype="multipart/form-data">
    <input type="text" name="title" placeholder="제목">
    <textarea name="content" placeholder="내용"></textarea>

    <input type="file" name="uploadFile">

    <button type="submit">등록</button>
</form>

중요한 부분은 두 가지입니다.

method="post" 파일 업로드는 보통 POST 요청으로 처리합니다.
enctype="multipart/form-data" 파일 데이터를 전송하기 위한 form 인코딩 방식입니다.
input type="file" 사용자가 파일을 선택할 수 있는 입력 요소입니다.

4. Controller에서 파일 받기

form의 name="uploadFile"과 Controller의 @RequestParam("uploadFile") 이름을 맞추면 파일을 받을 수 있습니다.

@Controller
@RequestMapping("/board")
public class BoardController {

    @GetMapping("/write")
    public String writeForm() {
        return "board/write";
    }

    @PostMapping("/write")
    public String write(
            BoardWriteDto boardWriteDto,
            @RequestParam("uploadFile") MultipartFile uploadFile
    ) {
        System.out.println("제목: " + boardWriteDto.getTitle());
        System.out.println("파일명: " + uploadFile.getOriginalFilename());

        return "redirect:/board/list";
    }
}

이 구조에서는 게시글 입력값은 BoardWriteDto로 받고, 파일은 MultipartFile로 따로 받습니다.

title, content
 → BoardWriteDto

uploadFile
 → MultipartFile

5. MultipartFile 주요 메서드

MultipartFile에는 업로드 파일 정보를 확인할 수 있는 메서드들이 있습니다.

메서드 설명
getOriginalFilename() 사용자가 업로드한 원본 파일명을 가져옵니다.
getSize() 파일 크기를 byte 단위로 가져옵니다.
getContentType() 파일의 Content-Type을 가져옵니다.
isEmpty() 파일이 비어 있는지 확인합니다.
transferTo() 업로드된 파일을 지정한 위치에 저장합니다.

예시는 다음과 같습니다.

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
    System.out.println("원본 파일명: " + file.getOriginalFilename());
    System.out.println("파일 크기: " + file.getSize());
    System.out.println("Content-Type: " + file.getContentType());
    System.out.println("비어 있는가: " + file.isEmpty());

    return "redirect:/";
}

6. 파일이 비어 있는지 확인하기

사용자가 파일을 선택하지 않고 form을 제출할 수도 있습니다. 따라서 파일을 저장하기 전에 isEmpty()로 확인하는 것이 좋습니다.

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
    if (file.isEmpty()) {
        System.out.println("업로드된 파일이 없습니다.");
        return "redirect:/upload";
    }

    System.out.println("파일명: " + file.getOriginalFilename());
    return "redirect:/";
}

파일 업로드가 필수라면 비어 있을 때 오류 메시지를 보여주면 됩니다. 파일 업로드가 선택사항이라면 파일이 있을 때만 저장 처리하면 됩니다.

필수 첨부파일
 → 비어 있으면 오류

선택 첨부파일
 → 비어 있으면 파일 저장 생략

7. 파일 저장하기

파일을 서버의 특정 폴더에 저장하려면 transferTo()를 사용할 수 있습니다.

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
    if (file.isEmpty()) {
        return "redirect:/upload";
    }

    String uploadDir = "C:/upload/";
    String originalFilename = file.getOriginalFilename();

    File saveFile = new File(uploadDir + originalFilename);
    file.transferTo(saveFile);

    return "redirect:/";
}

위 코드는 업로드 파일을 C:/upload/ 폴더에 저장하는 간단한 예시입니다. 하지만 실제 프로젝트에서는 원본 파일명을 그대로 저장 파일명으로 사용하는 것은 피하는 것이 좋습니다.


8. 원본 파일명을 그대로 쓰면 안 되는 이유

사용자가 업로드한 원본 파일명을 그대로 서버 저장 파일명으로 사용하면 문제가 생길 수 있습니다.

사용자 A → profile.png 업로드
사용자 B → profile.png 업로드

같은 이름이면 파일이 덮어써질 수 있음

또한 파일명에 특수문자나 경로처럼 보이는 값이 포함될 수도 있습니다. 그래서 원본 파일명은 표시용으로만 저장하고, 서버 저장 파일명은 새로 만드는 것이 안전합니다.

원본 파일명 사용자가 업로드한 파일 이름입니다. 화면 표시용으로 사용합니다.
저장 파일명 서버에 실제 저장할 파일 이름입니다. 중복 방지를 위해 새로 만듭니다.

9. UUID로 저장 파일명 만들기

파일명 중복을 줄이기 위해 UUID를 사용할 수 있습니다.

String originalFilename = file.getOriginalFilename();
String storedFilename = UUID.randomUUID() + "_" + originalFilename;

더 깔끔하게 확장자를 분리해서 저장 파일명을 만들 수도 있습니다.

private String createStoredFilename(String originalFilename) {
    String ext = "";

    int dotIndex = originalFilename.lastIndexOf(".");
    if (dotIndex != -1) {
        ext = originalFilename.substring(dotIndex);
    }

    return UUID.randomUUID() + ext;
}

예를 들어 사용자가 cat.png를 업로드하면 서버에는 다음처럼 저장할 수 있습니다.

원본 파일명
 → cat.png

저장 파일명
 → 9f2a7c3e-aaaa-bbbb-cccc-123456789abc.png

이렇게 하면 서로 다른 사용자가 같은 이름의 파일을 올려도 덮어쓰기 위험을 줄일 수 있습니다.


10. Service로 파일 저장 분리하기

Controller 안에 파일 저장 코드를 모두 넣으면 Controller가 무거워집니다. 파일 저장 로직은 Service나 별도 Component로 분리하는 것이 좋습니다.

@Service
public class FileStorageService {

    private final String uploadDir = "C:/upload/";

    public UploadFileDto storeFile(MultipartFile file) throws IOException {
        if (file.isEmpty()) {
            return null;
        }

        String originalFilename = file.getOriginalFilename();
        String storedFilename = createStoredFilename(originalFilename);

        File saveFile = new File(uploadDir + storedFilename);
        file.transferTo(saveFile);

        return new UploadFileDto(originalFilename, storedFilename, file.getSize());
    }

    private String createStoredFilename(String originalFilename) {
        String ext = "";

        int dotIndex = originalFilename.lastIndexOf(".");
        if (dotIndex != -1) {
            ext = originalFilename.substring(dotIndex);
        }

        return UUID.randomUUID() + ext;
    }
}

파일 저장 결과는 DTO로 반환할 수 있습니다.

public class UploadFileDto {

    private String originalFilename;
    private String storedFilename;
    private long fileSize;

    public UploadFileDto(String originalFilename, String storedFilename, long fileSize) {
        this.originalFilename = originalFilename;
        this.storedFilename = storedFilename;
        this.fileSize = fileSize;
    }

    public String getOriginalFilename() {
        return originalFilename;
    }

    public String getStoredFilename() {
        return storedFilename;
    }

    public long getFileSize() {
        return fileSize;
    }
}

이렇게 분리하면 Controller는 요청 처리에 집중하고, 파일 저장은 FileStorageService가 담당합니다.


11. 게시판 등록과 파일 업로드 함께 처리하기

게시글 등록과 첨부파일 업로드를 함께 처리하는 흐름은 다음과 같습니다.

1. 사용자가 게시글 입력
2. 파일 선택
3. form 제출
4. Controller가 BoardWriteDto와 MultipartFile 받기
5. Service가 게시글 저장
6. FileStorageService가 파일 저장
7. DB에 첨부파일 정보 저장
8. 목록으로 redirect

Controller 예시는 다음과 같습니다.

@Controller
@RequestMapping("/board")
public class BoardController {

    private final BoardService boardService;

    public BoardController(BoardService boardService) {
        this.boardService = boardService;
    }

    @PostMapping("/write")
    public String write(
            BoardWriteDto boardWriteDto,
            @RequestParam("uploadFile") MultipartFile uploadFile
    ) throws IOException {
        boardService.write(boardWriteDto, uploadFile);
        return "redirect:/board/list";
    }
}

Service에서는 게시글 저장과 파일 저장 흐름을 담당할 수 있습니다.

@Service
public class BoardService {

    private final BoardMapper boardMapper;
    private final FileStorageService fileStorageService;

    public BoardService(
            BoardMapper boardMapper,
            FileStorageService fileStorageService
    ) {
        this.boardMapper = boardMapper;
        this.fileStorageService = fileStorageService;
    }

    public void write(BoardWriteDto boardWriteDto, MultipartFile uploadFile) throws IOException {
        boardMapper.insertBoard(boardWriteDto);

        UploadFileDto uploadFileDto = fileStorageService.storeFile(uploadFile);

        if (uploadFileDto != null) {
            boardMapper.insertBoardFile(uploadFileDto);
        }
    }
}

실제 프로젝트에서는 게시글 번호를 먼저 생성하거나, 게시글 저장 후 생성된 번호를 파일 정보에 함께 저장해야 합니다.


12. DB에 저장해야 하는 파일 정보

파일 자체는 서버 폴더나 외부 저장소에 저장하고, DB에는 파일 정보를 저장하는 경우가 많습니다.

컬럼 설명
file_no 파일 번호
board_no 어떤 게시글에 연결된 파일인지 나타내는 게시글 번호
original_filename 사용자가 업로드한 원본 파일명
stored_filename 서버에 실제 저장된 파일명
file_size 파일 크기
content_type 파일 Content-Type

핵심은 원본 파일명과 저장 파일명을 구분하는 것입니다. 화면에서는 원본 파일명을 보여주고, 실제 파일 접근이나 삭제에는 저장 파일명을 사용합니다.


13. 여러 파일 업로드하기

여러 파일을 업로드하려면 HTML input에 multiple 속성을 추가할 수 있습니다.

<form action="/board/write" method="post" enctype="multipart/form-data">
    <input type="text" name="title">
    <textarea name="content"></textarea>

    <input type="file" name="uploadFiles" multiple>

    <button type="submit">등록</button>
</form>

Controller에서는 List<MultipartFile>로 받을 수 있습니다.

@PostMapping("/write")
public String write(
        BoardWriteDto boardWriteDto,
        @RequestParam("uploadFiles") List<MultipartFile> uploadFiles
) throws IOException {
    for (MultipartFile file : uploadFiles) {
        if (!file.isEmpty()) {
            System.out.println(file.getOriginalFilename());
        }
    }

    return "redirect:/board/list";
}

여러 파일을 처리할 때도 파일이 비어 있는지 확인하고 저장하는 흐름은 같습니다.


14. 업로드 용량 설정

Spring Boot에서는 업로드 가능한 파일 크기를 설정할 수 있습니다. application.properties에 다음처럼 작성할 수 있습니다.

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=30MB
max-file-size 파일 하나의 최대 크기
max-request-size 요청 전체의 최대 크기

예를 들어 파일 하나는 최대 10MB, 여러 파일을 포함한 전체 요청은 최대 30MB까지 허용하는 설정입니다.


15. 파일 업로드 시 주의할 점

파일 업로드는 단순히 저장만 하면 끝나는 기능이 아닙니다. 보안과 안정성을 위해 몇 가지를 반드시 조심해야 합니다.

주의점 설명
원본 파일명 그대로 저장 금지 파일명 중복과 보안 문제가 생길 수 있습니다.
확장자 검증 허용할 파일 확장자를 제한하는 것이 좋습니다.
파일 크기 제한 너무 큰 파일 업로드를 막아야 합니다.
저장 경로 관리 코드에 경로를 하드코딩하기보다 설정값으로 관리하는 것이 좋습니다.
DB에는 파일 정보 저장 원본명, 저장명, 크기, 타입 등을 저장해 관리합니다.

처음에는 업로드 성공에만 집중하기 쉽지만, 실제 프로젝트에서는 파일명 중복, 확장자, 용량, 삭제 처리까지 함께 고려해야 합니다.


16. 자주 하는 실수

1) form에 enctype을 빼먹는 경우

<form action="/board/write" method="post">
    <input type="file" name="uploadFile">
</form>

파일 업로드 form에는 enctype="multipart/form-data"가 필요합니다.

<form action="/board/write" method="post" enctype="multipart/form-data">
    <input type="file" name="uploadFile">
</form>

2) input name과 @RequestParam 이름이 다른 경우

<input type="file" name="uploadFile">
@RequestParam("file") MultipartFile uploadFile

HTML에서는 uploadFile로 보냈는데 Controller에서는 file을 찾고 있습니다. 이름을 맞춰야 합니다.

3) 파일이 비어 있는지 확인하지 않는 경우

파일을 선택하지 않아도 요청은 들어올 수 있습니다. 저장 전에 isEmpty()를 확인하는 것이 좋습니다.

if (file.isEmpty()) {
    return "redirect:/upload";
}

4) 원본 파일명으로 바로 저장하는 경우

원본 파일명을 그대로 저장하면 파일명이 중복될 수 있습니다. 서버 저장 파일명은 UUID 등을 이용해 새로 만드는 것이 좋습니다.

String storedFilename = UUID.randomUUID() + ext;

5) Controller에 파일 저장 로직을 모두 넣는 경우

Controller에 저장 경로, 파일명 생성, 저장 처리, DB 저장까지 모두 넣으면 코드가 복잡해집니다. 파일 저장 로직은 Service나 Component로 분리하는 것이 좋습니다.


17. 전체 흐름 정리

파일 업로드 흐름을 전체적으로 정리하면 다음과 같습니다.

1. HTML form에 enctype="multipart/form-data" 설정
2. input type="file"로 파일 선택
3. POST 요청으로 파일 전송
4. Controller에서 MultipartFile로 파일 받기
5. isEmpty()로 파일 존재 여부 확인
6. 원본 파일명과 확장자 확인
7. UUID로 저장 파일명 생성
8. transferTo()로 서버 폴더에 저장
9. DB에 파일 정보 저장
10. 등록 완료 후 redirect

정리

MultipartFile은 Spring MVC에서 업로드된 파일을 받을 때 사용하는 객체입니다. 파일 업로드 form에는 반드시 enctype="multipart/form-data"를 설정해야 하며, Controller에서는 MultipartFile로 파일을 받을 수 있습니다.

MultipartFile 업로드된 파일을 표현하는 Spring 객체입니다.
isEmpty() 파일이 비어 있는지 확인합니다.
getOriginalFilename() 원본 파일명을 가져옵니다.
transferTo() 파일을 서버에 저장합니다.
UUID 저장 파일명 중복을 줄이기 위해 사용할 수 있습니다.

처음에는 다음 기준을 꼭 기억하면 됩니다.

HTML form
 → enctype="multipart/form-data"

Controller
 → MultipartFile로 파일 받기

저장 전
 → isEmpty() 확인

파일명
 → 원본 파일명과 저장 파일명 분리

저장 파일명
 → UUID로 새로 생성

DB
 → 파일 자체가 아니라 파일 정보를 저장

연습해보기

아래 요구사항을 기준으로 파일 업로드 기능을 직접 설계해보세요.

게시글 작성 기능

입력값:
- 제목
- 내용
- 첨부파일 1개

처리:
1. 게시글 정보를 저장한다.
2. 파일이 있으면 서버 폴더에 저장한다.
3. 원본 파일명과 저장 파일명을 DB에 저장한다.
4. 등록 완료 후 게시글 목록으로 이동한다.

다음 글에서는 업로드한 파일을 화면에 보여주거나 다운로드하는 방법을 정리하겠습니다. 파일을 저장하는 것에서 끝내지 않고, 저장한 파일을 다시 사용자에게 제공하는 흐름까지 이어가면 게시판 첨부파일 기능이 완성됩니다.