C++ 순환 참조 | shared_ptr 메모리 누수 "weak_ptr로 해결"

C++ 순환 참조 | shared_ptr 메모리 누수 "weak_ptr로 해결"

이 글의 핵심

C++ 순환 참조에 대한 실전 가이드입니다. shared_ptr 메모리 누수 등을 예제와 함께 상세히 설명합니다.

들어가며: “shared_ptr을 썼는데 메모리 누수가 생겼어요"

"참조 카운트가 0이 안 돼요”

C++에서 shared_ptr은 자동 메모리 관리를 제공하지만, 순환 참조(Circular Reference)가 발생하면 참조 카운트가 0이 되지 않아 메모리 누수가 발생합니다.

// ❌ 순환 참조
class Node {
public:
    std::shared_ptr<Node> next;  // 다음 노드
    std::shared_ptr<Node> prev;  // 이전 노드
    
    ~Node() {
        std::cout << "~Node\n";  // 호출 안 됨!
    }
};

int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    
    node1->next = node2;  // node1 → node2
    node2->prev = node1;  // node2 → node1 (순환!)
    
    // node1과 node2의 참조 카운트가 2로 유지됨
    // main 끝나도 소멸자 호출 안 됨 → 메모리 누수
}

이 글에서 다루는 것:

  • 순환 참조란?
  • weak_ptr로 해결
  • 실전 패턴 (부모-자식, 캐시, 옵저버)
  • 디버깅 방법

목차

  1. 순환 참조란?
  2. weak_ptr 기초
  3. 실전 패턴
  4. 디버깅 방법
  5. 정리

1. 순환 참조란?

참조 카운팅 원리

// shared_ptr 참조 카운팅
auto ptr1 = std::make_shared<int>(42);
// 참조 카운트: 1

auto ptr2 = ptr1;
// 참조 카운트: 2

ptr2.reset();
// 참조 카운트: 1

ptr1.reset();
// 참조 카운트: 0 → 메모리 해제

순환 참조 발생

// ❌ 순환 참조 예시
class Person {
public:
    std::string name;
    std::shared_ptr<Person> partner;  // 배우자
    
    Person(const std::string& n) : name(n) {
        std::cout << name << " 생성\n";
    }
    
    ~Person() {
        std::cout << name << " 소멸\n";
    }
};

int main() {
    auto alice = std::make_shared<Person>("Alice");
    auto bob = std::make_shared<Person>("Bob");
    
    alice->partner = bob;   // Alice → Bob (Bob의 참조 카운트: 2)
    bob->partner = alice;   // Bob → Alice (Alice의 참조 카운트: 2)
    
    std::cout << "alice use_count: " << alice.use_count() << '\n';  // 2
    std::cout << "bob use_count: " << bob.use_count() << '\n';      // 2
    
    // main 끝
    // alice의 지역 변수 소멸 → Alice의 참조 카운트: 1 (Bob이 여전히 참조)
    // bob의 지역 변수 소멸 → Bob의 참조 카운트: 1 (Alice가 여전히 참조)
    // 둘 다 참조 카운트가 0이 안 됨 → 메모리 누수!
}

// 출력:
// Alice 생성
// Bob 생성
// alice use_count: 2
// bob use_count: 2
// (소멸자 호출 안 됨!)

문제: Alice와 Bob이 서로를 참조해서 참조 카운트가 0이 되지 않음.


2. weak_ptr 기초

weak_ptr이란?

weak_ptr참조 카운트를 증가시키지 않는 약한 참조입니다.

auto ptr = std::make_shared<int>(42);
std::cout << ptr.use_count() << '\n';  // 1

std::weak_ptr<int> weak = ptr;
std::cout << ptr.use_count() << '\n';  // 1 (변화 없음!)

ptr.reset();
// weak_ptr은 댕글링 상태

weak_ptr 사용법

auto ptr = std::make_shared<int>(42);
std::weak_ptr<int> weak = ptr;

// ✅ lock()으로 shared_ptr로 변환
if (auto shared = weak.lock()) {
    std::cout << *shared << '\n';  // 42
} else {
    std::cout << "객체가 소멸됨\n";
}

// ✅ expired()로 유효성 확인
if (weak.expired()) {
    std::cout << "객체가 소멸됨\n";
}

순환 참조 해결

// ✅ weak_ptr로 해결
class Person {
public:
    std::string name;
    std::weak_ptr<Person> partner;  // weak_ptr 사용!
    
    Person(const std::string& n) : name(n) {
        std::cout << name << " 생성\n";
    }
    
    ~Person() {
        std::cout << name << " 소멸\n";
    }
    
    void setPartner(std::shared_ptr<Person> p) {
        partner = p;
        
        // partner 사용 시 lock() 필요
        if (auto p = partner.lock()) {
            std::cout << name << "의 배우자: " << p->name << '\n';
        }
    }
};

