FureverHomes 프로젝트 - 게시판 글 등록, 다중파일 처리
게시판 관련 기능에 대해서 정리를 해볼건데 먼저 게시글 등록 기능에 대해서 정리해보려고 한다. 게시판은 이미지를 함께 첨부할 수도 있고 첨부하지 않을 수도 있다. 다중파일을 처리하는 방법까지 함께 정리해볼 예정이다.
1. 게시글 등록 (파일x)
Board 엔티티
먼저 Board 엔티티 필드를 작성해줬다. 작성일과 수정일은 BaseEntity를 상속받아 사용하도록 구성했고 게시글 작성자 정보가 필요하므로 Member 엔티티와 연관관계를 매핑해줬다. 그리고 각각의 필드를 빌더로 생성해줬다.
@Entity
@Getter
@NoArgsConstructor
public class Board extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id; //게시판 아이디
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member; //연관관계 매핑 - 멤버 (작성자 필요)
@Lob
@Column(nullable = false)
private String title; //게시판 제목
@Lob
@Column(nullable = false)
private String content; //게시판 글
@Column(nullable = false)
private int views; //조회수
@Builder
public Board(Member member, String title, String content, int views) {
this.member = member;
this.title = title;
this.content = content;
this.views = views;
}
}
BoardReqDTO
먼저 입력받아올 데이터들을 처리하기 위해 BoardReqDTO를 작성해주고 엔티티로 변환활 수 있도록 toEntity 메소드를 생성해준다.
@Getter
public class BoardReqDTO {
private Long memberId;
private String title;
private String content;
public Board toEntity(Member member) {
return Board.builder()
.member(member)
.title(title)
.content(content)
.views(0).build();
}
}
BoardService
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
private final MemberRepository memberRepository;
//게시글 등록
@Transactional
public Long insertBoard(final Long memberId, final BoardReqDTO boardReqDTO) {
Member member = memberRepository.findById(memberId).orElseThrow(() -> new EntityNotFoundException("Member 객체가 없음"));
Board board = boardRepository.save(boardReqDTO.toEntity(member));
return board.getId();
}
}
게시글을 저장하는 기능은 서비스 계층에서 따로 로직을 구성해줄 부분이 거의 없다. 작성중인 회원의 정보만 가지고 와서 board 엔티티에 넣어주기만 하면 된다. save 메소드를 쓰되 매개변수로는 BoardReqDTO에서 만들어준 toEntity 메소드를 사용해서 DTO를 엔티티로 변환해서 저장할 수 있도록 만들어주면 된다.
BoardController
@RestController
@RequestMapping("/fureverhomes")
public class BoardRestController {
//게시판 등록
@PostMapping("/board.create")
public ResponseEntity<Long> insertBoard(@RequestBody BoardReqDTO boardReqDTO, HttpServletRequest request) {
Long boardId = boardService.insertBoard((Long) request.getSession().getAttribute("loginMember"), boardReqDTO);
if (boardId == null) {
return ResponseEntity.internalServerError().build();
}
return ResponseEntity.ok(boardId);
}
}
정상적으로 처리가 되면 boardId를 응답코드에 넣어서 반환할건데 이는 게시글 상세 페이지 URL을 동물 상세 페이지처럼 "/board/{boardId}" 이렇게 설정할 예정이기 때문이다.
board-create.html & js
<main>
<div class="section section-lg pt-5" th:style="'min-height: 100vh'">
<div class="container">
<div class="row">
<div class="col-12 mt-4">
<form action="#" method="post" class="card border-light p-4 mb-4" id="board_form">
<h1 class="h5 mb-4">게시글 등록</h1>
<div class="form-group">
<label for="title">제목</label>
<input type="text" placeholder="" class="form-control" id="title" required>
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea rows="10" class="form-control text-gray" id="content" required></textarea>
</div>
<div class="row">
<div class="col">
<button class="btn btn-warning mt-2 animate-up-2"
type="button" id="create-btn">등록하기</button>
<a class="btn mt-2 animate-up-2 btn-danger" type="button" th:href="@{/fureverhomes/board}">취소</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</main>
$(document).ready(function () {
$("#create-btn").click(function () {
let postURL = "/fureverhomes/board.create"
let data = {
title: $("#title").val(),
content: $("#content").val()
}
$.ajax({
type: "POST",
url: postURL,
contentType: 'application/json',
data: JSON.stringify(data),
async: false,
success: function (data, status) {
location.href="/fureverhomes/board/view"
},
error: function (data, textStatus) {
alert("등록 실패!")
}
});
})
});
html은 각 태그의 Id를 눈여겨볼 필요가 있는데 등록하기 버튼을 누르게 되면 ajax를 이용해서 post 요청을 보내게 된다. 제목과 내용에 입력되어 있는 값들이 title, content에 저장되게 되고, 정상적으로 처리가 되면 전체 게시글 목록 페이지로 이동된다.
2. 다중 파일 처리
Board 엔티티
먼저 Board 엔티티를 조금 수정해줘야 한다. File 엔티티와 연관관계 매핑을 맺어줘야 하는데 하나의 게시글에는 여러개의 파일이 있을 수 있고, 하나의 파일은 하나의 게시글에 포함될 수 있다. 일대다 매핑을 해주고 하나의 게시글에는 여러 개의 파일이 있을 수 있으므로 File 엔티티를 리스트로 받아올 수 있도록 필드를 생성해준다. 게시글이 지워지게 되면 파일도 자연스럽게 지워질 수 있도록 Cascade 타입을 All으로 고아 객체를 true로 설정해준다.
@Entity
@Getter
@NoArgsConstructor
public class Board extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id; //게시판 아이디
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member; //연관관계 매핑 - 멤버 (작성자 필요)
// ** 수정사항 **
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
private List<File> files = new ArrayList<>(); //연관관계 매핑 - 파일
@Lob
@Column(nullable = false)
private String title; //게시판 제목
@Lob
@Column(nullable = false)
private String content; //게시판 글
@Column(nullable = false)
private int views; //조회수
@Builder
public Board(Member member, String title, String content, int views) {
this.member = member;
this.title = title;
this.content = content;
this.views = views;
}
// ** 수정사항 **
public void addFile(File file) {
this.files.add(file);
if (file.getBoard() != this) {
file.setBoard(this);
}
}
File와 Board는 양방향 연관관계를 가지고 있으므로 addFile 메소드를 생성해서 List에 File을 추가하도록 만들어줬다.
File 엔티티
@Entity
@Getter
@NoArgsConstructor
public class File {
@Id @GeneratedValue
@Column(name = "FILE_ID")
private Long id;
@ManyToOne
@JoinColumn(name = "BOARD_ID")
private Board board;
private String original_name; //원본 이름
private String save_name; //저장 이름
private Long size; //파일 사이즈
private String file_path; //파일 경로
private LocalDate createDate = LocalDate.now(); //생성 날짜
private Boolean isDelete = false; //삭제 여부
@Builder
public File(String original_name, String save_name, Long size, String file_path) {
this.original_name = original_name;
this.save_name = save_name;
this.size = size;
this.file_path = file_path;
}
public void setBoard(Board board) {
this.board = board;
if(!board.getFiles().contains(this))
board.getFiles().add(this);
}
}
파일 엔티티에는 원본 파일 이름, 저장용 파일 이름, 파일 경로, 사이즈, 생성 날짜와 삭제 여부 필드를 생성해줬다. Board와 마찬가지로 setBoard 메소드를 이용해서 Board 객체를 저장할 수 있도록 만들어줬다.
FileDTO
@Getter
public class FileDTO {
private String originalFileName;
private String saveFileName;
private Long fileSize;
public FileDTO(String originalFileName, String saveFileName, Long fileSize) {
this.originalFileName = originalFileName;
this.saveFileName = saveFileName;
this.fileSize = fileSize;
}
public File toEntity(File file) {
return File.builder()
.original_name(originalFileName)
.save_name(saveFileName)
.size(fileSize).build();
}
}
파일DTO를 하나 만들어서 원본 이름, 저장 이름, 사이즈 정보를 담도록 만들어줬다.
FileUtils
파일 처리를 위해서 FileUtils 클래스를 하나 생성해줬는데 이 클래스는 파일에 대한 다양한 작업을 해줄 클래스이다. 파일을 생성해 지정한 저장경로에 저장해주고, 삭제도 해주는 작업을 해줄 예정이다.
@Component
public class FileUtils {
public List<File> parseFileInfo(List<MultipartFile> multipartFiles) throws Exception{
List<File> fileList = new ArrayList<>(); //반환할 파일 리스트
//파일이 존재할 경우
if (!CollectionUtils.isEmpty(multipartFiles)) {
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
String current_date = now.format(dateTimeFormatter);
//프로젝트 디렉터 내의 저장을 위한 절대 경로 설정
String absolutePath = new java.io.File("").getAbsolutePath() + java.io.File.separator + java.io.File.separator;
//파일을 저장할 세부 경로 지정
String dbpath = "/board_images" + java.io.File.separator + current_date;
String path = "src/main/resources/static/board_images" + java.io.File.separator + current_date;
java.io.File file = new java.io.File(path);
//디렉터가 존재하지 않을 경우
if (!file.exists()) {
boolean wasSuccessful = file.mkdir();
if(!wasSuccessful) System.out.println("파일 디렉터 생성 실패");
}
//다중 파일 처리
for (MultipartFile multipartFile : multipartFiles) {
//파일 확장자 추출
String originalExtension;
String contentType = multipartFile.getContentType();
//확장자명이 존재하지 않을 경우는 처리하지 않음
if (ObjectUtils.isEmpty(contentType)) {
break;
}
else {
if(contentType.contains("image/jpeg"))
originalExtension = ".jpg";
else if(contentType.contains("image/png"))
originalExtension = ".png";
else if(contentType.contains("image/jpg"))
originalExtension = ".jpg";
else break;
}
//저장 파일명 생성
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
String save_name = uuid + originalExtension;
//파일 DTO 생성
FileDTO fileDTO = new FileDTO(multipartFile.getOriginalFilename(), save_name, dbpath, multipartFile.getSize());
//파일 엔티티 생성
File fileEntity = File.builder()
.original_name(fileDTO.getOriginalFileName())
.save_name(fileDTO.getSaveFileName())
.file_path(fileDTO.getFilePath())
.size(fileDTO.getFileSize()).build();
//생성후 리스트 추가
fileList.add(fileEntity);
//지정한 경로에 저장
file = new java.io.File(absolutePath + path + java.io.File.separator + save_name);
multipartFile.transferTo(file);
//파일 권한 설정(쓰기, 읽기)
file.setWritable(true);
file.setReadable(true);
}
}
return fileList;
}
}
먼저 스프링에 해당 클래스를 빈으로 등록하기 @Component를 붙여준다.
여기서 @Bean과 @Component의 차이점에 대한 궁금증이 생겼다. 둘 다 빈으로 등록하는 것은 같은데 서로 다른 어노테이션이 존재하는 이유는 뭐일까?
- @Bean vs @Component
정답은 둘의 용도가 다르기 때문이다.
먼저 @Bean의 경우 개발자가 직접 제어가 불가능한 외부 라이브러리 등을 빈으로 등록하고자 할 때 사용된다.
예를 들어 ArrayList 같은 외부 라이브러리를 빈으로 등록하기 위해서는 해당 객체를 반환하는 메소드를 만들고 @Bean 어노테이션을 붙여주면 된다.
@Bean
public ArrayList<String> list() {
return new ArrayList<String>();
}
@Component의 경우는 개발자가 직접 작성한 클래스를 빈으로 등록하고자 할 때 사용된다.
위 FileUtils 클래스 같은 경우가 바로 이 경우이다.
각 역할에 따라 주석처리로 적어놓은 부분들을 보면 이해가 쉽게 될 것이다. 폴더명을 날짜로 생성해 안에 파일들을 저장해줄 생각이다. 절대 경로를 설정한 후, 세부 경로를 설정해주면 되는데 나 같은 경우는 데이터베이스에 들어갈 경로를 따로 지정해주고 싶어서 dbpath랑 path 두 개를 만들었는데 각자의 개발 방향에 따라 맞춰서 생성해주면 된다. 하나의 파일이 아니라 다중 파일을 처리해줄거기 때문에 foreach를 이용해 확장자를 추출하고 저장명이 겹치는 일을 방지하기 위해 UUID를 생성하도록 만들어줬다. 그리고 폴더를 생성해서 파일을 저장하기 위해서 쓰기와 읽기 권한을 설정해줬다.
BoardService
BoardService 클래스도 약간의 수정이 필요한데, 매개변수로 file을 함께 받아와야 한다.
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
private final MemberRepository memberRepository;
//게시글 등록
@Transactional
public Long insertBoard(final Long memberId, final Map<String, String> param, final List<MultipartFile> files) throws Exception{
String title = param.get("title");
String content = param.get("content");
BoardReqDTO boardReqDTO = BoardReqDTO.builder().title(title).content(content).build();
Member member = memberRepository.findById(memberId).orElseThrow(() -> new EntityNotFoundException("Member 객체가 없음"));
Board board = boardRepository.save(boardReqDTO.toEntity(member));
List<File> fileList = fileUtils.parseFileInfo(files);
if (!fileList.isEmpty()) {
for (File file : fileList) {
board.addFile(fileRepository.save(file));
}
}
return board.getId();
}
}
클라이언트로부터 파일에 대한 처리를 해주고 싶으면 MultipartFile 타입으로 받아와야 한다. FileUtils 클래스에서 FileDTO를 엔티티로 변환하는 과정까지 수행해주고 있으므로 서비스 계층에는 받아온 file들을 parseFileInfo 메소드를 이용해 FileUtils 클래스로 보내주기만 하면 된다. 이후 board.addFile 메소드를 이용해 file 엔티티를 저장해주면 된다.
BoardController
@RestController
@RequestMapping("/fureverhomes")
public class BoardRestController {
//게시판 등록
@PostMapping("/board.create")
public ResponseEntity<Long> insertBoard(@RequestPart(value = "customFile", required = false)List<MultipartFile> files,
@RequestParam Map<String, String> params,
HttpServletRequest request) throws Exception {
Long boardId = boardService.insertBoard((Long) request.getSession().getAttribute("loginMember"), params, files);
if (boardId == null) {
return ResponseEntity.internalServerError().build();
}
return ResponseEntity.ok(boardId);
}
}
파일 업로드를 위해서는 클라이언트에서는 formData를 사용하여 파일 데이터를 전송할 것이고, 그렇다면 @RequestPart를 사용하는 것이 일반적이다. value명과 폼 데이터의 이름이 일치해야 한다. File 데이터는 필수가 아니기 때문에 required = false로 설정해줬다. formData는 파일 데이터 외에 기타 데이터도 불러올 수 있는데 @RequestPart(value = "reqDTO") BoardReqDTO boardReqDTO 처럼 사용할 수도 있다고 한다. 그러나 나는 title, content를 넘겨줄 것이기 때문에 그냥 @RequestParam을 사용하기로 했다.
board-create.html & js
이미 작성해둔 html 파일에서 이미지 업로드 부분을 추가적으로 작성해준다.
<main>
<div class="section section-lg pt-5" th:style="'min-height: 100vh'">
<div class="container">
<div class="row">
<div class="col-12 mt-4">
<form action="#" method="post" class="card border-light p-4 mb-4" id="board_form">
<h1 class="h5 mb-4">게시글 등록</h1>
<div class="form-group">
<label for="title">제목</label>
<input type="text" placeholder="" class="form-control" id="title" required>
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea rows="10" class="form-control text-gray" id="content" required></textarea>
</div>
<!-- ** 수정 사항 ** -->
<h2 class="h6 mt-2">이미지 업로드</h2>
<div id="fileForm">
<div class="file-upload-row d-flex justify-content-between align-items-center mt-2">
<div class="col-10 custom-file2">
<input id="customFile" type="file" class="custom-file-input2" accept=".jpg,.jpeg,.png">
<label class="custom-file-label" for="customFile" id="show-files">Choose file</label>
</div>
<button type="button" class="btn btn-warning mt-1 ml-1" th:style="'height: calc(1.5em + 1.2625rem);'" id="add-img-btn">추가</button>
<button type="button" class="btn btn-danger mt-1 ml-0" th:style="'height: calc(1.5em + 1.2625rem);'" id="del-img-btn">삭제</button>
</div>
</div>
<small class="text-gray font-weight-light font-xs d-block mt-2">이미지는 최대 10MB까지 가능하며 jpeg, jpg, png만 올릴 수 있습니다.</small>
<!-- ** 수정 사항 ** -->
<div class="row">
<div class="col">
<button class="btn btn-warning mt-2 animate-up-2"
type="button" id="create-btn">등록하기</button>
<a class="btn mt-2 animate-up-2 btn-danger" type="button" th:href="@{/fureverhomes/board}">취소</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</main>
태그들을 살펴보면 추가와 삭제 버튼이 보이는데 이 부분은 추가 버튼을 눌렸을 때 파일 업로드 폼이 하나 더 생성되고, 삭제 버튼을 눌렀을 때는 파일 업로드 폼이 삭제되는 기능을 가지고 있다. 다중 파일을 처리해주기 위해 이러한 방법을 택했다.
$(document).ready(function () {
//파일 업로드 폼 추가
$(document).on("click", "#add-img-btn", function () {
addFileUpload();
});
//파일 업로드 폼 삭제
$(document).on("click", "#del-img-btn", function () {
$(this).closest(".file-upload-row").remove();
})
$("#create-btn").click(function () {
let title = $("#title").val();
let content = $("#content").val();
if (title === "" || title === null || content === "" || content === null) {
alert("내용을 적어주세요.")
return false;
}
let postURL = "/fureverhomes/board.create";
// formData를 생성하고 파일 데이터와 제목, 내용 추가
const formData = new FormData($("#board_form")[0]);
$(".file-upload-row").each(function (index, element) {
let filesInput = $(element).find(".custom-file-input2")[0];
let files = filesInput.files;
$.each(files, function (index, file) {
formData.append("customFile", file);
});
});
formData.append("title", title);
formData.append("content", content);
$.ajax({
type: "POST",
url: postURL,
data: formData,
processData: false, // processData를 false로 설정하여 jQuery가 데이터를 처리하지 않도록 함
contentType: false,
async: false,
success: function (data, status) {
location.href="/fureverhomes/board/" + data;
},
error: function () {
alert("등록 실패!")
}
});
})
// 파일 이름 바꾸기
$(document).on("change", "#customFile", function () {
let fileValue = $(this).val().split("\\");
let fileName = fileValue[fileValue.length - 1]; // 파일명
$(this).siblings(".custom-file-label").text(fileName);
});
});
function addFileUpload() {
let newFileUploadRow = $("#fileForm .file-upload-row:first").clone();
newFileUploadRow.find(".custom-file-input2").val("");
newFileUploadRow.find("#show-files").text("Choose file");
newFileUploadRow.appendTo("#fileForm");
}
파일 이름 바꾸기 함수는 파일을 업로드 했을 경우 파일 업로드 폼에 적힌 내용을 원본 사진명으로 바꿔주기 위해서이다. 이렇게 하면 사용자가 이미지를 올렸는지 올리지 않았는지를 확인할 수 있을 것이다.