INFLEARN

[스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술] 11. 파일 업로드

ch010104 2026. 7. 5. 21:24

1. 파일 업로드 소개

일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 두 가지 방식(application/x-www-form-urlencoded, multipart/form-data)의 차이를 명확히 이해해야 합니다.

1) application/x-www-form-urlencoded 방식

HTML Form 데이터를 서버로 전송하는 가장 기본적인 방법입니다. Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에 다음 내용을 자동으로 추가합니다.

Content-Type: application/x-www-form-urlencoded
  • 전송 메커니즘: 폼에 입력한 필드 데이터를 HTTP Body에 문자로 username=kim&age=20과 같이 & 구분자를 연결하여 전송합니다.
  • 한계점: 파일을 업로드하려면 문자가 아닌 바이너리 데이터를 전송해야 합니다. 이 방식은 문자를 전송하는 방식이므로 대용량 바이너리 파일을 전송하기에 적합하지 않습니다. 또한 보통 실무에서는 이름, 나이 같은 일반 문자 데이터와 파일(바이너리)을 동시에 전송해야 하는 상황이 많습니다. 문자와 바이너리를 동시 전송하기 위해 HTTP는 multipart/form-data 방식을 제공합니다.

2) multipart/form-data 방식

문자와 바이너리 데이터를 동시에 한 번에 전송할 수 있도록 고안된 방식입니다.

이 방식을 사용하려면 HTML Form 태그에 반드시 enctype="multipart/form-data" 속성을 지정해야 합니다.

웹 브라우저가 생성한 요청 HTTP 메시지 예시

POST /save HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=----XXX
Content-Length: 10457

------XXX
Content-Disposition: form-data; name="username"

kim
------XXX
Content-Disposition: form-data; name="age"

20
------XXX
Content-Disposition: form-data; name="file1"; filename="intro.png"
Content-Type: image/png

109238a900p3eqwokjasd09ou3oirjwoe9u34ouief... [바이너리 데이터]
------XXX--
  • 메시지 구조 특징:
    • 헤더의 Content-Type에 boundary(경계 구분선) 키가 자동 생성됩니다. (예: boundary=----XXX)
    • HTTP 바디 내부는 이 boundary 값을 기준으로 각 전송 필드가 분할되어 담깁니다. 각 부분을 Part라고 부릅니다.
    • 일반 폼 데이터 파트는 각 항목별로 문자가 전송되고, 파일 파트의 경우 파일 이름(filename)과 해당 파일의 Content-Type 헤더가 별도로 추가된 후 바이너리 데이터가 이어집니다.
    • 메시지의 마지막 구분선 끝에는 -가 붙어 전송의 끝을 알립니다.

2. 프로젝트 기본 메인 인덱스 페이지

사용자 편의 및 실습 메뉴 이동을 위해 추가해 두는 정적 리소스 페이지입니다.

resources/static/index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<ul>
    <li>상품 관리
        <ul>
            <li><a href="/servlet/v1/upload">서블릿 파일 업로드1</a></li>
            <li><a href="/servlet/v2/upload">서블릿 파일 업로드2</a></li>
            <li><a href="/spring/upload">스프링 파일 업로드</a></li>
            <li><a href="/items/new">상품 파일, 이미지 업로드</a></li>
        </ul>
    </li>
</ul>
</body>
</html>

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

가장 로우 레벨인 서블릿(Servlet)이 제공하는 Part 기술을 통해 어떻게 멀티파트 데이터를 처리하는지 알아봅니다.

1) 컨트롤러 구현 (ServletUploadControllerV1)

package hello.upload.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.Part;
import java.io.IOException;
import java.util.Collection;

@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);

        // 멀티파트 요청 시 각각 분할되어 들어오는 Part들을 수집
        Collection<Part> parts = request.getParts();
        log.info("parts={}", parts);

        return "upload-form";
    }
}
  • request.getParts(): multipart/form-data 전송 방식에서 각각의 경계(boundary)로 구분되어 넘어온 부분 데이터들을 수집해서 확인할 수 있게 해줍니다.

