본문 바로가기
Spring Boot

스프링 부트(Spring Boot) 게시판 - 다중 첨부파일 업로드하기 (다중 파일 업로드 & 다운로드 구현하기 1/3) [Thymeleaf, MariaDB, IntelliJ, Gradle, MyBatis]

by 도뎡 2023. 5. 19.
반응형
  • 본 게시판 프로젝트는 단계별(step by step)로 진행되니, 이전 단계를 진행하시는 것을 권장드립니다.
  • DBMS 툴은 DBeaver를 이용하며, DB는 MariaDB를 이용합니다. (MariaDB 설치하기)
  • 화면 처리는 HTML5 기반의 자바 템플릿 엔진인 타임리프(Thymeleaf)를 사용합니다.

이전 글까지는 게시판에 회원 기능을 구현하고, 인터셉터와 세션을 이용해서 로그인이 되지 않은 회원은 게시판을 이용할 수 없도록 처리해 보았습니다.

지금부터는 본 글을 시작으로 총 세 번에 걸쳐 파일 업로드/다운로드 기능을 구현해 볼 건데요. 단일 파일의 경우에는 게시글(tb_post) 테이블 하나로 처리가 가능하지만, 댓글과 같이 하나의 게시글에서 여러 개의 파일을 관리할 수 있도록 다중으로 파일을 업로드하는 방법을 알아보겠습니다.

 

1. 파일 테이블 구조

아래 표는 파일 테이블의 전체 칼럼입니다.

칼럼 설명
id 테이블의 PK(Primary Key)를 의미합니다.
post_id 테이블의 FK(Foreign Key)로, 파일과 연결되는 게시글 번호를 의미합니다.
original_name 업로드 하는 파일의 원본 이름을 의미합니다.
save_name 실제로 디스크에 저장되는 파일명을 의미합니다.
size 파일의 크기를 의미합니다.
delete_yn 파일 삭제 여부를 의미합니다.
created_date 파일 생성일시를 의미합니다.
deleted_date 파일 삭제일시를 의미합니다. 

 

원본 파일명과 저장 파일명을 따로 두는 이유

동일한 경로에 동일한 이름의 파일이 업로드되는 경우, OS에 따라 파일이 저장되지 않거나 이름이 자동으로 바뀐 채 업로드되는 이슈가 있습니다. 윈도우의 경우에는 파일명 뒤에 숫자가 붙게 되는데요. 이러한 상황이 벌어지면 디스크에 업로드된 파일을 찾을 수 없게 됩니다.

이와 같은 이슈를 방지하기 위해 원본 파일명(original_name)에는 업로드한 파일의 이름을 그대로 저장하고, 저장 파일명(save_name)에는 흔히 UUID라고 부르는 고유 식별자를 저장해서, UUID를 기준으로 파일을 구분하게 됩니다.

 

최종 수정일시(modified_date)가 없는 이유

게시판, 댓글, 회원과는 달리 파일 테이블에는 modified_date 칼럼이 없습니다. 예를 들어 기존에 파일이 업로드된 게시글을 수정한다고 가정했을 때, 파일이 추가/삭제되거나 기존 파일이 다른 파일로 변경되는 경우, 기존에 등록된 모든 파일을 삭제 처리한 후 추가/삭제/변경된 파일을 새롭게 생성하는 방식을 이용하기 때문입니다.

말로는 이해가 쉽지 않으실 수 있으니, 실제로 파일을 처리하는 과정에서 다시 한번 설명드리겠습니다.

 

2. 파일 테이블 생성하기

DBMS 툴에서 아래 명령어를 실행해 테이블을 생성해 주시면 되는데요. 댓글과 마찬가지로 하나의 게시글에 N개의 파일이 포함되는 구조입니다. 즉, 게시글과 파일도 각각 1:N 관계가 되어야 하기 때문에 'fk_post_file' FK 제약 조건을 추가해 주었습니다.

