// Developer · Debugging Reports

뉴스도 디버깅이 필요해

마주친 오류를 재현하고, 원인을 추적하고, 기록합니다. Java · Spring · DB · 실무 트러블슈팅.

debug — today.log
$ debug --trace "오늘의 뉴스"
> 헤드라인 파싱 중...
> 원인 추적: 근거 누락 · 출처 불명
> 패치 적용: 사실 확인 + 출처 보강
✓ RESOLVED
TOTAL
TODAY
YESTERDAY

Categories

웹개발/실무노트

Spring MultipartFile 파일 업로드 기본, JSP와 Controller 처리 흐름 정리

자바를잡아2026. 7. 13. 08:00
반응형

Spring MultipartFile 파일 업로드에서 파일이 null로 들어오거나 isEmpty()가 계속 true라면 먼저 JSP form, MultipartResolver, Controller 파라미터 이름을 순서대로 확인해야 한다.

파일 업로드가 어려운 이유는 코드 한 줄이 아니라 흐름이 끊기기 쉽기 때문이다. form에 enctype이 빠지면 Controller에 파일이 들어오지 않고, MultipartResolver가 없으면 요청을 파싱하지 못하며, 저장 경로가 운영 서버 기준과 맞지 않으면 로컬에서만 성공한다.

핵심 결론: Spring MVC에서 파일 업로드는 JSP의 multipart/form-data 요청을 MultipartResolver가 해석하고, Controller가 MultipartFile로 받아 저장하는 흐름이다.

확인 순서: JSP form 설정 → MultipartResolver 등록 → Controller 파라미터 확인 → 파일명/저장명 분리 → 저장 경로와 용량 제한 확인.

MultipartFile은 어디에서 쓰는가

MultipartFile은 Spring에서 업로드된 파일 하나를 표현하는 인터페이스다. 사용자가 브라우저에서 선택한 파일은 HTTP multipart 요청으로 서버에 전달되고, Spring은 그 요청을 해석해서 Controller 메서드의 MultipartFile 파라미터에 넣어준다.

실무에서는 게시판 첨부파일, 프로필 이미지, 엑셀 일괄 업로드, 증빙자료 제출, 이미지 등록 기능에서 자주 사용한다. 단순히 파일을 받는 것처럼 보여도 파일명, 확장자, 크기, 저장 위치, DB 저장 정보까지 함께 설계해야 한다.

업로드 요청의 전체 흐름

Spring MultipartFile 파일 업로드 흐름은 아래처럼 보면 된다.

  1. JSP 또는 HTML 화면에서 input type="file"을 사용한다.
  2. form의 methodpost, enctypemultipart/form-data로 둔다.
  3. Spring MVC 설정에 MultipartResolver를 등록한다.
  4. Controller에서 MultipartFile 파라미터로 파일을 받는다.
  5. 원본 파일명과 저장 파일명을 분리한다.
  6. 서버 디스크에 저장하고, DB에는 파일 메타데이터를 저장한다.

이 중 하나라도 빠지면 파일이 null로 들어오거나, 빈 파일처럼 보이거나, 저장 단계에서 예외가 난다. Spring MVC 정적 리소스 경로와 마찬가지로 설정 위치가 중요하므로 Spring MVC resources 설정을 다뤘던 프로젝트라면 같은 관점으로 보면 이해가 쉽다.

JSP form 설정

enctype이 가장 먼저다

파일 업로드 form에서 가장 먼저 확인할 것은 enctype="multipart/form-data"다. 이 값이 없으면 브라우저가 파일 내용을 일반 폼 데이터처럼 보내지 않기 때문에 Controller에서 MultipartFile로 받을 수 없다.

<form action="/board/write" method="post" enctype="multipart/form-data">
  <input type="text" name="title" />
  <input type="file" name="attachFile" />
  <button type="submit">등록</button>
</form>

여기서 inputname 값인 attachFile은 Controller의 파라미터명과 맞추는 편이 좋다. 이름이 다르면 @RequestParam으로 명시해야 한다.

여러 파일을 받을 때

여러 파일을 업로드하려면 HTML input에 multiple을 붙이고 Controller에서는 배열이나 리스트로 받는다.

<form action="/board/write" method="post" enctype="multipart/form-data">
  <input type="file" name="files" multiple />
  <button type="submit">업로드</button>
</form>

