게시판 설계(2) - JPA 연관관계

강서진·2023년 12월 15일
0
post-custom-banner

board와 post는 1:N, post와 reply 역시 1:N 관계를 맺고 있다. 여태 view 등의 요청이 들어올 때마다 쿼리 메서드로 결과를 받아 출력하였지만, JPA에서 연관관계를 설정하면 DB를 객체지향적으로 다룰 수 있게 된다.
솔직히 말로만 들어서는 잘 모르겠다. 일단 따라해보기로 한다.

board - post

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로 감싸는 것을 얘기하는 것이 아닌가 한다.

post-custom-banner

0개의 댓글