CodeGym /행동 /C# SELF /순환 참조 문제

순환 참조 문제

C# SELF
레벨 46, 레슨 3
사용 가능

1. 도입

순환(또는 원형) 참조는 한 객체가 직접적이든 간접적이든 다른 객체를 참조하고, 그 다른 객체가 결국 다시 첫 객체를 참조할 때 발생한다.

실전 예제

우리 책 도서관 예제로 현실적인 케이스를 만들어보자. 클래스 Book는 Author 속성을 가지고, Author 클래스는 자신의 모든 책을 기억하기 위해 List<Book> 타입의 Books 속성을 가진다고 하자.


public class Author
{
    public string Name { get; set; }
    public int BirthYear { get; set; }
    public List<Book> Books { get; set; } = new List<Book>();
}

public class Book
{
    public string Title { get; set; }
    public Author Author { get; set; }
}

이제 한 명의 저자와 한 권의 책을 만들고 서로 연결하면 "닫힌 고리"가 된다:


var author = new Author { Name = "마르셀 프루스트", BirthYear = 1871 };
var book = new Book { Title = "스완 쪽으로", Author = author };

author.Books.Add(book);

// 이제 author는 book을 참조하고, book은 author를 참조한다!

왜 문제가 되나?

이런 객체를 JSON으로 직렬화하면 직렬화기가 속성을 따라 내려가다가 저자 안에 책이 있고, 책 안에 다시 저자가 있고... 다시 책이 있고... 무한히 계속된다는 걸 본다.

author -> books[] -> author -> books[] ...

거울 앞에 서서 또 다른 거울을 보는 느낌인데 — 반사가 끝없이 이어진다. 다만 예쁜 반사가 아니라 스택 오버플로우(StackOverflowException)가 날 뿐이다.

2. 직렬화기가 순환 참조를 어떻게 처리하나?

직렬화 에러

기본적으로 System.Text.Json은 순환 참조를 처리하지 못한다. 이런 구조를 직렬화하려 하면 JsonException가 발생한다: "A possible object cycle was detected".

에러를 일으키는 예:


string json = JsonSerializer.Serialize(author); // 팡! JsonException

시각화:

graph TD;
    Author --> Book;
    Book --> Author;

3. 순환 참조 문제를 어떻게 해결할까?

실무에서 쓰는 몇 가지 접근법을 보자. 장단점이 있으니 상황에 맞게 골라야 한다. 아쉽게도 만능 "마법 플래그"는 없다.

직렬화 전에 순환을 제거하기

가장 단순한 방법은 애초에 그렇게 만들지 않거나, 직렬화 직전에 순환을 끊는 것이다.

실무 예:


// 임시로 author의 books 참조를 제거
var authorToSerialize = new Author
{
    Name = author.Name,
    BirthYear = author.BirthYear,
    Books = null // 또는 아예 속성을 포함하지 않아도 됨
};

string json = JsonSerializer.Serialize(authorToSerialize);
// 이제 문제 없이 직렬화된다!

장점: 간단하고 빠르고 이해하기 쉽다.
단점: 데이터 일부를 잃는다(역직렬화 후에는 역참조를 복원할 수 없음).

[JsonIgnore] 속성 사용

순환에 관여하는 속성에 [JsonIgnore]를 달아서 무시하게 만들 수도 있다:


public class Author
{
    public string Name { get; set; }
    public int BirthYear { get; set; }
    
    [JsonIgnore] 
    public List<Book> Books { get; set; }
}

이제 Author를 직렬화할 때 Books는 포함되지 않는다. 위 방법과 비슷하지만 선언적으로 처리되므로 깜빡할 위험이 줄어든다.

장점: 더 간단하고 실수할 가능성이 적음.
단점: 저자의 책 정보가 JSON에서 사라진다.

중첩 객체 대신 식별자 사용

양쪽 정보를 유지해야 하는데 순환은 피하고 싶다면, 중첩된 객체 대신 고유 식별자(Id)를 사용하자:


public class Book
{
    public string Title { get; set; }
    public int AuthorId { get; set; } // Author 대신 Id만 저장
}
public class Author 
{
    public int AuthorId { get; set; }
    public string Name { get; set; }
    // 책들을 저장하지 않거나 Id 리스트만 저장
}

이제 JSON에는 객체가 아니라 식별자만 나온다. 데이터베이스나 REST API, 명확한 참조가 필요한 시스템에서 흔히 쓰는 패턴이다.

장점: 순환 없음, JSON이 가볍고 Id로 참조를 복원 가능.
단점: 객체 모델이 달라지고, 역직렬화 시 Id로 조회하는 로직이 필요하다.