2) 업로드 폼 뷰 (resources/templates/upload-form.html)

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 등록 폼</h2>
    </div>
    <h4 class="mb-3">상품 입력</h4>
    <form th:action method="post" enctype="multipart/form-data">
        <ul>
            <li>상품명 <input type="text" name="itemName"></li>
            <li>파일<input type="file" name="file" ></li>
        </ul>
        <input type="submit"/>
    </form>
</div> <!-- /container -->
</body>
</html>

3) 로깅 설정 (application.properties)

HTTP 요청 전송 데이터 및 바디 상세 정보 로그를 확인하기 위해 옵션을 추가합니다.

logging.level.org.apache.coyote.http11=trace

주의 (스프링 부트 3.2 이상): 스프링 부트 3.2 버전부터는 debug 대신 반드시 trace를 사용해야 아파치 Coyote의 HTTP 요청 메시지 로그가 콘솔에 정상 출력됩니다.

4) 멀티파트 서블릿 옵션 및 제어법

(1) 업로드 사이즈 제한 설정

무제한 크기의 대용량 파일 업로드를 방지하기 위해 파일 업로드 사이즈를 제한할 수 있습니다. 설정 범위를 초과하면 MaxUploadSizeExceededException (또는 내부적으로 SizeLimitExceededException)이 발생합니다.

spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
  • max-file-size: 단일 파일 업로드 시 가질 수 있는 최대 파일 사이즈 크기입니다. (기본값: 1MB)
  • max-request-size: 멀티파트 요청 한 번에 전송할 수 있는 전체 파트 데이터 합의 최대 사이즈 크기입니다. (기본값: 10MB)

(2) 멀티파트 처리 기능 활성화 여부 제어 (spring.servlet.multipart.enabled)

  • spring.servlet.multipart.enabled=false (기능 끄기)
    • 서블릿 컨테이너가 멀티파트 요청에 대해 아무런 가공 처리를 하지 않습니다.
    • 결과적으로 HTTP 바디 분석을 건너뛰게 되므로 request.getParameter("itemName") 및 request.getParts()의 호출 결과가 모두 비어있거나 null을 반환합니다.
  • spring.servlet.multipart.enabled=true (기본값: true)
    • 스프링 부트가 작동하면서 서블릿 컨테이너에 멀티파트 요청을 처리하도록 지정합니다.
    • 옵션을 켜면 컨트롤러의 request 객체가 단순 RequestFacade에서 스프링이 가공한 StandardMultipartHttpServletRequest 타입으로 변환됩니다.
    • 이에 따라 일반 파라미터 텍스트뿐만 아니라 파트(Part) 바이너리 데이터까지 추출 및 조작이 가능한 상태가 됩니다.

참고 (MultipartResolver의 역할):
멀티파트 활성화 옵션이 켜져 있으면 스프링의 DispatcherServlet은 멀티파트 리졸버(MultipartResolver)를 실행합니다. 멀티파트 리졸버는 일반 HttpServletRequest를 받아 멀티파트 처리에 특화된 MultipartHttpServletRequest 인터페이스를 구현한 구체 클래스(StandardMultipartHttpServletRequest)로 변환하여 넘겨줍니다. 이를 통해 컨트롤러에서 가공 처리된 편리한 객체를 다룰 수 있습니다. 다만, 이후 설명할 스프링 제공의 MultipartFile이 압도적으로 쓰기 편리하므로 MultipartHttpServletRequest 자체를 실무에서 직접 주입받아 쓰는 일은 드뭅니다.

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

서블릿의 Part 인터페이스 기능을 확장하여 실제로 클라이언트가 전송한 파일을 지정한 서버의 물리 디렉토리에 저장하는 코드를 실습합니다.

1) 파일 물리 디렉토리 설정 및 주의사항

가장 먼저 실제 로컬 컴퓨터에 파일이 저장될 물리 디렉토리 경로를 설정하고, 직접 해당 폴더를 생성해야 합니다.

resources/main/application.properties

file.dir=파일 업로드 경로 설정 (예: /Users/study/file/ 또는 C:/study/file/)

필독 주의사항:

  1. 운영체제에 알맞은 실제 물리 경로 폴더를 미리 하드디스크에 만들어 두어야 합니다. (미생성 시 입출력 예외 발생)
  2. application.properties 설정 파일에 기입할 때 경로의 마지막에 **/ (슬래시)**가 누락되지 않도록 주의해야 합니다.