CREATE TABLE `tb_file` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '파일 번호 (PK)',
  `post_id` bigint(20) NOT NULL COMMENT '게시글 번호 (FK)',
  `original_name` varchar(255) NOT NULL COMMENT '원본 파일명',
  `save_name` varchar(40) NOT NULL COMMENT '저장 파일명',
  `size` int(11) NOT NULL COMMENT '파일 크기',
  `delete_yn` tinyint(1) NOT NULL COMMENT '삭제 여부',
  `created_date` datetime NOT NULL DEFAULT current_timestamp() COMMENT '생성일시',
  `deleted_date` datetime DEFAULT NULL COMMENT '삭제일시',
  PRIMARY KEY (`id`),
  KEY `fk_post_file` (`post_id`),
  CONSTRAINT `fk_post_file` FOREIGN KEY (`post_id`) REFERENCES `tb_post` (`id`)
) COMMENT '파일';

 

생성된 파일(tb_file) 테이블의 구조는 다음과 같습니다.

파일(tb_file) 테이블 구조

 

3. 파일 요청(Request) 클래스 생성하기

다음은 업로드된 파일 정보를 저장(INSERT)하는 용도로 사용할 파일 요청 클래스입니다. 물리적인 파일은 디스크에 저장되기 때문에, 파일의 부가적인 정보만 DB에 저장해 주면 됩니다.

package com.study.domain.file;

import lombok.Builder;
import lombok.Getter;

@Getter
public class FileRequest {

    private Long id;                // 파일 번호 (PK)
    private Long postId;            // 게시글 번호 (FK)
    private String originalName;    // 원본 파일명
    private String saveName;        // 저장 파일명
    private long size;              // 파일 크기

    @Builder
    public FileRequest(String originalName, String saveName, long size) {
        this.originalName = originalName;
        this.saveName = saveName;
        this.size = size;
    }

    public void setPostId(Long postId) {
        this.postId = postId;
    }

}

 

메서드 설명
생성자 생성자 메서드에 @Builder 어노테이션이 선언되어 있는데요. @Builder는 롬복에서 제공해주는 기능으로, 빌더 패턴(Builder pattern)으로 객체를 생성할 수 있게 해줍니다. 빌더 패턴은 생성자 파라미터가 많은 경우에 가독성을 높여주기도 하고, 아래 코드와 같이 변수에 값을 넣어주는 순서를 달리하거나, 원하는 변수에만 값을 넣어 객체를 생성할 수 있습니다.

파미페럿님 블로그에 빌더 패턴에 대한 설명이 정말 쉽게 정리되어 있으니 한번쯤은 읽어보시기를 권장드립니다.
setPostId( ) 파일은 게시글이 생성(INSERT) 된 후에 처리되어야 합니다. 해당 메서드는 생성된 게시글 ID를 파일 요청 객체의 postId에 저장하는 용도로 사용되는데요. 객체 생성 시점에 같이 처리하지 않고 set 메서드를 이용해서 처리하는 이유는 뒤에서 설명드리도록 하겠습니다.

 

    // 일반적인 생성자를 통한 객체 생성
    FileRequest fileRequest = new FileRequest("테스트.txt", "abcdeabcde.txt", 10768);
    
    // 빌더 패턴을 통한 객체 생성 1
    FileRequest fileRequest = FileRequest.builder()
            .originalName("테스트.txt")
            .saveName("abcdeabcde.txt")
            .size(10768)
            .build();
    
    // 빌더 패턴을 통한 객체 생성 2
    FileRequest fileRequest = FileRequest.builder()
            .size(10768)
            .saveName("abcdeabcde.txt")
            .originalName("테스트.txt")
            .build();
    
    // 빌더 패턴을 통한 객체 생성 3
    FileRequest fileRequest = FileRequest.builder()
            .saveName("abcdeabcde.txt")
            .build();

 

4. 파일 Mapper 인터페이스 생성하기

다음은 MyBatis Mapper 인터페이스입니다. 게시글, 댓글, 회원과는 달리 파일 업로드 CRUD는 필요한 메서드를 그때그때 추가하는 방향으로 진행해 보겠습니다.

