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 테스트 코드를 작성한다.
@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());
}
}
이렇게 네 개의 레파지토리 메소드를 테스트하였다.
게시글과 관련된 작업이라고 생각하기 때문에 굳이 새로운 서비스를 생성하지 않을 것이다. 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
는 좋아요를 철회하는 메소드로써 좋아요된 이력이 없다면 예외처리된다. 커스텀 익셉션은 추후에 추가하기로하고 임시방편으로 런타임 익셉션을 사용하도록 하였다.
@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);
}
우선 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문을 쓰면서 되게 복잡해 보이지만 다음과 같은 구성으로 이뤄졌단 것을 알면 간단해 보일 것이다.
target
에 저장하고 모든 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를 요청한다.
새로 추가한 메소드에서 예외 시 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 클래스를 반환하도록 하였다.
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;
}
}
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);
}
유닛 테스트를 통해서 원하는 예외가 잘 발생하는지 확인한다.
@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 설정은 여기를 참고하세요.