여러 파일 업로드는 단일 파일보다 예외 처리가 더 중요하다. 한 파일은 정상이고 다른 파일은 확장자가 잘못됐을 때 전체를 실패시킬지, 정상 파일만 저장할지 기준을 먼저 정해야 한다.

Spring MVC 설정

MultipartResolver 등록

전통적인 Spring MVC 프로젝트에서는 servlet-context.xml 또는 MVC 설정 파일에 MultipartResolver를 등록한다. Apache Commons FileUpload 기반을 쓰는 프로젝트라면 CommonsMultipartResolver를 자주 본다.

<bean id="multipartResolver"
      class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
  <property name="defaultEncoding" value="UTF-8" />
  <property name="maxUploadSize" value="10485760" />
</bean>

maxUploadSize는 업로드 가능한 최대 크기다. 위 예시는 약 10MB다. 운영에서는 이 값만 볼 것이 아니라 WAS, 프록시, 웹서버의 업로드 제한도 함께 봐야 한다.

의존성 확인

CommonsMultipartResolver를 쓰는 경우 commons-fileupload 의존성이 필요할 수 있다. Maven 프로젝트라면 pom.xml에서 확인한다.

<dependency>
  <groupId>commons-fileupload</groupId>
  <artifactId>commons-fileupload</artifactId>
  <version>1.5</version>
</dependency>

Spring과 MyBatis를 함께 쓰는 오래된 SI 프로젝트에서는 XML 설정과 Maven 의존성이 같이 얽혀 있는 경우가 많다. 이런 프로젝트에서는 Maven/Gradle 연동 설정을 보듯이 의존성, 설정 파일, classpath를 같이 확인해야 한다.

Controller에서 MultipartFile 받기

단일 파일 처리

Controller에서는 @RequestParam으로 파일을 받는다. 화면의 input name이 attachFile이면 아래처럼 맞춘다.

@PostMapping("/board/write")
public String write(
        @RequestParam("title") String title,
        @RequestParam("attachFile") MultipartFile attachFile) throws IOException {

    if (!attachFile.isEmpty()) {
        String originalName = attachFile.getOriginalFilename();
        long size = attachFile.getSize();
        String contentType = attachFile.getContentType();
    }

    return "redirect:/board/list";
}

isEmpty()는 업로드된 파일이 비어 있는지 확인할 때 사용한다. 사용자가 파일을 선택하지 않았을 수도 있으므로 저장 전에 반드시 확인한다.

DTO와 같이 받을 때

게시글 제목, 내용, 작성자 같은 일반 필드와 파일을 함께 받을 때는 DTO와 MultipartFile을 같이 둔다.

@PostMapping("/board/write")
public String write(BoardWriteRequest request,
                    @RequestParam("attachFile") MultipartFile attachFile) {
    // request.getTitle(), request.getContent()
    // attachFile 저장 처리
    return "redirect:/board/list";
}

DTO 안에 MultipartFile 필드를 넣는 방식도 가능하지만, 초급 단계에서는 파일 파라미터를 분리하는 편이 디버깅하기 쉽다. 일반 폼 데이터와 파일 데이터가 어디서 들어오는지 눈에 보이기 때문이다.

파일 저장 처리

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

업로드 파일을 저장할 때 원본 파일명을 그대로 쓰면 충돌과 보안 문제가 생길 수 있다. 사용자가 같은 이름의 파일을 여러 번 올릴 수도 있고, 파일명에 공백이나 특수문자가 들어갈 수도 있다.

실무에서는 원본 파일명은 DB에 표시용으로 보관하고, 실제 저장 파일명은 UUID나 날짜 기반 값으로 새로 만든다.

String originalName = attachFile.getOriginalFilename();
String extension = "";

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

String savedName = UUID.randomUUID().toString() + extension;

원본 파일명은 사용자에게 보여줄 때 필요하고, 저장 파일명은 서버 디스크에서 충돌 없이 관리하기 위해 필요하다. 두 값을 섞어 쓰면 다운로드 기능을 만들 때도 헷갈린다.

서버 디스크에 저장

파일 저장은 transferTo를 사용한다. 저장 폴더가 없으면 먼저 생성해야 한다.

Path uploadDir = Paths.get("D:/upload/board");
Files.createDirectories(uploadDir);

Path targetPath = uploadDir.resolve(savedName);
attachFile.transferTo(targetPath.toFile());

로컬 Windows 경로와 운영 Linux 경로가 다를 수 있으므로 저장 경로는 코드에 박아두기보다 설정 파일로 분리하는 편이 낫다. Tomcat에 배포하는 프로젝트라면 운영 경로와 context path 기준도 함께 봐야 하므로 Tomcat 배포 경로를 같이 확인하는 것이 좋다.

