참고한 게시글 : https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C
서론
- 제이쿼리를 이용해 게시판 프로젝트에 Toast UI 에디터 / 뷰어를 적용시킵니다.
- 이미지 파일 업로드 기능을 구현합니다. (에디터의 hooks 옵션을 통해 이미지를 비동기통신으로 업로드 하고 출력합니다.)
- JPA로 게시글과 파일 사이의 맵핑용 엔티티 클래스를 생성해 양방향 관계를 맺어줍니다.
- 게시글 등록시에 글에 첨부되지 않은 이미 서버에 업로드된 이미지 파일을 삭제하고 첨부된 이미지와 게시글을 양방향 관계로 맺어주는 로직을 구현해봅니다.
- 게시글 수정시에 글에서 지워진 이미지나 마찬가지로 서버에 업로드되었지만 첨부되지 않은 잉여 이미지파일을 삭제해주고 연결관계를 끊어주는 로직을 구현합니다.
- 게시글 삭제시에 등록되있던 이미지까지 통째로 삭제해주는 로직을 구현합니다.
본론
벨로그 글을 작성할 때 , 이미지를 등록하거나 복사 후 붙여넣기 할때 어떻게 업로드가 되고 첨부가 될까요?
제 깃허브 프로필 사진을 캡처 후 붙여 넣기를 해보겠습니다
이렇게 같은 이미지를 여러 번 붙여 넣기 해도 매번 새로 파일이 업로드되고 바로 게시글에서 조회가 가능한데요,
해당 로직의 흐름은 이런 것 같습니다..
- 게시글에 이미지를 붙여 넣기 혹은 업로드 시에 비동기 통신으로 컨트롤러로 해당 이미지를 전송
- 전송된 이미지는 서버에 저장되고, DB에 이미지의 정보 저장 후 해당 파일 이름+@ 랜덤 문자열을 반환
- 앞단은 전달받은 파일 이름으로 GET method 로 이미지 출력을 요청
- 뒷단은 이미지 파일을 바이트 배열로 전달해 작성 폼에서 바로 이미지 출력
만약 이미지를 붙여 넣기 혹은 업로드 후에 해당 글에서 다시 이미지를 삭제 후 업로드하거나, 게시글이 삭제되었거나 수정 시에도 이미지가 해당 글에서 지워진다면 남은 잉여 파일은 어떻게 처리할까?🤔
가 해당 글의 핵심이 될 것 같습니다. 🤔
게시판 프로젝트에 Toast UI 에디터/뷰어 적용시키기 💁🏻
기존에 제가 진행하던 토이 프로젝트의 글 작성과 뷰어는 단순히 textarea 태그와 타임리프의 th:text 문법을 이용해서 작성/출력을 했었는데요,
게시판에 에디터를 적용시켜보면 얼마나 깔끔해 보일까? 혹은 이것도 어떻게 보면 open api를 활용하는 것과 비슷하다고 생각하여 적용시켜보게 되었습니다.
저는 프런트를 전혀 알지 못하기 때문에 제이쿼리로 진행했습니다.
<!-- TOAST UI Editor CDN(JS) -->
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<!-- TOAST UI Editor CDN(CSS) -->
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
적용을 시킬 작성용 폼과 게시글 출력을 시킬 html 파일의 head에 저것들을 다 넣어줍니다.
const editor = new toastui.Editor({
el: document.querySelector('#editor1'),
initialEditType: 'markdown',
previewStyle: 'vertical',
height: '500px',
initialValue: '내용을 입력해주세요',
events: {
change: function () {
console.log(editor.getMarkdown());
}
},
hooks: {
addImageBlobHook: (blob, callback) => {
// blob : Java Script 파일 객체
//console.log(blob);
const formData = new FormData();
formData.append('file', blob);
let url = '/files/';
$.ajax({
type: 'POST',
enctype: 'multipart/form-data',
url: '/files/upload',
data: formData,
dataType: 'json',
processData: false,
contentType: false,
cache: false,
timeout: 600000,
success: function(data) {
//console.log('ajax 이미지 업로드 성공');
url += data.fileName;
fileIds += data.id + ",";
// callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
// 형식 : ![대체 텍스트](주소)
callback(url, '사진 대체 텍스트 입력');
},
error: function(e) {
//console.log('ajax 이미지 업로드 실패');
//console.log(e.abort([statusText]));
callback('image_load_fail', '사진 대체 텍스트 입력');
}
});
}
}
});
출처 :Toast UI Editor 이미지 업로드 - io_
해당 코드는 io_님 블로그에서 긁어왔습니다.
에디터로 별다른 코드를 추가하지 않고 이미지를 업로드하거나 붙여 넣기 하면 에디터에 몇천 자+@ 의 base64형식으로 이미지 파일이 입력되는데요, 에디터 자체에 hooks 옵션이 존재합니다.
hooks 옵션은 해당 base64 형식의 이미지를 바로 처리하고 에디터에 리턴해줍니다.
$(document).ready(function() {
const viewer = toastui.Editor.factory({
el: document.querySelector("#articleContent"),
viewer: true,
height: "500px",
initialValue: document.getElementById("content").value,
});
}
);
해당 코드는 게시글을 조회하는 뷰 script 태그에 붙여 넣으면 됩니다.
뷰어를 따로 또 적용시키는 이유는 , 에디터에서 지원하는 기능들이 단순 html 태그로는 출력이 되지 않는 경우가 있기 때문에 뷰어도 적용해주었습니다.
게시판에서 이미지를 업로드하면 불필요한 장문의 글자를 가로 채주는 기능이 구현됐으니 이제 이미지/파일을 업로드하고 받아오는 로직을 구현해야겠죠? 🤔
파일 컨트롤러 / 서비스 생성하기
연관관계 맵핑용 중간 엔티티 클래스 생성하기
기존에 File 엔티티 클래스는 작성이 되어있다는 가정하에, 게시글 엔티티와 파일 엔티티를 연결해줄 중간 엔티티 클래스를 생성해줍니다.
@Entity
@Getter
@NoArgsConstructor
public class ArticleSaveFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@OneToOne
private Article article;
@Setter
@ManyToOne
private SaveFile saveFile;
private ArticleSaveFile(Long id, Article article, SaveFile saveFile) {
this.id = id;
this.article = article;
this.saveFile = saveFile;
}
public static ArticleSaveFile of (Article article, SaveFile saveFile) {
return new ArticleSaveFile(null, article, saveFile);
}
}
해당 맵핑은 한 게시글에 여러 개의 파일이 첨부될 수 있기 때문에, 단순 게시글 엔티티와 파일 엔티티에 연관관계를 설정해주지 않고 중간 엔티티 클래스를 생성해 맵핑을 해주었습니다.
그리고 맵핑 엔티티를 저장할 리포지토리도 따로 생성해주었는데요, 여기서 고민할 수 있는 부분은 두 가지였습니다.
- 파일 엔티티는 단순히 혼자서 업로드되고 삭제될 수 없는 것 같은데 파일 서비스가 단독적으로 동작할 수 있게 해도 될까?
- 해당 고민은 파일 엔티티는 파일 저장소를 구현하는 프로젝트가 아닌 이상 단독으로 다뤄질 일이 없다고 생각했기 때문에 파일 CRUD가 필요한 컨트롤러에 주입해주는 것으로 타협했습니다.
- 그러면 애초에 다른 비즈니스 로직에서 파일 리포지토리를 사용하면 되지 않나? 에 대한 고민은 지금 프로젝트는 서비스 클래스 단위로 트랜잭션이 이뤄지기 때문에 컨트롤러에서 파일 서비스로 파일을 다룰 수 있도록 역할을 분담해주었습니다.
- 해당 고민은 파일 엔티티는 파일 저장소를 구현하는 프로젝트가 아닌 이상 단독으로 다뤄질 일이 없다고 생각했기 때문에 파일 CRUD가 필요한 컨트롤러에 주입해주는 것으로 타협했습니다.
- 이런 중간 엔티티용 서비스와 컨트롤러도 만들어서 역할을 분리해야 할까?
- 연관관계 맵핑 엔티티는 마찬가지로 각각의 엔티티가 존재하지 않으면 존재할 이유도 없기 때문에, 각각 비즈니스 로직에 리포지토리를 주입해 사용하도록 했습니다.
파일 엔티티 비즈니스 로직 설계하기
파일을 등록/조회/삭제 처리를 해줄 비즈니스 로직을 설계해줍니다.
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class SaveFileService {
private final SaveFileRepository saveFileRepository;
private final ArticleSaveFileRepository articleSaveFileRepository;
@Transactional(readOnly = true)
public SaveFile.SaveFileDto getFile(Long fileId) {
log.info("getFile() fileId: {}", fileId);
return saveFileRepository.findById(fileId).map(SaveFile.SaveFileDto::from).orElseThrow(()-> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId));
}
@Transactional(readOnly = true)
public SaveFile.SaveFileDto getFileByFileName(String fileName) {
log.info("getFile() fileId: {}", fileName);
return saveFileRepository.findByFileName(fileName).toDto();
}
public void deleteFile(Long fileId) {
log.info("deleteFile() fileId: {}", fileId);
File file = new File(saveFileRepository.getReferenceById(fileId).getFilePath());
if (file.exists()) {
if(file.delete()){
log.info("파일삭제 성공");
}
}
articleSaveFileRepository.deleteBySaveFileId(fileId);
saveFileRepository.deleteById(fileId);
}
public SaveFile.SaveFileDto saveFile(SaveFile.SaveFileDto saveFile) {
log.info("saveFile() saveFile: {}", saveFile);
return SaveFile.SaveFileDto.from(saveFileRepository.save(saveFile.toEntity()));
}
일단 해당 파일들을 저장/삭제/조회하는 로직만 구현해주었는데요, 파일 업로드 시에 db가 아닌 서버에 저장되는 로직은 유틸 클래스를 생성해서 정적 메서드로 처리하도록 했습니다.
public static File getMultipartFileToFile(MultipartFile multipartFile) throws IOException {
File file = new File(uploadPath,getFileNameWithUUID(multipartFile.getOriginalFilename()));
multipartFile.transferTo(file);
return file;
}
이렇게 해당 메서드를 선언하여 서버에 저장하고, 또 따로 File 타입을 (해당 File타입은 엔티티가 아닙니다) File엔티티 dto 로 변환해주는 메소드를 선언해 편의성을 챙겼습니다.
여기서 신경 쓸건 파일이 생성되지 않거나 제대로 된 파일을 전달받지 못해서 비어있는 파일 정보를 가지고 db에 저장하는 걸 방지하기 위해 해당 메서드들은 파일 컨트롤러에서 호출하도록 구현했습니다.
파일 컨트롤러 생성하기
비즈니스 로직을 구현해주었으니 이제 앞단에서 전달받은 파일을 어떻게 처리할지를 컨트롤러에서 구현해줍니다.
@RestController
@Slf4j
@RequiredArgsConstructor
public class SaveFileController {
private final SaveFileService saveFileService;
@PostMapping("/files")
public ResponseEntity<?> uploadFile(@RequestPart("file")MultipartFile file, @AuthenticationPrincipal UserAccount.BoardPrincipal principal) throws IOException {
try{
SaveFile.SaveFileDto fileDto = saveFileService.saveFile(FileUtil.getFileDtoFromMultiPartFile(file,principal!=null ? principal.getUsername() : "anonymous"));
return new ResponseEntity<>(fileDto, HttpStatus.OK);
}catch (Exception e){
log.error("uploadFile() error: {}", e.getMessage());
return new ResponseEntity<>(ErrorMessages.FILE_UPLOAD_FAIL, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@GetMapping("/files/{fileName}")
public ResponseEntity<?> getFile(@PathVariable String fileName) throws IOException {
try{
File file = FileUtil.getFileFromSaveFile(saveFileService.getFileByFileName(fileName));
byte[] imgArray = IOUtils.toByteArray(new FileInputStream(file));
return new ResponseEntity<>(imgArray,HttpStatus.OK);
}
catch (EntityNotFoundException e){
return new ResponseEntity<>(ErrorMessages.NOT_FOUND,HttpStatus.NOT_FOUND);
}
}
}
파일이 비어있다면(그럴리는 없지만) IOException 이 발생할 것이기 때문에 해당 예외 처리를 컨트롤러에서 진행해줍니다.
하지만 이미지가 없다고 출력이 안되면 안 되기 때문에 catch 문으로 에러 메시지를 반환해줍니다.
이미지 업로드 후 db 반영 결과 조회하기
이제 이미지를 업로드/붙여 넣기 를 하면 즉시 작성 폼에는 이미지 출력용 url과 대체 텍스트가 입력됩니다.
이렇게 이미지를 붙여넣기 하면 그 즉시 POST 메서드로 비동기 통신을 하게 되는데요 ,
이렇게 바로 DB에 반영이 됩니다.
이제 여기서 파일 컨트롤러와 게시글 컨트롤러는 분리되어있는데 게시글 서비스 클래스에서 연관관계를 맺을 수 있을까?
에 대한 로직 구현이 필요합니다.
해당 내용은 글이 길어졌기 때문에 다음 글에서 설명하도록 하겠습니다 :)
'Spring' 카테고리의 다른 글
스프링부트 + 타임리프로 동기식 댓글수정 기능 구현하기 (0) | 2022.12.13 |
---|---|
스프링부트 + JPA 양방향 맵핑 해시태그 기능으로 구현하기 (0) | 2022.12.13 |
스프링 부트+ JPA 로 파일 입출력을 쉽게 구현해보자 (Spring Data JPA) (0) | 2022.12.13 |
스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력) (0) | 2022.12.13 |
스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -2 (0) | 2022.12.13 |