[Spring] 28. 업로드한 파일 조회와 다운로드 처리하기

[Spring] 28. 업로드한 파일 조회와 다운로드 처리하기

이전 글에서는 MultipartFile을 사용해 파일을 업로드하고 서버에 저장하는 방법을 살펴봤습니다. 파일 업로드 기능은 단순히 파일을 받는 것에서 끝나지 않습니다. 저장한 파일을 다시 화면에 보여주거나, 사용자가 다운로드할 수 있게 만들어야 합니다.

이번 글에서는 업로드한 파일을 조회하고 다운로드하는 흐름을 정리하겠습니다. 게시판 첨부파일, 프로필 이미지, 리뷰 이미지, 상품 이미지 기능에서 자주 사용하는 내용입니다.

핵심은 간단합니다. 파일 자체는 서버 폴더에 저장하고, DB에는 파일 정보를 저장한 뒤, Controller를 통해 파일을 다시 읽어 사용자에게 응답하면 됩니다.


1. 파일 업로드 이후 필요한 흐름

파일을 업로드했다면 이후에는 보통 두 가지 기능이 필요합니다.

파일 보기 이미지 파일을 화면에 표시하거나 첨부파일 목록을 보여줍니다.
파일 다운로드 사용자가 첨부파일을 내려받을 수 있게 합니다.

게시판 첨부파일을 예로 들면 다음 흐름이 필요합니다.

1. 사용자가 게시글과 파일을 업로드한다.
2. 서버는 파일을 저장한다.
3. DB에는 원본 파일명, 저장 파일명, 파일 크기 등을 저장한다.
4. 게시글 상세 화면에서 첨부파일 목록을 조회한다.
5. 사용자가 파일명을 클릭하면 다운로드 요청을 보낸다.
6. 서버가 저장된 파일을 읽어 응답한다.

2. 파일 자체와 파일 정보 구분하기

파일 업로드를 설계할 때 가장 중요한 것은 파일 자체와 파일 정보를 구분하는 것입니다.

파일 자체 서버 폴더, 클라우드 스토리지, 외부 저장소 등에 저장합니다.
파일 정보 DB에 저장합니다. 예: 원본 파일명, 저장 파일명, 파일 크기, 파일 타입

예를 들어 사용자가 cat.png를 업로드했을 때 서버에는 UUID로 만든 파일명으로 저장할 수 있습니다.

원본 파일명
 → cat.png

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

화면에는 원본 파일명을 보여주고, 실제 파일을 찾을 때는 저장 파일명을 사용합니다.


3. DB에 저장할 파일 정보

첨부파일을 관리하려면 DB에 파일 정보를 저장해야 합니다. 게시판 첨부파일 테이블은 다음과 같이 설계할 수 있습니다.

컬럼 설명
file_no 파일 번호
board_no 파일이 연결된 게시글 번호
original_filename 사용자가 업로드한 원본 파일명
stored_filename 서버에 실제 저장된 파일명
file_size 파일 크기
content_type 파일 Content-Type
reg_date 등록일

파일을 다운로드할 때는 file_no로 DB에서 파일 정보를 조회한 뒤, stored_filename으로 실제 파일을 찾으면 됩니다.

fileNo로 DB 조회
 → originalFilename 확인
 → storedFilename 확인
 → 서버 폴더에서 storedFilename 파일 찾기
 → 사용자에게 다운로드 응답

4. 파일 정보 DTO 만들기

DB에서 조회한 파일 정보를 담기 위한 DTO를 만들 수 있습니다.

public class BoardFileDto {

    private Long fileNo;
    private Long boardNo;
    private String originalFilename;
    private String storedFilename;
    private Long fileSize;
    private String contentType;

    public Long getFileNo() {
        return fileNo;
    }

    public void setFileNo(Long fileNo) {
        this.fileNo = fileNo;
    }

    public Long getBoardNo() {
        return boardNo;
    }

    public void setBoardNo(Long boardNo) {
        this.boardNo = boardNo;
    }

    public String getOriginalFilename() {
        return originalFilename;
    }

    public void setOriginalFilename(String originalFilename) {
        this.originalFilename = originalFilename;
    }

    public String getStoredFilename() {
        return storedFilename;
    }

    public void setStoredFilename(String storedFilename) {
        this.storedFilename = storedFilename;
    }

    public Long getFileSize() {
        return fileSize;
    }

    public void setFileSize(Long fileSize) {
        this.fileSize = fileSize;
    }

    public String getContentType() {
        return contentType;
    }

    public void setContentType(String contentType) {
        this.contentType = contentType;
    }
}

이 DTO는 파일 자체를 담는 객체가 아닙니다. 파일을 찾고 보여주기 위한 정보를 담는 객체입니다.


5. 게시글 상세 화면에서 첨부파일 목록 보여주기

