* Resource 인터페이스: 컴퓨터 내 물리적으로 저장된 파일을 읽을 수 있는 인터페이스. 해당 인터페스의 구현체를 클라이언트에게 반환하면 파일을 다운로드 받을 수 있음.
1. FileMapper 인터페이스 수정 - 게시글 내 클릭한 파일의 상세정보 조회 메서드 추가
FileResponse getFileBySeq(int seq); // 파일 상세정보 조회
DB 내 파일 테이블에서 파일의 상세정보를 조회 할 수 있도록 메서드를 추가한다.
리턴 타입은 당연히 FileResponse 이다.
2. FileMapper XML 수정 - 파일 SEQ 값을 참조하여 파일의 정보를 조회하는 쿼리 추가
<select id="getFileBySeq" resultMap="fileResponse">
SELECT
SEQ,
<include refid="fileColumns"></include>
FROM
BOARD_FILE
WHERE
DELETE_YN = 'N'
AND SEQ = #{seq}
</select>
요청된 파일의 seq 값을 이용해 해당 seq의 파일 정보를 조회하는 쿼리를 추가한다.
FileResponse 로 변환하기 위해 resultMap 설정하는거 잊지말자.
3. FileService 클래스 수정 - 메서드 추가
public FileResponse getFileBySeq(int seq) {
return fileMapper.getFileBySeq(seq);
}
파일 조회 메서드를 서비스에도 추가해준다.
4. FileUtils 클래스 수정 - 클라이언트에게 제공할 Resource 구현체 생성
//파일 Resource 객체 생성
public Resource readFileAsResource(final FileResponse file) {
String uploadedDate = file.getCreatedDate().toLocalDateTime().format(DateTimeFormatter.ofPattern("yyMMdd"));
String filename = file.getSaveName();
Path filePath = Paths.get(uploadPath, uploadedDate, filename);
try {
Resource resource = new UrlResource(filePath.toUri());
if(resource.exists() == false || resource.isFile() == false) {
throw new RuntimeException("file not found: "+filePath.toString());
}
return resource;
} catch (MalformedURLException e) {
throw new RuntimeException("file not found: "+filePath.toString());
}
}
앞서 말했던 이번 예제에서 가장 중요한 Resource 구현체를 생성해주는 메서드이다.
Path filePath = Paths.get(uploadPath, uploadedDate, filename): 앞선 과정 중 DB에서 조회하여 파라미터로 넘어온 파일의 정보를 이용해 해당 파일이 저장되어 있는 파일의 경로
Resource resource = new UrlResource(filePath.toUri()): 위에서 만든 파일의 경로를 Resource 인터페이스의 구현체인 UrlResource 객체 생성자에 집어넣어 결국 클라이언트에게 제공할 수 있는 Resource 객체를 만들게 된다.
생성한 Resource가 존재하지 않거나 파일이 아닐 경우에 인지할 수 있는 간단한 예외처리를 한다.
5. FileRestController 클래스 수정 - 파일 다운로드 매핑 메소드 추가
// 파일 다운로드
@GetMapping("fileDownload")
public ResponseEntity<Resource> downloadFile(@RequestParam("seq") int seq) {
FileResponse file = fileService.getFileBySeq(seq);
Resource resource = fileUtils.readFileAsResource(file);
try {
String filename = URLEncoder.encode(file.getOriginalName(), "UTF-8"); // 다운로드 시 파일명
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) // HTTP MIME 타입 설정
.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.getOriginalName());
}
}
다운로드 요청을 받고 앞서 추가한 메서드들을 호출해서 파일의 정보를 조회하고, 조회된 파일 정보를 가지고 Resource 객체를 만들어서 클라이언트에게 반환한다.
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) // HTTP MIME 타입 설정
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; fileName=\""+filename+"\";") // 이 응답은 파일 다운로드
.header(HttpHeaders.CONTENT_LENGTH, file.getSize() + "") // 헤더에 파일크기 설정
.body(resource);
클라이언트에게 반환되는 이 응답은 파일 다운로드이다. 라는 것을 알려주기 위한 HTTP 헤더 설정 방법이다.
HttpHeaders.CONTENT_DISPOSITION: 이 값을 HTTP 헤더에 설정해주어야 클라이언트가 파일을 다운로드 받을 수 있다.
@Getter
@ToString
public class FileResponse {
private int seq;
private int boardSeq;
private String originalName;
private String saveName;
private long size;
private String deleteYn;
private Timestamp createdDate;
private Timestamp deletedDate;
}
DB에 저장된 파일 테이블을 통해 파일정보를 클라이언트에게 응답해주기 위한 응답용 객체이다.
2. FileMapper 인터페이스 수정 - 게시글에 등록된 파일 조회 메서드 추가
@Mapper
public interface FileMapper {
void saveAll(List<FileRequest> files); // 파일 정보 저장
List<FileResponse> getFilesByBoardSeq(int boardSeq); // 게시글 파일 리스트 조회
}
List<FileResponse> getFilesByBoardSeq(int boardSeq):게시글 기본키인 boardSeq 값을 참조하여 파일 테이블 내 해당 게시글의 파일들의 정보를 조회하는 메서드
앞서 생성한 Mapper 인터페이스의 getFilesBoardSeq() 메서드와 연결할 쿼리를 작성한다.
게시글 테이블의 기본키인 BOARD_SEQ 칼럼을 참조하여 해당 게시글의 해당되는 파일들을 조회한다.
조회 결과를 FileResponse 객체로 변환하여야 하기 때문에 ResultMap 태그를 작성해준다.
4. FileService 클래스 수정 - 게시글에 등록된 파일 조회 메서드 추가
public List<FileResponse> getFilesByBoardSeq(int boardSeq) { // 파일 정보 조회
return fileMapper.getFilesByBoardSeq(boardSeq);
}
Mapper 인터페이스를 통해 XML 쿼리까지 연결되기 위해 FileService 클래스에도 메서드를 추가해준다.
5. FileRestController 클래스 생성
@RestController
@RequiredArgsConstructor
public class FileRestController {
private final FileService fileService;
@GetMapping("/fileList")
public ResponseEntity<Object> getFileList(@RequestParam("boardSeq") int seq) {
List<FileResponse> files = new ArrayList<>();
files = fileService.getFilesByBoardSeq(seq);
return ResponseEntity.status(HttpStatus.OK).body(files);
}
}
파일 정보 조회 요청을 받을 컨트롤러를 생성한다.
해당 컨트롤러는 API 형식으로 통신이 진행될 것이기 때문에 @RestController 어노테이션을 지정해준다.
ResponseEntity 객체를 통해 게시글의 파일들의 정보를 JSON 형태로 리턴한다.
6. 게시글 상세 페이지 HTML 수정 - 게시글 파일 정보 영역 및 AJAX 통신 함수 추가
<tr>
<td>첨부파일</td>
<td id="files"></td>
</tr>
다음과 같이 게시글 상세 페이지에 파일 정보가 보이게 될 영역을 추가한다
window.onload = () => { // 상세 페이지가 로드되면 파일정보 조회 API 실행
getFiles();
}
function getFiles() { // AJAX 통신을 통해 파일정보를 불러옴.
let seq = [[${board.seq}]] || '';
$.ajax({
type: 'get',
url: `/fileList?boardSeq=${seq}`,
success: function(result) {
console.log(result);
setFileInfo(result)
}
})
}
function setFileInfo(files) { // HTML 내 파일영역에 불러온 파일정보 삽입
let fileHtml = '<div class="file_down"><div class="cont">';
files.forEach(row => {
fileHtml += `<a>${row.originalName}</a>`
})
fileHtml += '</div></div>'
document.getElementById('files').innerHTML = fileHtml
}
게시글 상세 페이지가 로드되면 getFiles() -> setFileInfo() 함수가 순서대로 실행된다.
getFiles(): AJAX를 통해 FileController에 파일 정보를 요청한다.
setFileInfo(): AJAX 요청이 성공하면 응답받은 파일 정보를 HTML 내 파일 영역에 삽입된다.
게시판 테이블의 기본키, serial 타입으로 인해 Auto Increament 속성 적용
title
게시글의 제목
writer
게시글의 작성자
reg_date
게시글의 작성일자
count
게시글의 조회수
content
게시글의 내용
해당 테이블이 존재한다는 가정하에 파일 테이블을 다음과 같이 생성한다.
Column
Description
seq
파일 테이블의 기본키, serial 타입으로 인해 Auto Increament 속성 적용
board_seq
게시판 테이블의 기본키를 참조하는 외래키, 게시글마다 첨부파일을 연결하기 위한 칼럼
original_name
업로드하는 첨부파일의 원본 파일명
save_name
컴퓨터 내 물리적으로 보관될 파일명
size
파일의 크기
delete_yn
파일의 삭제 여부
created_date
파일의 생성일자
deleted_date
파일의 삭제일자
여기서 핵심적인 칼럼은 original_name과 save_name이다. 두 칼럼 모두 파일의 이름을 의미하지만, DB 테이블에만 저장되는 original_name과 컴퓨터 내 물리적으로 저장되는 save_name은 서로 달라야 한다.
예를 들어, 서로 다른 사용자가 "춘식이.jpg" 라는 이름의 파일을 저장했을 때 이 첨부파일의 이름을 그대로 서버 컴퓨터 내 저장하게 되면 각각 어느 사용자가 업로드한 춘식이인지 구별할 수 없다. 그래서 실제로 저장되는 파일명은 랜덤값을 통해 고유한 식별값으로 지정되어야 한다.
2. FileRequest 클래스 생성
@Getter
public class FileRequest {
private int boardSeq; // 게시글 번호(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 setBoardSeq(int boardSeq) {
this.boardSeq = boardSeq;
}
}
앞서 생성한 파일 테이블에 저장하기 위한 파일 정보를 담을 용도인 FileRequest 클래스이다.
파일 정보를 손쉽게 설정하기 위해 빌더패턴을 사용한다.
boardSeq(게시글 번호)는 게시글이 생성된 후에 설정을 해줘야 하기 때문에 파일 정보들과 설정 시기가 맞지 않아 따로 Setter 메소드로 제외한다.
3. FileMapper 인터페이스 생성
@Mapper
public interface FileMapper {
void saveAll(List<FileRequest> files); // 파일 정보 저장
}
saveAll(List<FileRequest files): 들어온 FileRequest 클래스를 통해 파일의 정보를 DB에 저장한다. 게시판에 다중 파일 첨부 기능을 도입할 것이기 때문에 여러 파일 정보를 저장하기 위해 List<FileRequest> 타입으로 받는다.
여러개의 파일을 저장할 것이기 때문에 List 형태로 넘어온 FileRequest를 <foreach>태그로 한꺼번에 INSERT 한다.
5. FileService 클래스 생성
@Service
@RequiredArgsConstructor
public class FileService {
private final FileMapper fileMapper;
@Transactional
public void saveFiles(final int boardSeq, final List<FileRequest> files) {
if(CollectionUtils.isEmpty(files)) {
return;
}
for(FileRequest file : files) {
file.setBoardSeq(boardSeq);
}
fileMapper.saveAll(files);
}
}
파일을 업로드할 게시글의 번호와 파일 정보를 받아 fileMapper의 saveAll 메소드를 호출하여 DB까지 연결되도록 한다.
saveAll(files)를 통해 여러개의 파일이 삽입되던 중 한가지의 파일을 삽입하다가 문제가 발생되면 해당 메소드 호출 이전으로 롤백될 수 있도록 @Transactional 어노테이션을 사용한다.
6. FileUtils 클래스 생성
@Component
public class FileUtils {
// 물리적으로 파일을 저장할 위치(C:\\boardPrc\\upload-files)
private final String uploadPath = Paths.get("D:", "boardPrc", "upload-files").toString();
// 다중 파일 업로드
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;
}
// 단일 파일 업로드
public FileRequest uploadFile(final MultipartFile multipartFile) {
if(multipartFile.isEmpty() ) {
return null;
}
// 디스크(폴더)에 저장할 파일명
String saveName = generateSaveFilename(multipartFile.getOriginalFilename());
// 오늘날짜 (240706)
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")).toString();
// 파일의 업로드 경로 (D:\\boardPrc\\upload-files\\240706\\saveName)
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() // 업로드 한 파일의 정보를 빌더패턴으로 DTO에 담는다.
.originalName(multipartFile.getOriginalFilename())
.saveName(saveName)
.size(multipartFile.getSize())
.build();
}
// saveName 생성 (실제로 폴더에 저장될 파일명)
private String generateSaveFilename(final String filename) {
String uuid = UUID.randomUUID().toString().replaceAll("-", ""); // 파일의 식별을 위해 랜덤값 지정
String extension = StringUtils.getFilenameExtension(filename);
return uuid + "." + extension;
}
private String getUploadPath() {
return makeDirectories(uploadPath);
}
private String getUploadPath(final String addPath) {
return makeDirectories(uploadPath + File.separator + addPath);
}
private String makeDirectories(final String path) {
File dir = new File(path);
if(dir.exists() == false) {
dir.mkdirs();
}
return dir.getPath();
}
파일을 직접 MultipartFile 객체를 이용해 서버 컴퓨터 내 물리적으로 업로드를 하고, 업로드 할 파일의 정보를 가지고 FileRequest 객체를 만드는 클래스이다.
이곳에서 만들어진 FileRequest 객체를 가지고 DB의 파일 정보 테이블에 삽입하게 된다.
String uploadPath = Paths.get("D:", "boardPrc", "upload-files").toString(): 물리적으로 파일을 저장할 경로를 담는 변수이다.
FileRequest uploadFile(MultipartFile multipartFile): 물리적으로 저장되는 파일명인 saveName 및 추가적인 폴더명을 생성하여 만든 파일 저장 경로를 가지고 서버 컴퓨터 내 실제로 파일을 업로드하고, 업로드 된 정보를 빌더 패턴을 통해 FileRequest 객체로 변환하여 리턴한다.
List<FileRequest> uploadFiles(List<MultipartFile> multipartFiles): MultipartFile 객체가 여러개일 경우 List 형태로 받아서 반복문으로 각 파일을 uploadFile() 메소드를 사용하여 다중 파일 처리를 하는 메소드이다.
String generateSaveFilename(String filename): 서버 컴퓨터 내 폴더에 물리적으로 저장할 파일명을 생성하는 메소드이다. 앞서 설명했듯이 디스크에 저장되는 파일명들은 각 고유한 식별값을 가져야 하므로 UUID라는 랜덤값을 이용해 생성한다.
String getUploadPath(): 맨 끝 상단에서 정의한 uploadPath 변수에 설정된 경로를 리턴하는데, 추가적인 경로 문자열을 파라미터로 넘기면 해당 경로를 추가하여 채로 리턴한다.
String makeDirectories(String path): getUploadPath() 메소드에서 사용하는 메소드로, 경로를 파라미터로 받고 해당 경로가 존재하지 않으면 폴더를 새로 생성하고, 존재하면 경로 그대로를 다시 리턴한다.
BoardService의 registBoard() 메소드는 기존에 boardMapper를 통해 게시글만 DB에 삽입하는 역할 뿐만 아니라 MultipartFile 객체를 처리하여 파일 업로드, 파일정보 DB 삽입 기능 등을 FileUtils와 FileService 클래스에게 직접 위임하는 역할이 추가되었다.
List<FileRequest> files = fileUtils.uploadFiles(multipartFiles): MultipartFile 객체를 앞서 작성한 FileUtils의 uploadFiles() 메소드에게 넘기며 호출한다.
fileService.saveFiles((int) board.get("SEQ"), files): 생성된 게시글의 기본키 값과 생성된 FileRequest를 가지고 FileService에게 DB에 삽입되도록 saveFiles() 메소드를 호출한다.