package com.study.domain.file;

import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface FileMapper {

    /**
     * 파일 정보 저장
     * @param files - 파일 정보 리스트
     */
    void saveAll(List<FileRequest> files);

}

 

saveAll( )

업로드된 파일의 정보를 DB에 저장합니다. 여러 개의 파일 정보를 한 번에 저장하기 위해 FileRequest를 List 타입으로 선언했습니다.

 

5. 파일 XML Mapper 생성하기

이번엔 FileMapper 인터페이스와 연결할 MyBatis XML Mapper입니다. src/main/resources/mappers에 FileMapper.xml을 추가한 후 saveAll 쿼리를 작성해 주세요.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.study.domain.file.FileMapper">

    <!-- tb_file 테이블 전체 컬럼 -->
    <sql id="fileColumns">
          id
        , post_id
        , original_name
        , save_name
        , size
        , delete_yn
        , created_date
        , deleted_date
    </sql>


    <!-- 파일 정보 저장 -->
    <insert id="saveAll" parameterType="list">
        INSERT INTO tb_file (
            <include refid="fileColumns" />
        ) VALUES
        <foreach item="file" collection="list" separator=",">
        (
              #{file.id}
            , #{file.postId}
            , #{file.originalName}
            , #{file.saveName}
            , #{file.size}
            , 0
            , NOW()
            , NULL
        )
        </foreach>
    </insert>

</mapper>

 

saveAll

MyBatis에서 foreach 태그를 사용하면 Collection 타입의 객체를 처리할 수 있습니다. item 속성의 file은 List에 담긴 각각의 FileRequest 객체이고, collection 속성의 list는 파라미터 타입을 의미하며, separator의 ' , '는 각 쿼리를 분리할 구분자를 의미합니다.

예를 들어 List에 세 개의 객체가 담겨있다고 했을 때 아래와 같은 형태로 SQL 쿼리가 실행되는데요. 이와 같이 다중으로 데이터를 INSERT 하는 쿼리를 보통 Bulk insert라고 표현합니다.

INSERT INTO tb_file (
  ...
) VALUES
(
  ...
),
(
  ...
),
(
  ...
)

 

6. 파일 서비스(Service) 클래스 생성하기

다음은 파일의 비즈니스 로직을 담당해 줄 서비스 클래스입니다. 여기서 비즈니스 로직은 DB에 저장할 파일의 논리적인 정보를 뜻합니다.

package com.study.domain.file;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import javax.transaction.Transactional;
import java.util.List;

@Service
@RequiredArgsConstructor
public class FileService {

    private final FileMapper fileMapper;

    @Transactional
    public void saveFiles(final Long postId, final List<FileRequest> files) {
        if (CollectionUtils.isEmpty(files)) {
            return;
        }
        for (FileRequest file : files) {
            file.setPostId(postId);
        }
        fileMapper.saveAll(files);
    }

}

 

saveFiles( )

게시글 번호(postId)와 파일 정보(files)를 전달받아, 업로드된 파일 정보를 테이블에 저장하는 역할을 합니다. 만약 게시글을 저장(INSERT 또는 UPDATE)하는 시점에 업로드된 파일이 없다면 로직을 종료하고, 파일이 있으면 모든 요청 객체에 게시글 번호(postId)를 세팅한 후 테이블에 파일 정보를 저장합니다.

set 메서드 사용 이유는 더 뒤에서 설명드리겠습니다.

 

7. 공통 파일 처리용 유틸 클래스 생성하기

페이징과 마찬가지로 파일 업로드/다운로드도 모든 영역에서 공통으로 사용할 수 있어야 합니다. 이 클래스는 디스크에 디렉터리(폴더)를 생성하거나, 파일을 업로드 또는 삭제하는 용도로 사용되는 클래스입니다. 우선은 업로드에 필요한 메서드만 정의했습니다.

package com.study.common.file;

import com.study.domain.file.FileRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Component
public class FileUtils {

    private final String uploadPath = Paths.get("C:", "develop", "upload-files").toString();

