하루의 일상💜

[Spring] #6. 게시판만들기_첨부파일 업로드/수정/삭제 본문

SpringBoot

[Spring] #6. 게시판만들기_첨부파일 업로드/수정/삭제

도하루박 2022. 12. 29. 20:10
반응형

UUID

스프링에서는 파일이름을 만들어주는 기능이 없기 때문에 무조건 원본파일 이름만 가져올 수 있다.

그렇기 때문에 자바쪽에서 사용하여 중복이 될수 있는 랜덤한 숫자를 가져와준다.

중복이 됐다는건 정말 말도 안되는 확률로 중복 유효아이디.

중복이 없는 유효한 값으로 모든 파일 앞에 UUID를 붙여준다.

-> 사용자가 똑같은 이름의 파일을 업로드 해도 중복될 일이 없다.

왜냐하면 실제로 업로드된 파일 이름 앞에는 UUID 가 붙기 때문에 중복 가능성이 없다.

 

FILE_IMAGE_CHECK CHAR(1)

일반파일의 썸네일과 이미지 파일의 썸네일이 다를 것이다. 

이미지 파일은 해당이미지를 썸네일로 만들어야 할 것이고 일반파일은 우리가 준비한 이미지를 넣어 줘야 한다.

그렇기 때문에 이미지 체크를 boolean 이아니라 char로 넣어 주었다.

DB TABLE 작성
CREATE SEQUENCE SEQ_FILE;

CREATE TABLE TBL_FILE(
	FILE_NUMBER NUMBER CONSTRAINT PK_FILE PRIMARY KEY,
	FILE_NAME VARCHAR2(500),
	FILE_UPLOAD_PATH VARCHAR(500),
	FILE_UUID VARCHAR(500),
	FILE_IMAGE_CHECK CHAR(1),
	BOARD_NUMBER NUMBER,
	CONSTRAINT FK_FILE FOREIGN KEY (BOARD_NUMBER) REFERENCES TBL_BOARD(BOARD_NUMBER)
);
FileVO
package com.example.app.domain.vo;

import lombok.Data;
import org.springframework.stereotype.Component;

@Component
@Data
public class FileVO {
    private Long fileNumber;
    private String fileName;
    private String fileUploadPath;
    private String fileUuid;
    private boolean fileImageCheck;
    private Long fileSize;
    private Long boardNumber;

