https://codinghentai.tistory.com/2
해당 글과 이어집니다.
저번 글에서 비동기 통신으로 즉각적으로 이미지를 업로드/붙여 넣기를 하면 이미지를 서버에 업로드 후 db에 저장, 그리고 출력까지 해오는 로직을 구현했었는데요,
이제부터는 게시글과 파일의 연관관계를 디테일하게 설정해 잉여파일까지 관리할 수 있는 로직을 구현해봅니다.
게시글 등록시에 중간 관계 엔티티 영속해주기
어떠한 기능을 구현하고자 할 때 가장 1차원적으로 생각해보는 게 큰 도움이 되는 것 같습니다.
지금 프로젝트의 구조는 게시글을 등록할때 리퀘스트 dto를 전달하는 방식으로 글을 등록했기 때문에,
단순히 파일 업로드 후 리스폰스 엔티티로 객체를 반환해준다면, 게시글 등록시에 리퀘스트 dto에 반환된 객체의 id값을 같이 받아오면 되겠구나! 하고 생각했습니다.
success: function(data) {
//console.log('ajax 이미지 업로드 성공');
url += data.fileName;
fileIds += data.id + ",";
// callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
// 형식 : ![대체 텍스트](주소)
callback(url, '사진 대체 텍스트 입력');
},
이렇게 에디터의 success 부분에 받아온 객체의 id를 문자열 변수에 append 하는 형태로 구현후에 같이 전달해줍니다.
그리고 비즈니스 로직을 수정해줄 건데요, ArticleSaveFile 엔티티 클래스는 게시글과 파일의 연관관계를 위해서 생성했기 때문에 repository는 게시글과 파일의 서비스 클래스에 둘 다 주입해줄 겁니다.
그전에 SaveFile 서비스에서 비즈니스 로직을 구현해주어야 합니다. 받아온 fileIds로 file set을 반환해주는 로직을 구현합니다.
public Set<SaveFile.SaveFileDto> getFileDtosFromRequestsFileIds(Article.ArticleRequest dto) {
StringTokenizer st = new StringTokenizer(dto.getFileIds(), ",");
Set<SaveFile.SaveFileDto> saveFileDtos = new HashSet<>();
while(st.hasMoreTokens()){
Long fileId = Long.valueOf(st.nextToken());
log.info("fileId: {}", fileId);
saveFileDtos.add(saveFileRepository.findById(fileId).map(SaveFile.SaveFileDto::from).orElseThrow(() -> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId)));
}
return saveFileDtos;
}
이렇게 HashSet을 생성해 StringTokenizer로 잘라서 dto로 가져와서 set에 담아 반환해줍니다.
이제 게시글 컨트롤러에서 리퀘스트 Dto 에 담긴 파일 아이디로 파일 dto를 가져와 같이 비즈니스 로직으로 넘겨줍니다.
@ResponseBody
@PostMapping
public ResponseEntity<String> saveArticle(@RequestBody @Valid Article.ArticleRequest dto, BindingResult bindingResult,
@AuthenticationPrincipal UserAccount.BoardPrincipal boardPrincipal) {
if (bindingResult.hasErrors()) {
return new ResponseEntity<>(ErrorMessages.ENTITY_NOT_VALID, HttpStatus.BAD_REQUEST);
}
if (boardPrincipal == null) {
return new ResponseEntity<>(ErrorMessages.ACCESS_TOKEN_NOT_FOUND, HttpStatus.BAD_REQUEST);
}
Set<SaveFile.SaveFileDto> saveFileDtos = saveFileService.getFileDtosFromRequestsFileIds(dto);
saveFileService.deleteUnuploadedFilesFromArticleContent(dto.getContent(), dto.getFileIds());
return new ResponseEntity<>(articleService.saveArticle(dto.toDto(boardPrincipal.toDto()), Hashtag.HashtagDto.from(dto.getHashtag()), saveFileDtos).id().toString(), HttpStatus.OK);
}
public Article.ArticleDto saveArticle(Article.ArticleDto dto, List<Hashtag.HashtagDto> hashtagDtos, Set<SaveFile.SaveFileDto> saveFileDtos) {
Article article =articleRepository.save(dto.toEntity());
for (Hashtag.HashtagDto hashtag : hashtagDtos) {
Hashtag hashtag1= hashtagRepository.findByHashtag(hashtag.hashtag()).orElseGet(()-> hashtagRepository.save(hashtag.toEntity()));
articlehashtagrepository.save(ArticleHashtag.of(article,hashtag1));
}
for (SaveFile.SaveFileDto saveFileDto : saveFileDtos) {
if(dto.content().contains(saveFileDto.fileName())){
articleSaveFileRepository.save(ArticleSaveFile.of(article, saveFileDto.toEntity()));
}
}
return Article.ArticleDto.from(article);
}
저는 해시태그도 양방향 관계를 중간 엔티티로 맺어주어서 등록하도록 했는데요, 파일도 마찬가지로 똑같은 로직으로 구현했습니다.
이렇게 구현하면 게시글에 이미지가 첨부되었을 때 중간 테이블에서 외래 키를 참조하여 데이터를 생성하게 됩니다.
하지만 게시글을 등록할 시점에 게시글 내용에 이미지가 존재하지 않는다면?(지워진 상태라면?)
위에 코드에서 각각의 파일 이름이 게시글의 내용에 포함되지 않으면 맵핑을 하지 않는 방향으로 구현할 수 있는데요,
그렇게 해버리면 단순히 파일만 업로드되고 서버상에 잔여 파일이 남고 DB에도 유령 데이터가 존재하게 됩니다.
저는 그래서 파일 엔티티를 관리하는 비즈니스 로직에 게시글의 콘텐츠와 각 파일의 이름을 비교해 삭제해버리는 로직을 구현해보았습니다.
-> 해당 로직을 응용해서 게시글 내에 '#'를 포함해 작성한다면 해시태그를 자동으로 등록하도록 하는 로직도 구현할 수 있지 않을까? 하는 생각도 들었습니다. 🤔
public void deleteUnuploadedFilesFromArticleContent(String content,String fileIds){
if(Objects.isNull(content) || Objects.isNull(fileIds) || fileIds.equals("")){
return;
}
for(String fileId : fileIds.split(",")){
if(!content.contains(saveFileRepository.getReferenceById(Long.parseLong(fileId)).getFileName())){
deleteFile(Long.parseLong(fileId));
}
}
}
이렇게 파일 서비스에 비즈니스 로직을 구현해주었는데요, delete를 담당하는 로직 자체에서 서버에 남아있는 파일까지 삭제해버리기 때문에 글 내용과 파일 아이디를 전달하면 작업할 수 있도록 구현해주었습니다.
이렇게 구현해주면 글을 등록할 시에 글 내용에 이미지 파일이 존재하지 않는다면 서버에 잔여 파일도 존재하지 않게 되고 쓸데없는 데이터를 입력하지도 않게 됩니다 🥸
시연 화면
이렇게 에디터에 이미지를 붙여 넣기 해주고 삭제 후 업로드해봅니다.
이렇게 포함을 시키지 않아도 잔여 이미지가 남지 않게 됩니다.
수정과 삭제의 경우엔 어떻게 대처를 할 수 있을까?
수정 같은 경우에는 글에 이미지가 포함되지 않았을 경우에 삭제해버리는 로직을 비슷하게 구현하면 됩니다.
다만 게시글을 등록할 때는 단순 즉각적으로 전달받은 업로드된 이미지의 id를 참조하여 삭제하면 됐지만
수정 같은 경우에는 파일 엔티티나 게시글 엔티티 자체가 각각 엔티티 자체에 서로의 엔티티를 참조하지 않고 중간 테이블이 존재하는 구조이기 때문에
수정 페이지 뷰에 기존에 게시글에 등록돼있던 fileId를 addattribute로 전달하고, 이미지를 업로드할 때마다 append 하는 방식으로 id를 받아와 마찬가지로 게시글에 포함이 안된 이미지는 삭제 후에 수정하는 방식으로 구현했습니다.
public static String fileIdsToString(Set<SaveFile.SaveFileDto> saveFiles){
StringBuilder sb = new StringBuilder();
for (int i = 0; i < saveFiles.size(); i++) {
sb.append(saveFiles.stream().toList().get(i).id()).append(",");
}
return sb.toString();
}
저는 이렇게 정적 유틸 메서드를 컨트롤러 유틸 클래스에 선언하고 호출했습니다.
@ResponseBody
@PutMapping
public ResponseEntity<?> updateArticle(
@RequestBody @Valid Article.ArticleRequest articleRequest,
BindingResult bindingResult,
@AuthenticationPrincipal UserAccount.BoardPrincipal boardPrincipal) {
try {
if (bindingResult.hasErrors()) {
return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
}
if (!Objects.equals(articleService.getWriterFromArticle(articleRequest.getArticleId()), boardPrincipal.username())) {
return new ResponseEntity<>(ErrorMessages.ACCOUNT_NOT_MATCH, HttpStatus.BAD_REQUEST);
}
Set<SaveFile.SaveFileDto> saveFileDtos = saveFileService.getFileDtosFromRequestsFileIds(articleRequest);
saveFileService.deleteUnuploadedFilesFromArticleContent(articleRequest.getContent(), Objects.requireNonNull(articleRequest.getFileIds()));
articleService.updateArticle(articleRequest.getArticleId(), articleRequest, Hashtag.HashtagDto.from(articleRequest.getHashtag()), saveFileDtos);
return new ResponseEntity<>("articleUpdating Success", HttpStatus.OK);
}
catch (EntityNotFoundException e){
return new ResponseEntity<>(ErrorMessages.ENTITY_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
저는 이렇게 현재 등록된 이미지와 새로 등록된 이미지 id를 받아와서 내용에 포함되어있지 않으면 파일을 삭제하고 수정하는 방식으로 구현해 잔여 파일을 처리해주었습니다 💆🏻♂️
삭제 같은 경우에도 마찬가지로 중간 관계 엔티티에서 id를 가져온후 중간관계 엔티티를 삭제하고 파일 엔티티를 게시글 엔티티 id를 토대로 불러와 삭제를 진행하고 게시글 엔티티를 삭제하도록 구현했습니다 :D
마치며
- 해당 기능을 구현하면서 처음으로 성능에 대한 고민을 할 수 있었습니다. (반복적인 쿼리 실행을 줄이기 위해 노력했습니다.)
- 해당 기능을 구현하면서 가장 많이 했던 고민은 역시나 역할 분담이었습니다. 파일 엔티티는 어떻게 보면 지금 프로젝트상 단독으로 처리되지 못하고 참조가 되는 구조였기 때문에 고민을 하게 되었습니다.
- 이번 기능 구현도 기본적인 에디터 옵션 사용을 제외한 나머지는 제가 직접 구현하게 되었습니다. 이제는 전체적인 프로젝트의 흐름을 어느 정도 머릿속으로 그릴 수 있게 된 것 같아서 기쁩니다 :D
해당 코드는 제 토이 프로젝트에서 작성한 코드들이며 구현 글들은 프로젝트의 문서화를 위해 작성한 글들입니다. 태클은 언제나 환영합니다!
'Spring' 카테고리의 다른 글
스프링부트 + 타임리프로 동기식 댓글수정 기능 구현하기 (0) | 2022.12.13 |
---|---|
스프링부트 + JPA 양방향 맵핑 해시태그 기능으로 구현하기 (0) | 2022.12.13 |
스프링 부트+ JPA 로 파일 입출력을 쉽게 구현해보자 (Spring Data JPA) (0) | 2022.12.13 |
스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력) (0) | 2022.12.13 |
스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -1 (0) | 2022.12.12 |