[프로젝트4] 1. 좋아요 기능 추가하기

rin·2020년 6월 19일
6
post-thumbnail

freeboard01을 복제하여 freeboard04를 만들었다.
이전 설정을 그대로 사용하며 좋아요와 댓글 기능을 추가하고 그것들을 이용하여 배치와 스케줄러를 돌리는 것이 최종 목표이다.

목표
1. 좋아요 기능에 필요한 도메인을 추가한다.
2. Repository를 만들고 테스트한다.
3. api와 service 메소드를 만들고 테스트한다.

도메인 추가

좋아요를 할 때마다 좋아요를 누른 계정id와 글id가 저장되는 테이블을 만들 것이다. 이는 이미 좋아요한 글인지 판단하여 하나의 계정으로 좋아요를 무한대로 하는 것을 막기 위함이다.

처음엔 BoardEntity에 좋아요 개수를 컬럼으로 추가하였다가 제외하였다. 실시간 반영을 하지 않을 계획이기 때문에 서버에서 클라이언트로 Dto를 전달해 줄 때만 좋아요 개수를 포함하도록 할 계획이므로 데이터를 가져올 때 매번 두 개의 테이블에 접근하도록 한다.

따라서 domain 패키지 하위에 GoodContentsHistory 패키지를 생성해주고 GoodContentsHistoryEntity를 만들어주자.

@Getter
@Entity
@Table(name="good_contents_history")
@NoArgsConstructor
public class GoodContentsHistoryEntity extends BaseEntity {

    @ManyToOne(optional = false)
    @JoinColumn(name = "boardId", nullable = false)
    private BoardEntity board;

    @ManyToOne(optional = false)
    @JoinColumn(name = "userId", nullable = false)
    private UserEntity user;

    @Builder
    public GoodContentsHistoryEntity(BoardEntity board, UserEntity user){
        this.board = board;
        this.user = user;
    }
}

CRUD 테스트

레포지토리를 추가하고 간단한 CRUD 테스트 코드를 작성한다.

@Repository
public interface GoodContentsHistoryRepository extends JpaRepository<GoodContentsHistoryEntity, Long> {
    Optional<GoodContentsHistoryEntity> findByUserAndBoard(UserEntity user, BoardEntity board);
    int countByBoard(BoardEntity boardEntity);
}

findByUserAndBoard : 동일한 글에 동일한 계정으로 이미 좋아요한 내역이 있는지 찾을 때 사용할 메소드
countByBoard : 특정 글에 좋아요가 총 몇 개인지 셀 때 사용할 메소드

테스트 코드는 아래와 같다.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
class GoodContentsHistoryRepositoryTest {

    @Autowired
    private GoodContentsHistoryRepository sut;

    @Autowired
    private BoardRepository boardRepository;

    @Autowired
    private UserRepository userRepository;

    private BoardEntity boardEntity;
    private UserEntity userEntity;
    private GoodContentsHistoryEntity goodContentsHistoryEntity;

    @BeforeEach
    public void init() {
        userEntity = userRepository.findAll().get(0);
        List<BoardEntity> boardEntities = boardRepository.findAll();
        for (BoardEntity entity : boardEntities) {
            if (entity.getWriter().equals(userEntity) == false) {
                boardEntity = entity;
                break;
            }
        }
        goodContentsHistoryEntity = GoodContentsHistoryEntity.builder().board(boardEntity).user(userEntity).build();
    }

    @Test
    void insert_test() {
        sut.save(goodContentsHistoryEntity);

        GoodContentsHistoryEntity savedEntity = sut.findById(goodContentsHistoryEntity.getId()).get();

        assertEquals(goodContentsHistoryEntity, savedEntity);
    }

    @Test
    void delete_test() {
        sut.save(goodContentsHistoryEntity);

        sut.delete(goodContentsHistoryEntity);
        Optional<GoodContentsHistoryEntity> deletedEntity = sut.findById(goodContentsHistoryEntity.getId());

        assertThat(deletedEntity.isPresent(), equalTo(false));
    }

    @Test
    @DisplayName("특정 게시글의 좋아요 개수를 가져온다.")
    void good_counting_test() {
        int goodCount = getGoodCount();

        BoardEntity newBoard = BoardEntity.builder().contents(LocalDateTime.now() + "-test").title(LocalDateTime.now() + "-test").writer(userEntity).build();
        insertGoodContentsHistory(newBoard, goodCount);

        int findCount = sut.countByBoard(newBoard);

        assertThat(findCount, equalTo(goodCount));
    }

    private int getGoodCount() {
        int max = (int) userRepository.count() - 1;
        return (int) (Math.random() * (max)) + 1;
    }