게시글 상세 화면에서는 게시글 정보와 함께 첨부파일 목록을 조회해야 합니다.

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

    private final BoardService boardService;

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

    @GetMapping("/{boardNo}")
    public String detail(@PathVariable Long boardNo, Model model) {
        BoardDto board = boardService.findBoard(boardNo);
        List<BoardFileDto> fileList = boardService.findBoardFiles(boardNo);

        model.addAttribute("board", board);
        model.addAttribute("fileList", fileList);

        return "board/detail";
    }
}

흐름은 다음과 같습니다.

GET /board/10
 → 게시글 정보 조회
 → 10번 게시글의 첨부파일 목록 조회
 → Model에 board, fileList 담기
 → 상세 화면 반환

JSP나 Thymeleaf에서는 fileList를 반복 출력하여 첨부파일 목록을 보여줄 수 있습니다.


6. 첨부파일 다운로드 링크 만들기

화면에서는 파일명을 클릭하면 다운로드 요청이 가도록 링크를 만들 수 있습니다.

<ul>
    <li th:each="file : ${fileList}">
        <a th:href="@{/board/files/{fileNo}/download(fileNo=${file.fileNo})}"
           th:text="${file.originalFilename}">
            첨부파일
        </a>
    </li>
</ul>

위 예시는 Thymeleaf 기준입니다. 파일명은 원본 파일명을 보여주고, 다운로드 요청에는 파일 번호를 사용합니다.

화면 표시
 → originalFilename

다운로드 요청
 → fileNo

저장 파일명을 URL에 직접 노출하지 않고 fileNo를 사용하는 것이 더 안전하고 관리하기 좋습니다.


7. Resource란?

Spring에서 파일을 응답할 때 Resource를 사용할 수 있습니다. Resource는 파일, URL, classpath 리소스 같은 자원을 표현하는 인터페이스입니다.

import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;

서버 폴더에 저장된 파일을 읽어 응답하려면 UrlResource를 사용할 수 있습니다.

Path filePath = Paths.get(uploadDir).resolve(storedFilename).normalize();
Resource resource = new UrlResource(filePath.toUri());

이렇게 만든 ResourceResponseEntity의 Body에 담아 반환하면 파일 다운로드 응답을 만들 수 있습니다.


8. 파일 다운로드 Controller 만들기

이제 파일 다운로드 Controller를 만들어보겠습니다.

