@JsonIgnoreProperties 애너테이션을 통한 StackOverFlowError 해결
1. Problem Context
API 엔드포인트(endpoint)를 개발하던 중 다음과 같은 에러가 발생했다.
- 잭슨(Jackson) 라이브러리가 직렬화(serialize)를 수행하는 과정에서 무한 재귀(infinite recursion)에 빠지면
StackOverflowError가 발생한다.
2023-09-14T00:42:29.816+09:00 ERROR 15028 --- [nio-8080-exec-6] 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.Exception.<init>(Exception.java:85) ~[na:na]
at java.base/java.io.IOException.<init>(IOException.java:80) ~[na:na]
at com.fasterxml.jackson.core.JacksonException.<init>(JacksonException.java:26) ~[jackson-core-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.core.JsonProcessingException.<init>(JsonProcessingException.java:25) ~[jackson-core-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.DatabindException.<init>(DatabindException.java:22) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.DatabindException.<init>(DatabindException.java:34) ~[jackson-databind-2.15.2.jar:2.15.2]
...
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serializeContentsUsing(IndexedListSerializer.java:142) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serializeContents(IndexedListSerializer.java:88) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serialize(IndexedListSerializer.java:79) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serialize(IndexedListSerializer.java:18) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.2.jar:2.15.2]
...
2. Problem Analysis
문제를 살펴보니 단순 조회용 API 엔드포인트였지만, 응답 객체가 문제를 일으킬 수 있는 구조였다. 당시 상황을 재현한 코드로 원인을 살펴보자.
먼저 순환 참조(circular reference)란 객체 참조 사이에 닫힌 루프(loop)가 생기는 것을 의미한다. 아래 그림을 보면 빨간색 참조 그래프가 서로 맞물려 닫힌 루프를 만든다.
- 참조하는 객체가 정리되지 않아 가비지 컬렉션(garbage collection) 대상이 되지 않으므로 메모리 누수가 발생할 수 있다.
- 메서드를 호출할 때 재귀 호출로 인해 스택 오버플로우 에러가 발생할 수 있다.
도메인 객체를 살펴보니 다음과 같은 구조를 가지고 있었다.
- 포스트(post) 객체는 자신의 댓글(reply) 객체를 리스트로 참조한다.
- 댓글 객체는 자신과 연관된 포스트 객체를 참조한다.
먼저 Post 레코드 클래스를 살펴보자.
package blog.in.action.domain;
import lombok.Builder;
import java.util.List;
public record Post(
long id,
String content,
List<Reply> replies
) {
@Builder
public Post {
}
public void addReply(Reply reply) {
this.replies.add(reply);
}
}
Reply 레코드 클래스는 다음과 같다.
package blog.in.action.domain;
import lombok.Builder;
public record Reply(
long id,
String content,
Post post
) {
@Builder
public Reply {
}
}
스프링(Spring) 프레임워크는 API 엔드포인트에서 객체를 반환할 때 JSON 형태로 직렬화(serialize)한다. 기본적으로 잭슨 라이브러리를 사용한다. 잭슨은 게터(getter), 세터(setter) 메서드를 기준으로 직렬화와 역직렬화(deserialize)를 수행한다. 위에서 살펴본 Post, Reply 도메인 객체를 JSON 형태로 직렬화하면 다음과 같은 흐름이 발생한다.
- Post 객체가 직렬화된다.
- Post 객체 내부의 replies 필드를 직렬화한다.
- Reply 객체가 직렬화된다.
- Reply 객체 내부의 post 필드를 직렬화한다.
- Post 객체가 직렬화된다.
- 이를 반복 수행하다 StackOverflowError가 발생한다.
3. Solve the problem
문제를 해결하는 방법은 여러 가지가 있지만, 이번 글에서는 @JsonIgnoreProperties 애너테이션을 사용한 해결 방법을 정리했다. JSON 직렬화 작업에서 순환 참조를 끊기 위해 @JsonIgnoreProperties 애너테이션을 사용한다. 특정 객체의 내부 프로퍼티 중 JSON 형태로 직렬화할 대상을 제외할 수 있다.
@Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotation
public @interface JsonIgnoreProperties {
/**
* Names of properties to ignore.
*/
public String[] value() default { };
...
}
정말로 StackOverflowError가 발생하지 않는지 테스트 코드를 통해 살펴보자. PostController 클래스에서 Post 객체와 Reply 객체의 순환 참조를 만든 뒤 이를 반환하는 엔드포인트를 준비한다.
package blog.in.action.controller;
import blog.in.action.domain.Post;
import blog.in.action.domain.Reply;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
@RestController
public class PostController {
@GetMapping("/posts")
public List<Post> getPosts() {
var post = Post.builder()
.id(1L)
.content("Hello World")
.replies(new ArrayList<>())
.build();
var reply = Reply.builder()
.id(1L)
.content("This is reply")
.post(post)
.build();
post.addReply(reply);
return List.of(post);
}
}
@JsonIgnoreProperties 애너테이션을 추가하기 전에 해당 API 경로를 호출하면 에러가 발생하는지 확인한다.
package blog.in.action;
import blog.in.action.controller.PostController;
import com.fasterxml.jackson.databind.JsonMappingException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.isNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
public class ActionInBlogTest {
MockMvc mockMvc;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders
.standaloneSetup(PostController.class)
.build();
}
@Test
void withoutJsonIgnoreProperties_throwException() {
var throwable = assertThrows(Exception.class, () -> mockMvc.perform(get("/posts")));
var cause = throwable.getCause();
assertInstanceOf(HttpMessageNotWritableException.class, throwable.getCause());
assertInstanceOf(JsonMappingException.class, cause.getCause());
cause.printStackTrace();
}
}
로그를 살펴보면 StackOverflowError가 원인임을 확인할 수 있다.
13:46:48.440 [main] INFO org.springframework.mock.web.MockServletContext -- Initializing Spring TestDispatcherServlet ''
13:46:48.441 [main] INFO org.springframework.test.web.servlet.TestDispatcherServlet -- Initializing Servlet ''
13:46:48.445 [main] INFO org.springframework.test.web.servlet.TestDispatcherServlet -- Completed initialization in 2 ms
13:46:48.566 [main] WARN org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver -- Failure while trying to resolve exception [org.springframework.http.converter.HttpMessageNotWritableException]
java.lang.IllegalStateException: Cannot set error status - response is already committed
at org.springframework.util.Assert.state(Assert.java:76)
at org.springframework.mock.web.MockHttpServletResponse.sendError(MockHttpServletResponse.java:586)
at org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver.sendServerError(DefaultHandlerExceptionResolver.java:581)
at org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver.handleHttpMessageNotWritable(DefaultHandlerExceptionResolver.java:548)
at org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver.doResolveException(DefaultHandlerExceptionResolver.java:221)
at org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver.resolveException(AbstractHandlerExceptionResolver.java:141)
at org.springframework.web.servlet.handler.HandlerExceptionResolverComposite.resolveException(HandlerExceptionResolverComposite.java:80)
at org.springframework.web.servlet.DispatcherServlet.processHandlerException(DispatcherServlet.java:1341)
at org.springframework.test.web.servlet.TestDispatcherServlet.processHandlerException(TestDispatcherServlet.java:144)
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1152)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1098)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1011)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
...
org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
...
Caused by: com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: blog.in.action.domain.Reply["post"]->blog.in.action.domain.Post["replies"]->java.util.ArrayList[0]...
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:787)
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178)
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serializeContentsUsing(IndexedListSerializer.java:142)
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serializeContents(IndexedListSerializer.java:88)
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serialize(IndexedListSerializer.java:79)
at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serialize(IndexedListSerializer.java:18)
...
이번에는 @JsonIgnoreProperties 애너테이션을 추가한 후 테스트를 실행해 보자. Reply 클래스를 다음과 같이 변경한다.
- Post 객체의 프로퍼티 중
replies를 JSON 직렬화 대상에서 제외한다.
package blog.in.action.domain;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
public record Reply(
long id,
String content,
@JsonIgnoreProperties(value = "replies")
Post post
) {
@Builder
public Reply {
}
}
아래 테스트 코드를 실행하면 정상적으로 응답을 받을 수 있다.
package blog.in.action;
import blog.in.action.controller.PostController;
import com.fasterxml.jackson.databind.JsonMappingException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.isNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
public class ActionInBlogTest {
MockMvc mockMvc;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders
.standaloneSetup(PostController.class)
.build();
}
// ...
@Test
void withJsonIgnoreProperties_isOk() throws Exception {
mockMvc.perform(get("/posts"))
.andExpect(jsonPath("$[0].id").value(1L))
.andExpect(jsonPath("$[0].content").value("Hello World"))
.andExpect(jsonPath("$[0].replies[0].id").value(1L))
.andExpect(jsonPath("$[0].replies[0].content").value("This is reply"))
.andExpect(jsonPath("$[0].replies[0].post.id").value(1L))
.andExpect(jsonPath("$[0].replies[0].post.content").value("Hello World"))
.andExpect(jsonPath("$[0].replies[0].post.replies").doesNotExist())
.andDo(print())
;
}
}
응답 메시지를 보면 Reply 객체가 참조하는 Post 객체의 replies 프로퍼티가 직렬화되지 않는 것을 확인할 수 있다.
13:53:06.203 [main] INFO org.springframework.mock.web.MockServletContext -- Initializing Spring TestDispatcherServlet ''
13:53:06.204 [main] INFO org.springframework.test.web.servlet.TestDispatcherServlet -- Initializing Servlet ''
13:53:06.208 [main] INFO org.springframework.test.web.servlet.TestDispatcherServlet -- Completed initialization in 2 ms
MockHttpServletRequest:
HTTP Method = GET
Request URI = /posts
Parameters = {}
Headers = []
Body = <no character encoding set>
Session Attrs = {}
Handler:
Type = blog.in.action.controller.PostController
Method = blog.in.action.controller.PostController#getPosts()
Async:
Async started = false
Async result = null
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = [{"id":1,"content":"Hello World","replies":[{"id":1,"content":"This is reply","post":{"id":1,"content":"Hello World"}}]}]
Forwarded URL = null
Redirected URL = null
Cookies = []
댓글남기기