2) 파일 저장용 컨트롤러 (ServletUploadControllerV2)

package hello.upload.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.Part;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;

@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {

    // application.properties에 설정된 물리 디렉토리 경로 주입
    @Value("${file.dir}")
    private String fileDir;

    @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());

            // Part 자체의 헤더 목록 출력
            Collection<String> headerNames = part.getHeaderNames();
            for (String headerName : headerNames) {
                log.info("header {}: {}", headerName, part.getHeader(headerName));
            }

            // 편의 메서드 제공
            // 클라이언트가 업로드한 실제 파일명 조회
            log.info("submittedFileName={}", part.getSubmittedFileName());
            // Part Body의 데이터 크기 조회
            log.info("size={}", part.getSize());

            // 텍스트 데이터를 문자셋 변환하여 로깅 (단순 확인용)
            InputStream inputStream = part.getInputStream();
            String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            log.info("body={}", body);

            // 실제 파일 데이터가 들어있다면 물리 파일로 디렉토리에 저장
            if (StringUtils.hasText(part.getSubmittedFileName())) {
                String fullPath = fileDir + part.getSubmittedFileName();
                log.info("파일 저장 fullPath={}", fullPath);

                // 지정된 전체 경로(디렉토리 + 파일명)로 바이너리를 파일 시스템에 직접 작성
                part.write(fullPath);
            }
        }

        return "upload-form";
    }
}

3) 서블릿 Part의 주요 메서드 기능 정리

  • part.getSubmittedFileName(): 클라이언트가 전송한 원래의 파일명(Original File Name)을 직접 얻어옵니다.
  • part.getInputStream(): 해당 Part가 보관하는 전송 바이너리 데이터를 직접 바이트 스트림 형태로 읽어옵니다.
  • part.write(String filepath): 파일의 최종 전체 경로를 매개변수로 받아 해당 스트림 바이너리 데이터를 디스크 파일로 즉시 저장합니다.

실습 시 로깅 팁:
대용량 파일 전송 테스트를 진행할 경우 콘솔창에 엄청난 양의 바이너리 바이트 문자열 및 아파치 Coyote HTTP 바디 로그가 무한히 찍히게 되어 시스템이 느려지거나 오류가 날 수 있습니다. 정상 동작이 확인되면 아래의 두 로깅 처리를 주석하거나 꺼두는 것이 좋습니다.

  1. logging.level.org.apache.coyote.http11=trace 설정 끄기
  2. 컨트롤러 내부의 스트림 보디 로깅 log.info("body={}", body); 제거하기

5. 스프링과 파일 업로드

서블릿 Part를 직접 제어하는 것은 다소 코드가 복잡하고 HttpServletRequest를 다루어야 해 번거롭습니다. 스프링은 이를 극도로 단순화해주는 최고의 도구인 MultipartFile 인터페이스를 제공합니다.

1) 컨트롤러 구현 (SpringUploadController)

package hello.upload.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;

@Slf4j
@Controller
@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";
    }
}

2) 스프링 파일 업로드 핵심 분석

  • @RequestParam MultipartFile file / @ModelAttribute 사용 가능:
    • HTML Form 내 파일 선택 <input type="file" name="file">의 name 값과 매핑되도록 컨트롤러 파라미터 이름을 지정하면 별도의 추출 작업 없이 바로 스프링이 파싱하여 주입해 줍니다.
  • MultipartFile의 핵심 메서드 API:
    • file.isEmpty(): 업로드 파일이 비어있는지 여부를 체크합니다. (선택되지 않은 빈 폼 전송 예외처리 방지)
    • file.getOriginalFilename(): 클라이언트 컴퓨터에서 보낸 원본 파일 이름을 즉시 반환합니다.
    • file.transferTo(File dest): 파일 입출력 로우 코드를 작성할 필요 없이, 매개변수로 지정된 최종 경로 File 객체 위치로 스트림을 알아서 비우고 안전하게 업로드 저장을 처리합니다.

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

실무에서 가장 밀접하게 다루는 "상품 이미지 갤러리 및 첨부파일 관리 서비스"를 비즈니스 시나리오 가정을 통해 설계하고 구현합니다.

