Jackson 양방향 관계 모델의 순환 참조 피하기

개요

public class Parent {
    private Integer no;
    private String name;
    private List<Child> childs;
}

public class Child {
    private Integer no;
    private String name;
    private Parent parent;
}
  • 이같은 상호 참조 관계의 양방양 도메인 모델들의 객체들을 별다른 설정없이 Jackson 라이브러리로 JSON 직렬화 시도하면 관계가 관계를 타고 타고 타고 타고.. 가다가 Infinite Loop StackOverflowError 예외가 발생한다.
org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
  • 이를 피하기 위해 Jackson 에서 제공하는 몇가지 방법과 각 방법으로 직렬화된 JSON 결과를 살펴보자

@JsonIgnore

public class Parent {
    private Integer no;
    private String name;
    @JsonIgnore
    private List<Child> childs;
}
  • 가장 쓸모없는 방법.
  • @JsonIgnore 를 붙인 프로퍼티는 직렬화에서 제외한다는 설정인데, 양방향 관계를 풀기 위해 제공되는 방법은 아니라고 봐야한다.
// Parent
{
    "no" : 1,
    "name" : "Parent 1"
}

// Child
{
    "no" : 1,
    "name" : "Child 1"
    
}

@JsonManageReference - @JsonBackReference

public class Parent {
    private Integer no;
    private String name;
    @JsonManageReference // Parent 가 childs 프로퍼티의 주인 모델이라 지정
    private List<Child> childs;
}

public class Child {
    private Integer no;
    private String name;
    @JsonBackReference // Child 의 parent 프로퍼티의 종속 모델임을 지정
    private Parent parent;
}
  • 양방향 관계 중 주 (@JsonManagedReference) - 종 (@JsonBackReference) 관계를 설정하여 주인 모델에선 종속 모델의 정보가 직렬화 되지만, 종속 모델에선 주인 모델이 직렬화 되지 않는다.
  • 양방향 관계라는게 실상 의연중의 주 - 종 관계가 성립되는 것이 일반적이긴 하지만, Restful API 를 제공해야 하는 입장에서 Child 의 Parent no 라도 알려줘야 getParent 를 하라는 암시라도 될텐데, 종속 모델은 부모 모델의 정보를 아예 지워버리므로. 그게 불가능하다.
// Parent
{
    "no" : 1,
    "name" : "Parent 1",
    "childs" : [
        {
            "no" : 1,
            "name" : "Child 1"
        },
        {
            "no" : 2,
            "name" : "Child 2"
        }
    ]
}

// Child
{
    "no" : 1,
    "name" : "Child 1"
}

@JsonIgnoreProperties

public class Parent {
    private Integer no;
    private String name;
    @JsonIgnoreProperties({"parent"}) // Child 모델의 parent 프로퍼티는 제외
    private List<Child> childs;
}

public class Child {
    private Integer no;
    private String name;
    @JsonIgnoreProperties({"childs"}) // Parent 모델의 childs 프로퍼티는 제외
    private Parent parent;
}
  • 이름부터 @JsonIgnore 의 강화 버전이란 뉘앙스를 물씬 풍기는 방법
  • @JsonIgnore 가 모델의 프로퍼티 자체를 직렬화에서 제외했던 것에서 한 발 나아가 모델의 프로퍼티에 해당하는 참조 모델을 직렬화할 때 제외시킬 참조 모델의 프로퍼티 이름를 지정한다. (reflection 의 향연)
  • 참조 모델의 제외 프로퍼티 지정은 배열로 감싸 다수의 제외 프로퍼티를 지정할 수 있다.
  • @JsonManagedReference - @JsonBackReference 가 주 - 종 관계를 고정적으로 선언하는 것이라면 @JsonIgnoreProperties 는 항상 스스로가 주인 모델인 것 처럼 취급하되, 내 종속 모델에 순환 참조를 야기하는 관계 프로퍼티가 있으니 이 프로퍼티는 제외하라.. 는 느낌적인 느낌의 방법
  • Restful API 를 제공하는 입장에서는 요건에 부합하지만, 설정 면에서 약간 번거롭다.
  • 또한 프로퍼티 명을 string 으로 지정한다는 점에서 프로퍼티명 변경 가능성에 있어 추후 관리 측면에서는 약간 부담스럽다.
  • IntelliJ 는 리팩토링 범주에 자동 감지해주긴 하는데, Eclipse 에서도 제대로 변경될지는 모르겠음.
  • 또 IntelliJ 의 리팩토링 지원도 간혹 너무 과하게 범주를 잡아버려 당황스러운 적도 있어서 좀 찝찝함.
