JAVA/SpringBoot

게시판 프로젝트 - 다중 첨부 파일 업로드, 다운로드 구현

whyHbr 2023. 11. 15. 21:11
728x90
반응형

- 파일 테이블 생성

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 '파일';

 

댓글과 마찬가지로 하나의 게시글에 n개의 파일이 포함되는 구조이다. 즉, 게시글과 파일도 각각 1:n관계가 되어야 하기 때문에 fk_post_file FK 제약 조건을 추가해 주었다.

 

- 파일 요청 request 클래스 생성하기

업로드된 파일 정보 저장Insert 할 때 사용할 파일 요청 클래스이다.

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는 롬복에서 제공해주는 기능으로 빌터패던으로 객체를 생성할 수 있게 해준다. 빌더 패턴은 생성자 파라미터가 많은 경우에 가독성을 높여주기도 하고 변수에 값을 넣어주는 순서를 달리하거나 원하는 변수에만 값을 넣어 객체를 생성할 수 있다.

setPostId(): 파일은 게시글이 생성 된 후에 처리되어야 한다. 해당 메서드는 생성된 게시글 ID를 파일 요청 객체의 PostId에 저장하는 용도로 사용된다. 

 

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

package com.study.domain.file;

import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface FileMapper {

    /**
     * 파일 정보 저장
     * @param files - 파일 정보 리스트
     */

 

saveAll(): 업로드된 파일의 정보를 DB에 저장한다. 여러 개의 파일 정보를 한 번에 저장하기 위헤 FileRequest를 List타입으로 선언했다.

 

- 파일 XML Mapper 생성

FileMapper 인터페이스와 연결할 MyBatis Xml Mapper이다.

src/main.resources/mappers 에 FileMapper.xml을 추가한 후 saveAll 쿼리를 작성한다.

!-- 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하는 쿼리를 보통 builk insert라고 표현한다.

 

-파일 서비스 클래스 생성

파일의 비즈니스 로직을 담당해줄 서비스 클래스이다. 여기서 비즈니스 로직은  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 or update) 하는 시점에 업로드된 파일이 없다면 로직을 종료하고 파일이 있으면 모든 요청 객체에 게시글 번호 postId를 세팅한 후 테이블에 파일 정보를 저장한다

 

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

페이징과 마찬가지로 파일 업로드, 다운로드도 모든 영역에서 공통으로 사용할 수 있어야 한다. 이 클래스는 디스트에 폴더응 생성하거나, 파일을 업로드 또는 삭제하는 사용되는 클래스이다.


        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 : 물리적으로 파일을 저장할 위치를 의미한다. 

 

uploadFiles() : 스프링은 파일 업로드를 쉽게 퍼리할 수 있도록 MultipartFile 인터페이스를 제공해줍니다. 사용자가 화면에서 파일을 업로드한 후 폼을 전송하면 multipartfile객체에 사용자가 업로드한 파일 정보가 담긴다

 

uploadFile(): 단일 파일을 디스크에 업로드한다. MultipartFile의 isEmpty() 는 파일의 유무를 체크하는 함수로 업로드된 파일이 없는 경우에는 null을 리턴해 로직을 종료한다..

메인 로직의 각 변수는 디스크에 저장할 파일명 (saveName), 오늘 날짜(today) , 파일의 업로드 경로, 업로드할 파일 객체(uploadFile) 을 의미한다

파일은 uploadPath에 해당되는 경로에 생성되며 MultipartFile의 trasferTo(0 가 정상적으로 실행되면 파일생성 (wirte)이 완료된다.

리턴하는 객체는 FileRequest타입의 객체로, 빌더 패턴이 적용된 코드이다. 결과적으로 해당 메서드가 리턴하는 객체에는 디스크에 생성된 파일 정보가 담기게 된다

 

generatesaveFilename() : uploadFile()의 변수 saveNameㅇ에서 호출되는 메서드이다. 이 파일명은 실제로 디스크에 저장되는 파일명을 의미한다.

 

getUploadPath() : 변수 uploadPath에 해당하는 경로를 리턴한다. addPath 파라미터가 선언된getUploadPath()는 uploadFile()의 변수 uploadPath 에서 호출하는 메서드이다. 지금은 기본 업로드 경로에 오늘 날짜를 연결하는 용도로 사용한다.

 

makeDirectories(): getUploadPath()에서 호출하는 메서드이다. 디스크에 경로에 해당하는 디렉터리가 없으면 path에 해당되는 모든 경로에 폴더를 생성한다.

 

 

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

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

 

PostRequest에 멈버 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
    private List<Long> removeFileIds = new ArrayList<>(); // 삭제할 첨부파일 id List

}

 

 

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

파일 업로드는 게시글 생성이 완료된 후에 처리 되어야 한다. PostController의 멤버로 FileService와 FileUtils를 추가하고 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);
}

 

 

-파일 처리용 함수 선언

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

                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;

                // 4. 삭제할 파일 id 추가
                if (id) {
                    removeFileId.add(id);
                }
            }


            // 파일 추가
            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, id) {

                // 1. 삭제할 파일 id 추가
                if (id) {
                    removeFileId.add(id);
                }

                // 2. 파일 영역 초기화 & 삭제
                const fileAddBtn = element.nextElementSibling;
                if (fileAddBtn) {
                    const inputs = element.previousElementSibling.querySelectorAll('input');
                    inputs.forEach(input => input.value = '')
                    return false;
                }
                element.parentElement.remove();
            }

        /*]]>*/
        </script>
    </th:block>
</html>
728x90