1) 요구사항 분석

  • 기본 상품 기능: 상품명 관리, 등록일자 등의 상품 관리 시나리오
  • 파일 구조 요구사항:
    • 일반 첨부파일 하나: 누구나 다운로드 받을 수 있는 이진 파일 (예: 설명문서 PDF 등)
    • 이미지 파일 여러개: 상품 상세 페이지에서 여러 장의 이미지가 뷰어 형태로 브라우저 화면에 직접 렌더링되어 표시되어야 합니다.
  • 핵심 흐름:
    • 이미지를 저장하면 브라우저의 <img> 태그를 통해 여러 장이 순차적으로 화면에 렌더링되어야 합니다.
    • 첨부파일은 다운로드 링크를 제공하고 클릭 시 파일 내려받기 기능이 온전히 이루어져야 합니다.

2) 도메인 모델 설계 및 구현

(1) 업로드 파일 메타 정보 도메인 (UploadFile)

서버와 사용자 간 전송되는 파일의 이원화 관리를 담당할 DTO 형식의 파일 도메인입니다.

package hello.upload.domain;

import lombok.Data;

@Data
public class UploadFile {

    private String uploadFileName; // 고객이 업로드할 때의 실제 파일명 (예: "dog.png")
    private String storeFileName;  // 서버 내부의 실제 저장소에 쓸 고유한 파일명 (예: "uuid-xxxx.png")

    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}

서버 내부 파일명 분리 설계의 당위성:
다수의 클라이언트가 동시에 "a.png", "my-photo.jpg"와 같은 파일명의 사진을 무차별 업로드하면 파일명 충돌(Collision)이 발생하여 기존 데이터가 유실되거나 덮어써지게 됩니다. 서버에서는 식별을 편하게 하기 위해 중복 확률이 사실상 제로에 수렴하는 임의의 문자열 식별자를 발급해 주는 물리 파일 명칭인 storeFileName을 사용해야 보안과 안정성을 모두 챙길 수 있습니다.

(2) 상품 엔티티 (Item)

package hello.upload.domain;

import lombok.Data;
import java.util.List;

@Data
public class Item {
    private Long id;
    private String itemName;
    private UploadFile attachFile;        // 단일 첨부파일
    private List<UploadFile> imageFiles; // 다중 이미지 파일들 리스트
}

(3) 인메모리 임시 리포지토리 (ItemRepository)

package hello.upload.domain;

import org.springframework.stereotype.Repository;

import java.util.HashMap;
import java.util.Map;

@Repository
public class ItemRepository {

    private final Map<Long, Item> store = new HashMap<>();
    private long sequence = 0L;

    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }

    public Item findById(Long id) {
        return store.get(id);
    }
}

3) 핵심 파일 저장 처리소 (FileStore)

실제 로컬 컴퓨터 디렉토리에 고유 식별 명칭(UUID)을 부여해 파일을 영속화하고, 확장자를 보존해 정해진 경로로 내보내는 비즈니스 핵심 파일 헬퍼 클래스입니다.

package hello.upload.file;

import hello.upload.domain.UploadFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@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));
            }
        }
        return storeFileResult;
    }

    // 단일 파일을 영속화(UUID 결합 및 확장자 보존) 한 후 핵심 파일 도메인 엔티티 반환
    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        if (multipartFile.isEmpty()) {
            return null;
        }

        String originalFilename = multipartFile.getOriginalFilename();
        // 서버 전용 영속 고유 식별자 결합 파일명 발급
        String storeFileName = createStoreFileName(originalFilename);

        // 최종 생성된 물리적 디스크 전체 경로에 실제 파일 파일 쓰기 전송
        multipartFile.transferTo(new File(getFullPath(storeFileName)));

        return new UploadFile(originalFilename, storeFileName);
    }

    // 겹치지 않는 고유 고정 랜덤 UUID 문자열과 오리지널 이미지 확장자 결합 처리 메서드
    private String createStoreFileName(String originalFilename) {
        String ext = extractExt(originalFilename); // 확장자 획득
        String uuid = UUID.randomUUID().toString(); // 고유 식별자 난수 생성
        return uuid + "." + ext; // 예: "51041c62-86e4-4274-801d-614a7d994edb.png"
    }

    // 업로드 경로 끝에 존재하는 . 구분자를 기반으로 파일 포맷 확장자 파싱 처리
    private String extractExt(String originalFilename) {
        int pos = originalFilename.lastIndexOf(".");
        return originalFilename.substring(pos + 1);
    }
}

4) 상품 등록 및 전송 폼 객체 DTO (ItemForm)

사용자가 파일 필드를 첨부하여 컨트롤러 영역으로 보낼 때, 스프링에서 파싱하는 멀티파트 수합 전용 객체입니다.

package hello.upload.controller;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Data
public class ItemForm {
    private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles; // 다중 이미지 전송 처리를 위해 List 형태로 정의
    private MultipartFile attachFile;       // 단일 이진 첨부파일
}

5) 통합 웹 API 및 뷰 제어 컨트롤러 (ItemController)

이 컨트롤러는 상품 등록뿐만 아니라 화면 이미지 브라우징 렌더링을 돕는 @ResponseBody(Resource), 그리고 브라우저 상의 자동 파일 팝업 다운로드를 설계해 주는 ResponseEntity 기능을 모두 담당합니다.

package hello.upload.controller;

import hello.upload.domain.UploadFile;
import hello.upload.domain.Item;
import hello.upload.domain.ItemRepository;
import hello.upload.file.FileStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.List;

@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {

    private final ItemRepository itemRepository;
    private final FileStore fileStore;

    // 1) 상품 등록 작성을 위한 빈 입력 폼 렌더링
    @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }

    // 2) 실제 멀티파트 데이터를 넘겨받아 도메인 생성 및 DB 저장 처리 후 리다이렉트
    @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}";
    }

    // 3) 등록 완료된 특정 단일 상품 상세 조회 뷰 매핑
    @GetMapping("/items/{id}")
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);
        return "item-view";
    }

    // 4) <img> 태그 요청 시 물리 저장 파일 경로를 읽어 바이너리 스트림으로 직접 화면에 렌더링
    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        // "file:/Users/.../uuid.png" 형태로 이미지 바이너리 리소스를 읽어 반환
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }

    // 5) 사용자 다운로드 요청 시 파일 메타데이터(한글 인코딩 포함)를 가공하여 다운로드 팝업 유도 처리
    @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);

        // 한글, 특수문자 파일 깨짐 방지를 위한 UTF-8 텍스트 인코딩 작업 필수
        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);

        // 다운로드 팝업을 보장해 주기 위한 규약 헤더 생성
        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(resource);
    }
}

API 상세 메커니즘 분석:

  • downloadImage 메서드:
    • 화면 뷰 단에 존재하는 <img> 태그의 src 주소 요청을 처리합니다.
    • 스프링은 파일 리소스를 추출하기 위해 가볍고 안정적인 UrlResource 컴포넌트를 제공합니다. 이를 이용해 로컬 물리 파일 위치를 바인딩하여 넘기며, @ResponseBody를 결합하여 웹 브라우저가 정적으로 이미지를 출력할 수 있도록 실시간 변환 스트림 전송을 완료합니다.
  • downloadAttach 메서드:
    • 일반 문서를 브라우저가 화면 파싱 시도로 직접 열어버리는 현상을 강제 억제하고 "다운로드 보장 팝업"을 띄우는 핵심 조치입니다.
    • HTTP 응답 헤더인 Content-Disposition 헤더 값을 attachment; filename="다운로드파일명"으로 맞춰 주면 모든 주요 웹 브라우저는 다운로드 처리를 실시합니다.
    • 특히 사용자가 처음 올릴 때 한글명을 사용했을 시 영문 외 글자가 완전히 깨지는 치명적인 버그가 존재하므로, 스프링 유틸인 UriUtils.encode(uploadFileName, StandardCharsets.UTF_8)로 브라우저 호환성을 필히 확보해야 합니다.

6) 뷰 웹 페이지 템플릿 마크업 작성

(1) 다중 상품 업로드 폼 (resources/templates/item-form.html)

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 등록</h2>
    </div>
    <form th:action method="post" enctype="multipart/form-data">
        <ul>
            <li>상품명 <input type="text" name="itemName"></li>
            <li>첨부파일<input type="file" name="attachFile" ></li>
            <!-- multiple="multiple" 속성 추가 시 단일 인풋 태그에서 여러 파일 선택 기능이 가능해집니다 -->
            <li>이미지 파일들<input type="file" multiple="multiple" name="imageFiles" ></li>
        </ul>
        <input type="submit"/>
    </form>