간단 비교표:

방법 순환 문제 해결? 데이터 손실? 적용 상황
[JsonIgnore] 예 예 중첩이 중요하지 않을 때
직접 참조 제거 예 예 직렬화 바로 전 간단 처리
객체 대신 Id 보관 예 아니오* REST, DB, 복잡한 시스템

* 데이터가 완전히 사라진 건 아니지만 바로 이용할 수는 없다(Id로 조회 필요).

4. System.Text.Json에 순환 직렬화를 가르치기

.NET 5부터 JsonSerializerOptions에 참조 모드가 추가되었다: options.ReferenceHandler = ReferenceHandler.Preserve.

이 모드는 반복되는 객체에 대해 특수 필드 $id와 $ref를 사용한다.

예제


var options = new JsonSerializerOptions
{
    WriteIndented = true,
    ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve
};

string json = JsonSerializer.Serialize(author, options);
Console.WriteLine(json);

생성되는 JSON 예시는 이렇다:

{
  "$id": "1",
  "Name": "마르셀 프루스트",
  "BirthYear": 1871,
  "Books": {
    "$id": "2",
    "$values": [
      {
        "$id": "3",
        "Title": "스완 쪽으로",
        "Author": {
          "$ref": "1"
        }
      }
    ]
  }
}
  • $id — JSON 내 객체의 고유 식별자
  • $ref — 이미 직렬화된 객체에 대한 참조

역직렬화할 때 모든 참조가 올바르게 복원된다(무한 루프나 스택 에러 없이).

특징과 제한

  • 이런 JSON은 프론트엔드에서 낯설다: 대부분의 JS 클라이언트는 $id/$ref를 추가 로직 없이 이해하지 못한다.
  • JSON 크기가 커지고 사람 눈으로 디버그하기가 더 까다롭다.
  • 명시적으로 ReferenceHandler.Preserve를 켜야만 동작한다.
  • 값 타입에는 적용되지 않는다(값 타입은 순환을 만들 수 없음).

이런 JSON을 어떻게 역직렬화하나?

보통 직렬화하던 방식과 같지만, 같은 JsonSerializerOptions를 사용하면 된다:


var deserializedAuthor = JsonSerializer.Deserialize<Author>(json, options);

5. Newtonsoft.Json (Json.NET)은?

역사적으로 Newtonsoft.Json은 System.Text.Json보다 먼저 순환을 처리하는 기능을 제공했다. 이를 위해 [JsonObject(IsReference = true)] 같은 어트리뷰트와 전역 설정이 있다.

참조를 위한 어트리뷰트


[JsonObject(IsReference = true)]
public class Author
{
    public string Name { get; set; }
    public List<Book> Books { get; set; }
}

[JsonObject(IsReference = true)]
public class Book
{
    public string Title { get; set; }
    public Author Author { get; set; }
}

그리고 이렇게 직렬화한다:


var settings = new JsonSerializerSettings
{
    PreserveReferencesHandling = PreserveReferencesHandling.Objects,
    Formatting = Formatting.Indented
};

string json = JsonConvert.SerializeObject(author, settings);

결과적으로 $id와 $ref가 포함된 JSON이 나온다. 동작은 ReferenceHandler.Preserve와 유사하다.

실무 요약

  • .NET 간 데이터 교환이면 참조 직렬화(ReferenceHandler.Preserve 또는 PreserveReferencesHandling)를 켜자.
  • 데이터가 JavaScript나 다른 클라이언트로 간다면 순환을 끊어라: [JsonIgnore], 참조 제거, 또는 Id 방식으로 전환하자.

6. 에러와 골치 아픈 문제 예방하기

초보자(심지어 경험자도)가 순환 때문에 직렬화기가 터지는 걸 자주 본다. 컬렉션이나 속성들이 서로를 가리키면 모델 아키텍처를 점검하자.

외부 교환에 필요 없는 속성에는 주저 말고 [JsonIgnore]를 사용하자.

전형적 함정은 "다대다" 관계 직렬화다(예: 학생 ↔ 강좌). 순환을 끊거나 참조 직렬화를 사용하지 않으면 잘 동작하지 않는다.

REST API에서는 보통 한쪽 방향으로만 객체를 전송한다: 예컨대 책은 저자를 알고, 저자는 계약상 책의 Id만 알거나 아예 책 정보를 모르게 한다.

2
과제
C# SELF, 레벨 46, 레슨 3
잠금
순환 참조가 있는 객체 생성하기
순환 참조가 있는 객체 생성하기
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION