board와 post는 1:N, post와 reply 역시 1:N 관계를 맺고 있다. 여태 view 등의 요청이 들어올 때마다 쿼리 메서드로 결과를 받아 출력하였지만, JPA에서 연관관계를 설정하면 DB를 객체지향적으로 다룰 수 있게 된다.
솔직히 말로만 들어서는 잘 모르겠다. 일단 따라해보기로 한다.
board와 post는 1:n 관계로, 한 개의 board가 여러 개의 post를 가지고 있다. 이를 연결해주기 위해 BoardEntity에 List<PostEntity>를 하나 만들어준다.
그런데 @Entity 안의 모든 변수는 전부 테이블의 칼럼으로 인식하기 때문에, 추가적으로 애너테이션으로 제외해줄 필요가 있다.
연관관계를 사용하여 맵핑을 해주는 @OneToMany 애너테이션을 붙여준다.
@OneToMany(mappedBy = "board")
private List<PostEntity> postList = List.of();
One은 BoardEntity, Many는 PostEntity가 된다. 마찬가지로 PostEntity로 이동해서 boardId에 연관관계를 설정해준다. 그러나 객체지향적으로 접근하는 만큼, boardId가 아닌 board로 변형해준다.
@ManyToOne이 붙으면, JPA는 해당하는 프로퍼티의 이름 뒤에 자동으로 "_id"를 붙이고 그 이름을 가진 칼럼을 찾는다.
@ManyToOne
private BoardEntity board;
이렇게 수정하면 PostService에서 PostEntity builder 패턴에서 오류가 난다. boardId를 받던 부분을 매개변수로 BoardEntity를 받도록 수정한다.
1) PostRequest에 boardId 추가 (일단 1로 고정)
private Long boardId=1L;
2) PostService에 BoardRepository 객체 생성
private final BoardRepository boardRepository;
3) PostService의 create 수정
BoardEntity boardEntity = boardRepository.findById(postRequest.getBoardId()).get(); PostEntity entity = PostEntity.builder() .board(boardEntity) ...
원칙적으로 boardEntity가 존재하는지도 확인하는 과정이 필요하지만, 우선은 건너뛰었다.
Board를 열람하는 메서드를 BoardApiController에 작성한다.
@GetMapping("/id/{id}")
public BoardEntity view(@PathVariable Long id){
return boardService.view(id);
}
BoardService에 view() 메서드를 작성한다.
public BoardEntity view(Long id) {
return boardRepository.findById(id).get();
}
마찬가지로 BoardEntity가 존재하는지 확인하는 과정을 거쳐야 한다. 이번에도 스킵한다.
이대로 서버를 돌리면 No Response가 뜨고 무한반복이 걸린다. view()를 호출했을 때, JSON을 만들려고 나온 오브젝트 매퍼가 BoardEntity에서 postList를 보고 PostEntity로 들어가고, 그 중 BoardEntity가 있어 또 BoardEntity로 넘어오게 된다. 이 과정이 계속 반복되고 있기 때문에 중간 어디선가에서 끊어주어야 한다.
하여, PostEntity의 board에 @JsonIgnore 애너테이션을 붙여준다. 이 애너테이션을 달면 JSON을 만들 때 호출되는 매퍼가 무시하게 되기 때문에 무한반복에 빠지는 것을 방지할 수 있다.
@ManyToOne
@JsonIgnore
private BoardEntity board; // board_id
이를 적용하면 오류 없이, 또 따로 쿼리메서드를 사용하지 않았는데도 보드와 보드에 속한 포스트들이 리스트에 담겨 함께 반환되는 것을 확인할 수 있다. 또, JsonIgnore를 붙였기 때문에 postList에 담긴 post들은 board에 대한 정보를 가지고 있지 않다.
/api/post/all에 들어가봐도 나오지 않는다.
이러한 무한반복은 또 ToString()에서 발생할 수 있다. 결과를 반환하는 게 아니라 로그에 출력한다면,
@GetMapping("/id/{id}")
public BoardEntity view(@PathVariable Long id){
BoardEntity entity = boardService.view(id);
log.info("result: {}", entity);
return boardService.view(id);
}
toString()에서 무한 반복이 일어난다. 따라서 이 경우에도 toString()을 무시하도록 @ToString.Exclude 애너테이션을 붙여준다.
@ManyToOne
@JsonIgnore
@ToString.Exclude
private BoardEntity board; // board_id
이러한 문제는 API에서 PostEntity를 반환하는 것이 아니라, PostEntity에 상응하는 DTO를 만들어서 DTO를 반환하는 것으로 예방할 수 있다.
board.model 패키지에 BoardDTO를 만든다. BoardEntity와 똑같이 생겼지만, 연관관계 애너테이션 등은 전부 제외하였다.
추가로 BoardDTO의 postList도 PostEntity가 아닌 PostDTO 리스트로 바꿔준다.
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class BoardDTO {
private Long id;
private String boardName;
private String status;
private List<PostDTO> postList = List.of();
}
마찬가지로 PostDTO도 만들어준다. 이때 다시 BoardEntity가 아닌 boardId를 받도록 바꿔준다.
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PostDTO {
private Long id;
private Long boardId;
private String userName;
private String password;
private String email;
private String status;
private String title;
private String content;
private LocalDateTime postedAt;
}
이 DTO들은 연결된 애너테이션이 없어 엔티티가 변형되더라도 안전한 객체이다.
Entity를 DTO로 변환해주는 기능을 board.service 패키지에 추가한다. 이 BoardConverter는 BoardEntity를 받아 BoardDTO로 데이터를 변환해주는 역할을 맡는다.
// BoardConverter
@Service
@RequiredArgsConstructor
public class BoardConverter {
private final PostConverter postConverter;
public BoardDTO toDTO(BoardEntity boardEntity){
BoardDTO boardDTO = BoardDTO.builder()
.id(boardEntity.getId())
.boardName(boardEntity.getBoardName())
.status(boardEntity.getStatus())
.postList()
.build();
return boardDTO;
}
}
postList를 받으려고 보면, PostDTO들을 받아와야 한다. 하여 중간에 PostConverter를 만들어준다.
@Service
public class PostConverter {
public PostDTO toDTO(PostEntity postEntity){
PostDTO postDTO = PostDTO.builder()
.id(postEntity.getId())
.boardId(postEntity.getBoard().getId())
.userName(postEntity.getUserName())
.password(postEntity.getPassword())
.email(postEntity.getEmail())
.status(postEntity.getStatus())
.title(postEntity.getTitle())
.content(postEntity.getContent())
.postedAt(postEntity.getPostedAt())
.build();
return postDTO;
}
}
다시 BoardConverter로 돌아온다. 아까 비워두었던 postList 부분을 PostConverter의 toDTO를 사용해 PostDTO 리스트로 만들 수 있다.
@Service
@RequiredArgsConstructor
public class BoardConverter {
private final PostConverter postConverter;
public BoardDTO toDTO(BoardEntity boardEntity){
List<PostDTO> postList = boardEntity.getPostList().stream()
.map(postEntity -> {
return postConverter.toDTO(postEntity);
}).collect(Collectors.toList());
return BoardDTO.builder()
.id(boardEntity.getId())
.boardName(boardEntity.getBoardName())
.status(boardEntity.getStatus())
.postList(postList)
.build();
}
}
BoardEntity가 아닌 BoardDTO를 반환하도록 BoardService를 수정한다.
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
private final BoardConverter boardConverter;
public BoardDTO create(BoardRequest boardRequest){
BoardEntity entity = BoardEntity.builder()
.id(1L)
.boardName(boardRequest.getBoardName())
.status("REGISTERED")
.build();
boardRepository.save(entity);
return boardConverter.toDTO(entity);
}
public BoardDTO view(Long id) {
BoardEntity entity = boardRepository.findById(id).get();
return boardConverter.toDTO(entity);
}
}
다시 실행해보면, postDTO에 board_id가 함께 출력된 것을 확인할 수 있다.
물론 이렇게 DTO를 반환하는 것보다는 API에 감싸서 내려보내는 것이 더 좋다고 한다. 강의중에 지나가다 나온 말이라 아무래도 Response로 감싸는 것을 얘기하는 것이 아닌가 한다.