본문 바로가기

C#

C# 직렬화 시 참조 순환 처리 전략

객체 그래프를 JSON/XML로 직렬화할 때 서로를 참조하는 양방향 관계(순환 참조)가 있으면 예외가 발생하거나 무한 루프가 생길 수 있습니다. .NET에서는 여러 전략으로 이 문제를 안전하게 처리할 수 있습니다.

1. 예제 모델과 문제 상황

아래 예제는 Department가 People을, Person이 다시 Department를 참조해 순환 구조를 이룹니다. 기본 System.Text.Json 직렬화는 순환을 감지하면 예외를 던집니다.

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Collections.Generic;

public class Person
{
    public string Name { get; set; }
    public Department Department { get; set; }
}

public class Department
{
    public string Name { get; set; }
    public List<Person> People { get; set; } = new();
}

// 그래프 생성
var dept = new Department { Name = "R&D" };
var alice = new Person { Name = "Alice", Department = dept };
dept.People.Add(alice);

// 기본 직렬화 (예외 발생)
// var json = JsonSerializer.Serialize(dept);

2. System.Text.Json - ReferenceHandler.Preserve로 그래프 보존

순환을 메타데이터($id, $ref)로 표현해 원래 그래프를 복원 가능하게 합니다. 페이로드가 커지고 가독성이 떨어지지만, 복원성이 필요한 경우 적합합니다.

using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
    ReferenceHandler = ReferenceHandler.Preserve,
    WriteIndented = true
};
string json = JsonSerializer.Serialize(dept, options);

3. System.Text.Json - IgnoreCycles로 참조 무시 (.NET 7+)

순환을 만드는 속성을 자동으로 생략해 직렬화합니다. 원래 그래프는 복원되지 않지만 API 응답 등 단방향 출력에 적합합니다.

using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
    ReferenceHandler = ReferenceHandler.IgnoreCycles,
    WriteIndented = true
};
string json = JsonSerializer.Serialize(dept, options);

4. [JsonIgnore]로 한쪽 참조 끊기

명시적으로 한쪽 참조를 무시해 기본 설정으로도 안전하게 직렬화합니다. 소비자가 양방향 정보가 필요 없다면 가장 단순합니다.

using System.Text.Json.Serialization;

public class Person
{
    public string Name { get; set; }

    [JsonIgnore]
    public Department Department { get; set; }
}

// 이제 기본 설정으로 직렬화 가능
// string json = JsonSerializer.Serialize(dept);

5. Newtonsoft.Json 설정

Newtonsoft.Json을 사용하는 경우 두 가지 접근이 있습니다.

using Newtonsoft.Json;

// 5-1) 순환을 만드는 속성 무시
var settingsIgnore = new JsonSerializerSettings
{
    ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
    Formatting = Formatting.Indented
};
string jsonIgnore = JsonConvert.SerializeObject(dept, settingsIgnore);

// 5-2) 참조 보존 ($id, $ref)
var settingsPreserve = new JsonSerializerSettings
{
    PreserveReferencesHandling = PreserveReferencesHandling.Objects,
    Formatting = Formatting.Indented
};
string jsonPreserve = JsonConvert.SerializeObject(dept, settingsPreserve);

6. XML 직렬화: DataContractSerializer

XML로 직렬화하면서 그래프를 보존하려면 PreserveObjectReferences를 사용합니다. [DataContract]/[DataMember]로 제어합니다.

using System.Runtime.Serialization;
using System.IO;
using System.Text;
using System.Collections.Generic;

[DataContract]
public class Person
{
    [DataMember]
    public string Name { get; set; }
    [DataMember]
    public Department Department { get; set; }
}

[DataContract]
public class Department
{
    [DataMember]
    public string Name { get; set; }
    [DataMember]
    public List<Person> People { get; set; } = new();
}

var settings = new DataContractSerializerSettings
{
    PreserveObjectReferences = true
};
var serializer = new DataContractSerializer(typeof(Department), settings);
using var ms = new MemoryStream();
serializer.WriteObject(ms, dept);
string xml = Encoding.UTF8.GetString(ms.ToArray());

7. DTO/Projection으로 안전한 출력 구조 만들기

실무에서는 직렬화 전용 DTO를 만들어 필요한 방향만 포함하는 것이 가장 안전하고 유지보수성도 좋습니다.

using System.Text.Json;
using System.Linq;

var dto = new
{
    Department = dept.Name,
    People = dept.People.Select(p => new { p.Name })
};
string json = JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });

8. 선택 가이드와 주의사항

복원이 필요하면 Preserve(또는 Newtonsoft의 PreserveReferencesHandling)를, 출력만 필요하면 IgnoreCycles나 [JsonIgnore]를 사용합니다. API는 DTO로 투영하는 전략을 권장합니다. EF Core를 사용할 때는 지연 로딩으로 순환이 쉽게 생길 수 있으니 필요 속성만 Select로 투영하고, AsNoTracking을 함께 고려합니다. Preserve 계열은 페이로드가 커지고 클라이언트 파싱 복잡도가 증가하니 비용을 감안해 선택합니다.