    /**
     * 다중 파일 업로드
     * @param multipartFiles - 파일 객체 List
     * @return DB에 저장할 파일 정보 List
     */
    public List<FileRequest> uploadFiles(final List<MultipartFile> multipartFiles) {
        List<FileRequest> files = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if (multipartFile.isEmpty()) {
                continue;
            }
            files.add(uploadFile(multipartFile));
        }
        return files;
    }

    /**
     * 단일 파일 업로드
     * @param multipartFile - 파일 객체
     * @return DB에 저장할 파일 정보
     */
    public FileRequest uploadFile(final MultipartFile multipartFile) {

        if (multipartFile.isEmpty()) {
            return null;
        }

        String saveName = generateSaveFilename(multipartFile.getOriginalFilename());
        String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")).toString();
        String uploadPath = getUploadPath(today) + File.separator + saveName;
        File uploadFile = new File(uploadPath);

        try {
            multipartFile.transferTo(uploadFile);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return FileRequest.builder()
                .originalName(multipartFile.getOriginalFilename())
                .saveName(saveName)
                .size(multipartFile.getSize())
                .build();
    }

    /**
     * 저장 파일명 생성
     * @param filename 원본 파일명
     * @return 디스크에 저장할 파일명
     */
    private String generateSaveFilename(final String filename) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        String extension = StringUtils.getFilenameExtension(filename);
        return uuid + "." + extension;
    }

    /**
     * 업로드 경로 반환
     * @return 업로드 경로
     */
    private String getUploadPath() {
        return makeDirectories(uploadPath);
    }

    /**
     * 업로드 경로 반환
     * @param addPath - 추가 경로
     * @return 업로드 경로
     */
    private String getUploadPath(final String addPath) {
        return makeDirectories(uploadPath + File.separator + addPath);
    }

    /**
     * 업로드 폴더(디렉터리) 생성
     * @param path - 업로드 경로
     * @return 업로드 경로
     */
    private String makeDirectories(final String path) {
        File dir = new File(path);
        if (dir.exists() == false) {
            dir.mkdirs();
        }
        return dir.getPath();
    }

}

 

구성 요소 설명
@Component @Bean은 개발자가 컨트롤 할 수 없는 외부 라이브러리를 빈으로 등록할 때 사용하고, @Component는 개발자가 직접 정의한 클래스를 빈으로 등록할 때 사용합니다.
uploadPath 물리적으로 파일을 저장할 위치를 의미합니다. 저는 윈도우 환경이기 때문에 C:\develop\upload-files를 기본 위치로 선언했습니다. 경로는 여러분이 원하시는 곳으로 선언하셔도 무관합니다.

보통 OS별 디렉터리 경로를 구분할 때 File.separator를 이용하고는 하는데요. Paths.get( )을 이용하면 OS에 상관없이 디렉터리 경로를 구분할 수 있습니다.
uploadFiles( ) 스프링은 파일 업로드를 쉽게 처리할 수 있도록 MultipartFile 인터페이스를 제공해 줍니다. 사용자가 화면에서 파일을 업로드한 후 폼을 전송하면, MultipartFile 객체에 사용자가 업로드한 파일 정보가 담깁니다.

이 메서드의 포인트는 변수 files에 uploadFile( )의 결괏값을 담아 리턴한다는 점인데요. 이유는 바로 아래 행에서 설명드리겠습니다.
uploadFile( ) 단일(1개) 파일을 디스크에 업로드합니다. MultipartFile의 isEmpty( )는 파일의 유무를 체크하는 함수로, 업로드 된 파일이 없는 경우에는 null을 리턴해서 로직을 종료합니다.

메인 로직의 각 변수는 디스크에 저장할 파일명(saveName), 오늘 날짜(today), 파일의 업로드 경로(uploadPath(디렉터리 + 파일명)), 업로드할 파일 객체(uploadFile)를 의미합니다.

파일은 uploadPath에 해당되는 경로에 생성되며, MultipartFile의 transferTo( )가 정상적으로 실행되면 파일 생성(write)이 완료됩니다.

