JSON의 양방향 관계 지원
게시 됨: 2022-03-11양방향 관계(예: 순환 참조)가 있는 엔터티를 포함하는 JSON 데이터 구조를 생성하려고 시도한 적이 있습니까? 가지고 있다면 "Uncaught TypeError: Converting circle structure to JSON" 줄을 따라 JavaScript 오류를 보았을 것입니다. 또는 Jackson 라이브러리를 사용하는 Java 개발자라면 "Could not write JSON: Infinite recursion (StackOverflowError) with root cause java.lang.StackOverflowError" 가 발생했을 수 있습니다.
이 문서에서는 이러한 오류를 발생시키지 않으면서 양방향 관계를 포함하는 JSON 구조를 생성하는 강력한 작업 방식을 제공합니다.
종종 이 문제에 대해 제공되는 솔루션에는 기본적으로 문제를 해결하지만 실제로 해결하지는 않는 해결 방법이 수반됩니다. 예에는 @JsonManagedReference 및 @JsonBackReference (직렬화에서 역 참조를 단순히 생략함)와 같은 Jackson 주석 유형을 사용하거나 @JsonIgnore 를 사용하여 관계의 측면 중 하나를 단순히 무시하는 것이 포함됩니다. 또는 데이터의 양방향 관계 또는 순환 종속성을 무시하는 사용자 지정 직렬화 코드를 개발할 수 있습니다.
그러나 우리는 양방향 관계의 어느 한쪽을 무시하거나 생략하고 싶지 않습니다. 우리는 오류를 생성하지 않고 양방향으로 그것을 보존하기를 원합니다. 실제 솔루션 은 JSON의 순환 종속성을 허용해야 하며 개발자가 이를 수정하기 위한 추가 조치를 취하지 않고 이에 대한 생각을 중단할 수 있도록 해야 합니다. 이 기사는 그렇게 하기 위한 실용적이고 직접적인 기술을 제공하며, 이는 오늘날의 프론트 엔드 개발자를 위한 표준 팁 및 사례 세트에 유용한 추가 기능을 제공할 수 있습니다.
간단한 양방향 관계의 예
이 양방향 관계(순환 종속성) 문제가 발생하는 일반적인 경우는 자식이 있는 부모 개체(참조)가 있고 해당 자식 개체가 차례로 부모에 대한 참조를 유지하려는 경우입니다. 다음은 간단한 예입니다.
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ] 위의 parent 개체를 JSON으로 변환하려고 하면(예: var parentJson = JSON.stringify(parent); 에서와 같이 stringify 메서드를 사용하여) Uncaught TypeError: Converting circle structure to JSON 예외가 발생합니다.
위에서 논의한 기술 중 하나를 사용할 수 있지만(예: @JsonIgnore 와 같은 주석 사용) 또는 단순히 자식에서 부모에 대한 위의 참조를 제거할 수 있지만 이는 문제를 해결 하기 보다는 피하는 방법입니다. 우리가 정말로 원하는 것은 각 양방향 관계를 유지하고 예외를 던지지 않고 JSON으로 변환할 수 있는 결과 JSON 구조입니다.
솔루션을 향한 이동
솔루션을 향한 한 가지 잠재적으로 분명한 단계는 각 개체에 어떤 형태의 개체 ID를 추가한 다음 부모 개체 에 대한 자식 참조를 부모 개체의 id 에 대한 참조로 바꾸는 것입니다. 예를 들어:
var obj = { "id": 100, "name": "I'm parent" } obj.children = [ { "id": 101, "name": "I'm first child", "parent": 100 }, { "id": 102, "name": "I'm second child", "parent": 100 } ]이 접근 방식은 양방향 관계 또는 순환 참조로 인해 발생하는 모든 예외를 확실히 방지합니다. 그러나 여전히 문제가 있으며 이러한 참조를 직렬화 및 역직렬화하는 방법을 생각할 때 그 문제가 분명해집니다.
문제는 위의 예를 사용하여 값 "100"에 대한 모든 참조가 부모 개체를 참조한다는 것을 알아야 한다는 것입니다(그것의 id 이기 때문에). 값이 "100"인 유일한 속성이 parent 속성인 위의 예에서는 잘 작동합니다. 그러나 값이 "100"인 다른 속성을 추가하면 어떻게 될까요? 예를 들어:
obj.children = [ { "id": 101, "name": "I'm first child", "priority": 100, // This is NOT referencing object ID "100" "parent": 100 // This IS referencing object ID "100" }, { "id": 102, "name": "I'm second child", "priority": 200, "parent": 100 } ] 값 "100"에 대한 참조가 개체를 참조한다고 가정하면 직렬화/역직렬화 코드에서 parent 가 값 "100"을 참조할 때 IS가 부모 개체의 id 를 참조한다는 것을 알 방법이 없습니다. priority 는 부모 개체의 id 를 참조하지 않는 값 "100"을 참조합니다(그리고 priority 가 부모 개체의 id 도 참조한다고 생각하기 때문에 해당 값을 부모 개체에 대한 참조로 잘못 대체합니다).
이 시점에서 "잠깐만요, 당신은 분명한 해결책을 놓치고 있습니다. 속성 값을 사용하여 개체 ID를 참조하고 있는지 확인하는 대신 속성 이름만 사용하는 것이 어떻습니까?” 사실, 그것은 선택사항이지만 매우 제한적입니다. 이는 항상 다른 객체를 참조하는 것으로 가정되는 "예약된" 속성 이름 목록을 미리 지정해야 함을 의미합니다("부모", "자식", "다음" 등의 이름). 그러면 해당 속성 이름 만 다른 개체에 대한 참조에 사용할 수 있으며 이러한 속성 이름은 항상 다른 개체에 대한 참조로 취급된다는 의미이기도 합니다. 따라서 이것은 대부분의 상황에서 실행 가능한 대안이 아닙니다.
따라서 속성 값을 객체 참조로 인식하는 것을 고수해야 할 것 같습니다. 그러나 이것은 다른 모든 속성 값에서 고유한 값을 보장 하기 위해 이러한 값이 필요함을 의미합니다. GUID(Globally Unique Identifiers)를 사용하여 고유한 값의 필요성을 해결할 수 있습니다. 예를 들어:
var obj = { "id": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc", "name": "I'm parent" } obj.children = [ { "id": "6616c598-0a0a-8263-7a56-fb0c0e16225a", "name": "I'm first child", "priority": 100, "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" // matches unique parent id }, { "id": "940e60e4-9497-7c0d-3467-297ff8bb9ef2", "name": "I'm second child", "priority": 200, "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" // matches unique parent id } ]그러면 작동해야 합니다. 맞죠?
네.
하지만…
완전 자동화된 솔루션
우리의 원래 도전을 기억하십시오. 우리는 예외를 생성하지 않고 JSON과 양방향 관계가 있는 객체를 직렬화 및 역직렬화할 수 있기를 원했습니다. 위의 솔루션이 이 작업을 수행하는 동안 (a) 각 개체에 고유 ID 필드 형식을 추가하고 (b) 각 개체 참조를 해당 고유 ID로 교체 해야 합니다. 이것은 작동할 것이지만 우리는 이런 방식으로 우리의 개체를 "수동으로" 수정할 필요 없이 기존 개체 참조와 함께 자동으로 작동하는 솔루션을 훨씬 선호합니다.
이상적으로는 직렬 변환기와 역직렬 변환기를 통해 개체 집합(임의의 속성 및 개체 참조 포함)을 전달할 수 있고(양방향 관계에 따라 예외를 생성하지 않고) 역직렬 변환기에서 생성된 개체가 정확하게 일치하도록 할 수 있습니다. 직렬 변환기에 공급된 개체입니다.
우리의 접근 방식은 직렬 변환기가 각 개체에 고유 ID(GUID 사용)를 자동으로 만들고 추가하도록 하는 것입니다. 그런 다음 개체 참조를 해당 개체의 GUID로 바꿉니다. (시리얼라이저는 이러한 ID에도 고유한 속성 이름 을 사용해야 합니다. 이 예에서는 속성 이름 앞에 "@"를 추가하는 것이 고유한지 확인하는 데 적합하기 때문에 @id 를 사용합니다.) 그런 다음 개체 ID에 해당하는 모든 GUID를 해당 개체에 대한 참조로 바꿉니다.
따라서 예제 로 돌아가서 직렬 변환기에 다음 개체 집합을 있는 그대로 공급하려고 합니다.
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]그런 다음 직렬 변환기가 다음과 유사한 JSON 구조를 생성할 것으로 예상합니다.
{ "@id": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc", "name": "I'm parent", "children": [ { "@id": "6616c598-0a0a-8263-7a56-fb0c0e16225a", "name": "I'm first child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, { "@id": "940e60e4-9497-7c0d-3467-297ff8bb9ef2", "name": "I'm second child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, ] }(JSON 포맷터 도구를 사용하여 모든 JSON 객체를 예쁘게 만들 수 있습니다.)
그런 다음 위의 JSON을 deserializer에 제공하면 원래 개체 집합이 생성됩니다(즉, 부모 개체와 두 자식, 서로 적절하게 참조).
이제 우리가 무엇을 하고 싶은지, 어떻게 하고 싶은지 알았으니 구현해 보겠습니다.
JavaScript에서 직렬 변환기 구현
다음은 예외를 발생시키지 않고 양방향 관계를 적절하게 처리하는 직렬 변환기의 작동하는 JavaScript 구현 샘플입니다.
var convertToJson = function(obj) { // Generate a random value structured as a GUID var guid = function() { function s4() { return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); } return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); }; // Check if a value is an object var isObject = function(value) { return (typeof value === 'object'); } // Check if an object is an array var isArray = function(obj) { return (Object.prototype.toString.call(obj) === '[object Array]'); } var convertToJsonHelper = function(obj, key, objects) { // Initialize objects array and // put root object into if it exist if(!objects) { objects = []; if (isObject(obj) && (! isArray(obj))) { obj[key] = guid(); objects.push(obj); } } for (var i in obj) { // Skip methods if (!obj.hasOwnProperty(i)) { continue; } if (isObject(obj[i])) { var objIndex = objects.indexOf(obj[i]); if(objIndex === -1) { // Object has not been processed; generate key and continue // (but don't generate key for arrays!) if(! isArray(obj)) { obj[i][key] = guid(); objects.push(obj[i]); } // Process child properties // (note well: recursive call) convertToJsonHelper(obj[i], key, objects); } else { // Current object has already been processed; // replace it with existing reference obj[i] = objects[objIndex][key]; } } } return obj; } // As discussed above, the serializer needs to use some unique property name for // the IDs it generates. Here we use "@id" since presumably prepending the "@" to // the property name is adequate to ensure that it is unique. But any unique // property name can be used, as long as the same one is used by the serializer // and deserializer. // // Also note that we leave off the 3rd parameter in our call to // convertToJsonHelper since it will be initialized within that function if it // is not provided. return convertToJsonHelper(obj, "@id"); }JavaScript에서 디시리얼라이저 구현
아래는 예외를 던지지 않고 양방향 관계를 적절하게 처리하는 deserializer의 작동하는 JavaScript 구현 샘플입니다.

var convertToObject = function(json) { // Check if an object is an array var isObject = function(value) { return (typeof value === 'object'); } // Iterate object properties and store all reference keys and references var getKeys = function(obj, key) { var keys = []; for (var i in obj) { // Skip methods if (!obj.hasOwnProperty(i)) { continue; } if (isObject(obj[i])) { keys = keys.concat(getKeys(obj[i], key)); } else if (i === key) { keys.push( { key: obj[key], obj: obj } ); } } return keys; }; var convertToObjectHelper = function(json, key, keys) { // Store all reference keys and references to object map if(!keys) { keys = getKeys(json, key); var convertedKeys = {}; for(var i = 0; i < keys.length; i++) { convertedKeys[keys[i].key] = keys[i].obj; } keys = convertedKeys; } var obj = json; // Iterate all object properties and object children // recursively and replace references with real objects for (var j in obj) { // Skip methods if (!obj.hasOwnProperty(j)) { continue; } if (isObject(obj[j])) { // Property is an object, so process its children // (note well: recursive call) convertToObjectHelper(obj[j], key, keys); } else if( j === key) { // Remove reference id delete obj[j]; } else if (keys[obj[j]]) { // Replace reference with real object obj[j] = keys[obj[j]]; } } return obj; }; // As discussed above, the serializer needs to use some unique property name for // the IDs it generates. Here we use "@id" since presumably prepending the "@" to // the property name is adequate to ensure that it is unique. But any unique // property name can be used, as long as the same one is used by the serializer // and deserializer. // // Also note that we leave off the 3rd parameter in our call to // convertToObjectHelper since it will be initialized within that function if it // is not provided. return convertToObjectHelper(json, "@id"); } 이 두 가지 방법을 통해 객체 세트(양방향 관계가 있는 객체 포함)를 전달하는 것은 본질적으로 식별 기능입니다. 즉, convertToObject(convertToJson(obj)) === obj 는 true로 평가됩니다.
자바/잭슨 예제
이제 이 접근 방식이 널리 사용되는 외부 라이브러리에서 어떻게 지원되는지 살펴보겠습니다. 예를 들어, Jackson 라이브러리를 사용하여 Java에서 어떻게 처리되는지 봅시다.
@JsonIdentityInfo(generator=ObjectIdGenerators.UUIDGenerator.class, property="@id") public class Parent implements Serializable { private String name; private List<Child> children = new ArrayList<>(); public String getName() { return name; } public void setName(String name) { this.name = name; } public List<Child> getChildren() { return children; } public void setChildren(List<Child> children) { this.children = children; } } @JsonIdentityInfo(generator=ObjectIdGenerators.UUIDGenerator.class, property="@id") public class Child implements Serializable { private String name; private Parent parent; public String getName() { return name; } public void setName(String name) { this.name = name; } public Parent getParent() { return parent; } public void setParent(Parent parent) { this.parent = parent; } } 이 두 Java 클래스 Parent 및 Child 는 이 기사의 시작 부분에 있는 JavaScript 예제에서와 동일한 구조를 나타냅니다. 여기서 요점은 @JsonIdentityInfo 주석을 사용하여 Jackson에게 이러한 개체를 직렬화/역직렬화하는 방법을 알려주는 것입니다.
예를 들어 보겠습니다.
Parent parent = new Parent(); parent.setName("I'm parent") Child child1 = new Child(); child1.setName("I'm first child"); Child child2 = new Child(); child2.setName("I'm second child"); parent.setChildren(Arrays.asList(child1, child2));부모 인스턴스를 JSON으로 직렬화한 결과 JavaScript 예제와 동일한 JSON 구조가 반환됩니다.
{ "@id": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc", "name": "I'm parent", "children": [ { "@id": "6616c598-0a0a-8263-7a56-fb0c0e16225a", "name": "I'm first child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, { "@id": "940e60e4-9497-7c0d-3467-297ff8bb9ef2", "name": "I'm second child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, ] }또 다른 장점
JSON에서 양방향 관계를 처리하는 설명된 접근 방식은 JSON 파일의 크기를 줄이는 데 도움이 될 수도 있습니다. 동일한 개체의 중복 복사본을 포함할 필요 없이 고유 ID로 개체를 참조할 수 있기 때문입니다.
다음 예를 고려하십시오.
{ "@id": "44f47be7-af77-9a5a-8606-a1e6df299ec9", "id": 1, "name": "I'm parent", "children": [ { "@id": "54f47be7-af77-9a5a-8606-a1e6df299eu8", "id": 10, "name": "I'm first child", "parent": "44f47be7-af77-9a5a-8606-a1e6df299ec9" }, { "@id": "98c47be7-af77-9a5a-8606-a1e6df299c7a", "id": 11, "name": "I'm second child", "parent": "44f47be7-af77-9a5a-8606-a1e6df299ec9" }, { "@id": "5jo47be7-af77-9a5a-8606-a1e6df2994g2", "id": 11, "name": "I'm third child", "parent": "44f47be7-af77-9a5a-8606-a1e6df299ec9" } ], "filteredChildren": [ "54f47be7-af77-9a5a-8606-a1e6df299eu8", "5jo47be7-af77-9a5a-8606-a1e6df2994g2" ] } filteredChildren 배열에서 볼 수 있듯이 참조된 개체 및 해당 콘텐츠의 복제본이 아닌 JSON에 개체 참조를 포함하기만 하면 됩니다.
마무리
이 솔루션을 사용하면 객체 및 데이터에 대한 제약을 최소화하는 방식으로 JSON 파일을 직렬화하는 동안 순환 참조 관련 예외를 제거할 수 있습니다. JSON 파일의 직렬화를 처리하기 위해 사용 중인 라이브러리에서 이러한 솔루션을 이미 사용할 수 없는 경우 제공된 구현 예제를 기반으로 고유한 솔루션을 구현할 수 있습니다. 도움이 되셨기를 바랍니다.
