자기참조관계에서 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으로 직렬화를 하는 과정에서,
무한으로 자기참조를 진행해서 스택오버플로우
오류가 떴습니다.
해결 하는 방법
다양한 방법이 있지만, 본 포스팅에선 두가지만 언급하겠습니다.
@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” 가 붙어진 인스턴스로 대체되기 때문에 순환참조가 발생하지 않습니다.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로 변환 했습니다.정상적으로 잘 출력되네요. 👍
- 엔티티를 직렬화 하는게 아닌, 별도의 DTO클래스를 직렬화하게 된다면, 순환 참조를 막을 수 있습니다.
Comments powered by Disqus.