리턴하는 객체는 FileRequest 타입의 객체로, 앞에서 말씀드린 빌더 패턴이 적용된 코드입니다. 결과적으로 해당 메서드가 리턴하는 객체에는 디스크에 생성된 파일 정보가 담기게 됩니다.
generateSaveFilename( ) uploadFile( )의 변수 saveName에서 호출하는 메서드입니다. 변수 uuid에는 32자리의 랜덤 문자열을, extension에는 업로드 한 파일의 확장자를 담아 (랜덤 문자열 + " . " + 파일 확장자)에 해당되는 파일명을 리턴합니다. 이 파일명은 실제로 디스크에 생성되는 파일명을 의미합니다.
getUploadPath( ) 변수 uploadPath에 해당되는 경로를 리턴합니다. addPath 파라미터가 선언된 getUploadPath( )는 uploadFile( )의 변수 uploadPath에서 호출하는 메서드로, 당장은 기본 업로드 경로에 오늘 날짜(today)를 연결하는 용도로 사용됩니다.
makeDirectories( ) getUploadPath( )에서 호출하는 메서드입니다. 디스크에 경로(path)에 해당되는 디렉터리(폴더)가 없으면, path에 해당되는 모든 경로에 폴더를 생성합니다.

예를 들어 path가 C:\a\b\c\d\e라고 가정하면, a~e까지의 모든 경로가 폴더로 생성됩니다.

 

8. 게시글 요청(PostRequest) 클래스 - 파일 수집용 변수 추가하기

게시글을 저장하면 PostController의 savePost( )가 실행되고, FileUtils로 사용자가 업로드한 파일(MultipartFile)을 전달하려면, 가장 먼저 PostController의 savePost( )에서 파라미터를 수집해야 합니다.

컨트롤러 메서드에 파라미터를 추가해도 되지만, 요청 클래스에서 한꺼번에 처리하는 게 가독성이 좋을 듯합니다. PostRequest에 멤버 files를 추가해 주세요. files에 담기는 정보는 화면 처리를 끝낸 후에 보여드리도록 하겠습니다.

package com.study.domain.post;

import lombok.Getter;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;

import java.util.ArrayList;
import java.util.List;

@Getter
@Setter
public class PostRequest {

    private Long id;                                          // PK
    private String title;                                     // 제목
    private String content;                                   // 내용
    private String writer;                                    // 작성자
    private Boolean noticeYn;                                 // 공지글 여부
    private List<MultipartFile> files = new ArrayList<>();    // 첨부파일 List

}

 

9. PostController - 멤버 추가 & 게시글 저장 메서드 수정하기

앞에서 말씀드렸듯이, 파일 업로드는 게시글 생성이 완료된 후에 처리되어야 합니다. 우선은 PostController의 멤버로 FileService와 FileUtils를 추가하고, savePost( )를 다음과 같이 변경해 주시면 되는데요. 마찬가지로 화면 처리가 끝난 후에 추가된 코드를 설명드리도록 하겠습니다.

    private final PostService postService;
    private final FileService fileService;
    private final FileUtils fileUtils;

    // 신규 게시글 생성
    @PostMapping("/post/save.do")
    public String savePost(final PostRequest params, Model model) {
        Long id = postService.savePost(params);
        List<FileRequest> files = fileUtils.uploadFiles(params.getFiles());
        fileService.saveFiles(id, files);
        MessageDto message = new MessageDto("게시글 생성이 완료되었습니다.", "/post/list.do", RequestMethod.GET, null);
        return showMessageAndRedirect(message, model);
    }

 

10. application.properties - 업로드 파일 사이즈 설정하기

스프링 부트는 기본적으로 업로드하는 파일의 사이즈에 제한을 두는데, 파일 한 개당 최대 사이즈는 1MB, 요청에 포함된 전체 파일의 합은 10MB로 설정되어 있습니다.