int main() {
    auto alice = std::make_shared<Person>("Alice");
    auto bob = std::make_shared<Person>("Bob");
    
    alice->setPartner(bob);   // Alice → Bob (weak_ptr)
    bob->setPartner(alice);   // Bob → Alice (weak_ptr)
    
    std::cout << "alice use_count: " << alice.use_count() << '\n';  // 1
    std::cout << "bob use_count: " << bob.use_count() << '\n';      // 1
    
    // main 끝
    // alice 소멸 → Alice의 참조 카운트: 0 → 소멸자 호출
    // bob 소멸 → Bob의 참조 카운트: 0 → 소멸자 호출
}

// 출력:
// Alice 생성
// Bob 생성
// Alice의 배우자: Bob
// Bob의 배우자: Alice
// alice use_count: 1
// bob use_count: 1
// Alice 소멸
// Bob 소멸

3. 실전 패턴

패턴 1: 부모-자식 관계

// ✅ 부모-자식 관계
class TreeNode {
public:
    int value;
    std::shared_ptr<TreeNode> left;   // 자식 (강한 참조)
    std::shared_ptr<TreeNode> right;  // 자식 (강한 참조)
    std::weak_ptr<TreeNode> parent;   // 부모 (약한 참조)
    
    TreeNode(int v) : value(v) {
        std::cout << "TreeNode(" << value << ") 생성\n";
    }
    
    ~TreeNode() {
        std::cout << "TreeNode(" << value << ") 소멸\n";
    }
    
    void setLeft(std::shared_ptr<TreeNode> node) {
        left = node;
        if (node) {
            node->parent = shared_from_this();
        }
    }
    
    void setRight(std::shared_ptr<TreeNode> node) {
        right = node;
        if (node) {
            node->parent = shared_from_this();
        }
    }
    
    void printPath() {
        std::cout << value;
        if (auto p = parent.lock()) {
            std::cout << " → ";
            p->printPath();
        } else {
            std::cout << " (루트)\n";
        }
    }
};

// enable_shared_from_this 사용
class TreeNode : public std::enable_shared_from_this<TreeNode> {
    // ... (위와 동일)
};

int main() {
    auto root = std::make_shared<TreeNode>(1);
    auto left = std::make_shared<TreeNode>(2);
    auto right = std::make_shared<TreeNode>(3);
    
    root->setLeft(left);
    root->setRight(right);
    
    left->printPath();   // 2 → 1 (루트)
    right->printPath();  // 3 → 1 (루트)
    
    // root 소멸 → left, right도 자동 소멸
}

// 출력:
// TreeNode(1) 생성
// TreeNode(2) 생성
// TreeNode(3) 생성
// 2 → 1 (루트)
// 3 → 1 (루트)
// TreeNode(1) 소멸
// TreeNode(2) 소멸
// TreeNode(3) 소멸

패턴 2: 캐시

// ✅ 캐시 (weak_ptr)
class ResourceCache {
    std::map<std::string, std::weak_ptr<Resource>> cache_;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        // 캐시에서 찾기
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            if (auto resource = it->second.lock()) {
                std::cout << "캐시 히트: " << key << '\n';
                return resource;
            } else {
                // 만료된 항목 제거
                cache_.erase(it);
            }
        }
        
        // 캐시 미스 → 새로 생성
        std::cout << "캐시 미스: " << key << '\n';
        auto resource = std::make_shared<Resource>(key);
        cache_[key] = resource;  // weak_ptr로 저장
        return resource;
    }
    
    void cleanup() {
        // 만료된 항목 제거
        for (auto it = cache_.begin(); it != cache_.end(); ) {
            if (it->second.expired()) {
                std::cout << "만료된 항목 제거: " << it->first << '\n';
                it = cache_.erase(it);
            } else {
                ++it;
            }
        }
    }
};

int main() {
    ResourceCache cache;
    
    {
        auto res1 = cache.get("data.txt");  // 캐시 미스
        auto res2 = cache.get("data.txt");  // 캐시 히트
        
        // res1, res2 소멸
    }
    
    cache.cleanup();  // 만료된 항목 제거
    
    auto res3 = cache.get("data.txt");  // 캐시 미스 (이미 소멸됨)
}

// 출력:
// 캐시 미스: data.txt
// 캐시 히트: data.txt
// 만료된 항목 제거: data.txt
// 캐시 미스: data.txt

패턴 3: 옵저버 패턴

// ✅ 옵저버 패턴 (weak_ptr)
class Observer {
public:
    virtual void update(const std::string& msg) = 0;
    virtual ~Observer() = default;
};

class Subject {
    std::vector<std::weak_ptr<Observer>> observers_;
    
public:
    void attach(std::shared_ptr<Observer> observer) {
        observers_.push_back(observer);
    }
    
