@JsonIgnoreProperties 애너테이션을 통한 StackOverFlowError 해결

4 분 소요


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) 대상이 되지 않으므로 메모리 누수가 발생할 수 있다.
  • 메서드를 호출할 때 재귀 호출로 인해 스택 오버플로우 에러가 발생할 수 있다.
https://en.wikipedia.org/wiki/Circular_reference


도메인 객체를 살펴보니 다음과 같은 구조를 가지고 있었다.

  • 포스트(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 형태로 직렬화하면 다음과 같은 흐름이 발생한다.

  1. Post 객체가 직렬화된다.
  2. Post 객체 내부의 replies 필드를 직렬화한다.
  3. Reply 객체가 직렬화된다.
  4. Reply 객체 내부의 post 필드를 직렬화한다.
  5. Post 객체가 직렬화된다.
  6. 이를 반복 수행하다 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 = []

TEST CODE REPOSITORY

REFERENCE

댓글남기기