우리는 파일당 최대 사이즈는 10MB로, 요청에 대한 전체 파일 사이즈는 50MB로 변경하도록 하겠습니다. application.properties에 아래 설정들을 추가해 주세요.

# change upload file size
spring.servlet.multipart.maxFileSize=10MB
spring.servlet.multipart.maxRequestSize=50MB

 

11. write.html - 파일 영역 & 함수 추가하기

백엔드(서버) 구성은 완료되었으니 마지막으로 화면 쪽만 손봐주면 됩니다.


11-1) 폼(form) 인코딩 타입 선언하기

프런트(화면)에서 서버로 파일을 전송하려면 form 태그에 enctype을 multipart/form-data로 선언해 주어야 합니다. enctype이 선언되어 있지 않으면, 기본값인 application/x-www-form-urlencoded로 폼 데이터가 전송됩니다.

우선은 write.html의 saveForm을 다음과 같이 변경해 주세요.

<form id="saveForm" method="post" autocomplete="off" enctype="multipart/form-data">

 

11-2) 첨부파일 영역 추가하기

다음으로 saveForm에서 내용(content)을 감싸고 있는 tr 태그 밑에 아래 코드를 추가해 주시면 되는데요. 첨부파일과 각 버튼에 선언된 이벤트(onchange, onclick)는 뒤에서 설명드리겠습니다.

    <tr>
        <th>첨부파일</th>
        <td colspan="3">
            <div class="file_list">
                <div>
                    <div class="file_input">
                        <input type="text" readonly />
                        <label> 첨부파일
                            <input type="file" name="files" onchange="selectFile(this);" />
                        </label>
                    </div>
                    <button type="button" onclick="removeFile(this);" class="btns del_btn"><span>삭제</span></button>
                    <button type="button" onclick="addFile();" class="btns fn_add_btn"><span>파일 추가</span></button>
                </div>
            </div>
        </td>
    </tr>

 

이제, 게시글 등록 페이지로 접속해 보면 첨부파일 영역이 추가된 걸 확인하실 수 있습니다.

게시글 등록 페이지 - 첨부파일 영역 추가

 

11-3) 파일 처리용 함수 선언하기

우선 JS 영역에 다음의 함수들을 작성해 주세요.

    // 파일 선택
    function selectFile(element) {

        const file = element.files[0];
        const filename = element.closest('.file_input').firstElementChild;

        // 1. 파일 선택 창에서 취소 버튼이 클릭된 경우
        if ( !file ) {
            filename.value = '';
            return false;
        }

        // 2. 파일 크기가 10MB를 초과하는 경우
        const fileSize = Math.floor(file.size / 1024 / 1024);
        if (fileSize > 10) {
            alert('10MB 이하의 파일로 업로드해 주세요.');
            filename.value = '';
            element.value = '';
            return false;
        }

        // 3. 파일명 지정
        filename.value = file.name;
    }


    // 파일 추가
    function addFile() {
        const fileDiv = document.createElement('div');
        fileDiv.innerHTML =`
            <div class="file_input">
                <input type="text" readonly />
                <label> 첨부파일
                    <input type="file" name="files" onchange="selectFile(this);" />
                </label>
            </div>
            <button type="button" onclick="removeFile(this);" class="btns del_btn"><span>삭제</span></button>
        `;
        document.querySelector('.file_list').appendChild(fileDiv);
    }


    // 파일 삭제
    function removeFile(element) {
        const fileAddBtn = element.nextElementSibling;
        if (fileAddBtn) {
            const inputs = element.previousElementSibling.querySelectorAll('input');
            inputs.forEach(input => input.value = '')
            return false;
        }
        element.parentElement.remove();
    }

 

selectFile( )

파일 다이얼로그(선택) 창에서 파일이 선택(열기) 또는 취소되었을 때 실행되는 onchange 함수로, 파일에 변화가 생겼을 때 실행되는 이벤트입니다. 함수의 인자로 전달하는 this는 file 타입의 input 태그, 즉 자기 자신(엘리먼트)을 의미합니다.

파일 다이얼로그(선택) 창

 