    private void insertGoodContentsHistory(BoardEntity newBoard, int goodCount) {
        boardRepository.save(newBoard);
        List<UserEntity> userEntities = userRepository.findAll().stream().filter(user -> user.equals(userEntity) == false).collect(Collectors.toList());
        for (int i = 0; i < goodCount; ++i) {
            sut.save(GoodContentsHistoryEntity.builder().user(userEntities.get(i)).board(newBoard).build());
        }
    }
    
    @Test
    @DisplayName("사용자와 게시글 엔티티를 이용해 좋아요 내역을 가져올 수 있다.")
    void find_test(){
        BoardEntity newContents = BoardEntity.builder().writer(userEntity).build();
        UserEntity loggedUser = userRepository.findAll().stream().filter(user -> user.equals(userEntity) == false).findFirst().get();

        saveNewBoardAndGoodHistory(newContents, loggedUser);

        Optional<GoodContentsHistoryEntity> savedEntity = sut.findByUserAndBoard(loggedUser, newContents);

        assertThat(savedEntity.get(), not(nullValue()));
    }

    private void saveNewBoardAndGoodHistory(BoardEntity newContents, UserEntity loggedUser) {
        boardRepository.save(newContents);
        sut.save(GoodContentsHistoryEntity.builder().user(loggedUser).board(newContents).build());
    }
}
  • 좋아요 버튼을 처음 누른 경우 → 내역 저장
  • 좋아요 버튼을 누른 상태에서 한 번 더 누른 경우 → 내역 삭제
  • 특정 게시글을 좋아요 개수 가져오기
  • 내가 좋아요한 게시글과 내 정보로 좋아요 내역을 가져오기

이렇게 네 개의 레파지토리 메소드를 테스트하였다.

api 구성

BoardService

게시글과 관련된 작업이라고 생각하기 때문에 굳이 새로운 서비스를 생성하지 않을 것이다. BoardService에서 좋아요를 추가하거나 삭제하는 작업을 수행한다.

     public void addGoodPoint(UserForm userForm, long boardId) {
        UserEntity user = Optional.of(userRepository.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
        BoardEntity target = Optional.of(boardRepository.findById(boardId).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));

        goodContentsHistoryRepository.findByUserAndBoard(user, target).ifPresent(none -> { throw new RuntimeException(); });

        goodContentsHistoryRepository.save(
                GoodContentsHistoryEntity.builder()
                        .board(target)
                        .user(user)
                        .build()
        );
    }

    public void deleteGoodPoint(UserForm userForm, long goodHistoryId, long boardId) {
        UserEntity user = Optional.of(userRepository.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
        BoardEntity target = Optional.of(boardRepository.findById(boardId).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));

        goodContentsHistoryRepository.findByUserAndBoard(user, target).orElseThrow(() -> new RuntimeException());

        goodContentsHistoryRepository.deleteById(goodHistoryId);
    }

클라이언트에서 해당 사용자가 좋아요를 누른 글인지 아닌지를 판단하여 다른 api 요청을 보내도록 할 것이다. 위 메소드는 각 api에서 호출할 메소드들이다.

addGoodPoint는 좋아요를 추가하는 메소드로써 이미 좋아요가 되어있는 글이라면 예외처리된다. deleteGoodPoint는 좋아요를 철회하는 메소드로써 좋아요된 이력이 없다면 예외처리된다. 커스텀 익셉션은 추후에 추가하기로하고 임시방편으로 런타임 익셉션을 사용하도록 하였다.

BoardApiController

    @PostMapping("/{id}/good")
    public void addGoodPoint(@PathVariable long id){
        if (httpSession.getAttribute("USER") == null) {
            throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
        }
        boardService.addGoodPoint((UserForm) httpSession.getAttribute("USER"), id);
    }

    @DeleteMapping("/{boardId}/good/{goodHistoryId}")
    public void deleteGoodPoint(@PathVariable long boardId, @PathVariable long goodHistoryId){
        if (httpSession.getAttribute("USER") == null) {
            throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
        }
        boardService.deleteGoodPoint((UserForm) httpSession.getAttribute("USER"), goodHistoryId, boardId);
    }

BoardApiControllerTest