DB에는 무엇을 저장해야 할까

파일 자체를 DB에 넣지 않는 경우

대부분의 게시판 첨부파일은 파일 자체를 DB에 넣기보다 서버 디스크나 NAS에 저장하고, DB에는 파일 정보를 저장한다. 일반적인 테이블 구성은 아래처럼 잡는다.

컬럼예시역할
file_id1001파일 식별자
post_id200게시글 식별자
original_name회의록.xlsx사용자가 올린 파일명
saved_nameuuid.xlsx서버에 저장된 파일명
file_pathD:/upload/board저장 경로
file_size39210파일 크기
content_typeapplication/vnd...MIME 타입

파일 다운로드 기능을 만들 때는 이 DB 정보를 기준으로 서버에 저장된 파일을 찾고, 사용자에게는 원본 파일명으로 내려준다.

트랜잭션 기준

파일 저장과 DB insert는 서로 다른 자원이다. DB insert는 롤백할 수 있지만 이미 디스크에 저장한 파일은 자동으로 지워지지 않는다. 그래서 저장 순서와 실패 처리 기준을 정해야 한다.

실무에서는 파일을 먼저 저장하고 DB insert가 실패하면 저장한 파일을 삭제하거나, DB를 먼저 임시 상태로 저장한 뒤 파일 저장 성공 후 상태를 갱신하는 방식을 쓴다. 규모가 작은 게시판이라도 실패 시 찌꺼기 파일이 쌓이지 않도록 정리 로직을 둔다.

확장자와 용량 제한

확장자 화이트리스트

파일 업로드에서 확장자 검사는 필수다. 특히 JSP, EXE, BAT 같은 파일이 업로드 경로에 저장되고 외부에서 접근 가능하면 보안 사고로 이어질 수 있다.

List<String> allowedExtensions = List.of(".jpg", ".png", ".pdf", ".xlsx");

if (!allowedExtensions.contains(extension.toLowerCase())) {
    throw new IllegalArgumentException("허용되지 않는 파일 형식입니다.");
}

확장자는 대소문자를 섞어 우회할 수 있으므로 소문자로 변환해서 비교한다. 또한 확장자만 믿지 말고 필요한 경우 MIME 타입이나 파일 시그니처까지 확인한다.

용량 제한 위치

업로드 용량 제한은 한 군데만 있는 것이 아니다. Spring 설정, Tomcat, Nginx 또는 Apache 같은 앞단 서버, 운영 서버 디스크 용량까지 함께 봐야 한다.

위치확인할 값증상
Spring MultipartResolvermaxUploadSize업로드 제한 예외
Tomcatconnector, request size요청 처리 실패
Nginxclient_max_body_size413 오류
디스크남은 용량저장 실패

운영에서만 업로드가 실패한다면 Spring 코드만 보지 말고 서버 설정과 로그를 같이 확인해야 한다.

자주 발생하는 문제

MultipartFile이 비어 있는 경우

Controller까지 요청은 들어오는데 MultipartFile이 비어 있다면 먼저 JSP form의 enctype을 확인한다. 그다음 input name과 Controller 파라미터명이 맞는지 본다.

@RequestParam("attachFile") MultipartFile attachFile

화면에서는 name="file"인데 Controller에서는 attachFile로 받으면 값이 매핑되지 않을 수 있다.

파일 저장 경로 오류

로컬에서는 D:/upload가 있지만 운영 Linux에는 해당 경로가 없을 수 있다. 반대로 운영 경로를 기준으로 /data/upload를 쓰면 로컬 Windows에서 테스트가 실패할 수 있다.

이 문제는 환경별 설정 파일로 해결하는 편이 좋다. 예를 들어 개발, 검증, 운영 환경마다 업로드 경로를 다르게 둔다.

upload.path=D:/upload/board

운영에서는 아래처럼 바뀔 수 있다.

upload.path=/data/upload/board

한글 파일명 문제

업로드 자체는 되는데 다운로드할 때 한글 파일명이 깨지는 경우가 있다. 업로드 글에서는 저장 구조만 먼저 잡고, 다운로드 응답에서는 브라우저별 Content-Disposition 처리까지 봐야 한다.

그래서 파일 업로드 기능은 업로드 하나로 끝나지 않는다. 목록 조회, 다운로드, 삭제, 수정 화면에서 기존 첨부파일 유지까지 이어진다.

실무 구현 순서

작게 성공시키기

처음부터 게시판 등록, 파일 저장, DB insert, 다운로드까지 한 번에 붙이면 어디서 깨졌는지 알기 어렵다. 먼저 작은 단위로 성공시키는 편이 낫다.

  1. JSP에서 파일 하나를 선택해 Controller까지 들어오는지 확인한다.
  2. isEmpty(), getOriginalFilename(), getSize()를 로그로 확인한다.
  3. 임시 폴더에 파일 저장을 성공시킨다.
  4. 원본 파일명과 저장 파일명을 분리한다.
  5. DB에 파일 메타데이터를 저장한다.
  6. 게시글과 파일 정보를 함께 조회한다.

이 순서대로 가면 문제가 생겨도 원인을 좁히기 쉽다. 파일이 Controller에 안 들어오는 문제와 저장 경로 문제를 섞어서 보면 디버깅 시간이 길어진다.

로그로 확인할 값

파일 업로드 디버깅에서는 아래 값만 찍어도 원인 파악이 빨라진다.

log.info("originalName={}", file.getOriginalFilename());
log.info("size={}", file.getSize());
log.info("contentType={}", file.getContentType());
log.info("isEmpty={}", file.isEmpty());

다만 운영 로그에 개인정보나 파일 내용을 남기면 안 된다. 파일명에도 개인정보가 들어갈 수 있으므로 운영 로그 정책에 맞게 마스킹하거나 최소한으로 남긴다.

실무에서 바로 쓰는 팁

  • form enctype부터 확인한다. 파일이 Controller에 안 들어오면 코드보다 JSP form을 먼저 본다.
  • 원본 파일명과 저장 파일명을 분리한다. 충돌 방지와 다운로드 처리를 위해 두 값을 따로 관리한다.
  • 저장 경로는 설정으로 뺀다. 로컬, 검증, 운영 서버의 경로가 다를 수 있다.
  • 확장자 제한은 화이트리스트로 둔다. 허용할 확장자만 열어두는 방식이 관리하기 쉽다.
  • 파일과 DB의 실패 처리를 같이 설계한다. DB insert 실패 시 디스크 파일을 정리하지 않으면 찌꺼기가 쌓인다.

자주 묻는 질문

  • Q. MultipartFile이 null로 들어옵니다. A. form의 enctype, input name, Controller의 @RequestParam 이름, MultipartResolver 등록 여부를 차례로 확인해야 합니다.
  • Q. 파일을 선택하지 않아도 요청이 들어오나요? A. 들어올 수 있습니다. 이 경우 MultipartFile은 비어 있을 수 있으므로 isEmpty()로 확인해야 합니다.
  • Q. 원본 파일명으로 저장해도 되나요? A. 권장하지 않습니다. 같은 이름 충돌, 특수문자, 보안 문제 때문에 UUID 같은 저장 파일명을 따로 만드는 편이 좋습니다.
  • Q. 업로드 용량 제한은 어디서 설정하나요? A. Spring MultipartResolver뿐 아니라 Tomcat, Nginx, Apache, 운영 디스크 용량까지 함께 확인해야 합니다.
  • Q. DB에는 파일을 직접 저장하나요? A. 보통 파일은 디스크나 NAS에 저장하고 DB에는 원본명, 저장명, 경로, 크기 같은 메타데이터를 저장합니다.
  • Q. 한글 파일명이 깨지는 문제도 업로드 설정 때문인가요? A. 업로드 인코딩 문제일 수도 있지만 다운로드 응답의 Content-Disposition 처리 문제인 경우도 많습니다.

정리

Spring MultipartFile 파일 업로드는 JSP에서 파일을 선택하고 Controller에서 받는 단순한 기능처럼 보이지만, 실제로는 form 설정, MultipartResolver, 의존성, 저장 경로, 파일명 정책, DB 저장 구조가 모두 맞아야 안정적으로 동작한다.

초급 개발자는 먼저 파일이 Controller까지 들어오는지 확인하고, 그다음 저장명 분리와 디스크 저장을 붙이는 순서로 진행하는 것이 좋다. 운영까지 고려한다면 확장자 제한, 용량 제한, 실패 시 파일 정리, 한글 파일명 다운로드까지 이어서 봐야 한다.

파일 업로드 오류는 대부분 Controller 코드보다 form 설정, MultipartResolver, 저장 경로에서 먼저 터진다.
반응형