StackOverflowError 해결 (feat. @JsonIgnoreProperties 애너테이션)

3 분 소요


1. 객체 순환 참조

Jackson 라이브러리를 통해 직렬화(Serialize) 된 Json 응답을 받는 경우 종종 StackOverFlowError가 발생합니다. 이런 경우 대부분 객체 사이의 순환 참조가 문제 발생의 원인입니다.

1.1. 순환 참조 예시

  • A 인스턴스가 B 인스턴스를 참조합니다.
  • B 인스턴스가 A 인스턴스를 참조합니다.
  • A 인스턴스를 직렬화하는 경우 참조하는 B 인스턴스가 함께 직렬화됩니다.
  • B 인스턴스를 직렬화하는 경우 참조하는 A 인스턴스가 함게 직렬화됩니다.
  • 이를 계속 반복 수행하다 StackOverFlow 에러가 발생합니다.

직렬화 시점에 둘 사이의 순환 참조를 끊어주기 위한 방법으로 @JsonIgnoreProperties 애너테이션을 사용합니다. @JsonIgnoreProperties 애너테이션을 살펴보면 다양한 위치에서 사용할 수 있음을 확인할 수 있습니다.

  • ElementType.ANNOTATION_TYPE - 애너테이션
  • ElementType.TYPE - 클래스, 인터페이스, enum
  • ElementType.METHOD - 메소드
  • ElementType.CONSTRUCTOR - 생성자
  • ElementType.FIELD - 필드(멤버변수, enum 상수)
@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 { };

    // ...
}

저의 경우 주로 필드에 사용하며 다음과 같은 동작이 수행되도록 클래스를 구성합니다.

1.2. 순환 참조 방지 예시

  • A 인스턴스가 B 인스턴스를 참조합니다.
  • B 인스턴스가 A 인스턴스를 참조합니다.
  • A 인스턴스를 직렬화하는 경우 참조하는 B 인스턴스가 함께 직렬화됩니다.
  • B 인스턴스를 직렬화하는 경우 @JsonIgnoreProperties 애너테이션을 통해 지정한 항목을 제외하고 직렬화를 수행합니다.

2. 테스트 코드

간단한 테스트 코드를 통해 만날 수 있는 에러 상황과 해결 방법에 대해 알아보도록 하겠습니다.

2.1. Dto 클래스

  • ADto, BDto, CDto 클래스를 작성합니다.
  • ADto 클래스와 BDto 클래스는 서로 순환 참조합니다.
  • ADto 클래스와 CDto 클래스는 서로 순환 참조합니다.
  • ADto 인스턴스를 직렬화할 때 CDto 인스턴스의 “adto” 필드는 제외하고 직렬화를 수행합니다.
@Getter
@Setter
@NoArgsConstructor
class ADto {

    public ADto(BDto bdto) {
        this.bdto = bdto;
    }

    public ADto(CDto cdto) {
        this.cdto = cdto;
    }

    private BDto bdto;

    @JsonIgnoreProperties(value = {"adto"})
    private CDto cdto;
}

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class BDto {

    private ADto adto;
}

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class CDto {

    private String name = "CDto";

    private ADto adto;
}

2.2. ErrorController 클래스

  • error 메소드는 ADto 인스턴스와 BDto 인스턴스의 순환 참조를 만들어 반환합니다.
  • ok 메소드는 ADto 인스턴스와 CDto 인스턴스의 순환 참조를 만들어 반환합니다.
@RestController
class ErrorController {

    @GetMapping("/error")
    public ADto error() {
        ADto aDto = new ADto(new BDto());
        aDto.getBdto().setAdto(aDto);
        return aDto;
    }

    @GetMapping("/ok")
    public ADto ok() {
        ADto aDto = new ADto(new CDto());
        aDto.getCdto().setAdto(aDto);
        return aDto;
    }
}

2.3. test_withoutJsonIgnoreProperties_throwStackOverFlowException 메소드

  • @JsonIgnoreProperties 애너테이션이 적용되지 않은 /error 경로로 API 요청을 수행합니다.
  • 서블릿(Servlet) 영역에서 직렬화 수행 중에 에러가 발생하기 때문에 NestedServletException을 예상할 수 있습니다.
    @Test
    public void test_withoutJsonIgnoreProperties_throwNestedServletException() {
        assertThrows(NestedServletException.class, () -> {
            try {
                mockMvc.perform(get("/error"));
            } catch (Exception e) {
                log.error(e);
                throw e;
            }
        });
    }
test_withoutJsonIgnoreProperties_throwStackOverFlowException 메소드 수행 결과
  • NestedServletException이 발생하여 테스트를 통과합니다.
  • 아래와 같은 로그를 확인할 수 있습니다.
org.springframework.web.util.NestedServletException: Request processing failed; nested exception is org.springframework.http.converter.HttpMessageConversionException: JSON mapping problem: blog.in.action.jackson.ADto["bdto"]->blog.in.action.jackson.BDto["adto"]-> ...
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-5.2.4.RELEASE.jar:5.2.4.RELEASE]
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.2.4.RELEASE.jar:5.2.4.RELEASE]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:634) ~[tomcat-embed-core-9.0.31.jar:9.0.31]
...

Caused by: org.springframework.http.converter.HttpMessageConversionException: JSON mapping problem: blog.in.action.jackson.ADto["bdto"]->blog.in.action.jackson.BDto["adto"]->blog.in.action.jackson.ADto["bdto"]->blog.in.action.jackson.BDto["adto"]->...
    at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:306) ~[spring-web-5.2.4.RELEASE.jar:5.2.4.RELEASE]
    at org.springframework.http.converter.AbstractGenericHttpMessageConverter.write(AbstractGenericHttpMessageConverter.java:104) ~[spring-web-5.2.4.RELEASE.jar:5.2.4.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:287) ~[spring-webmvc-5.2.4.RELEASE.jar:5.2.4.RELEASE]
...

Caused by: com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: blog.in.action.jackson.ADto["bdto"]->blog.in.action.jackson.BDto["adto"]->blog.in.action.jackson.ADto["bdto"]->...
    at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:737) ~[jackson-databind-2.10.2.jar:2.10.2]
    at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:166) ~[jackson-databind-2.10.2.jar:2.10.2]
    at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:727) ~[jackson-databind-2.10.2.jar:2.10.2]
    at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:722) ~[jackson-databind-2.10.2.jar:2.10.2]

2.4. test_withJsonIgnoreProperties_isOk 메소드

  • @JsonIgnoreProperties 애너테이션이 적용된 /ok 경로로 API 요청을 수행합니다.
    @Test
    public void test_withJsonIgnoreProperties_isOk() throws Exception {
        mockMvc.perform(get("/ok"))
            .andExpect(status().isOk())
            .andDo(print());
    }
test_withJsonIgnoreProperties_isOk 메소드 수행 결과
  • 에러 없이 테스트가 통과합니다.
  • {"bdto":null,"cdto":{"name":"CDto"}} 응답을 받았음을 로그를 통해 확인이 가능합니다.
MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /ok
       Parameters = {}
          Headers = []
             Body = <no character encoding set>
    Session Attrs = {}

Handler:
             Type = blog.in.action.jackson.ErrorController
           Method = blog.in.action.jackson.ErrorController#ok()

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 = {"bdto":null,"cdto":{"name":"CDto"}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

CLOSING

개발 초기에 이런 에러를 많이 만났었습니다. 양방향 참조가 되도록 JPA 엔티티(Entity) 설계를 해놓은 모습이 컨트롤러 영역까지 그대로 반영되는 경우 주로 발생하였습니다.

TEST CODE REPOSITORY