16 4월 2025

순환 참조와 메모리 누수: C# 코드를 C++로 포팅하는 방법

코드가 성공적으로 변환되고 컴파일된 후에도, 특히 메모리 관리와 관련하여 런타임 문제가 종종 발생합니다. 이는 가비지 컬렉터가 있는 C# 환경에서는 일반적이지 않은 문제입니다. 이 글에서는 순환 참조 및 객체 조기 삭제와 같은 특정 메모리 관리 문제를 자세히 살펴보고, 저희의 접근 방식이 이러한 문제를 감지하고 해결하는 데 어떻게 도움이 되는지 보여드리겠습니다.

메모리 관리 문제

1. 순환 강한 참조 (Circular Strong References)

C#에서는 가비지 컬렉터가 도달할 수 없는 객체 그룹을 감지하고 제거함으로써 순환 참조를 올바르게 처리할 수 있습니다. 그러나 C++에서는 스마트 포인터가 참조 카운팅을 사용합니다. 만약 두 객체가 강한 참조(SharedPtr)로 서로를 참조하면, 프로그램의 나머지 부분에서 더 이상 외부 참조가 없더라도 이들의 참조 카운트는 절대 0이 되지 않습니다. 이는 이 객체들이 차지하는 리소스가 절대 해제되지 않으므로 메모리 누수로 이어집니다.

일반적인 예를 고려해 봅시다:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this); // Document가 Element를 참조
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc; // Element가 Document를 역참조
    }
}

이 코드는 다음과 같이 변환됩니다:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    SharedPtr<Document> owner; // 강한 참조
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

여기서 Document 객체는 Element에 대한 SharedPtr를 포함하고, Element 객체는 Document에 대한 SharedPtr를 포함합니다. 강한 참조의 순환이 생성됩니다. 처음에 Document에 대한 포인터를 보유했던 변수가 범위를 벗어나더라도, 상호 참조 때문에 두 객체의 참조 카운트는 1로 유지됩니다. 객체들은 절대 삭제되지 않습니다.

이 문제는 순환에 관련된 필드 중 하나, 예를 들어 Element.owner 필드에 CppWeakPtr 속성을 설정하여 해결됩니다. 이 속성은 변환기에게 이 필드에 대해 약한 참조(WeakPtr)를 사용하도록 지시하며, 이는 강한 참조 카운트를 증가시키지 않습니다.

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
}

class Element {
    [CppWeakPtr] private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

결과 C++ 코드:

class Document : public Object {
    SharedPtr<Element> root; // 강한 참조
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    WeakPtr<Document> owner; // 이제 약한 참조
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

이제 ElementDocument에 대한 약한 참조를 보유하여 순환을 끊습니다. 마지막 외부 SharedPtr<Document>가 사라지면 Document 객체가 삭제됩니다. 이는 root 필드(SharedPtr<Element>)의 삭제를 유발하여 Element의 참조 카운트를 감소시킵니다. 만약 Element에 대한 다른 강한 참조가 없었다면, Element도 삭제됩니다.

2. 생성 중 객체 삭제 (Object Deletion During Construction)

이 문제는 객체가 생성되는 도중에, 즉 객체에 대한 “영구적인” 강한 참조가 설정되기 전에, SharedPtr를 통해 다른 객체나 메서드로 전달될 경우 발생합니다. 이 경우 생성자 호출 중에 생성된 임시 SharedPtr가 유일한 참조일 수 있습니다. 만약 이 임시 SharedPtr가 호출 완료 후 소멸되면, 참조 카운트가 0이 되어 아직 완전히 생성되지 않은 객체의 소멸자가 즉시 호출되고 객체가 삭제될 수 있습니다.

예를 들어 보겠습니다:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
    public void Prepare(Element elm)
    {
        ...
    }
}

class Element {
    public Element(Document doc)
    {
        doc.Prepare(this);
    }
}

변환기는 다음을 출력합니다:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // 조기 삭제 방지
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // 조기 삭제 방지
        doc->Prepare(this);
    }
}

Document::Prepare 메서드에 진입할 때 임시 SharedPtr 객체가 생성되고, 이 객체에 대한 강한 참조가 남아있지 않기 때문에 불완전하게 생성된 Element 객체를 삭제할 수 있습니다. 이전 글에서 설명했듯이, 이 문제는 Element 생성자 코드에 로컬 ThisProtector guard 변수를 추가하여 해결됩니다. 변환기는 이를 자동으로 수행합니다. guard 객체의 생성자는 this에 대한 강한 참조 카운트를 1 증가시키고, 소멸자는 객체 삭제를 유발하지 않고 다시 감소시킵니다.

3. 생성자가 예외를 던질 때 객체 이중 삭제

객체의 생성자가 일부 필드가 이미 생성되고 초기화된 후 예외를 던지는 상황을 고려해 봅시다. 이 필드들은 다시 생성 중인 객체에 대한 강한 참조를 포함할 수 있습니다.

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
        throw new Exception("Failed to construct Document object");
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

변환 후 다음을 얻습니다:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this);
        root = MakeObject<Element>(this);
        throw Exception(u"Failed to construct Document object");
    }
}

class Element {
    SharedPtr<Document> owner;
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this);
        owner = doc;
    }
}

문서 생성자에서 예외가 발생하고 실행이 생성자 코드를 벗어나면 스택 풀기(stack unwinding)가 시작되며, 여기에는 불완전하게 생성된 Document 객체의 필드 삭제가 포함됩니다. 이는 다시 삭제 중인 객체에 대한 강한 참조를 포함하는 Element::owner 필드의 삭제로 이어집니다. 이는 이미 해체 중인 객체의 삭제를 초래하여 다양한 런타임 오류를 유발합니다.