@GetMapping("/files/{fileNo}/download")
public ResponseEntity<Resource> download(@PathVariable Long fileNo) throws IOException {
    BoardFileDto fileDto = boardService.findBoardFile(fileNo);

    String uploadDir = "C:/upload/";
    Path filePath = Paths.get(uploadDir)
            .resolve(fileDto.getStoredFilename())
            .normalize();

    Resource resource = new UrlResource(filePath.toUri());

    if (!resource.exists() || !resource.isReadable()) {
        return ResponseEntity.notFound().build();
    }

    String encodedFilename = URLEncoder.encode(
            fileDto.getOriginalFilename(),
            StandardCharsets.UTF_8
    ).replaceAll("\\+", "%20");

    String contentDisposition = "attachment; filename=\"" + encodedFilename + "\"";

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

위 코드의 핵심은 다음과 같습니다.

fileNo DB에서 파일 정보를 조회하는 기준입니다.
storedFilename 서버 폴더에서 실제 파일을 찾는 이름입니다.
Resource 응답 Body에 담을 파일 리소스입니다.
Content-Disposition 브라우저가 파일을 다운로드로 처리하도록 알려주는 Header입니다.

9. Content-Disposition이란?

Content-Disposition은 브라우저에게 응답 데이터를 어떻게 처리할지 알려주는 Header입니다. 파일 다운로드에서는 보통 attachment를 사용합니다.

Content-Disposition: attachment; filename="cat.png"

이 Header가 있으면 브라우저는 응답을 파일 다운로드로 처리할 수 있습니다.

attachment 브라우저에서 다운로드로 처리하도록 합니다.
filename 다운로드될 때 사용자에게 보일 파일명입니다.

파일명에 한글이나 공백이 있을 수 있으므로 인코딩 처리를 해주는 것이 좋습니다.


10. 이미지 파일 화면에 보여주기

다운로드가 아니라 이미지를 화면에 바로 보여주고 싶은 경우도 있습니다. 예를 들어 프로필 이미지나 리뷰 이미지를 보여주는 기능입니다.

이미지 조회용 Controller를 따로 만들 수 있습니다.

@GetMapping("/files/{fileNo}/view")
public ResponseEntity<Resource> viewFile(@PathVariable Long fileNo) throws IOException {
    BoardFileDto fileDto = boardService.findBoardFile(fileNo);

    String uploadDir = "C:/upload/";
    Path filePath = Paths.get(uploadDir)
            .resolve(fileDto.getStoredFilename())
            .normalize();

    Resource resource = new UrlResource(filePath.toUri());

    if (!resource.exists() || !resource.isReadable()) {
        return ResponseEntity.notFound().build();
    }

    MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;

    if (fileDto.getContentType() != null) {
        mediaType = MediaType.parseMediaType(fileDto.getContentType());
    }

    return ResponseEntity.ok()
            .contentType(mediaType)
            .body(resource);
}

이 방식은 다운로드 Header를 붙이지 않습니다. 대신 이미지의 Content-Type을 설정해서 브라우저가 화면에 표시할 수 있게 합니다.

다운로드
 → Content-Disposition: attachment

화면 표시
 → Content-Type: image/png, image/jpeg 등

11. img 태그로 이미지 표시하기

이미지 조회 URL을 만들었다면 화면에서는 img 태그로 표시할 수 있습니다.

<img th:src="@{/board/files/{fileNo}/view(fileNo=${file.fileNo})}"
     alt="첨부 이미지">

브라우저는 src에 있는 URL로 이미지 요청을 보냅니다. 서버는 해당 파일을 읽어 이미지 응답으로 돌려줍니다.

브라우저 img 태그 렌더링
 → /board/files/1/view 요청
 → 서버가 파일 읽기
 → image/png 또는 image/jpeg 응답
 → 화면에 이미지 표시

이미지 파일이 아닌 첨부파일은 다운로드 링크로 보여주고, 이미지 파일은 미리보기로 보여주는 식으로 나눌 수 있습니다.


12. 파일 다운로드 Service로 분리하기

파일 조회와 다운로드 코드도 Controller에 모두 넣으면 길어집니다. 파일 정보를 조회하고 Resource를 만드는 로직은 Service로 분리할 수 있습니다.

@Service
public class FileDownloadService {

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

    public Resource loadAsResource(String storedFilename) throws IOException {
        Path filePath = Paths.get(uploadDir)
                .resolve(storedFilename)
                .normalize();

        Resource resource = new UrlResource(filePath.toUri());

        if (!resource.exists() || !resource.isReadable()) {
            throw new FileNotFoundException("파일을 찾을 수 없습니다.");
        }

        return resource;
    }
}

Controller는 더 간단해집니다.

@GetMapping("/files/{fileNo}/download")
public ResponseEntity<Resource> download(@PathVariable Long fileNo) throws IOException {
    BoardFileDto fileDto = boardService.findBoardFile(fileNo);
    Resource resource = fileDownloadService.loadAsResource(fileDto.getStoredFilename());

    String encodedFilename = URLEncoder.encode(
            fileDto.getOriginalFilename(),
            StandardCharsets.UTF_8
    ).replaceAll("\\+", "%20");

    return ResponseEntity.ok()
            .header(
                    HttpHeaders.CONTENT_DISPOSITION,
                    "attachment; filename=\"" + encodedFilename + "\""
            )
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(resource);
}

이렇게 분리하면 Controller는 요청과 응답 처리에 집중하고, 파일을 찾는 로직은 Service가 담당합니다.


13. 저장 경로를 설정값으로 관리하기

업로드 경로를 코드에 직접 작성하는 것은 좋지 않습니다. 환경에 따라 경로가 달라질 수 있기 때문입니다.

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

로컬 개발 환경, 운영 서버, 리눅스 서버에서는 경로가 다를 수 있습니다. 그래서 application.properties에 설정값으로 분리하는 것이 좋습니다.

file.upload-dir=C:/upload/

Service에서는 @Value로 읽을 수 있습니다.

@Service
public class FileDownloadService {

    @Value("${file.upload-dir}")
    private String uploadDir;

    public Resource loadAsResource(String storedFilename) throws IOException {
        Path filePath = Paths.get(uploadDir)
                .resolve(storedFilename)
                .normalize();

        return new UrlResource(filePath.toUri());
    }
}

설정으로 분리하면 나중에 서버 환경이 바뀌어도 코드 수정 없이 설정값만 변경할 수 있습니다.


14. 파일 다운로드 시 주의할 점

파일 다운로드 기능은 보안과 안정성을 꼭 고려해야 합니다. 특히 사용자가 요청한 값으로 서버의 아무 파일이나 읽게 만들면 위험합니다.

주의점 설명
저장 파일명을 직접 입력받지 않기 URL에서 storedFilename을 그대로 받으면 파일 접근 위험이 커질 수 있습니다.
fileNo로 DB 조회하기 파일 번호로 DB를 조회한 뒤 저장 파일명을 가져오는 구조가 좋습니다.
파일 존재 여부 확인 실제 파일이 없거나 읽을 수 없으면 404 처리를 해야 합니다.
다운로드 권한 확인 비공개 게시글이나 본인 파일은 권한 검사가 필요할 수 있습니다.
경로 조작 방지 ../ 같은 경로 조작을 막아야 합니다.

실제 서비스에서는 파일을 내려주기 전에 해당 사용자가 그 파일을 다운로드할 권한이 있는지도 확인해야 합니다.


15. 자주 하는 실수

1) 저장 파일명을 URL에 그대로 노출하는 경우

