[Spring] 다중 파일 업로드&다운로드 구현

·2024년 5월 21일

spring

목록 보기
8/18

1. 파일 엔티티

@Entity
@Getter
@Setter
@NoArgsConstructor
public class File extends Timestamped {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // 파일 ID
    
    @Column(name = "scheduleId", nullable = false)
    private Long scheduleId; // 일정 ID
    
    @Column(name = "fileName", nullable = false)
    private String fileName; // 원본 파일명
    
    @Column(name = "saveName", nullable = false)
    private String saveName; // 저장되는 파일명
    
    @Column(name = "size", nullable = false)
    private long size; // 파일의 크기
    
    @Column(name = "isDeleted")
    private int isDeleted ; // 삭제 여부

    public File(FileRequestDto fileRequestDto) {
        this.scheduleId = fileRequestDto.getScheduleId();
        this.fileName = fileRequestDto.getFileName();
        this.saveName = fileRequestDto.getSaveName();
        this.size = fileRequestDto.getSize();
    }
}
/**
 * Entity 작성일, 수정일 자동생성 클래스
 */
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class Timestamped {

    @CreatedDate
    @Column(updatable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime modifiedAt;
}
// 스프링부트 애플리케이션에 해당 애노테이션을 지정해야 Timestamped를 사용할 수 있다.
@EnableJpaAuditing
@SpringBootApplication
public class NbcampSpringPersonalTaskApplication {

2. 파일 DTO

RequestDto

@Getter
@Setter // 파일은 일정이 생성된 후에 처리되어야 해서 scheduleId setter를 위해 지정
public class FileRequestDto {

    private Long scheduleId; // 일정 번호
    private String fileName; // 파일 이름
    private String saveName; // 저장할 파일 이름
    private Long size;       // 파일 크기

    @Builder
    public FileRequestDto(String fileName, String saveName, Long size) {
        this.fileName = fileName;
        this.saveName = saveName;
        this.size = size;
    }
}

ResponseDto

@Getter
public class FileResponseDto {

    private Long id;                    // 파일 번호
    private Long scheduleId;            // 일정 번호
    private String fileName;            // 파일 이름
    private String saveName;            // 저장할 파일 이름
    private Long size;                  // 파일 크기
    private LocalDateTime createdAt;    // 생성일
    private LocalDateTime modifiedAt;   // 수정일

    public FileResponseDto(File file) {
        this.id = file.getId();
        this.scheduleId = file.getScheduleId();
        this.fileName = file.getFileName();
        this.saveName = file.getSaveName();
        this.size = file.getSize();
        this.createdAt = file.getCreatedAt();
        this.modifiedAt = file.getModifiedAt();
    }
}

3. 파일 Repository

public interface FileRepository extends JpaRepository<File, Long> {
		// 일정 ID에 따른 모든 파일 정보 찾기
    List<File> findAllByScheduleId(Long scheduleId);
    // 일정 ID에 따른 모든 파일 정보 삭제
    void deleteAllByScheduleId(Long scheduleId);
}

4. 파일 Service

@Service
@RequiredArgsConstructor
public class FileService {

    private final FileRepository fileRepository;

    /**
     * 파일 정보 저장 메서드
     * @param scheduleId 일정 ID
     * @param files 파일 목록
     */
    public void savaFiles(Long scheduleId, List<FileRequestDto> files) {
        // 파일 목록이 비었는지 확인
        if (CollectionUtils.isEmpty(files)) {
            return;
        }
        // 파일 객체에 일정 ID를 주입해준다.
        for (FileRequestDto file : files) {
            file.setScheduleId(scheduleId);
        }
        List<File> fileList = files.stream().map(File::new).toList();
        fileRepository.saveAll(fileList);
    }

    /**
     * 일정 첨부파일 목록 조회 메서드
     * @param scheduleId 일정 ID
     * @return 파일 목록
     */
    public List<FileResponseDto> findAllFilesByScheduleId(Long scheduleId) {
        return fileRepository.findAllByScheduleId(scheduleId).stream().map(FileResponseDto::new).toList();
    }

    /**
     * 파일 객체 반환 메서드
     * @param id 파일 ID
     * @return 파일
     */
    public FileResponseDto findFileById(Long id) {
        File file = fileRepository.findById(id).orElseThrow(()-> new ScheduleException(ErrorCode.FILE_NOT_FOUND));
        return new FileResponseDto(file);
    }

    /**
     * 파일 정보 삭제 메서드
     * @param id 파일 ID
     */
    public void deleteAllByScheduleId(Long id) {
        fileRepository.deleteAllByScheduleId(id);
    }
}

일정 Service (일정 삭제 시 파일도 함께 삭제)

/**
 * 일정,파일 삭제 메서드
 * @param id 일정 ID
 * @param requestDto 일정 정보
 * @return 일정 ID
 */
@Transactional
public Long deleteSchedule(Long id, ScheduleRequestDto requestDto) {
    Schedule schedule = findScheduleById(id);
    schedule.validatePassword(requestDto);
    scheduleRepository.delete(schedule);

    List<FileResponseDto> files = fileService.findAllFilesByScheduleId(id); // 삭제할 파일 정보 조회
    fileUtils.deleteFiles(files); // 디스크에서 파일 삭제
    fileService.deleteAllByScheduleId(id); // DB에서 파일 정보 삭제
    return schedule.getId();
}

5. 파일 Util 클래스

package com.sparta.nbcampspringpersonaltask.utils;

import com.sparta.nbcampspringpersonaltask.dto.FileRequestDto;
import com.sparta.nbcampspringpersonaltask.dto.FileResponseDto;
import com.sparta.nbcampspringpersonaltask.enumType.ImgFileType;
import com.sparta.nbcampspringpersonaltask.enumType.ErrorCode;
import com.sparta.nbcampspringpersonaltask.exception.ScheduleException;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Path;
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
@Slf4j
public class FileUtils {
    // 파일 변조 체크
    private final Tika tika = new Tika();
    // 저장될 디스크 경로
    private final String uploadPath = Paths.get("/Users","yudonghyeon","Desktop","내일배움캠프","개인과제","upload-files").toString();

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

    /**
     * 단일 파일 업로드 메서드
     * @param multipartFile 파일
     * @return DB에 저장할 파일 정보
     */
    private FileRequestDto uploadFile(MultipartFile multipartFile) {
			  // 파일 유무 체크
        if (multipartFile.isEmpty()) {
            return null;
        }

        // 이미지 파일인지 확인
        validImgFile(multipartFile);

        // 저장할 파일명
        String saveName = generateSaveFilename(multipartFile.getOriginalFilename());
        // 저장할 폴더명
        String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd"));
        // 저장될 경로
        String uploadPath = getUploadPath(today) + File.separator + saveName;
        File uploadFile = new File(uploadPath);

        try {
		        // 디스크에 파일 생성
            multipartFile.transferTo(uploadFile);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return FileRequestDto.builder()
                .fileName(multipartFile.getOriginalFilename())
                .saveName(saveName)
                .size(multipartFile.getSize())
                .build();
    }

    /**
     * 저장할 파일명 생성 메서드
     * @param filename 원본 파일명
     * @return 디스크에 저장할 파일명
     */
    private String generateSaveFilename(final String filename) {
		    // 32자리의 랜덤 문자열
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        // 업로드할 파일의 확장자
        String extension = StringUtils.getFilenameExtension(filename);
        return uuid + "." + extension;
    }

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

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

    /**
     * 파일 변조 체크 메서드
     * @param file 파일
     * @return boolean
     */
    private void validImgFile(MultipartFile file) {
        try {
            InputStream inputStream = file.getInputStream();
            // mime type(확장자)을 체크
            // 파일의 확장자를 변경하여도 mime type을 체크해준다.
            String mimeType = tika.detect(inputStream);
           
            // enum 클래스를 통해 업로드 가능한 확장자인지 체크(예외 발생)
            ImgFileType.getImgFileType(mimeType);
        }catch (IOException e){ // getInputStream() 예외 처리
            log.error(e.getMessage());
        }
    }

    /**
     * 다운로드할 첨부파일 리소스 조회
     * @param file 첨부파일 상세정보
     * @return 첨부파일 리소스
     */
    public Resource readFileAsResource(FileResponseDto file) {
        String uploadDate = file.getCreatedAt().toLocalDate().format(DateTimeFormatter.ofPattern("yyMMdd"));
        String fileName = file.getSaveName();
        Path filePath = Paths.get(uploadPath,uploadDate, fileName);
        System.out.println(filePath);
        try {
            Resource resource = new UrlResource(filePath.toUri());
            if (!resource.exists() || !resource.isFile()) {
                throw new ScheduleException(ErrorCode.FILE_NOT_FOUND);
            }
            return resource;
        } catch (MalformedURLException e) {
            throw new ScheduleException(ErrorCode.FILE_NOT_FOUND);
        }
    }

    /**
     * 다중 파일 삭제 (from Disk)
     * @param files 삭제할 파일 정보 목록
     */
    public void deleteFiles(List<FileResponseDto> files) {
        if (CollectionUtils.isEmpty(files)) {
            return;
        }
        for (FileResponseDto file : files) {
            String uploadDate = file.getCreatedAt().toLocalDate().format(DateTimeFormatter.ofPattern("yyMMdd"));
            deleteFile(uploadDate, file.getSaveName());
        }
    }

    /**
     * 단일 파일 삭제 (from Disk)
     * @param uploadDate 추가 경로
     * @param fileName 저장한 파일명
     */
    private void deleteFile(String uploadDate, String fileName) {
        String filePath = Paths.get(uploadPath,uploadDate,fileName).toString();
        File file = new File(filePath);
        if (file.exists()) {
            file.delete();
        }
    }
}

6. Controller (일정, 파일)

일정 컨트롤러 (파일 업로드)

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ScheduleController {

    private final ScheduleService scheduleService;
    private final FileService fileService;
    private final FileUtils fileUtils;

    /**
     * 일정 등록하는 메서드
     * @param requestDto 일정 요청 DTO(제목, 담당자, 내용, 비밀번호, 첨부파일)
     * @return 일정 응답 DTO
     */
    @PostMapping("/schedules")
    public ScheduleResponseDto createSchedule(@Valid ScheduleRequestDto requestDto) {
        ScheduleResponseDto responseDto = scheduleService.createSchedule(requestDto);
        // 파일 업로드
        List<FileRequestDto> files = fileUtils.uploadFiles(requestDto.getFiles());
        // 파일 정보 DB에 저장
        fileService.savaFiles(responseDto.getId(), files);
        return responseDto;
    }
}

파일 컨트롤러

package com.sparta.nbcampspringpersonaltask.controller;

import com.sparta.nbcampspringpersonaltask.dto.FileResponseDto;
import com.sparta.nbcampspringpersonaltask.service.FileService;
import com.sparta.nbcampspringpersonaltask.utils.FileUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.List;

@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {

    private final FileService fileService;
    private final FileUtils fileUtils;

    /**
     * 파일 목록 조회 메서드
     * @param ScheduleId 일정 ID
     * @return 파일 목록
     */
    @GetMapping
    public List<FileResponseDto> getAllFiles(Long ScheduleId) {
        return fileService.findAllFilesByScheduleId(ScheduleId);
    }

    /**
     * 파일 다운로드 메서드
     * @param id 파일 ID
     * @return 파일 다운로드
     */
    @GetMapping("/download/{id}")
    public ResponseEntity<Resource> downloadFile(@PathVariable Long id){
		    // 파일 상세 정보
        FileResponseDto file = fileService.findFileById(id);
        // 파일 리소스
        Resource resource = fileUtils.readFileAsResource(file);
        try {
            String filename = URLEncoder.encode(file.getFileName(), "UTF-8");
            return ResponseEntity.ok()
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; fileName=\"" + filename + "\";")
                    .header(HttpHeaders.CONTENT_LENGTH, file.getSize()+"")
                    .body(resource);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("filename encoding failed : " + file.getFileName());
        }
    }
}

7. View

// 일정 조회 HTML
function addHTML(id, title, content, writer, modifiedAt) {
    // 1. HTML 태그를 만듭니다.
    let tempHtml = `<div class="card">
                <!-- date/username 영역 -->
                <div class="metadata">
                    <div id="${id}-title" class="title">
                        ${title}
                    </div>
                    <input type="text" value="${title}" id="${id}-editTitle" style="display: none">
                </div>
                <div class="metadata">
                    <div id="${id}-writer" class="writer">
                        <span>담당자 : </span>${writer}
                    </div>
                    <input type="text" value="${writer}" id="${id}-editWriter" style="display: none">
                    <div class="date">
                        <span>수정 : </span>${modifiedAt}
                    </div>
                </div>
                <!-- contents 조회/수정 영역-->
                <div class="contents">
                    <div id="${id}-contents" class="text">
                        ${content}
                    </div>
                    <div id="${id}-editarea" class="edit">
                        <textarea id="${id}-textarea" class="te-edit" name="" id="" cols="30" rows="5"></textarea>
                    </div>
                </div>
                <input type="text" id="${id}-editPassword" style="display: none" placeholder="비밀번호를 입력하세요.">
                <!-- 첨부파일 영역 -->
                <button id="${id}-fileBtn"token interpolation">${id}')">첨부파일 보기</button>
                <div id="${id}-files-box">
                </div>
                <!-- 버튼 영역-->
                <div class="footer">
                    <img id="${id}-edit" class="icon-start-edit" src="images/edit.png" alt=""token interpolation">${id}')">
                    <img id="${id}-delete" class="icon-delete" src="images/delete.png" alt=""token interpolation">${id}')">
                    <img id="${id}-submit" class="icon-end-edit" src="images/done.png" alt=""token interpolation">${id}')">
                    <img id="${id}-submitDelete" class="icon-end-edit" src="images/done.png" alt=""token interpolation">${id}')">
                </div>
            </div>`;
    // 2. #cards-box 에 HTML을 붙인다.
    $('#cards-box').append(tempHtml);
}

// 일정 등록
function writeSchedule() {

    let title = $('#title').val();
    let writer = $('#writer').val();
    let password = $('#password').val();
    let content = $('#content').val();

    // formData 생성 
    let formData = new FormData();
    formData.append("title",title);
    formData.append("writer",writer);
    formData.append("password",password);
    formData.append("content",content);

    let fileCount = $("input[name='files']").length;

    if (fileCount > 0) {
        for (var i=0; i<fileCount; i++) {
            if ($("input[name='files']")[i].files[0] != null) {
                formData.append("files", $("input[name='files']")[i].files[0]);
            }
        }
    }

    $.ajax({
        type: "POST",
        url: "/api/schedules",
        contentType: false, // formData 사용 시 꼭 필요
        processData: false, // formData 사용 시 꼭 필요
        data: formData,
        success: function (response) {
            alert('일정이 성공적으로 작성되었습니다.');
            window.location.reload();
        },
        error: err => {
            alert(err.responseJSON.message);
        }
    });
}

// 파일 선택
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;
}

let fileCnt = 1;
let totalCnt = 10;
// 파일 추가
function addFile() {
    if (fileCnt >= totalCnt) {
        alert("10개 이하의 파일만 업로드할 수 있습니다.")
    }else {
        const fileDiv = document.createElement('div');
        fileDiv.innerHTML =`
            <div class="file_input">
                <input type="text" readonly />
                <label> 첨부파일
                    <input type="file" name="files" />
                </label>
            </div>
            <button type="button" class="btns del_btn"><span>삭제</span></button>
        `;
        document.querySelector('.file_list').appendChild(fileDiv);
        fileCnt++;
    }
}

// 파일 삭제
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();
}

// 파일 목록 조회
function getFileList(ScheduleId) {
    $(`#${ScheduleId}-files-box`).empty();

    $.ajax({
        type: 'GET',
        url: '/api/files',
        data: {"ScheduleId":ScheduleId},
        success: function (response) {
            for (let i = 0; i < response.length; i++) {
                let message = response[i];
                let id = message['id'];
                let scheduleId = message['scheduleId'];
                let fileName = message['fileName'];
                let saveName = message['saveName'];
                let size = message['size'];
                addFileHTML(id, scheduleId, fileName, saveName, size);
            }
        },error: err => {
            alert(err.responseJSON.message);
        }
    })
}

// 파일 목록 HTML
function addFileHTML(id, scheduleId, fileName, saveName, size) {
    // 1. HTML 태그를 만듭니다.
    let tempHtml = `<div id="${id}-file">
                                <a href="http://localhost:8080/api/files/download/${id}">${fileName}</a>
                            </div>`;
    // 2. #cards-box 에 HTML을 붙인다.
    $(`#${scheduleId}-files-box`).append(tempHtml);
}

0개의 댓글