    public void create(String fileName, String fileUploadPath, String fileUuid, boolean fileImageCheck) {
        this.fileName = fileName;
        this.fileUploadPath = fileUploadPath;
        this.fileUuid = fileUuid;
        this.fileImageCheck = fileImageCheck;
    }
}
FileMapper.xml
<mapper namespace="com.example.app.mapper.FileMapper">
    <insert id="insert">
        INSERT INTO TBL_FILE(FILE_NUMBER, FILE_NAME, FILE_UPLOAD_PATH, FILE_UUID, FILE_IMAGE_CHECK, FILE_SIZE, BOARD_NUMBER)
        VALUES(SEQ_FILE.NEXTVAL, #{fileName}, #{fileUploadPath}, #{fileUuid}, #{fileImageCheck}, #{fileSize}, #{boardNumber})
    </insert>

    <delete id="delete">
        DELETE FROM TBL_FILE
        WHERE BOARD_NUMBER = #{boardNumber}
    </delete>

    <select id="selectAll" resultType="fileVO">
        SELECT FILE_NUMBER, FILE_NAME, FILE_UPLOAD_PATH, FILE_UUID, FILE_IMAGE_CHECK, FILE_SIZE, BOARD_NUMBER
        FROM TBL_FILE
        WHERE BOARD_NUMBER = #{boardNumber}
    </select>
</mapper>

첨부파일은 수정이 없기 때문에 추가 삭제 조회만 만들어준다.

 

FileMapper
package com.example.app.mapper;

import com.example.app.domain.vo.FileVO;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface FileMapper {
//    파일 추가
    public void insert(FileVO fileVO);
//    파일 삭제
    public void delete(Long boardNumber);
//    파일 조회
    public List<FileVO> selectAll(Long boardNumber);
}
FileDAO
package com.example.app.repository;

import com.example.app.domain.vo.BoardVO;
import com.example.app.domain.vo.Criteria;
import com.example.app.domain.vo.FileVO;
import com.example.app.mapper.BoardMapper;
import com.example.app.mapper.FileMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class FileDAO {
    private final FileMapper fileMapper;
    //    파일 추가
    public void save(FileVO fileVO){
        fileMapper.insert(fileVO);
    }
    //    파일 삭제
    public void remove(Long boardNumber){
        fileMapper.delete(boardNumber);
    }
    //    파일 조회
    public List<FileVO> findAll(Long boardNumber){
        return fileMapper.selectAll(boardNumber);
    }

}

 

 

첨부파일은 단독으로 쓰이지 않고 게시글에 의해 쓰여진다.

그렇기 때문에 FileService를 따로 생성하지 않고 BoardService에서 사용해야한다.

BoardController에서는 게시글 등록 후 파일을 등록할 수 있는 순서로 쿼리를 작성해야 한다.

@Service
@RequiredArgsConstructor @Qualifier("community") @Primary
public class CommunityBoardService implements BoardService {
    private final BoardDAO boardDAO;
    private final FileDAO fileDAO;
    
@Override
public void register(BoardDTO boardDTO) { //DTO 생성
    boardDAO.save(boardVO);
    fileDAO.save(fileVO);
}

위와 같이 fileDAO를 받아서 파라미터 값을 fileVO 값을 받아야 하는데 BoardVO 값 안에는 fileVO 가 없으므로 BoardDTO를 생성해서 fileVO값을 받아 올 수 있도록 해야한다.

BoardDTO
package com.example.app.domain.vo;

import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Component;

import java.io.File;
import java.util.List;

@Component
@Data
public class BoardDTO {
    private Long boardNumber;
    private String boardTitle;
    private String boardWriter;
    private String boardContent;
    private String boardRegisterDate;
    private String boardUpdateDate;

    private List<FileVO> files;

    public void create(BoardVO boardVO) {
        this.boardNumber = boardVO.getBoardNumber();
        this.boardTitle = boardVO.getBoardTitle();
        this.boardWriter = boardVO.getBoardWriter();
        this.boardContent = boardVO.getBoardContent();
        this.boardRegisterDate = boardVO.getBoardRegisterDate();
        this.boardUpdateDate = boardVO.getBoardUpdateDate();
    }

    public void create(String boardTitle, String boardWriter, String boardContent) {
        this.boardTitle = boardTitle;
        this.boardWriter = boardWriter;
        this.boardContent = boardContent;
    }

    public void create(String boardTitle, String boardWriter, String boardContent, List<FileVO> files) {
        this.boardTitle = boardTitle;
        this.boardWriter = boardWriter;
        this.boardContent = boardContent;
        this.files = files;
    }
}

 

register를 받는 모든 파라미터를 BoardDTO로 변경해준다

Mapper →  DAO   service 단위테스트

 

BoardService

첨부파일은 한 게시물마다 여러개이므로 테이블로 분리를 했기 때문에 반복문으로 첨부파일 첨부 여부를 분리 해야 한다.

추가
	@Override
    @Transactional(rollbackFor = Exception.class)
    public void register(BoardDTO boardDTO) {
        boardDAO.save(boardDTO);
        List<FileVO> files = boardDTO.getFiles();
//        Optional : 검증
        Optional.ofNullable(files).ifPresent(fileList -> {
            fileList.forEach(file -> {
                file.setBoardNumber(boardDTO.getBoardNumber());
                fileDAO.save(file);
            });
        });
    }

ofNullable(files)가 null 일때에 ifPresent를 실행시킨다. 반대로 null이 아닐때에는 아무것도 실행시키지 않는다.

 @Transactional(rollbackFor = Exception.class) 어떤 Exception이 발생하게 되면 rollback 하게 된다.

 

package com.example.app.service;

@Service
@RequiredArgsConstructor @Qualifier("community") @Primary
public class CommunityBoardService implements BoardService {
    private final BoardDAO boardDAO;
    private final FileDAO fileDAO;

// 추가
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void register(BoardDTO boardDTO) {
        boardDAO.save(boardDTO);
        List<FileVO> files = boardDTO.getFiles();
        Optional.ofNullable(files).ifPresent(fileList -> {
            fileList.forEach(file -> {
                file.setBoardNumber(boardDTO.getBoardNumber());
                fileDAO.save(file);
            });
        });
    }

// 수정
    @Override
    public void modify(BoardDTO boardDTO) {
        boardDAO.setBoardVO(boardDTO);
        fileDAO.remove(boardDTO.getBoardNumber());
        List<FileVO> files = boardDTO.getFiles();
        Optional.ofNullable(files).ifPresent(fileList -> {
            fileList.forEach(file -> {
                file.setBoardNumber(boardDTO.getBoardNumber());
                fileDAO.save(file);
            });
        });
    }

// 삭제
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void remove(Long boardNumber) {
        fileDAO.remove(boardNumber);
        boardDAO.remove(boardNumber);
    }


//조회
    @Override
    public BoardDTO show(Long boardNumber) {
    //첨부파일 까지 같이 조회해야하기 때문에 DTO로 가져온다.
        BoardDTO boardDTO = new BoardDTO();
        boardDTO.create(boardDAO.findById(boardNumber));
        boardDTO.setFiles(fileDAO.findAll(boardNumber));
        return boardDTO;
    }


// 전체조회
    @Override
    public List<BoardVO> showAll(Criteria criteria) {
        return boardDAO.findAll(criteria);
    }


// 갯수
    @Override
    public int getTotal() {
        return boardDAO.findCountAll();
    }
}

 

porn.xml

원본의 용량이 크기 때문에 용량을 줄여주기 위해서 썸네일을 사용하기 위해 porn.xml에 추가해준다.

또한 thumbnailator를 통해서 원본이미지, 썸네일용 이미지로 나눠서 데이터가 전달이 된다.

<dependency>
    <groupId>net.coobird</groupId>
    <artifactId>thumbnailator</artifactId>
    <version>0.4.8</version>
</dependency>

 

application.properties

업로드가 되었을 때 어느정도의 용량을 할당할 것인지에 대해 설

#multipart
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-request-size=40MB
spring.servlet.multipart.max-file-size=40MB
spring.servlet.multipart.file-size-threshold=100MB

 

FileController
@RestController
@RequestMapping("/file/*")
public class FileController {
    @PostMapping("/upload")
//    작성하기 페이지, 수정하기 페이지에서 /upload를 사용하게 될 것이다. 그때 파일 버튼에 change 이벤트를 줄때 Ajax를 사용하여
//    /upload로 이동하게 설정 할 것이기 때문에 postMapping을 사용한 것이다.

    public List<FileVO> upload(List<MultipartFile> upload) throws IOException {
        String rootPath = "C:/upload";
//        외부 경로로 파일 경로 설정 -> C드라이브에 upload 파일을 생성해놔야 한다.
        String uploadPath = getUploadPath();
        List<FileVO> files = new ArrayList<>();

        File uploadFullPath = new File(rootPath, uploadPath);
        if(!uploadFullPath.exists()){uploadFullPath.mkdirs();}

        for(MultipartFile multipartFile : upload){
//            화면쪽에서 여러 파일이 들어오기 때문에 하나씩 반복문에 담게 된다.
            FileVO fileVO = new FileVO();
            UUID uuid = UUID.randomUUID();
//            매 반복마다 UUID 실행
            String fileName = multipartFile.getOriginalFilename();
//            사용자가 업로드한 원본 파일의 이름
            String uploadFileName = uuid.toString() + "_" + fileName;
//            실제 업로드된 파일의 이름

            fileVO.setFileName(fileName); 
            fileVO.setFileUuid(uuid.toString());
            fileVO.setFileUploadPath(getUploadPath());
            fileVO.setFileSize(multipartFile.getSize());

            File fullPath = new File(uploadFullPath, uploadFileName);
            multipartFile.transferTo(fullPath);

//            checkImageType 
//            사용자가 업로드한 파일이 이미지인지 아닌지 검사
            if(Files.probeContentType(fullPath.toPath()).startsWith("image")){
                FileOutputStream out = new FileOutputStream(new File(uploadFullPath, "s_" + uploadFileName));
                Thumbnailator.createThumbnail(multipartFile.getInputStream(), out, 100, 100);
//                원본파일의 이름에 _s를 붙인다음 출력을하여 사이즈 설정까지 하는 쿼리
                
                out.close();
                fileVO.setFileImageCheck(true);
//                가져온 파일이 img인지 검사 ->jpg, png...이미지 파일들은 모두 image로 시작한다.
            }

            files.add(fileVO);
        }
        return files;
    }
    
      @GetMapping("/display")
    public byte[] display(String fileName) throws IOException{
        return FileCopyUtils.copyToByteArray(new File("C:/upload", fileName));
//        실제 경로에 있는 것을 Byte별로 return 한다.
    }
    
     @PostMapping("/delete")
    public void delete(FileVO fileVO) {
        File file = new File("C:/upload", fileVO.getFileUploadPath() + "/" + fileVO.getFileName());
//        업로드된 이미파일 삭제
        if(file.exists()){
            file.delete();
        }
        if(fileVO.isFileImageCheck()){
            file = new File("C:/upload", fileVO.getFileUploadPath() + "/s_" + fileVO.getFileName());
//            업로드된 썸네일파일 삭제
            if(file.exists()){
                file.delete();
            }
        }
    }
write.html
<div class="field">
    <h4>첨부파일</h4>
    <input type="file" name="upload" multiple>
    <!-- mutiple : 여러개의 첨부파일을 동시에 업로드 할 수 있다. -->
</div>
<div class="field">
    <div class="uploadResult">
        <ul></ul>
    </div>

 

//FileController
public List<FileVO> upload(List<MultipartFile> upload) throws IOException
//upload와 매핑이 되어서 MultipartFile에 자동으로 add가 될 것이다.

위의 name="upload"는 FileController의 upload부분과 매핑이 된다.

 

페이지 이동없이 change이벤트를 통해 Ajax로 파일 업로드를 진행할 것이기 때문에 해당 html파일에 script를 작성해준다.

<script>
//file에 change 이벤트 설정
$("input[type='file']").on('change', function(){
    let formData = new FormData();
    let files = this.files;

    Array.from(files).forEach(file => arrayFile.push(file));
    const dataTransfer = new DataTransfer();
    arrayFile.forEach(file => dataTransfer.items.add(file));
    $(this)[0].files = dataTransfer.files;

    $(files).each(function(i, file){
       formData.append("upload", file);
    });

    $.ajax({
        url: "/file/upload",
        type: "post",
        data: formData,
        contentType: false,
        processData: false,
        success: showUploadResult
    })
});
</script>
//썸네일 파일 업로드
//이미지여부 판단
function showUploadResult(files){
    let text = "";
    $(files).each(function(i, file){
        text += `<li data-file-size="` + file.fileSize + `" data-file-name="` + file.fileName + `" data-file-upload-path="` + file.fileUploadPath + `" data-file-uuid="` + file.fileUuid + `" data-file-image-check="` + file.fileImageCheck + `">`;
        text += `<span>X</span>`;
        if(!file.fileImageCheck){
            text += `<img src="/images/attach.png" width="100">`;
        }else{
            text += `<img src="/file/display?fileName=` + file.fileUploadPath + `/s_` + file.fileUuid + "_" + file.fileName + `">`;
        }
        text += `<p>` + file.fileName +`(` + parseInt(file.fileSize / 1024) + `KB)</p>`
        text += `</li>`;
    });
    $(".uploadResult ul").append(text);
}
//썸네일파일 삭제
let arrayFile = [];
let queryString = [[${criteria.queryString}]];

$(".uploadResult ul").on("click", "span", function(){
    const $li = $(this).closest("li");
    let i = $(".uploadResult ul span").index($(this));
    let fileUploadPath = $li.data("file-upload-path");
    let fileName = $li.data("file-uuid") + "_" + $li.data("file-name");
    let fileImageCheck = $li.data("file-image-check");

    $.ajax({
        url: "/file/delete",
        type: "post",
        data: {fileUploadPath: fileUploadPath, fileName: fileName, fileImageCheck: fileImageCheck},
        success: function(){
            $li.remove();
            arrayFile.splice(i, 1);
            const dataTransfer = new DataTransfer();
            arrayFile.forEach(file => dataTransfer.items.add(file));
            $("input[name='upload']")[0].files = dataTransfer.files;
        }
    });
});
//썸네일 개수별로 정렬
$("input[type='submit']").on("click", function(e){
    e.preventDefault();
    let text = "";
    $.each($(".uploadResult ul li"), function(i, li){
        let fileName = $(li).data("file-name");
        let fileUploadPath = $(li).data("file-upload-path");
        let fileUuid = $(li).data("file-uuid");
        let fileSize = $(li).data("file-size");
        let fileImageCheck = $(li).data("file-image-check");
        text += `<input type="hidden" name="files[` + i + `].fileName" value="` + fileName + `">`;
        text += `<input type="hidden" name="files[` + i + `].fileUploadPath" value="` + fileUploadPath + `">`;
        text += `<input type="hidden" name="files[` + i + `].fileUuid" value="` + fileUuid + `">`;
        text += `<input type="hidden" name="files[` + i + `].fileSize" value="` + fileSize + `">`;
        text += `<input type="hidden" name="files[` + i + `].fileImageCheck" value="` + fileImageCheck + `">`;
    });
    $("form#writeForm").append(text).submit();
});

 

 

반응형