포스트

자기참조관계에서 JSON을 직렬화할때 순환참조를 해결하는 법

본 포스트에서는 자기참조관계 Entity에서 데이터를 조회하고, JSON으로 직렬화할때 무한 순환 문제를 해결하는 방법을, 게시판 댓글-대댓글 구현을 예시로 공유합니다.

예시 상황

데이터베이스 테이블

엔티티 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ....(생략)
public class Comment extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;

    @Column(name = "post_type", length = 15)
    private String postType;

    @Column(name = "post_id", nullable = false)
    private Long postId;

    @OneToOne
    @JoinColumn(name = "writer_id")
    private User writer;

    @Column(name = "content", nullable = false, length = 500)
    private String content;

    @Column(name = "is_removed", nullable = false)
    private boolean isRemoved = false;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_comment_id")
    private Comment parentComment;

    @OneToMany(mappedBy = "parentComment", fetch = FetchType.LAZY)
    private List<Comment> childComments;
}

comment 엔티티는 하나의 테이블에서 댓글과 대댓글을 표현하고 있기 때문에,
parentComment, childComments 필드로 자기참조를 하고 있습니다.

댓글-대댓글 조회 Repository

1
2
3
4
5
6
7
8
public List<Comment> getCommentsByPost(String postType, Long postId) {
	return queryFactory.selectFrom(comment)
			.leftJoin(comment.childComments).fetchJoin()
			.leftJoin(comment.writer).fetchJoin()
			.where(checkPost(postType, postId)
					.and(isParentComment()))
			.fetch();
}

실행 결과

RestController에서 Repository의 getCommentsByPost()를 호출하여 List<Comment>를 출력한 결과입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2023-07-11T19:43:29.320+09:00 ERROR 93162 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError)] with root cause
java.lang.StackOverflowError: null
   at java.base/java.lang.ClassLoader.defineClass1(Native Method) ~[na:na]
   at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1012 undefined) ~[na:na]
   at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:150 undefined) ~[na:na]
   at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:862 undefined) ~[na:na]
   at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:760 undefined) ~[na:na]
   at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:681 undefined) ~[na:na]
   at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:639 undefined) ~[na:na]
   at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188 undefined) ~[na:na]
   at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:520 undefined) ~[na:na]
   at com.fasterxml.jackson.databind.JsonMappingException.prependPath(JsonMappingException.java:455 undefined) ~[jackson-databind-2.15.0.jar:2.15.0]
   at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:790 undefined) ~[jackson-databind-2.15.0.jar:2.15.0]
   at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178 undefined) ~[jackson-databind-2.15.0.jar:2.15.0]
   at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732 undefined) ~[jackson-databind-2.15.0.jar:2.15.0]
   at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772 undefined) ~[jackson-databind-2.15.0.jar:2.15.0]
   at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178 undefined) ~[jackson-databind-2.15.0.jar:2.15.0]
   at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(CollectionSerializer.java:145 undefined) ~[jackson-databind-2.15.0.jar:2.15.0]
   at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107 undefined)

JSON으로 직렬화를 하는 과정에서,
무한으로 자기참조를 진행해서 스택오버플로우 오류가 떴습니다.

해결 하는 방법

