(Spring) 취향 기반 향수 추천 서비스 - 10. 향수 리뷰 게시판

김준석·2023년 7월 12일
0

향수 추천 서비스

목록 보기
11/21
post-thumbnail

졸업 전시회를 성공적으로 마치고, 추가적으로 개발하고 싶은 기능이 있었던 부분들을 개발하려고 합니다.
개발할 기능은 향수 리뷰 기능입니다. 사용자는 자신이 쓰는 향수에 대한 리뷰 게시글을 작성할 수 있도록 개발할 것이며, 해당 리뷰에 대한 좋아요 기능까지 구현하려고 합니다.

도메인

@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을 더 분리해야겠다고 생각을 했습니다.
또, 검색한 게시물이 없는 것은 예외 상황이 아니라, 그냥 목록 조회에 아무것도 나오지 않은 상황이라고 판단하여 빈 객체를 반환해준다는 의견도 있었는데 이것 또한 맞는 말 같았습니다.

Controller

@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));
    }

}

Swagger

@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를 처음 사용해보았는데, 간편하고 유연하게 가상 객체를 생성할 수 있어서 편리하다고 생각했습니다. 더 숙달해야겠습니다 하하..!

profile
기록하면서 성장하기!

0개의 댓글