[SpringBoot - 파일 다루기] 4. 기존 게시판 프로젝트에 '파일 업로드' 기능 추가하기 (완)
0. 개요
- '파일 다루기' 시리즈의 궁극적 목표인 게시판 프로젝트에 파일 업로드 기능을 이식할 것이다.
[기존 게시판 프로젝트]
https://github.com/yashin20/board_service_spring
GitHub - yashin20/board_service_spring: Spring Boot - Board Service
Spring Boot - Board Service. Contribute to yashin20/board_service_spring development by creating an account on GitHub.
github.com
1. 포스팅 목표
- 1. 기존 게시판 프로젝트에 파일 업로드 기능 이식하기
- 2. 다중 파일 업로드 기능 구현
- 3. 게시판 프로젝트의 정상적인 작동
위 3가지 목표의 완수를 위해 포스팅을 진행한다.
2. 구현 방식
- 클라이언트가 게시글 생성을 할때 게시글에 파일을 업로드 할 수 있다.
- 물리적인 업로드된 파일은 서버의 로컬 저장소에 저장된다.
- 'SaveFile' 엔티티를 통해 업로드된 파일의 관계를 관리하여, 소속된 게시글과 매핑한다.
3. 기능구현 (코드)
1. 'application.yml' - 서버 로컬 저장소 경로 지정
spring:
# 업로드 파일의 최대 크기를 설정
servlet:
multipart:
enabled: true
max-file-size: 10000MB #10GB
max-request-size: 10000MB #10GB
# file-size-threshold: 0
#중략...
# 파일 업로드 경로
file:
path: C:\SpringBoot\upload
+) WebConfig.java - HTTP 링크와 서버 로컬 저장소 주소 연결
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// HTTP 요청 경로 "/upload/**"를 로컬 디렉터리 "C:/SpringBoot/upload"와 연결
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:///C:/SpringBoot/upload/");
}
}
2. 'SaveFile.java' - 파일 Entity
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class SaveFile {
@Id @GeneratedValue
@Column(name = "saveFile_id")
private Long id;
private String originalName; //원본 파일명
private String saveName; //저장 파일명
private String fileType; //저장 파일 타입
private Long size; //파일 크기
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post; //소속 게시글
//연관 관계 설정
public void setPost(Post post) {
this.post = post;
}
}
3. 'FileDto.java'
public class FileDto {
@Data
public static class Request {
private Long id; //파일 번호 (PK)
private MultipartFile file;
/**
* 게시글 번호 (FK) - 게시글을 먼저 저장하여, DB로 부터 POST_ID를 부여 받아야 넣을 수 있음!
*/
// private Long postId; //게시글 번호 (FK)
private String originalName; //원본 파일명
private String saveName; //저장 파일명
private String fileType;
private Long size; //파일 크기
public Request(MultipartFile file) {
this.file = file;
}
//DTO -> toEntity
public SaveFile toEntity() {
return SaveFile.builder()
.originalName(originalName)
.saveName(saveName)
.fileType(fileType)
.size(size)
.build();
}
}
※ 'postId' 속성을 사용하지 않은 이유
- 'SaveFile' 엔티티가 소속된 게시글(Post)를 지정하기 위해, 'POST_ID'를 사용해야 한다고 생각하였다.
그러나 생성된 Post가 DB에 저장이 된 후 'POST_ID'가 발급되게 된다. 따라서 'SaveFile'은 그제서야 'POST_ID'를 이용할 수 있게 된다.
이러한 문제가 있어서, 'SaveFile' 과 'Post'를 매핑하기 위해 사용할 방식은 "연관관계 설정" 이다.
DB에 저장되기전 Post 객체 -> 'POST_ID' 없음
DB에 저장되기전 SaveFile 객체 -> 'SAVE_FILE_ID' 없음
각 매핑관계에 따라 객체의 연관관계 설정 메서드를 이용하여 진행한다.
public class SaveFile {
중략...
//연관 관계 설정 메서드
public void setPost(Post post) {
this.post = post;
}
}
--------------------------------------
public class Post extends BaseEntity {
중략...
//연관 관계 메서드
public void addFile(SaveFile file) {
this.files.add(file);
file.setPost(this); //파일에 게시글 연관 관계 설정
}
}
4. 'FileRepository.java' - JPA 이용
5-1. 'FileService.java'
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FileService {
private final FileRepository fileRepository;
//파일 업로드 경로 지정
@Value("${file.path}")
public String uploadFolder;
private static final long MAX_FILE_SIZE = 1024L * 1024L * 1024L * 10L; //10GB
//다수의 파일을 한번에 저장
@Transactional
public List<SaveFile> uploadFiles(List<FileDto.Request> fileRequests) {
List<SaveFile> savedFiles = new ArrayList<>();
for (FileDto.Request request : fileRequests) {
/* request - file */
checkFileType(request.getFile());
//저장 파일명 구성 - saveName
UUID uuid = UUID.randomUUID();
String originalFilename = request.getFile().getOriginalFilename();
String saveFileName = uuid + "_" + originalFilename;
System.out.println("원본 파일명: " + originalFilename);
System.out.println("저장 파일명: " + saveFileName);
//파일 경로 설정
Path path = Paths.get(uploadFolder, saveFileName);
try {
Files.write(path, request.getFile().getBytes());
} catch (Exception e) {
e.printStackTrace();
}
String[] types = request.getFile().getContentType().split("/");//파일 타입 입력
String fileType = types[0]; //image, video, text ...
long size = request.getFile().getSize();
request.setOriginalName(originalFilename);
request.setSaveName(saveFileName);
request.setFileType(fileType);
request.setSize(size);
/* request - file, originalName, saveName, fileType, size */
SaveFile saveFile = request.toEntity();
savedFiles.add(saveFile);
}
fileRepository.saveAll(savedFiles); //다수 파일 저장
return savedFiles;
}
/**
* 파일 타입 확인하기
*/
private void checkFileType(MultipartFile file) {
String contentType = file.getContentType();
System.out.println("파일 타입: " + contentType);
}
public List<SaveFile> findAll() {
return fileRepository.findAll();
}
public List<SaveFile> findByPostId(Long postId) {
return fileRepository.findByPostId(postId);
}
}
이전 포스팅에서 달라진 점은 매개변수를 List<FileDto.Request> 를 이용하여 다수의 파일을 한번에 생성한다는 것이다.
5-2. 'PostService.java'
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
private final MemberRepository memberRepository;
private final FileService fileService;
/** Create Post **/
@Transactional
public Post createPost(PostDto.Request dto) {
//1. request dto -> entity
Post post = dto.toEntity();
//2. 파일 저장 및 연관 관계 설정
if (dto.getFileRequestDtoList() != null && !dto.getFileRequestDtoList().isEmpty()) {
List<SaveFile> saveFiles = fileService.uploadFiles(dto.getFileRequestDtoList());
for (SaveFile saveFile : saveFiles) {
post.addFile(saveFile);
}
}
//3. 게시글 저장
return postRepository.save(post);
}
앞서 언급한 "연관 관계 메서드"를 이용하여 'SaveFile' 과 'Post' 간의 연관관계를 설정한다.
6. 'PostController.java'
@PostMapping("/new")
public String createPost(@ModelAttribute("createPostForm") @Validated(PostDto.Request.Create.class) PostDto.Request dto,
@RequestParam("files") List<MultipartFile> files,
BindingResult bindingResult, Model model) {
/*'유효성 검사' 에러처리*/
if (bindingResult.hasErrors()) {
FieldError titleError = bindingResult.getFieldError("title");
if (titleError != null) {
model.addAttribute("titleError", titleError.getDefaultMessage());
}
FieldError contentError = bindingResult.getFieldError("content");
if (contentError != null) {
model.addAttribute("contentError", contentError.getDefaultMessage());
}
return "posts/create";
}
try {
//0. 현재 로그인된 회원을 글쓴이로 등록
dto.setMember(memberService.getCurrentMember());
//1. 첨부파일 DTO 리스트로 변환
List<FileDto.Request> fileDtos = files.stream()
.filter(file -> !file.isEmpty())
.map(FileDto.Request::new)
.toList();
dto.setFileRequestDtoList(fileDtos);
//2. 게시글 등록
Post post = postService.createPost(dto);
} catch (DataAlreadyExistsException | PasswordCheckFailedException ex) {
bindingResult.reject("errorMessage", ex.getMessage());
model.addAttribute("errorMessage", ex.getMessage());
return "posts/create";
}
return "redirect:/";
}
!문제 발생 - 다수 파일 업로드 과정에서 에러 발생!
html로 부터 List<MultipartFile> files 를 변수로서 Controller에 받아오려 하는데 계속해서 아래와 같은 오류가 발생한다!
```
Internal server error: Validation failed for argument [0] in public java.lang.String project.board_service.controller.PostController.createPost(project.board_service.dto.PostDto$Request,java.util.List,org.springframework.validation.BindingResult,org.springframework.ui.Model): [Field error in object 'createPostForm' on field 'files': rejected value [[org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@7e147991, org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@6b61360f, org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@75e09733]]; codes [typeMismatch.createPostForm.files,typeMismatch.files,typeMismatch.java.util.List,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createPostForm.files,files]; arguments []; default message [files]]; default message [Failed to convert property value of type 'java.util.ArrayList' to required type 'java.util.List' for property 'files'; Cannot convert value of type 'org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile' to required type 'project.board_service.dto.FileDto$Request' for property 'files[0]': no matching editors or conversion strategy found]]
```
위 에러를 간략히 정리하자면,
HTML에서 넘어오는 파일 변수인 " List<MultipartFile> files "가 PostDto.Create 에서 타입이 일치하지 않는다는 것이다.
테스트를 위해, 컨트롤러의 모든 기능을 주석처리하고 파일이 잘 넘어오는지 확인하기 위해
@PostMapping("/new")
public String createPost(@ModelAttribute("createPostForm") @Validated(PostDto.Request.Create.class) PostDto.Request dto,
@RequestParam("files") List<MultipartFile> files,
BindingResult bindingResult, Model model) {
// 1. 넘어온 파일들 처리
for (MultipartFile file : files) {
System.out.println("파일 이름: " + file.getOriginalFilename());
System.out.println("파일 크기: " + file.getSize());
System.out.println("파일 타입: " + file.getContentType());
}
return "redirect:/";
}
이렇게만 남겨 두었기에 이해가 잘 되지 않았다.
"아니 변환할 것이 없는데 왜 에러가 발생하는거지? "
"PostDto.Request 와 files 변수는 별개로 취급되어서 HTML에서 백엔드로 넘어오는것인데 Spring이 왜 타입일치를 검사하는것이지?" 라는 의문이 들었다.
오랜 시간동안 고민을 하다가 그 이유를 알게 되었다.
문제원인은 'PostDto.Request' 에 있는 'List<FileDto.Request> files' 와 컨트롤러를 통해 넘어온 " List<MultipartFile> files "가 서로 변수명이 같기 때문에, Spring은 당연히 두가지 변수가 매핑되는것이라고 판단하여 대입을 하려했지만, 둘의 타입이 다르기에 에러를 반환한것 이었다.
해결방법은 간단했다.
두가지 변수명을 서로 다르게 바꾸면된다.
나는 PostDto.Request의 files를 -> fileRequestDtoList로 변환하였다.
정상적으로 작동하는 것을 볼 수 있다.
문제 해결!
7. Front - end (create.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Post Create Page</title>
<!-- Core theme CSS (includes Bootstrap)-->
<link href="/css/styles.css" rel="stylesheet"/>
</head>
<body>
<!--header-->
<header class="header" th:replace="~{fragments/header :: header}"></header>
<div class="d-flex" id="wrapper">
<!-- Page content wrapper-->
<div id="page-content-wrapper">
<div class="container mt-5">
<!-- Page content-->
<div class="container-fluid">
<h2 class="text-center mb-4">게시글 생성 페이지</h2>
<form th:action="@{/posts/new}" th:object="${createPostForm}" method="post" enctype="multipart/form-data"
class="p-4 border rounded shadow-sm bg-light">
<!-- Title Field -->
<div class="mb-3">
<label for="create-title" class="form-label">제목</label>
<input id="create-title" th:field="*{title}" type="text"
class="form-control" placeholder="제목을 입력하세요" required>
<!-- Error message -->
<div th:if="${titleError}" class="form-text text-danger">
<p th:text="${titleError}"></p>
</div>
</div>
<!-- Content Field -->
<div class="mb-3">
<label for="create-content" class="form-label">내용</label>
<textarea id="create-content" th:field="*{content}" rows="20"
class="form-control" placeholder="내용을 입력하세요" required></textarea>
<!-- Error message -->
<div th:if="${contentError}" class="form-text text-danger">
<p th:text="${contentError}"></p>
</div>
</div>
<!--파일 업로드-->
<div class="mb-3">
<label for="file-section" class="form-label">첨부 파일</label>
<div id="file-section">
<div class="file-input-row d-flex align-items-center mb-2">
<input type="file" class="form-control" name="files" multiple><!--multiple - 다중 파일을 위한 속성-->
<button type="button" class="btn btn-danger btn-sm remove-file">삭제</button>
</div>
</div>
<button type="button" id="add-file-button" class="btn btn-secondary btn-sm mt-2">파일 추가</button>
</div>
<!-- Submit Button -->
<div class="d-grid mb-3">
<button type="submit" class="btn btn-primary">작성하기</button>
</div>
<!-- General Error Message -->
<div th:if="${errorMessage}" class="form-text text-danger text-center">
<p th:text="${errorMessage}"></p>
</div>
</form>
</div>
</div>
</div>
</div>
<!--footer-->
<footer class="footer" th:replace="~{fragments/footer :: footer}"></footer>
<script>
//파일 업로드 줄 추가
document.getElementById('add-file-button').addEventListener('click', function () {
const fileSection = document.getElementById('file-section');
//파일 업로드 줄 생성
const newRow = document.createElement('div');
newRow.classList.add('file-input-row', 'd-flex', 'align-items-center', 'mb-2');
newRow.innerHTML = `
<input type="file" class="form-control me-2" name="files" required>
<button type="button" class="btn btn-danger btn-sm remove-file">삭제</button>
`;
fileSection.appendChild(newRow);
})
//파일 업로드 줄 삭제
document.getElementById('add-file-button').addEventListener('click', function (e) {
if(e.target.classList.contains('remove-file')) {
const row = e.target.closest('.file-input-row');
row.remove();
}
});
</script>
<!-- Bootstrap core JS-->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
파일 업로드 관련 속성들은 앞선 포스팅에서 언급한 것과 새로운 것이 추가되었다.
1. <form> 태그의 [enctype="multipart/form-data"] 속성
- 이 속성이 있어야 form 을 통해 파일 업로드 POST 요청을 보낼 수 있다.
2. <input> 태그의 [type="file"] 속성
- <input> 태그가 다루는 것이 파일 임을 정의
3. <input> 태그의 [multiple] 속성
- 다수의 파일을 다루기 위함을 정의
3번째 속성이 다수의 파일을 한번에 업로드 하기 위해서 중요한 속성이다.
4. 작동확인
1. 업로드할 파일 목록
'CSV', 'xlsx', 'pptx', 'mp4', 'jpg', 'txt' 등 다양한 종류의 파일이 존재
2. 게시글 생성시 첨부파일 업로드
9개의 파일을 모두 추가한다.
'작성하기' 버튼을 눌러서 게시글 작성을 한다.
3. 업로드된 파일 확인
모든파일이 업로드 된 것을 확인할 수 있다.
4. DB 확인
'157' 번 게시글이 작성된 것을 확인할 수 있다.
[SaveFile Table]
'157'번 게시글 소속의 첨부파일로 파일 업로드가 된 것을 확인할 수 있다.
5. 웹 화면 확인
웹 상에서 업로드된 파일을 확인 할 수 있다.
5. 마무리
3가지 목적을 모두 이루었다.
게시판 프로젝트에서 조금 아쉬운 부분이라고 생각하고 있던, 파일 업로드 기능을 직접 구현해 보았다.
이 과정을 통해서 웹 서비스에서 '파일'을 다루는 것에 자신감을 가지게 되었고, 구현 과정에서 직면하는 문제를 해결하는 과정에서 배운 것이 정말 많았다.
기초적인 웹 서비스인 '게시판 프로젝트'를 심적으로도 마무리 하고 끝낸 것 같아서 조금은 후련한 느낌이 들었다.
- 시리즈 포스팅 확인하기
[SpringBoot - 파일 다루기] 1. 이미지 파일 업로드 및 저장하기
https://notorious.tistory.com/422
[SpringBoot - 파일 다루기] 1. 이미지 파일 업로드 및 저장하기
0. 개요게시판 등 다양한 웹서비스에서 제공하는 기능인 파일 업로드 기능을 구현하는 것을 목표로 한다.다양한 파일 업로드 중 '이미지' 파일 업로드를 첫 대상으로 한다. 기존에 제작했던 게
notorious.tistory.com
[SpringBoot - 파일 다루기] 2. 업로드한 이미지 파일 웹에서 확인하기
https://notorious.tistory.com/423
[SpringBoot - 파일 다루기] 2. 업로드한 이미지 파일 웹에서 확인하기
0. 개요- 이전 포스팅에서 클라이언트가 '이미지 파일'을 업로드 하여, 서버에 저장하는 기능을 구현하였다.저장된 이미지를 클라이언트가 확인하는 기능이 필요하다는 것을 느꼈다.따라서, 클
notorious.tistory.com
[SpringBoot - 파일 다루기] 3. 동영상 파일(MP4) 다루기
https://notorious.tistory.com/424
[SpringBoot - 파일 다루기] 3. 동영상 파일(MP4) 다루기
0. 개요- 이전 포스팅은 '이미지 파일'을 다루는 것에 초점을 두었다.이번 포스팅은 동영상 파일(MP4)를 업로드 기능을 구현하고자 한다. [현 상태에서 동영상(MP4) 파일 업로드 한다면...?] 1. '동영
notorious.tistory.com
[SpringBoot - 파일 다루기] 4. 기존 게시판 프로젝트에 '파일 업로드' 기능 추가하기 (완)
https://notorious.tistory.com/425