[Spring Boot] JPA 연관 관계로 인한 순환 참조 해결

boms·2023년 8월 21일
2

Trouble Shooting

목록 보기
1/4

Issue 🙌

BoardApiController에서 PostEntity와 양방향 매핑된 BoardEntity 객체를 반환했는데 직렬화 과정에서 데이터가 반복적으로 출력되었다.

Problem 🤔

양방향 매핑된 두 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 ... 와 같이 순환한다.

Solution 🙌

@JsonIgnore❓

계속해서 순환한다면 다시 돌아가기 전에 끊어주는 방법이 있다. @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 매핑되는지 알 수가 없다.

toString❗️

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 연관관계로 인한 순환참조를 깔끔히 해결하는 방법은 무엇이 있을까?

DTO의 활용 👍

애초에 문제는 연관관계와 같은 필요 이상의 정보를 내려주고 있기 때문에 발생한다. 즉 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();
    }
}

Takeaway

  • 다른 클래스와 중요 로직이 포함된 entity를 response로 사용하지 말자.
  • Dto를 왜 사용하는지 배우는 계기가 되었다.
profile
2023.08.21~

0개의 댓글