엔티티 양방향 매핑 순환 참조

김동훈·2023년 4월 19일
0

이번 게시글은 지난 게시글에서 언급했던 엔티티간 양방향 매핑 순환 참조에 대해서 얘기해보도록 하겠습니다.


[문제상황] post-comment 간 양방향 매핑 순환참조 문제

제가 마주한 문제에 대해 이야기 하기전에 이해를 돕기 위해 문제가 발생하게 된 post, comment 엔티티를 보여드리겠습니다.

public class Post extends BaseEntity {
    private String postTitle;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @Column(columnDefinition = "TEXT")
    private String postContent;

    @Column(unique = true)
    private String postOriginId;

    @OneToMany(mappedBy = "post")
    List<Comment> comments= new ArrayList(); //양방향 매핑 순환참조 문제 발생.

}
public class Comment extends BaseEntity {
    @Column(columnDefinition = "TEXT")
    private String commentContent;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;

    @Column(unique = true)
    private String commentOriginId;

}

(BaseEntity는 pk값, 생성시간, 변경시간등 기본적인 field를 가지고 있습니다.)

위 코드를 보시면 Post에서 @OneToMany로 comment와 관계를, Comment에서는 @ManyToOne으로 관계를 맺고 있습니다. 게시글 1개에 댓글 여러개가 달릴 수 있으니, 이러한 연관관계는 너무 당연한겁니다. 이때, 게시글을 조회는 다음과 같은 순서로 이루어집니다.

1. 게시글 조회 (post)
2. 게시글과 매핑된 댓글 조회(comment)
3. 조회된 댓글과 매핑된 게시글 조회

그럼 코드와 함께 직접 실행 결과를 보여드리겠습니다.

@GetMapping("/post/{postOriginId}")
    public ResponseEntity<Post> getPost(@PathVariable String postOriginId) {

        return new ResponseEntity( postService.getPost(postOriginId), HttpStatus.OK);

    }
@Transactional
    public Post getPost(String postOriginId) {
        System.out.println("게시글 조회 =======================================");
        Optional<Post> byPostOriginId = postRepository.findByPostOriginId(postOriginId);
        System.out.println("byPostOriginId = " + byPostOriginId.get().toString());
        return byPostOriginId.get();
    }

예상 response는 다음과 같은 형태 일겁니다.

{
    "postTitle": "the title",
    "user": {
        "userId": "author",
        "name": "donghun"
    },
    "postContent": "hello velog!!",
    "postOriginId": "post1",
    "comments": [
        {
            "userId": "user1",
            "content": "첫번째 댓글!",
            "postOriginId": "post1",
            "commentOriginId": "comment1"
        },
        {
            "userId": "user2",
            "content": "두번째 댓글!",
            "postOriginId": "post1",
            "commentOriginId": "comment2"
        },
        {
            "userId": "user3",
            "content": "세번째 댓글!",
            "postOriginId": "post1",
            "commentOriginId": "comment3"
        }
    ]
}

하지만 실제로 실행시켜보면 stackoverflowerror가 발생합니다...

원인

Could not write JSON: Infinite recursion (StackOverflowError) 무한 재귀로 인해 stackoverflow가 발생했다고 합니다. 쭉 읽다보면
through reference chain: Solo.SpringBootStudy.comment.Comment["post"]-> Solo.SpringBootStudy.post.Post["comments"] -> org.hibernate.collection.internal.PersistentBag[0] -> Solo.SpringBootStudy.comment.Comment["post"] 가 적혀있습니다.
이러한 에러가 발생한 이유는, 서버단에서 클라이언트단으로 response를 내려주기 위해서는 json형태로 내보내야 합니다. 하지만 post엔티티를 직렬화 하려는데 post에서 comment를 원하고 있고, 그 comment에서도 post를 원하고 있기때문에 서로 계속 호출 하고 있기 때문입니다.

해결방법

지난 게시글에서 말한 결론중 하나인 DTO의 사용입니다. 물론 dto말고 JsonIgnoreProperties라는 것을 사용해 해결 할 수도 있지만, DTO를 사용 해보도록 하겠습니다.

JsonIgnoreProperties

@OneToMany(mappedBy = "post")
@JsonIgnoreProperties("post")
List<Comment> comments = new ArrayList();

JsonIgnoreProperties에 무시할 properties의 이름을 적으면 그 값에 의해 호출이 막을 수 있습니다.

response로 내려줄 dto생성

public class PostDetailDto {
    private String postTitle;
    private User user;
    private String postContent;
    private String postOriginId;
    private List<CommentDto> comments;

}

service단의 getPost함수의 변화

@Transactional
    public PostDetailDto getPost(String postOriginId) {
        System.out.println("게시글 조회 =======================================");

        Optional<Post> byPostOriginId = postRepository.findByPostOriginId(postOriginId);
        Post post = byPostOriginId.get();
        List<Comment> comments = post.getComments();
        List<CommentDto> commentDtos = new ArrayList<>();
        
        for (Comment comm : comments) { // 댓글을 commentDto로 변환.
            CommentDto build = CommentDto.builder()
                    .commentOriginId(comm.getCommentOriginId())
                    .userId(comm.getUser().getUserId())
                    .postOriginId(comm.getPost().getPostOriginId())
                    .content(comm.getCommentContent())
                    .build();
            commentDtos.add(build);
        }
        PostDetailDto dtoBuilder = PostDetailDto.builder()
                .postTitle(post.getPostTitle())
                .user(post.getUser())
                .postContent(post.getPostContent())
                .postOriginId(post.getPostOriginId())
                .comments(commentDtos)
                .build();

        return dtoBuilder;
    }

이런 저런 변환 과정이 추가된 모습입니다. 우선 엔티티들은 모두 dto로 변환해주었습니다. 그 이유는 앞서 말했듯이 엔티티들이 서로를 계속 호출 하게 되기 때문에 이를 막기 위해서입니다. 그리고 reponse로 내려보낼 게시글 dto에 댓글dto리스트를 담아 리턴하고 있습니다.

결론.

  • Entity를 Controller단에서 다루지 말고 DTO로 변환하여 클라이언트단과 소통하자!
profile
董訓은 영어로 mentor

0개의 댓글