    void notify(const std::string& msg) {
        // 만료된 옵저버 제거
        observers_.erase(
            std::remove_if(observers_.begin(), observers_.end(),
                 {
                    return weak.expired();
                }),
            observers_.end()
        );
        
        // 유효한 옵저버에게 알림
        for (auto& weak : observers_) {
            if (auto observer = weak.lock()) {
                observer->update(msg);
            }
        }
    }
};

class ConcreteObserver : public Observer {
    std::string name_;
public:
    ConcreteObserver(const std::string& name) : name_(name) {}
    
    void update(const std::string& msg) override {
        std::cout << name_ << " 수신: " << msg << '\n';
    }
};

int main() {
    Subject subject;
    
    {
        auto obs1 = std::make_shared<ConcreteObserver>("Observer1");
        auto obs2 = std::make_shared<ConcreteObserver>("Observer2");
        
        subject.attach(obs1);
        subject.attach(obs2);
        
        subject.notify("이벤트 1");
        // obs1, obs2 소멸
    }
    
    subject.notify("이벤트 2");  // 만료된 옵저버는 알림 안 받음
}

// 출력:
// Observer1 수신: 이벤트 1
// Observer2 수신: 이벤트 1
// (이벤트 2는 출력 없음)

4. 디버깅 방법

방법 1: use_count() 확인

auto ptr = std::make_shared<int>(42);
std::cout << "참조 카운트: " << ptr.use_count() << '\n';

auto ptr2 = ptr;
std::cout << "참조 카운트: " << ptr.use_count() << '\n';  // 2

// 예상보다 높으면 순환 참조 의심

방법 2: Valgrind

# Valgrind로 메모리 누수 탐지
valgrind --leak-check=full ./myapp

# 출력 예시:
# definitely lost: 16 bytes in 2 blocks
# indirectly lost: 0 bytes in 0 blocks

방법 3: AddressSanitizer

# ASan으로 컴파일
g++ -fsanitize=address -g -o myapp main.cpp

# 실행
./myapp

# 출력 예시:
# ERROR: LeakSanitizer: detected memory leaks

방법 4: 소멸자 로그

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass 생성\n";
    }
    
    ~MyClass() {
        std::cout << "MyClass 소멸\n";  // 호출 안 되면 누수
    }
};

정리

순환 참조 해결

관계강한 참조약한 참조
부모 → 자식shared_ptr-
자식 → 부모-weak_ptr
캐시-weak_ptr
옵저버-weak_ptr
양방향 연결shared_ptrweak_ptr

핵심 규칙

  1. 순환 참조는 weak_ptr로 끊기
  2. 부모는 shared_ptr, 자식은 weak_ptr
  3. lock()으로 사용, expired()로 확인
  4. 캐시와 옵저버는 weak_ptr

체크리스트

  • 양방향 참조가 있는가?
  • weak_ptr을 사용하는가?
  • lock()으로 shared_ptr로 변환하는가?
  • use_count()로 참조 카운트를 확인하는가?

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ shared_ptr vs unique_ptr | 스마트 포인터 비교
  • C++ 메모리 누수 탐지 | Valgrind·ASan
  • C++ 스마트 포인터 | 완벽 가이드
  • C++ enable_shared_from_this | shared_from_this()

자주 하는 실수

실수 1: 양방향 연결 리스트

// ❌ 흔한 실수: 양방향 연결 리스트
class Node {
public:
    int data;
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // ❌ 순환 참조!
};

// ✅ 올바른 구현
class Node {
public:
    int data;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // ✅ weak_ptr 사용
};

실수 2: 이벤트 리스너

// ❌ 실수: 리스너가 발행자를 참조
class Publisher {
    std::vector<std::shared_ptr<Listener>> listeners_;
};

class Listener {
    std::shared_ptr<Publisher> publisher_;  // ❌ 순환!
};

// ✅ 올바른 구현
class Listener {
    std::weak_ptr<Publisher> publisher_;  // ✅ weak_ptr
};

실수 3: 부모-자식 양방향 참조

// ❌ 실수: 자식이 부모를 shared_ptr로 참조
class Parent {
    std::vector<std::shared_ptr<Child>> children_;
};

class Child {
    std::shared_ptr<Parent> parent_;  // ❌ 순환!
};

// ✅ 올바른 구현
class Child {
    std::weak_ptr<Parent> parent_;  // ✅ weak_ptr
};

실무 트러블슈팅

문제: 메모리 사용량이 계속 증가

증상:

# 메모리 사용량 모니터링
$ top -p <pid>
# 메모리가 계속 증가하고 해제되지 않음

진단:

// use_count() 확인
std::cout << "참조 카운트: " << ptr.use_count() << '\n';
// 예상보다 높으면 순환 참조 의심