/download?filename=9f2a7c3e-aaaa-bbbb.png

이 방식보다 파일 번호를 사용하는 것이 좋습니다.

/board/files/10/download

파일 번호로 DB를 조회해서 실제 저장 파일명을 가져오는 구조가 더 관리하기 좋습니다.

2) Content-Disposition을 설정하지 않는 경우

다운로드 기능에서 Content-Disposition을 설정하지 않으면 브라우저가 파일을 바로 열어버리거나 기대와 다르게 처리할 수 있습니다.

.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"file.png\"")

3) 한글 파일명 인코딩을 고려하지 않는 경우

파일명에 한글이나 공백이 있으면 다운로드 시 파일명이 깨질 수 있습니다. URLEncoder 등을 사용해 인코딩 처리를 고려해야 합니다.

String encodedFilename = URLEncoder.encode(
        originalFilename,
        StandardCharsets.UTF_8
).replaceAll("\\+", "%20");

4) 파일 존재 여부를 확인하지 않는 경우

DB에는 파일 정보가 있는데 실제 서버 폴더에는 파일이 없을 수 있습니다. 파일을 응답하기 전에 존재 여부와 읽기 가능 여부를 확인해야 합니다.

if (!resource.exists() || !resource.isReadable()) {
    return ResponseEntity.notFound().build();
}

5) 모든 첨부파일을 이미지처럼 보여주려는 경우

PDF, ZIP, DOCX 같은 파일은 이미지처럼 표시할 수 없습니다. 이미지는 미리보기로 보여주고, 일반 첨부파일은 다운로드 링크로 제공하는 식으로 구분하는 것이 좋습니다.

이미지 파일
 → 화면 미리보기 가능

일반 파일
 → 다운로드 링크 제공

16. 전체 흐름 정리

파일 조회와 다운로드 흐름을 정리하면 다음과 같습니다.

1. 게시글 상세 화면에서 첨부파일 목록을 조회한다.
2. 화면에는 원본 파일명을 보여준다.
3. 사용자가 파일명을 클릭한다.
4. /board/files/{fileNo}/download 요청이 들어온다.
5. fileNo로 DB에서 파일 정보를 조회한다.
6. storedFilename으로 서버 폴더에서 실제 파일을 찾는다.
7. Resource로 파일을 읽는다.
8. Content-Disposition Header를 설정한다.
9. ResponseEntity에 Resource를 담아 응답한다.
10. 브라우저가 파일을 다운로드한다.

정리

업로드한 파일을 다시 사용자에게 제공하려면 파일 정보 조회, 실제 파일 읽기, 응답 Header 설정이 필요합니다. 파일 자체는 서버 폴더나 외부 저장소에 저장하고, DB에는 원본 파일명과 저장 파일명 같은 파일 정보를 저장합니다.

fileNo 파일 정보를 조회하는 기준입니다.
originalFilename 사용자에게 보여줄 원본 파일명입니다.
storedFilename 서버에서 실제 파일을 찾을 때 사용하는 저장 파일명입니다.
Resource 파일을 응답 Body에 담기 위한 리소스 객체입니다.
Content-Disposition 브라우저가 파일을 다운로드로 처리하도록 알려주는 Header입니다.

처음에는 다음 기준을 기억하면 좋습니다.

화면 표시
 → 원본 파일명 사용

실제 파일 찾기
 → 저장 파일명 사용

다운로드 요청
 → fileNo 사용

파일 응답
 → ResponseEntity<Resource>

다운로드 처리
 → Content-Disposition: attachment

연습해보기

아래 요구사항을 기준으로 첨부파일 다운로드 기능을 설계해보세요.

게시글 상세 화면

1. 게시글 정보를 보여준다.
2. 첨부파일 목록을 보여준다.
3. 파일명은 originalFilename으로 표시한다.
4. 다운로드 링크는 /board/files/{fileNo}/download로 만든다.
5. Controller는 fileNo로 파일 정보를 조회한다.
6. storedFilename으로 실제 파일을 찾는다.
7. Content-Disposition Header를 설정해 다운로드 응답을 반환한다.

다음 글에서는 Spring에서 Interceptor를 사용해 로그인 체크, 권한 체크 같은 공통 요청 처리를 하는 방법을 정리하겠습니다. Controller마다 로그인 여부를 반복해서 확인하지 않고, 요청이 Controller에 도착하기 전에 공통으로 검사하는 흐름입니다.