우선 addGoodPoint api 메소드에 대한 테스트 코드이다.

    @Test
    @DisplayName("아직 좋아요하지 않은 글에 좋아요를 한다.")
    public void good() throws Exception {
        Map<String, Object> target = getTargetUserAndBoard();
        BoardEntity targetBoard = (BoardEntity) target.get("targetBoard");
        setMockHttpSession((UserEntity) target.get("targetUser"));

        assert targetBoard != null;

        mvc.perform(post("/api/boards/" + targetBoard.getId() + "/good")
                .session(mockHttpSession))
                .andExpect(status().isOk());
    }
   
    private Map<String, Object> getTargetUserAndBoard() {
        boolean findTarget = false;
        long boardTotal = boardRepository.count();
        int findSize = 30;

        Map<String, Object> target = new HashMap<>();

        List<UserEntity> userEntities = userRepository.findAll();

        for (UserEntity user : userEntities) {
            for (int index = 0; index <= boardTotal / findSize; index += findSize) {
                Page<BoardEntity> boardEntityPage = boardRepository.findAll(PageRequest.of(index, findSize));
                List<BoardEntity> boardEntities = boardEntityPage.getContent().stream().filter(entity -> entity.getWriter().equals(user) == false).collect(Collectors.toList());
                for (BoardEntity board : boardEntities) {
                    if (goodContentsHistoryRepository.findByUserAndBoard(user, board).isPresent() == false) {
                        target.put("targetUser", user);
                        target.put("targetBoard", board);
                        findTarget = true;
                        break;
                    }
                }
                if (findTarget) { break; }
            }
            if (findTarget) { break; }
        }
        return target;
    }
    
    private void setMockHttpSession(UserEntity targetUser) {
        assert targetUser != null;

        mockHttpSession.clearAttributes();
        mockHttpSession.setAttribute("USER", UserForm.builder().accountId(targetUser.getAccountId()).password(targetUser.getPassword()).build());
    }

요청을 보내기 위해 테스트용 데이터를 셋팅하는 과정이 길어서 그렇지 내용은 특별할 것이 없다. 🤔

getTargetUserAndBoard가 3중 for문을 쓰면서 되게 복잡해 보이지만 다음과 같은 구성으로 이뤄졌단 것을 알면 간단해 보일 것이다.

  • 1 for 문 : 모든 유저 엔티티를 순회한다.
  • 2 for 문 : 다음과 같은 조건으로 boardEntity 리스트를 만든다
    • limit index, findSize
    • 현재 선택된 유저(1 for)가 작성하지 않은 글
  • 3 for 문 : 2 for 문에서 만들어진 boardEntity 리스트를 순회하며 현재 유저가 현재 게시글에 좋아요를 표시했는지 확인한다. → 내역이 없으면 Map target에 저장하고 모든 for 문을 벗어난다.
  • for 문을 벗어나는 방법에는 미리 정의해둔 boolean findTarget을 사용한다.

다음은 deleteGoodPoint api 메소드에 대한 테스트 코드이다.

    @Test
    @DisplayName("좋아요 했던 글을 취소한다.")
    void good_cancel() throws Exception {
        GoodContentsHistoryEntity goodContentsHistoryEntity = getGoodContentsHistoryEntity();

        mvc.perform(delete("/api/boards/"+goodContentsHistoryEntity.getBoard().getId()+"/good/"+goodContentsHistoryEntity.getId())
                .session(mockHttpSession))
                .andExpect(status().isOk());
    }

    private GoodContentsHistoryEntity getGoodContentsHistoryEntity() {
        Map<String, Object> target = getTargetUserAndBoard();
        BoardEntity targetBoard = (BoardEntity) target.get("targetBoard");
        UserEntity targetUser = (UserEntity) target.get("targetUser");

        assert targetBoard != null;
        assert targetUser != null;

        GoodContentsHistoryEntity goodContentsHistoryEntity = goodContentsHistoryRepository.save(
                GoodContentsHistoryEntity.builder()
                        .board(targetBoard)
                        .user(targetUser)
                        .build()
        );
        setMockHttpSession(targetUser);

        return goodContentsHistoryEntity;
    }

getTargetUserAndBoard를 이용해 내역이 없는 게시글-사용자 쌍을 추출하고 goodContentsHistoryRepository.save를 이용해 새로 추가한 엔티티를 제거하는 api를 요청한다.

Exception 추가

새로 추가한 메소드에서 예외 시 RunTimeException을 발생시키던 것을 커스텀 예외를 발생시키는 것으로 변경할 것이다.

이전에 BoardException과 UserException 처리를 하면서 필요한 클래스들은 만들어 두었기 때문에 이번에도 타입만 추가해주면 되지만, 복습 겸 코드를 보고 넘어가자.

규모가 큰 프로젝트가 아니기 때문에 실제로 발생하는 예외는 FreeBoardException 하나로 퉁치고 있다. 🤔 알다시피 예외가 발생하면 에러 메세지가 뜨는데, 각 enumType이 고유의 에러 메세지를 가지도록 하여 super() 생성자의 인자로 이를 전달해준다.
상위 클래스인 RunTimeException은 또 한번 상위의 Exception에 이 메세지 값을 전달해주고 최종적으론 Exception의 상위 클래스인 Throwable의 멤버 변수인 private String detailMessage가 이 값을 저장하게 된다.