해당 함수에서 핵심 변수는 file입니다. file에는 파일 선택 창에서 업로드한 파일 객체가 담기며, 서버로 폼을 전송했을 때, 이 파일 객체가 MultipartFile 타입으로 전송됩니다. file에 담기는 정보는 아래와 같습니다.

파일 객체 구조

 

메인 로직을 순서대로 설명드리겠습니다. 먼저, 변수 file은 file 타입의 input(이하 files)에 담긴 파일 객체를, filename은 text 타입의 input에 담기는 파일명을 의미합니다.

1번 로직은 파일 선택 창이 취소된(닫힌) 경우에 실행됩니다. 이때 files의 value는 초기화되므로, filename을 빈 값으로 초기화한 후 로직을 종료합니다.

2번 로직은 업로드된 파일의 size(바이트 단위)를 MB(메가바이트) 단위로 변경해서 파일 크기를 체크합니다. 바이트 단위의 숫자를 1024로 두 번 나누면 MB 단위가 되는데요. Math.floor( )를 이용해서 소수점을 제거한 후 파일 사이즈가 10MB를 초과하면 filename과 files의 value를 초기화하고 로직을 종료합니다.

마지막으로 3번 로직을 통해 업로드된 파일의 원본 이름을 filename에 세팅합니다.

 

addFile( )

파일 추가 버튼과 연결된 onclick 이벤트입니다. addFile( )을 실행하면 아래와 같이 첨부파일 영역이 하나씩 추가됩니다. fileDiv의 HTML은 form에 추가한 HTML에서 파일 추가 버튼만 제거되었고, 나머지는 전부 동일합니다.

게시글 등록 페이지 - 다중 첨부 모드 예시

 

개발자 도구(F12)의 Elements(요소) 탭을 통해 파일 영역의 HTML을 확인해 보시면 이해에 도움이 되실 겁니다. div.file_list의 자식 요소(div)들은 각각의 파일 영역을 의미합니다.

파일 영역(div.file_list) 구조

 

removeFile( )

삭제 버튼과 연결된 onclick 이벤트입니다. removeFile( )의 this는 영역별 삭제 버튼 자신을 의미하며, if 문의 조건은 파일 추가 버튼의 유무, 즉 첫 번째 파일이 삭제된 경우를 의미합니다.

첫 번째 파일 영역에는 파일 추가 버튼이 있기 때문에 div.file_input 안의 모든 input을 찾아 값을 초기화하고, 그 외 나머지는 addFile( )로 추가한 파일 영역(div) 자체를 DOM에서 제거합니다.

 

12. 게시글에 파일 업로드 해보기

먼저, 10MB를 초과하는 파일을 선택한 경우입니다.

파일 사이즈가 10MB를 초과하는 경우

 

첨부파일 선택/추가/변경/삭제 모두 다 정상적으로 작동되고 있습니다. 저는 최종적으로 아래 세 개 파일을 디스크에 업로드해 보겠습니다.

업로드 할 파일 목록

 

지금부터 백엔드 영역의 로직을 설명드리겠습니다. 아래 이미지는 savePost( )의 params로 넘어온 파일 객체(files)입니다. 저는 첨부파일을 세 개 등록했는데요. 여기서 핵심은 폼으로 전송하는 file 타입 input의 name이 모두 동일해야 파일을 List 타입으로 수집할 수 있다는 점입니다.

params - 파일 객체(files) 구조

 

설명에 앞서 PostController의 savePost( ) 구조를 꼭 확인해 주세요.

    // 신규 게시글 생성
    @PostMapping("/post/save.do")
    public String savePost(final PostRequest params, Model model) {
        Long id = postService.savePost(params);  // 1. 게시글 INSERT
        List<FileRequest> files = fileUtils.uploadFiles(params.getFiles()); // 2. 디스크에 파일 업로드
        fileService.saveFiles(id, files); // 3. 업로드 된 파일 정보를 DB에 저장
        MessageDto message = new MessageDto("게시글 생성이 완료되었습니다.", "/post/list.do", RequestMethod.GET, null);
        return showMessageAndRedirect(message, model);
    }

 

