BoardApiController에서 PostEntity와 양방향 매핑된 BoardEntity 객체를 반환했는데 직렬화 과정에서 데이터가 반복적으로 출력되었다.
양방향 매핑된 두 Entity를 그대로 조회하면 서로를 순환하며 조회하는 순환참조가 일어난다. 이로 인해 StackOverflowError가 발생한다.
아래는 순환참조가 발생하는 게시판 예시다.
'''생략'''
@ToString
public class BoardEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String boardName;
private String status;
@OneToMany(
mappedBy = "board"
)
private List<PostEntity> postList = List.of();
}
'''생략'''
@ToString
public class PostEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userName;
private String password;
private String email;
private String status;
private String title;
@Column(columnDefinition = "TEXT")
private String content;
private LocalDateTime postedAt;
@OneToMany(
mappedBy = "post"
)
private List<ReplyEntity> replyEntityList = List.of();
@ManyToOne
private BoardEntity board;
}
BoardEntity에는 1:N 그리고 PostEntity에는 N:1로 서로 매핑 되어있다. 만약 BoardApiController에서 BoardEntity를 response로 반환하면 Json 데이터로 변환되는데 이 과정에서 BoardEntity의 postList는 PostEntity를 참조한다. 그런데 문제는 PostEntity의 board는 다시 BoardEntity를 참조한다. 즉 BoardEntity -> PostEntity -> BoardEntity -> PostEntity ... 와 같이 순환한다.
계속해서 순환한다면 다시 돌아가기 전에 끊어주는 방법이 있다. @JsonIgnore를 PostEntity의 board에 붙여주면 JSON 데이터에 null로 들어가게된다. 따라서 브라우저 측 JSON에서 BoardEntity -> PostEntity로 끝낼 수 있다.
public class PostEntity {
'''생략'''
@ManyToOne
@JsonIgnore
private BoardEntity board;
}
하지만 이 방법은 순환참조의 근본적인 문제를 해결하지 못한다.아래는 response 객체에 대한 JSON 데이터다.
{
"id": 1,
"userName": "이효림",
"password": "1111",
"email": "hyohyo@gmail.com",
"status": "REGISTERED",
"title": "배송 지연",
"content": "배송이 안와요!",
"postedAt": "2023-08-21T17:00:05",
"replyEntityList":[]
},
@JsonIgnore가 붙은 board는 JSON 데이터에서 null이 되기 때문에 PostEntity에 대한 board_id 정보가 누락된다. 원래 목표였던 JSON 직렬화는 성공하지만 어느 BoardEntity와 N:1 매핑되는지 알 수가 없다.
java.lang.StackOverflowError: null
at com.example.simpleboard.board.db.BoardEntity.toString(BoardEntity.java:16) ~[main/:na]
at java.base/java.lang.String.valueOf(String.java:4225) ~[na:na]
at com.example.simpleboard.post.db.PostEntity.toString(PostEntity.java:19) ~[main/:na]
at java.base/java.lang.String.valueOf(String.java:4225) ~[na:na]
at java.base/java.lang.StringBuilder.append(StringBuilder.java:173) ~[na:na]
at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:459) ~[na:na]
at org.hibernate.collection.spi.PersistentBag.toString(PersistentBag.java:590) ~[hibernate-core-6.2.6.Final.jar:6.2.6.Final]
at java.base/java.lang.String.valueOf(String.java:4225) ~[na:na]
at com.example.simpleboard.board.db.BoardEntity.toString(BoardEntity.java:16) ~[main/:na]
at java.base/java.lang.String.valueOf(String.java:4225) ~[na:na]
at com.example.simpleboard.post.db.PostEntity.toString(PostEntity.java:19) ~[main/:na]
더 나아가 반환하는 BoardEntity 로그를 찍어보니 비슷한 문제가 생겼다. 문제는 BoardEntity와 PostEntity에 사용된 @toString에서 시작한다. 위 로그를 보면 BoardEntity의 toString은 PostEntity를 참조하고 PostEntity는 BoardEntity를 참조한다. JSON 직렬화 뿐만 아니라 로깅에서도 동일한 순환 문제가 발생하는 것이다. 이 문제는 PostEntity의 board에 @toString.exlude를 붙여줘서 끊어주면 해결 가능하다.
따라서 @JsonIgnore를 사용하면 JSON 직렬화는 해결하지만 board_id 정보가 누락되고 toString에서도 동일한 문제가 생긴다. 그렇다면 Entity 연관관계로 인한 순환참조를 깔끔히 해결하는 방법은 무엇이 있을까?
애초에 문제는 연관관계와 같은 필요 이상의 정보를 내려주고 있기 때문에 발생한다. 즉 BoardEntity와 PostEntity에서 필요한 정보만 Dto에 따로 담아 보내면 순환참조의 근본적인 문제를 해결할 수 있다.
아래와 같이 BoardDto와 PostDto 클래스를 만든다.
'''생략'''
public class BoardDto {
private Long id;
private String boardName;
private String status;
private List<PostDto> postDtoList = List.of();
}
'''생략'''
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;
}
아노테이션을 다 제거하고 PostDto의 BoardEntity board를 Long boardId로 바꿈으로서 Dto에는 양방향 관계의 결과값만 들어가고 참조하지 않게 만들었다. 각각의 Entity 값을 Dto로 옮기는 작업은 다음과 같이 구현했다.
@Service
@RequiredArgsConstructor
public class BoardConverter {
private final PostConverter postConverter;
public BoardDto toDto(BoardEntity boardEntity){
var postList= boardEntity.getPostEntityList().stream()
.map(postConverter::toDto)
.collect(Collectors.toList());
return BoardDto.builder()
.id(boardEntity.getId())
.boardName(boardEntity.getBoardName())
.status(boardEntity.getStatus())
.postDtoList(postList)
.build();
}
}
@Service
public class PostConverter {
public PostDto toDto (PostEntity postEntity){
return PostDto.builder()
.id(postEntity.getId())
.userName(postEntity.getUserName())
.status(postEntity.getStatus())
.email(postEntity.getEmail())
.password(postEntity.getPassword())
.title(postEntity.getTitle())
.content(postEntity.getContent())
.postedAt(postEntity.getPostedAt())
.boardId(postEntity.getBoard().getId())
.build();
}
}