Element.owner 필드에 CppWeakPtr 속성을 설정하면 이 문제가 해결됩니다. 그러나 속성이 배치될 때까지 이러한 애플리케이션을 디버깅하는 것은 예측 불가능한 종료로 인해 어렵습니다. 문제 해결을 단순화하기 위해 특별한 디버그 빌드 모드가 있습니다. 이 모드에서는 내부 객체 참조 카운터가 힙으로 이동되고 플래그가 추가됩니다. 이 플래그는 객체가 완전히 생성된 후에만 - MakeObject 함수 수준에서, 생성자를 나간 후 - 설정됩니다. 플래그가 설정되기 전에 포인터가 소멸되면 객체는 삭제되지 않습니다.

4. 객체 체인 삭제 (Deleting Chains of Objects)

class Node {
    public Node next;
}
class Node : public Object {
public:
    SharedPtr<Node> next;
}

객체 체인의 삭제는 재귀적으로 수행되므로, 체인이 길 경우(수천 개 이상의 객체) 스택 오버플로우가 발생할 수 있습니다. 이 문제는 반복을 통해 체인을 삭제하는 파이널라이저(소멸자로 변환됨)를 추가하여 해결됩니다.

순환 참조 찾기

순환 참조 문제를 해결하는 것은 간단합니다 - C# 코드에 속성을 추가하면 됩니다. 나쁜 소식은 C++용 제품 출시를 담당하는 개발자는 일반적으로 어떤 특정 참조가 약해야 하는지, 심지어 순환이 존재하는지조차 모른다는 것입니다.

순환 검색을 용이하게 하기 위해, 유사하게 작동하는 도구 세트를 개발했습니다. 이 도구들은 두 가지 내부 메커니즘에 의존합니다: 전역 객체 레지스트리와 객체의 참조 필드 정보 추출입니다.

전역 레지스트리에는 현재 존재하는 객체의 목록이 포함됩니다. System::Object 클래스의 생성자는 현재 객체에 대한 참조를 이 레지스트리에 배치하고, 소멸자는 이를 제거합니다. 당연히 이 레지스트리는 릴리스 모드에서 변환된 코드의 성능에 영향을 미치지 않도록 특별한 디버그 빌드 모드에서만 존재합니다.

객체의 참조 필드에 대한 정보는 System::Object 수준에서 선언된 가상 함수 GetSharedMembers()를 호출하여 추출할 수 있습니다. 이 함수는 객체의 필드에 보관된 스마트 포인터와 해당 대상 객체의 전체 목록을 반환합니다. 라이브러리 코드에서는 이 함수가 수동으로 작성되지만, 생성된 코드에서는 변환기에 의해 내장됩니다.

이러한 메커니즘이 제공하는 정보를 처리하는 몇 가지 방법이 있습니다. 이들 간의 전환은 적절한 변환기 옵션 및/또는 전처리기 상수를 사용하여 수행됩니다.

  1. 해당 함수가 호출되면, 현재 존재하는 객체의 전체 그래프(타입, 필드, 관계 정보 포함)가 파일에 저장됩니다. 이 그래프는 나중에 graphviz 유틸리티를 사용하여 시각화할 수 있습니다. 일반적으로 이 파일은 누수를 편리하게 추적하기 위해 각 테스트 후에 생성됩니다.
  2. 해당 함수가 호출되면, 현재 존재하는 객체 중 순환 종속성(관련된 모든 참조가 강한 참조인 경우)을 가진 그래프가 파일에 저장됩니다. 따라서 그래프에는 관련 정보만 포함됩니다. 이미 분석된 객체는 이 함수의 후속 호출 분석에서 제외됩니다. 이렇게 하면 특정 테스트에서 정확히 무엇이 누수되었는지 훨씬 쉽게 확인할 수 있습니다.
  3. 해당 함수가 호출되면, 현재 존재하는 고립 섬(isolation islands) - 해당 객체에 대한 모든 참조가 동일한 집합 내의 다른 객체에 의해 보유되는 객체 집합 - 에 대한 정보가 콘솔에 출력됩니다. 정적 또는 지역 변수에 의해 참조되는 객체는 이 출력에 포함되지 않습니다. 각 유형의 고립 섬, 즉 일반적인 섬을 생성하는 클래스 집합에 대한 정보는 한 번만 출력됩니다.
  4. SharedPtr 클래스의 소멸자는 수명 주기를 관리하는 객체에서 시작하여 객체 간의 참조를 순회하고, 감지된 모든 순환(시작 객체에 강한 참조를 따라 다시 도달할 수 있는 모든 경우)에 대한 정보를 출력합니다.

또 다른 유용한 디버깅 도구는 MakeObject 함수에 의해 객체의 생성자가 호출된 후 해당 객체에 대한 강한 참조 카운트가 0인지 확인하는 것입니다. 만약 0이 아니라면, 이는 잠재적인 문제 - 참조 순환, 예외 발생 시 정의되지 않은 동작 등 - 를 나타냅니다.

요약

C#과 C++ 타입 시스템 간의 근본적인 불일치에도 불구하고, 변환된 코드가 원본에 가까운 동작으로 실행될 수 있도록 하는 스마트 포인터 시스템을 구축했습니다. 동시에 이 작업은 완전 자동 모드로 해결되지 않았습니다. 잠재적인 문제를 찾는 작업을 크게 단순화하는 도구들을 만들었습니다.

관련 뉴스

관련 동영상

관련 기사