// Parent
{
    "no" : 1,
    "name" : "Parent 1",
    "childs" : [
        {
            "no" : 1,
            "name" : "Child 1"
        },
        {
            "no" : 2,
            "name" : "Child 2"
        }
    ]
}

// Child
{
    "no" : 1,
    "name" : "Child 1",
    "parent" : {
        "no" : 1,
        "name" : "Parent 1"
    }
}

@JsonIdentityInfo

@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property="no")
public class Parent {
    private Integer no;
    private String name;
    private House house;
    private List<Child> childs;
}

@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property="no")
public class Child {
    private Integer no;
    private String name;
    private House house;
    private Parent parent;
}

@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property="no")
private class House {
    private Integer no;
    private String name;
}
  • 지금까지의 방법들이 클래스 프로퍼티에 어노테이션을 붙여왔던 것에 반해, @JsonIdentityInfo 는 클래스에 어노테이션을 붙인다.
  • 이 방법에 유념해야할 것은, @JsonIdentityInfo 가 붙은 모델은 직렬화 과정 중 한번이라도 직렬화되면 다음번 직렬화 차례에서 어노테이션에서 식별용으로 지정한 프로퍼티로만 직렬화 된다는 것이다.
  • 사용하는 입장에선 순환 참조 관계의 모델 구조에서만 종속 모델이 직렬화될 때 식별자가 노출되는 것을 자연히 기대할텐데, 그게 아니라 결과 중 한번이라도 직렬화 되었다면 모델 관계와 무관하게 무조건 식별자로만 직렬화 된다.
  • 추구하는 Restful API 의 결과가 굉장히 심플하게 떨어지기에 얼핏보면 Restful 규약에 충실할 것 같지만,도메인이 복잡해지면, 상호 참조 관계가 두 모델간에만 엮이지 않기 때문에, 동일 모델이 여러 모델 하위에 종속될 수도 있는데, 상위에서 한번 직렬화된 종속 모델을 한번의 API 호출 결과 내에서 재활용하려면 JSON Node 구조를 역추적해가며 파싱해 써야 하므로 받는 클라이언트 입장에선 꽤나 골치아플 여지가 있다.
  • 이건 Jackson 라이브러리가 직렬 간소화를 상호 순환 참조 관계에서만 이뤄지도록 수정되거나, 그것을 위한 또다른 어노테이션이 추가로 제공되어야하는 것 아닌가 싶다.
// Parent
{
    "no" : 1,
    "name" : "Parent 1",
    "house" : {
        "no" : 1,
        "name" : "House 1"
    },
    "childs" : [
        {
            "no" : 1,
            "name" : "Child 1",
            "house" : 1 // Parent 에서 House 가 직렬화 되었기에 구분자 프로퍼티로만 표현됨
        },
        {
            "no" : 2,
            "name" : "Child 1",
            "house" : 1 // Parent 에서 House 가 직렬화 되었기에 구분자 프로퍼티로만 표현됨
        }
    ]
}

// Child
{
    "no" : 1,
    "name" : "Child 1",
    "house" : {
        "no" : 1,
        "name" : "House 1"
    },
    "parent" : {
        "no" : 1,
        "name" : "Parent 1",
        "childs" : [1, 2]
    }
}