</div> <!-- /container -->
</body>
</html>
  • 다중 파일 셀렉션 포인트:
    • HTML <input type="file"> 필드 내에 multiple="multiple" 속성을 주입함으로써 사용자는 쉬프트/컨트롤 키 클릭을 통해 복수 개의 이미지 파일들을 동시 취합해 전송할 수 있습니다.
    • 이 데이터들은 컨트롤러에서 정상적으로 수합되어 List<MultipartFile> imageFiles 데이터 바인딩 필드로 유입됩니다.

(2) 상품 조회 및 갤러리 뷰 (resources/templates/item-view.html)

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 조회</h2>
    </div>
    상품명: <span th:text="${item.itemName}">상품명</span><br/>

    <!-- 일반 이진 파일 다운로드 링크 배치 -->
    첨부파일: <a th:if="${item.attachFile}"
                th:href="|/attach/${item.id}|"
                th:text="${item.getAttachFile().getUploadFileName()}" /><br/>

    <!-- 타임리프 루프 태그를 순회하며 컨트롤러의 물리 이미지를 <img> 태그로 연속 드로잉 -->
    <img th:each="imageFile : ${item.imageFiles}"
         th:src="|/images/${imageFile.getStoreFileName()}|"
         width="300" height="300"/>
</div> <!-- /container -->
</body>
</html>

 

  • 첨부 파일 다운로드: th:if="${item.attachFile}" 조건문을 이용하여 파일이 존재할 때에만 하이퍼링크가 걸려 다운로드 되도록 설정합니다.
  • 이미지 갤러리 시각화: 타임리프의 th:each 반복 지시문을 사용해 다중 이미지에 보관해 두었던 고유 식별 명칭 storeFileName 리스트를 뽑아옵니다. 이후 루프를 통해 /images/ 컨트롤러 주소로 직접 src를 가리켜주면 화면에 여러 장의 사진이 이쁘게 출력되어 조회가 완료됩니다.

7. 핵심 정리

  1. HTML Form 전송 방식의 구분:
    • 일반 텍스트 데이터 전송은 application/x-www-form-urlencoded를 사용합니다.
    • 파일과 같은 대용량 바이너리 전송을 위해서는 폼 영역의 분할을 가능케 하는 multipart/form-data 지정이 필수적입니다.
  2. 서블릿 Part 인터페이스:
    • 멀티파트 활성화 설정(spring.servlet.multipart.enabled=true)이 켜져 있으면, 서블릿 컨테이너의 스트림 파싱을 통해 request.getParts() 등으로 쪼개진 바디 파트의 명칭, 헤더, 원본 파일명 획득 및 바이너리 저장이 가능해집니다.
  3. 스프링 MultipartFile 인터페이스의 압도적 편의성:
    • 복잡한 저수준 코드를 획기적으로 축소하여 어노테이션 파라미터 @RequestParam MultipartFile 한 줄 선언만으로 주입 완료됩니다.
    • file.transferTo(destFile) 메소드를 원클릭 호출하면 간편하게 실제 파일 영속 저장을 처리할 수 있습니다.
  4. 실무형 파일 이원화 관리 아키텍처:
    • 고객이 업로드한 최초 파일명(uploadFileName)과 서버 내부용 고유 랜덤 고정 UUID 파일명(storeFileName)을 이원화 설계해야 파일명 중복 충돌을 원천 차단하고 보안을 강화할 수 있습니다.
  5. 다운로드 및 인코딩 기법:
    • 이미지와 같은 뷰 리소스 표출 시에는 단순 바이너리 데이터를 넘겨 주면 됩니다.
    • 실제 파일 디스크 내려받기 처리 유도를 위해 응답 헤더 구성 규약인 Content-Disposition 설정을 세팅하고, 한글 자모가 다 망가지고 깨지는 버그를 미연에 막으려 브라우저 친화적 한글 인코딩 도구인 UriUtils.encode를 항상 잊지 말고 결합하여 설계해야 합니다.