졸업 전시회를 성공적으로 마치고, 추가적으로 개발하고 싶은 기능이 있었던 부분들을 개발하려고 합니다.
개발할 기능은 향수 리뷰 기능입니다. 사용자는 자신이 쓰는 향수에 대한 리뷰 게시글을 작성할 수 있도록 개발할 것이며, 해당 리뷰에 대한 좋아요 기능까지 구현하려고 합니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "perfume_review_board")
@EntityListeners(AuditingEntityListener.class)
public class PerfumeReviewBoard {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "review_board_id", nullable = false)
private Long boardId;
@NotNull
@CreatedDate
private LocalDateTime createdDateTme;
@NotNull
@ManyToOne
private Member writer;
@NotNull
private String title;
@NotNull
private Content content;
@Builder
public PerfumeReviewBoard(final Long boardId, final Member member, final String title,
final Content content) {
this.boardId = boardId;
this.writer = member;
this.content = content;
this.title = title;
}
public void updatePost(String title, Content content){
this.title = title;
this.content = content;
}
Content 객체에는 향수 이미지 경로와 리뷰 글을 담을 Text를 멤버 변수로 만들었습니다.
updatePost()는 게시글의 수정을 위한 메서드입니다.
public ReviewBoardResponse writeReview(Long memberId, ReviewBoardRequest boardRequest) {
Perfume perfume = perfumeService.findPerfumeByName(boardRequest.getPerfumeName());
Member member = memberService.findByMemberPk(memberId);
PerfumeReviewBoard perfumeReviewBoard = boardRequest.toEntity(member, boardRequest.getContent(), perfume);
PerfumeReviewBoard savedBoard = reviewBoardRepository.save(perfumeReviewBoard);
return ReviewBoardResponse.builder()
.boardId(savedBoard.getBoardId())
.build();
}
ReviewBoardService.java
@Transactional
public ReviewBoardResponse modifyReview(PostUpdateRequest postUpdateRequest) {
PerfumeReviewBoard perfumeReviewBoard = boardService.findBoardById(postUpdateRequest.getBoardId());
perfumeReviewBoard.updatePost(postUpdateRequest.getTitle(), postUpdateRequest.getContent());
return ReviewBoardResponse.builder()
.title(postUpdateRequest.getTitle())
.content(postUpdateRequest.getContent())
.build();
}
게시글 수정 기능입니다. 여기서 Jpa의 save()를 사용하여 업데이트할 지 , @Transactional을 사용하여 업데이트할 지 고민했습니다.
updatePost()를 통해 객체의 상태를 변경하고 이후에 save()를 통해 db에 따로 반영을 해야한다는 점에서, 객체지향적인 관점에서 어긋났다고 생각하였습니다.
이에 관해서 좀 더 찾아보았는데, 테스트 관점에서 의미없는 save()의 호출로 인해 불필요한 Mocking을 해야한다는 단점도 찾을 수 있었습니다.
또 최근 Jpa에 대해 공부하면서 변경 감지라는 기능을 학학습했습니다. 간단하게 말하면 엔티티의 변경 사항을 데이터베이스에 자동으로 반영하는 기능입니다.
Jpa가 엔티티를 entityManager에 보관할 때 최초 상태를 복사해서 저장(스냅샷)해 두는데, 트랜잭션을 커밋하면 entityManager에서 flush()가 먼저 호출되고 이후 스냅샷과 변경된 엔티티를 비교하여 수정이 되었다면이를 Sql 저장소에 보냅니다.
public void updatePost(String title, Content content){
this.title = title;
this.content = content;
}
@Transactional
public Long deleteReviewPost(PostDeleteRequest postDeleteRequest) {
memberService.findByMemberPk(postDeleteRequest.getMemberId());
reviewBoardRepository.deleteByBoardId(postDeleteRequest.getBoardId());
return postDeleteRequest.getBoardId();
}
삭제는 인증이 필요하기 때문에 Member의 pk를 찾은 후 deleteByBoard가 수행되어야 합니다. 이 두 단계의 작업의 일관성이 보장되어야 하기 때문에 트랜잭션으로 엮어주었습니다.
public ReviewBoardResponse showPost(Long boardId) {
PerfumeReviewBoard perfumeReviewBoard = reviewBoardRepository.findByBoardId(boardId)
.orElseThrow(ReviewPostNotFoundException::new);
ReviewBoardResponse reviewBoardResponse = ReviewBoardResponse.builder()
.boardId(perfumeReviewBoard.getBoardId())
.title(perfumeReviewBoard.getTitle())
.content(perfumeReviewBoard.getContent())
.build();
return reviewBoardResponse;
}
public List<PerfumeReviewBoard> showSearchedPosts(String content) {
List<PerfumeReviewBoard> perfumeReviewBoards = reviewBoardRepository
.findByTitleContainingOrContentContaining(content, content);
return perfumeReviewBoards;
}
게시글 검색에 관한 메서드입니다. 게시글 검색 시 아무 게시글도 조회가 되지 않을 경우, 응답을 빈 리스트로 할 지 아니면 예외를 발생시킬지에 대해 고민했습니다.
우선 프론트엔드를 담당한 개발자가 빈 객체를 응답하는 게 더 편하다고 하였기 때문에 빈 객체를 반환하도록 하였습니다.
빈 리스트를 반환할 경우에는 예외에 대한 처리를 따로 해주지 않아도 되어, 부하 회피와 간편한 코드를 작성할 수 있습니다.
만약 예외를 응답할 경우에는 기존에 있던 PostNotFoundException을 보내게 되면 단일 게시물 조회와 검색 어떤 것에서 오류가 난 건지에 대해 어려움을 겪을 것입니다. 따라서 예외를 응답할 경우 Exception을 더 분리해야겠다고 생각을 했습니다.
또, 검색한 게시물이 없는 것은 예외 상황이 아니라, 그냥 목록 조회에 아무것도 나오지 않은 상황이라고 판단하여 빈 객체를 반환해준다는 의견도 있었는데 이것 또한 맞는 말 같았습니다.
@RestController
@RequestMapping("/board")
public class ReviewBoardController implements ReviewBoardControllerDocs {
private final ReviewBoardService reviewBoardService;
public ReviewBoardController(ReviewBoardService reviewBoardService) {
this.reviewBoardService = reviewBoardService;
}
@LoginCheck
@PostMapping("/write/{memberId}")
public ResponseEntity<ReviewBoardResponse> writeReview(@PathVariable final Long memberId, @RequestBody ReviewBoardRequest reviewBoardRequest) {
return ResponseEntity.ok(reviewBoardService.writeReview(memberId, reviewBoardRequest));
}
@LoginCheck
@PatchMapping("/update")
public ResponseEntity<ReviewBoardResponse> updatePost(@RequestBody PostUpdateRequest postUpdateRequest) {
return ResponseEntity.ok().body(reviewBoardService.modifyReview(postUpdateRequest));
}
@LoginCheck
@DeleteMapping("/delete-post")
public ResponseEntity<Long> deletePost(@RequestBody PostDeleteRequest postDeleteRequest) {
return ResponseEntity.ok(reviewBoardService.deleteReviewPost(postDeleteRequest));
}
@GetMapping("/show-post/{boardId}")
public ResponseEntity<ReviewBoardResponse> showPost(@PathVariable final Long boardId) {
return ResponseEntity.ok(reviewBoardService.showPost(boardId));
}
@GetMapping("/show-searched-posts")
public ResponseEntity<List<PerfumeReviewBoard>> showSearchedPosts(@RequestParam String content) {
return ResponseEntity.ok(reviewBoardService.showSearchedPosts(content));
}
}
@Tag(name = "향수 리뷰 게시판 Api")
public interface ReviewBoardControllerDocs {
@Operation(summary = "리뷰 게시글 작성")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자의 접근",
headers = @Header(name = "Authorization", description = "Access Token"))
})
ResponseEntity<ReviewBoardResponse> writeReview(@Parameter(name = "memberId", description = "Member PK값") @PathVariable Long memberId,
@Parameter(name = "BoardRequest", description = "") @RequestBody ReviewBoardRequest boardRequest);
@Operation(summary = "리뷰 게시글 수정")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자의 접근",
headers = @Header(name = "Authorization", description = "AccessToken"))
})
ResponseEntity<ReviewBoardResponse> updatePost(@Parameter(name = "PostUpdateRequest", description = "수정된 글 내용") @RequestBody PostUpdateRequest postUpdateRequest);
@Operation(summary = "리뷰 게시글 삭제")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자의 접근",
headers = @Header(name = "Authorization", description = "AccessToken"))
})
ResponseEntity<Long> deletePost(@Parameter(name = "PostDeleteRequest", description = "memberId, boardId") @RequestBody PostDeleteRequest postDeleteRequest);
@Operation(summary = "게시글 단일 조회")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "404", description = "해당 게시글을 찾을 수 없음")
})
ResponseEntity<ReviewBoardResponse> showPost(@Parameter(name = "boardId", description = "게시글 번호") @PathVariable final Long boardId);
@Operation(summary = "게시글 검색")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(description = "빈 객체 응답 시 -> 게시글 찾을 수 없음")
})
ResponseEntity<List<PerfumeReviewBoard>> showSearchedPosts(@Parameter(name = "content", description = "내용") @RequestParam String content);
}
@ExtendWith(MockitoExtension.class)
public class ReviewBoardServiceTest {
@InjectMocks
private ReviewBoardService reviewBoardService;
@Mock
private ReviewBoardRepository reviewBoardRepository;
@Mock
private MemberService memberService;
@Mock
private PerfumeService perfumeService;
@DisplayName("향수 리뷰를 작성한다")
@Test
void writeReview() {
Long memberId = 1L;
Content content = new Content("임시 내용", "임시 url");
ReviewBoardRequest boardRequest = ReviewBoardRequest.builder()
.writer(memberId)
.title("임시 제목")
.content(content)
.perfumeName("에르메스 오드시트론느와")
.build();
Member mockMember = Member.builder()
.id(1L)
.build();
Perfume mockPerfume = Perfume.builder()
.perfumeName("에르메스 오드시트론느와")
.build();
PerfumeReviewBoard mockBoard = PerfumeReviewBoard.builder()
.boardId(1L)
.member(mockMember)
.perfumeImageUrl(content.getImageUrl())
.title(boardRequest.getTitle())
.build();
when(memberService.findByMemberPk(memberId)).thenReturn(mockMember);
when(perfumeService.findPerfumeByName(boardRequest.getPerfumeName())).thenReturn(mockPerfume);
when(reviewBoardRepository.save(any(PerfumeReviewBoard.class))).thenReturn(mockBoard);
ReviewBoardResponse result = reviewBoardService.writeReview(memberId, boardRequest);
Assertions.assertEquals(mockBoard.getBoardId(), result.getBoardId());
}
@DisplayName("게시글 수정하면 수정된 내용이 반영된다.")
@Test
void updatePost() {
Long boardId = 1L;
Content resultContent = new Content("변경", "변경");
PostUpdateRequest postUpdateRequest = PostUpdateRequest.builder()
.boardId(boardId)
.title("변경")
.content(resultContent)
.build();
PerfumeReviewBoard mockBoard = PerfumeReviewBoard.builder()
.title("변경")
.content(resultContent)
.boardId(1L)
.build();
when(reviewBoardRepository.findByBoardId(boardId)).thenReturn(Optional.of(mockBoard));
ReviewBoardResponse result = reviewBoardService.modifyReview(postUpdateRequest);
Assertions.assertAll(
() -> Assertions.assertEquals(result.getBoardId(), postUpdateRequest.getBoardId()),
() -> Assertions.assertEquals(result.getTitle(), postUpdateRequest.getTitle()),
() -> Assertions.assertEquals(result.getContent(), postUpdateRequest.getContent())
);
}
@DisplayName("리뷰를 삭제한다.")
@Test
void deleteReview() {
Long memberId = 1L;
Long boardId = 1L;
PostDeleteRequest postDeleteRequest = new PostDeleteRequest(1L, 1L);
Member mockMember = Member.builder()
.memberId(memberId)
.build();
PerfumeReviewBoard mockReviewBoard = PerfumeReviewBoard.builder()
.boardId(boardId)
.build();
when(memberService.findByMemberPk(memberId)).thenReturn(mockMember);
doNothing().when(reviewBoardRepository).deleteByBoardId(mockReviewBoard.getBoardId());
Assertions.assertDoesNotThrow(() -> reviewBoardService.deleteReviewPost(postDeleteRequest));
}
@DisplayName("게시글을 단일 조회한다.")
@Test
void showOnePost() {
Long boardId = 1L;
PerfumeReviewBoard mockBoard = PerfumeReviewBoard.builder()
.boardId(boardId)
.build();
when(reviewBoardRepository.findByBoardId(boardId)).thenReturn(Optional.of(mockBoard));
ReviewBoardResponse result = reviewBoardService.showPost(boardId);
Assertions.assertEquals(boardId, result.getBoardId());
}
@DisplayName("게시글 검색")
@Test
void searchPost() {
String title = "조말론";
Content content = new Content("구찌", "url");
PerfumeReviewBoard expectedCaseOne = PerfumeReviewBoard.builder()
.title("조말론 냄새 좋아요!")
.build();
PerfumeReviewBoard expectedCaseTwo = PerfumeReviewBoard.builder()
.content(new Content("구찌 사랑해", "url"))
.build();
List<PerfumeReviewBoard> mockReviewBoard = new ArrayList<>();
mockReviewBoard.add(expectedCaseOne);
mockReviewBoard.add(expectedCaseTwo);
when(reviewBoardRepository.findByTitleContainingOrContentContaining(anyString(), eq("구찌"))).thenReturn(mockReviewBoard);
List<PerfumeReviewBoard> result = reviewBoardService.showSearchedPosts("구찌");
Assertions.assertEquals(expectedCaseTwo.getBoardId(), result.get(1).getBoardId());
}
}
테스트코드에 Mockito를 처음 사용해보았는데, 간편하고 유연하게 가상 객체를 생성할 수 있어서 편리하다고 생각했습니다. 더 숙달해야겠습니다 하하..!