@JsonView

Serialize Config Classes

public interface ParentView { }

public interface ChildView { }

Models

public class Parent {
    private Integer no;
    private String name;
    @JsonView({ParentView.class})
    private List<Child> childs;
}

public class Child {
    private Integer no;
    private String name;
    private House house;
    @JsonView({ChildView.class})
    private Parent parent;
}

Case - Spring Controller

// Get Parent
@JsonView(value={Parent.class})
@RequestMapping
public ResponseEntity<Parent> getParent(...) { ... }


// Get Child
@JsonView(value={Child.class})
@RequestMapping
public ResponseEntity<Child> getChild(...) { ... }

Case - Writer

// Parent Serialize
String parent = mapper.writerWithView(ParentView.class).writeValueAsString(parent);

// Child Serialize
String child = mapper.writerWithView(ChildView.class).writeValueAsString(parent);
  • @JsonView 는 가장 손이 많이 가는 방법으로, 양방향 관계 모델들의 직렬화 뿐 아니라 API 의 용도나 의도에 따라 다양한 Case by case 결과를 내는 방법으로 사용될 수 있는 (그리고 드물게 사용되어 왔을) 방법이다.
  • 우선 직렬화의 Case 를 지정하는 명칭으로 구분 가능한 인터페이스들을 선언한다.
  • 예제에선 Parent.class 와 Child.class 이며, 이후 모델의 프로퍼티에 직렬화가 되는 Case 를 선언한 클래스들로 지정해준다.
  • name 이라는 프로퍼티를 ParentView Case 에만 직렬화 하고 싶다면 name 이란 프로퍼티에 @JsonView(value={ParentView.class}) 로 지정해줄 수 있고, 현재는 양방향 모델이 문제이므로 child 프로퍼티에 @JsonView(value={ParentView.class}) 로 지정해주면 Parent 모델과 양방향 관계를 가지는 child 프로퍼티는 ParentView Case 로 직렬화 할 때만 직렬화 대상에 들어간다.
  • 반대로 Child 모델의 parent 프로퍼티에는 ChildView.class 를 지정해주면 parent 프로퍼티는 ChildView Case 로 직렬화 할때만 직렬화 된다. 이제 최종 직렬화 수행 직전 직렬화 Case 를 지정해주면 된다.
  • 원하는 형태의 커스터마이징이 가장 잘 되는 방법이긴 한데, 여러 모델간의 관계를 최종 View Case 로 지정하다보면 커스터마이징하는 자신이 다소 헷갈릴 소지가 있고, 최종으로 직렬화될 Case 를 개발자가 스스로 잘 정리할 수 있어야 한다는 것이 좀 번거롭다.
  • 직렬화 Case 를 지정하는 interface class 는 서로 상속 관계를 활용할 수 있으므로 View Case 의 상속 관계 지정까지 감안해서 최종 View 를 어떻게 설계할 것인가 Case by Case 로 고려하다보면 머리가 좀 아프다.
  • 위 3가지 방법들이 모든 경우에 적용되는 것에 반해 @JsonView 는 훨씬 예외 사항을 처리하기에 자유롭다.
// Get Parent
{
    "no" : 1,
    "name" : "Parent 1",
    "childs" : [
        {
            "no" : 1,
            "name" : "Child 1",
        },
        {
            "no" : 2,
            "name" : "Child 1",
        }
    ]
}

// Get Child
{
    "no" : 1,
    "name" : "Child 1"
    "parent" : {
        "no" : 1,
        "name" : "Parent 1"
    }
}

결론

  • 개인적 취향으로는 머리가 아파도 @JsonView 가 어떤 예외 케이스를 작성해야 할지 모르는 상황을 대비하기엔 좋아하는 방법이다. 다만 모든 모델들을 하나하나 지정할 필요는 없을 것 같고, 여러 방법들을 모델의 성격에 적절히 조합하여 사용하는 것이 바람직 할 것 같다.