다양한 방법이 있지만, 본 포스팅에선 두가지만 언급하겠습니다.

  1. @JsonBackReference@JsonManagedReference를 사용하기
    com.fasterxml.jackson 패키지에서는 다음과 같은 Annotation을 지원합니다. 각 Annotation들의 Java-doc을 확인해볼까요?

    • @JsonBackReference
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
     /**  
     * Annotation used to indicate that associated property is part of  
     * two-way linkage between fields; and that its role is "child" (or "back") link.  
     * Value type of the property must be a bean: it can not be a Collection, Map,  
     * Array or enumeration.  
     * Linkage is handled such that the property  
     * annotated with this annotation is not serialized; and during deserialization,  
     * its value is set to instance that has the "managed" (forward) link.  
     *<p>  
     * All references have logical name to allow handling multiple linkages; typical case  
     * would be that where nodes have both parent/child and sibling linkages. If so,  
     * pairs of references should be named differently.  
     * It is an error for a class to have multiple back references with same name,  
     * even if types pointed are different.  
     *<p>  
     * Note: only methods and fields can be annotated with this annotation: constructor  
     * arguments should NOT be annotated, as they can not be either managed or back  
     * references.  
     */
    

    (JsonBackReference 초간단 번역)
    -> 양방향 링크에서 그 역할이 하위(child) 링크임을 나타내는데 사용하는 어노테이션입니다.
    이 어노테이션으로 어노테이션된 프로퍼티가 직렬화되지 않도록 처리되며, 역직렬화 중에 해당 값은 “managed” 링크가 있는 인스턴스로 설정됩니다.

    • @JsonManagedReference
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
     /**  
     * Annotation used to indicate that annotated property is part of  
     * two-way linkage between fields; and that its role is "parent" (or "forward") link.  
     * Value type (class) of property must have a single compatible property annotated with  
     * {@link JsonBackReference}. Linkage is handled such that the property  
     * annotated with this annotation is handled normally (serialized normally, no  
     * special handling for deserialization); it is the matching back reference  
     * that requires special handling  
     *<p>  
     * All references have logical name to allow handling multiple linkages; typical case  
     * would be that where nodes have both parent/child and sibling linkages. If so,  
     * pairs of references should be named differently.  
     * It is an error for a class to have multiple managed references with same name,  
     * even if types pointed are different.  
     *<p>  
     * Note: only methods and fields can be annotated with this annotation: constructor  
     * arguments should NOT be annotated, as they can not be either managed or back  
     * references.  
     *  
     * @author tatu  
     */
    

    (JsonManagedReference 초간단 번역)
    -> 양방향 링크에서 그 역할이 상위(parent) 링크임을 나타내는데 사용하는 어노테이션입니다. 이 어노테이션으로 어노테이션된 프로퍼티가 정상적으로 처리되도록 처리됩니다.(정상적으로 직렬화, 역직렬화를 위한 특별한 처리 없음)

    최종 요약하면,
    직렬화를 수행할 필드 -> @JsonManagedReference 직렬화를 수행하지 않을 필드 -> @JsonBackReference 를 사용 하라고 합니다.

    1
    2
    3
    4
    5
    6
    7
    8
    
     @JsonBackReference // 여기!!
     @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "parent_comment_id")
     private Comment parentComment;
    	
     @JsonManagedReference // 여기!!
     @OneToMany(mappedBy = "parentComment", fetch = FetchType.LAZY)
     private List<Comment> childComments;
    

    따라서 childComments 필드에 @JsonManagedReference를 붙인다면 해당 필드는 직렬화가 되고, parentComment 필드에 @JsonBackReference를 붙인다면 “Managed” 가 붙어진 인스턴스로 대체되기 때문에 순환참조가 발생하지 않습니다.

  2. Entity 객체가 아닌, DTO 객체로 변환하여 사용하기

    • 엔티티를 직렬화 하는게 아닌, 별도의 DTO클래스를 직렬화하게 된다면, 순환 참조를 막을 수 있습니다.
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      
       @Getter
       @NoArgsConstructor
       public class CommentResponse {
       private CommentItem parentComment;
       private List<CommentItem> childComments = new ArrayList<>();
      	
       public CommentResponse(CommentItem parentComment, List<CommentItem> childComments) {
           this.parentComment = parentComment;
           this.childComments = childComments;
       }
      	
       @Getter
       @SuperBuilder
       public static class CommentItem extends LocalDateResponse {
           private final Long id;
           private final UserDto writer;
           private final String content;
      	
           public CommentItem(LocalDateResponseBuilder<?, ?> b, Long id, UserDto writer, String content) {
               super(b);
               this.id = id;
               this.writer = writer;
               this.content = content;
           }
       }
       }
      

    별도로 생성한 DTO 클래스 입니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
     public List<CommentResponse> getCommentsByPost(String postType, Long postId) {
         List<Comment> result = queryFactory.selectFrom(comment)
                 .leftJoin(comment.childComments).fetchJoin()
                 .leftJoin(comment.writer).fetchJoin()
                 .where(checkPost(postType, postId)
                         .and(isParentComment()))
                 .fetch();
    	
         return commentToResponse(result); // !!!별도의 변환 메서드!!!
     }
    	
     private List<CommentResponse> commentToResponse(List<Comment> result) {
         // Entity To DTO 변환 로직
         return response;
     }
    

    Repository에서 commentToResponse()메서드를 이용하여 쿼리 결과를 DTO로 변환 했습니다.

    정상적으로 잘 출력되네요. 👍

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

Comments powered by Disqus.