16 4월 2025
코드가 성공적으로 변환되고 컴파일된 후에도, 특히 메모리 관리와 관련하여 런타임 문제가 종종 발생합니다. 이는 가비지 컬렉터가 있는 C# 환경에서는 일반적이지 않은 문제입니다. 이 글에서는 순환 참조 및 객체 조기 삭제와 같은 특정 메모리 관리 문제를 자세히 살펴보고, 저희의 접근 방식이 이러한 문제를 감지하고 해결하는 데 어떻게 도움이 되는지 보여드리겠습니다.
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;
}
}
이제 Element
는 Document
에 대한 약한 참조를 보유하여 순환을 끊습니다. 마지막 외부 SharedPtr<Document>
가 사라지면 Document
객체가 삭제됩니다. 이는 root
필드(SharedPtr<Element>
)의 삭제를 유발하여 Element
의 참조 카운트를 감소시킵니다. 만약 Element
에 대한 다른 강한 참조가 없었다면, Element
도 삭제됩니다.
이 문제는 객체가 생성되는 도중에, 즉 객체에 대한 “영구적인” 강한 참조가 설정되기 전에, 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 증가시키고, 소멸자는 객체 삭제를 유발하지 않고 다시 감소시킵니다.
객체의 생성자가 일부 필드가 이미 생성되고 초기화된 후 예외를 던지는 상황을 고려해 봅시다. 이 필드들은 다시 생성 중인 객체에 대한 강한 참조를 포함할 수 있습니다.
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
함수 수준에서, 생성자를 나간 후 - 설정됩니다. 플래그가 설정되기 전에 포인터가 소멸되면 객체는 삭제되지 않습니다.
class Node {
public Node next;
}
class Node : public Object {
public:
SharedPtr<Node> next;
}
객체 체인의 삭제는 재귀적으로 수행되므로, 체인이 길 경우(수천 개 이상의 객체) 스택 오버플로우가 발생할 수 있습니다. 이 문제는 반복을 통해 체인을 삭제하는 파이널라이저(소멸자로 변환됨)를 추가하여 해결됩니다.
순환 참조 문제를 해결하는 것은 간단합니다 - C# 코드에 속성을 추가하면 됩니다. 나쁜 소식은 C++용 제품 출시를 담당하는 개발자는 일반적으로 어떤 특정 참조가 약해야 하는지, 심지어 순환이 존재하는지조차 모른다는 것입니다.
순환 검색을 용이하게 하기 위해, 유사하게 작동하는 도구 세트를 개발했습니다. 이 도구들은 두 가지 내부 메커니즘에 의존합니다: 전역 객체 레지스트리와 객체의 참조 필드 정보 추출입니다.
전역 레지스트리에는 현재 존재하는 객체의 목록이 포함됩니다. System::Object
클래스의 생성자는 현재 객체에 대한 참조를 이 레지스트리에 배치하고, 소멸자는 이를 제거합니다. 당연히 이 레지스트리는 릴리스 모드에서 변환된 코드의 성능에 영향을 미치지 않도록 특별한 디버그 빌드 모드에서만 존재합니다.
객체의 참조 필드에 대한 정보는 System::Object
수준에서 선언된 가상 함수 GetSharedMembers()
를 호출하여 추출할 수 있습니다. 이 함수는 객체의 필드에 보관된 스마트 포인터와 해당 대상 객체의 전체 목록을 반환합니다. 라이브러리 코드에서는 이 함수가 수동으로 작성되지만, 생성된 코드에서는 변환기에 의해 내장됩니다.
이러한 메커니즘이 제공하는 정보를 처리하는 몇 가지 방법이 있습니다. 이들 간의 전환은 적절한 변환기 옵션 및/또는 전처리기 상수를 사용하여 수행됩니다.
SharedPtr
클래스의 소멸자는 수명 주기를 관리하는 객체에서 시작하여 객체 간의 참조를 순회하고, 감지된 모든 순환(시작 객체에 강한 참조를 따라 다시 도달할 수 있는 모든 경우)에 대한 정보를 출력합니다.또 다른 유용한 디버깅 도구는 MakeObject
함수에 의해 객체의 생성자가 호출된 후 해당 객체에 대한 강한 참조 카운트가 0인지 확인하는 것입니다. 만약 0이 아니라면, 이는 잠재적인 문제 - 참조 순환, 예외 발생 시 정의되지 않은 동작 등 - 를 나타냅니다.
C#과 C++ 타입 시스템 간의 근본적인 불일치에도 불구하고, 변환된 코드가 원본에 가까운 동작으로 실행될 수 있도록 하는 스마트 포인터 시스템을 구축했습니다. 동시에 이 작업은 완전 자동 모드로 해결되지 않았습니다. 잠재적인 문제를 찾는 작업을 크게 단순화하는 도구들을 만들었습니다.