CustomException → RunTimeException → Exception → Throwable

컨트롤러에서 발생하는 모든 Exception은 ExceptionHandler가 처리하는데, 커스텀 핸들러에 FreeboardException.class를 등록함으로써 FreeboardException이 발생하면 이 핸들러가 작동하게 된다. 필자는 모든 커스텀 익셉션은 200 http status를 사용하고 클러이언트에서 에러 메세지를 확인 할 수 있도록 Error라는 static 클래스를 반환하도록 하였다.

GoodContentsHistoryExceptionType

goodContentsHistory 패키지 하위에 enums 패키지를 생성하고 예외처리에 사용할 Enum을 만들었다.

@Getter
public enum GoodContentsHistoryExceptionType implements BaseExceptionType {

    CANNOT_FIND_HISTORY(3001, 200, "해당 글에 대한 좋아요 내역을 찾을 수 없습니다."),
    HISTORY_ALREADY_EXISTS(3002, 200, "이미 좋아요한 글입니다.");

    private int errorCode;
    private int httpStatus;
    private String errorMessage;

    GoodContentsHistoryExceptionType(int errorCode, int httpStatus, String errorMessage) {
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.errorMessage = errorMessage;
    }
}

BoardService

RuntimeException을 발생시켰던 부분을 FreeBoardException으로 바꿔주었다.

변경된 전체 코드는 아래와 같다.

    public void addGoodPoint(UserForm userForm, long boardId) {
        UserEntity user = Optional.of(userRepository.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
        BoardEntity target = Optional.of(boardRepository.findById(boardId).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));

        goodContentsHistoryRepository.findByUserAndBoard(user, target).ifPresent(none -> {
            throw new FreeBoardException(GoodContentsHistoryExceptionType.HISTORY_ALREADY_EXISTS);
        });

        goodContentsHistoryRepository.save(
                GoodContentsHistoryEntity.builder()
                        .board(target)
                        .user(user)
                        .build()
        );
    }

    public void deleteGoodPoint(UserForm userForm, long goodHistoryId, long boardId) {
        UserEntity user = Optional.of(userRepository.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
        BoardEntity target = Optional.of(boardRepository.findById(boardId).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));

        goodContentsHistoryRepository.findByUserAndBoard(user, target).orElseThrow(() -> new FreeBoardException(GoodContentsHistoryExceptionType.CANNOT_FIND_HISTORY));

        goodContentsHistoryRepository.deleteById(goodHistoryId);
    }

BoardServiceUnitTest

유닛 테스트를 통해서 원하는 예외가 잘 발생하는지 확인한다.

    @Test
    @DisplayName("이미 좋아요한 내역이 있는 글에 좋아요를 추가하는 시도를 할 경우, 예외를 발생시킨다.")
    public void addGoodExceptionTest() {
        given(mockUserRepo.findByAccountId(anyString())).willReturn(new UserEntity());
        given(mockBoardRepo.findById(anyLong())).willReturn(Optional.of(new BoardEntity()));
        given(mockGoodHistoryRepo.findByUserAndBoard(any(), any())).willReturn(Optional.of(new GoodContentsHistoryEntity()));

        Throwable e = assertThrows(FreeBoardException.class,
                () -> sut.addGoodPoint(UserForm.builder().accountId("mock").build(), anyLong())
        );

        assertEquals(GoodContentsHistoryExceptionType.HISTORY_ALREADY_EXISTS.getErrorMessage(), e.getMessage());
        verify(mockGoodHistoryRepo, never()).save(any());
    }

    @Test
    @DisplayName("좋아요한 내역이 없는 글에 좋아요를 취소하려는 시도를 할 경우, 예외를 발생시킨다.")
    public void cancelGoodExceptionTest() {
        given(mockUserRepo.findByAccountId(anyString())).willReturn(new UserEntity());
        given(mockBoardRepo.findById(anyLong())).willReturn(Optional.of(new BoardEntity()));
        given(mockGoodHistoryRepo.findByUserAndBoard(any(), any())).willReturn(Optional.ofNullable(null));

        Throwable e = assertThrows(FreeBoardException.class,
                () -> sut.deleteGoodPoint(UserForm.builder().accountId("mock").build(), 1L, 2L)
        );

        assertEquals(GoodContentsHistoryExceptionType.CANNOT_FIND_HISTORY.getErrorMessage(), e.getMessage());
        verify(mockGoodHistoryRepo, never()).deleteById(anyLong());
    }

전체 코드는 github에서 확인 할 수 있습니다.

github 레파지토리 복사(fork X) 및 intellij 설정은 여기를 참고하세요.

profile
🌱 😈💻 🌱

0개의 댓글