가장 먼저 1번 로직을 통해 게시글을 생성합니다. 게시글 저장에 실패했는데 파일이 등록되는 건 잘못된 시나리오이기 때문에 순서를 꼭 지켜주어야 합니다.

2번 로직이 실행되면 files를 순환(반복)해서 각 파일을 디스크에 업로드합니다. 여기서 핵심은 files.add( )에서 호출하는 uploadFile( )인데요. 디버깅을 통해 객체의 구조를 확인해 가며 한 단계씩 진행해 보시면 빠른 이해에 도움이 되실 겁니다.

FileUtils - uploadFiles( ) 디버깅 모드

 

uploadFile( )이 정상적으로 실행되면 uploadPath에 해당되는 경로에 업로드한 파일이 생성됩니다. 아래 두 이미지는 uploadFile( )이 실행되기 이전과 이후의 디렉터리 구조입니다.

업로드 디렉터리 - 생성 이전

 

업로드 디렉터리 - 생성 이후

 

uploadFiles( )의 실행이 종료되면 업로드 경로에는 saveName에 해당되는 이름의 파일들이 생성된 것을 확인하실 수 있습니다.

업로드 디렉터리 - 파일 생성(write) 완료

 

마지막으로 uploadFiles( )는 디스크에 생성된 파일 정보를 요청 객체(FileRequest)에 담아 리턴합니다.

FileUtils - uploadFiles( )가 리턴하는 데이터

 

다시 컨트롤러로 돌아와서 FileService의 saveFiles( )를 호출하는데요. 바로 앞의 이미지를 보시면 files의 모든 객체는 게시글 번호(postId)가 전부 null인 상태입니다.

FileUtils는 이름 그대로 파일을 처리하기 위한 유틸 클래스입니다. 디스크에 디렉터리(폴더) 또는 파일을 생성하거나 삭제하는 역할만 잘 수행하면 되지, 게시글 번호(postId)를 알 필요도, 파라미터로 받을 필요도 전혀 없습니다.

FileRequest의 postId는 파일 테이블에 저장되는 값이니, 파일 테이블의 비즈니스 로직을 담당하는 FileService에게 책임을 넘겨야 합니다.

FileService - saveFiles( ) 디버깅 모드

 

마지막으로 PostController의 savePost( )가 정상적으로 종료된 후 파일 테이블을 SELECT 해보면, 디스크에 업로드된 파일 정보가 INSERT 된 것을 확인하실 수 있습니다.

파일(tb_file) 테이블 SELECT 결과

 

마치며

여기까지 가장 기본적인 파일 업로드 기능을 구현해 보았습니다. 실무에서 파일 업로드는 확장자 체크는 물론이고, 여러 가지 케이스에 대비해서 예외 처리를 꼭 해주어야 합니다.

만약, 악의를 가진 사용자가 업로드한 파일에 바이러스가 있다고 가정했을 때, 이 파일을 다운로드하면 PC를 포맷해야 하거나, 아예 사용할 수 없는 지경에까지 이를 수 있습니다.

오늘 구현한 기능은 파일 업로드에서 가장 기초적인 방법으로, 클라이언트와 서버가 파일 객체(MultipartFile)를 어떻게 주고받는지만 확실히 이해하고 넘어가 주시면 되겠습니다.

전체적인 로직의 흐름을 이해하기 어려우시다면, PostController의 savePost( )에서 호출하는 모든 로직을 디버깅을 통해 한 단계씩 실행해 보시는 걸 권장드립니다.

다음 글에서는 게시글에 등록된 파일을 상세 페이지에 출력하는 기능과, 게시글을 수정할 때 파일을 추가/변경/삭제하는 기능, 그리고 기존에 등록된 파일을 그대로 유지하는 기능을 구현합니다.

오늘도 방문해 주신 여러분께 감사의 말씀을 드립니다. 좋은 하루 보내세요 :)

Board.zip
0.93MB

반응형

댓글