[Spring] #6. 게시판만들기_첨부파일 업로드/수정/삭제
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();
});