해결:

// 1. weak_ptr로 변경
// 2. 소멸자에 로그 추가
~MyClass() {
    std::cout << "소멸자 호출\n";
}
// 3. Valgrind로 누수 확인
// valgrind --leak-check=full ./myapp

문제: 프로그램 종료 시 크래시

증상:

Segmentation fault (core dumped)

원인: weak_ptr이 만료된 객체에 접근

해결:

// ✅ 항상 lock() 후 nullptr 체크
if (auto ptr = weak.lock()) {
    ptr->doSomething();
} else {
    std::cout << "객체가 이미 소멸됨\n";
}

성능 영향 분석

참조 카운트 오버헤드

작업shared_ptrweak_ptr오버헤드
생성힙 할당 + 카운터 초기화카운터 증가+10%
복사카운터 증가 (원자적)카운터 증가 (원자적)+5%
lock()-카운터 증가 + 체크+15%
소멸카운터 감소 + 체크카운터 감소+5%
// 벤치마크 결과 (1백만 번 반복)
// shared_ptr만: 100ms
// shared_ptr + weak_ptr: 115ms (15% 오버헤드)

베스트 프랙티스

1. 소유권 명확히 하기

// ✅ 명확한 소유권
class Document {
    std::vector<std::shared_ptr<Page>> pages_;  // 소유
};

class Page {
    std::weak_ptr<Document> document_;  // 참조만
};

2. 순환 참조 체크리스트

  • 양방향 참조가 있는가?
  • 부모-자식 관계인가?
  • 캐시나 옵저버 패턴인가?
  • use_count()가 예상보다 높은가?

3. 코드 리뷰 체크포인트

// 🔍 리뷰 시 확인사항
// 1. shared_ptr끼리 서로 참조하는가?
class A {
    std::shared_ptr<B> b_;  // ⚠️ B도 A를 참조?
};

// 2. 컨테이너에 shared_ptr을 저장하는가?
std::vector<std::shared_ptr<Observer>> observers_;  // ⚠️ Observer가 역참조?

// 3. 콜백에 shared_ptr을 캡처하는가?
[ptr = shared_ptr](){ ptr->foo(); };  // ⚠️ 순환 가능성?

실무 시나리오

시나리오 1: GUI 위젯 트리

// ✅ 실무 예시: GUI 위젯 계층
class Widget {
    std::weak_ptr<Widget> parent_;  // 부모 참조
    std::vector<std::shared_ptr<Widget>> children_;  // 자식 소유
    
public:
    void setParent(std::shared_ptr<Widget> parent) {
        parent_ = parent;
    }
    
    void addChild(std::shared_ptr<Widget> child) {
        children_.push_back(child);
        child->setParent(shared_from_this());
    }
    
    std::shared_ptr<Widget> getParent() const {
        return parent_.lock();
    }
};

// 사용
auto window = std::make_shared<Widget>();
auto button = std::make_shared<Widget>();
window->addChild(button);
// window 소멸 → button도 자동 소멸

시나리오 2: 게임 엔티티 시스템

// ✅ 실무 예시: 게임 엔티티
class Entity {
    std::weak_ptr<Scene> scene_;  // 씬 참조
    std::vector<std::shared_ptr<Component>> components_;  // 컴포넌트 소유
};

class Scene {
    std::vector<std::shared_ptr<Entity>> entities_;  // 엔티티 소유
};

// 엔티티가 씬을 weak_ptr로 참조 → 순환 참조 방지

시나리오 3: 네트워크 연결 관리

// ✅ 실무 예시: 연결 풀
class ConnectionPool {
    std::vector<std::weak_ptr<Connection>> connections_;
    
public:
    void cleanup() {
        // 만료된 연결 제거
        connections_.erase(
            std::remove_if(connections_.begin(), connections_.end(),
                 {
                    return weak.expired();
                }),
            connections_.end()
        );
    }
    
    size_t activeConnections() const {
        return std::count_if(connections_.begin(), connections_.end(),
             {
                return !weak.expired();
            });
    }
};

마치며

순환 참조shared_ptr의 참조 카운트가 0이 되지 않아 발생하는 메모리 누수입니다.

핵심 원칙:

  1. 양방향 참조는 weak_ptr로 끊기
  2. 부모는 shared_ptr, 자식은 weak_ptr
  3. lock()으로 사용

실무 팁:

  • use_count()로 주기적으로 참조 카운트 확인
  • 소멸자에 로그를 추가해 누수 탐지
  • Valgrind나 ASan으로 정기적으로 검사

weak_ptr로 순환 참조를 끊어 메모리 누수를 방지하세요.

다음 단계: 순환 참조를 이해했다면, C++ 스마트 포인터 가이드에서 더 깊이 배워보세요.


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |