C++ Observer Pointer | '관찰 포인터' 가이드
이 글의 핵심
C++ Observer Pointer: "관찰 포인터" 가이드. 관찰 포인터 기본·사용 패턴.
들어가며
관찰 포인터(Observer Pointer)는 소유권 없이 객체를 참조만 하는 포인터입니다. 스마트 포인터가 소유권을 관리하는 반면, 관찰 포인터는 객체의 수명에 관여하지 않고 단순히 관찰만 합니다. C++ 코드베이스가 unique_ptr과 shared_ptr로 소유권을 명확히 관리하기 시작하면서, 오히려 “소유권이 전혀 필요 없는” 나머지 포인터들의 역할이 더 뚜렷해졌습니다. 부모 객체에 대한 역참조, 콜백에 넘기는 this, 컨테이너 안의 특정 원소를 가리키는 임시 참조 같은 경우가 대표적인데, 이런 곳에 실수로 소유권 있는 스마트 포인터를 쓰면 오히려 소유권 그래프가 꼬이거나 순환 참조가 생길 수 있습니다.
실전 경험에서 배운 교훈
관찰 포인터는 표준 라이브러리에 아직 정식으로 채택되지 않은 개념(제안된 std::observer_ptr는 표준에 포함되지 않았습니다)이라, 실제로는 그냥 raw pointer를 “이건 소유하지 않는다”는 의도로 사용하는 경우가 대부분입니다. 문제는 raw pointer만 봐서는 그것이 소유권을 가진 포인터인지 단순 관찰용인지 코드만으로 구분할 수 없다는 점입니다. 이 글에서는 관찰 포인터가 실제로 어떤 상황에서 쓰이는지, 그리고 소유 객체보다 관찰 포인터가 더 오래 살아남아 댕글링이 발생하는 전형적인 실수를 어떻게 피하는지 코드로 짚어봅니다.
1. 관찰 포인터 기본
소유권 vs 관찰
핵심은 “누가 이 객체를 delete할 책임을 지는가”라는 질문입니다. unique_ptr<Widget>인 owner는 그 책임을 명확히 지고 있고, 스코프를 벗어나면 자동으로 Widget을 소멸시킵니다. 반면 owner.get()으로 얻은 raw pointer observer는 Widget을 가리킬 뿐 어떤 책임도 지지 않습니다. 아래 코드에서 observer->use()는 owner가 아직 살아있는 동안에는 안전하지만, 만약 owner가 먼저 소멸된 뒤에 observer를 사용하면 이미 해제된 메모리에 접근하는 댕글링 포인터 문제가 발생합니다.
#include <memory>
#include <iostream>
class Widget {
public:
Widget() {
std::cout << "Widget 생성" << std::endl;
}
~Widget() {
std::cout << "Widget 소멸" << std::endl;
}
void use() {
std::cout << "Widget 사용" << std::endl;
}
};
int main() {
// 소유권 있음
std::unique_ptr<Widget> owner = std::make_unique<Widget>();
// 소유권 없음 (관찰만)
Widget* observer = owner.get();
observer->use(); // OK
// owner가 소멸되면 observer는 댕글링 포인터
return 0;
}
핵심 개념:
- 소유 포인터: 객체 수명 관리 (
unique_ptr,shared_ptr) - 관찰 포인터: 객체 참조만, 수명 관리 안 함 (raw pointer)
2. 사용 패턴
아래 세 가지 패턴은 트리 구조의 역참조, 이벤트 콜백, 컨테이너 안 특정 원소 접근이라는 관찰 포인터의 대표적인 활용 상황을 보여줍니다.
패턴 1: 부모-자식 관계
트리나 계층 구조에서는 부모가 자식들을 unique_ptr로 소유하는 것이 자연스럽지만, 자식이 자신의 부모를 다시 가리켜야 할 때 만약 자식도 부모를 shared_ptr로 소유해버리면 부모↔자식 사이에 순환 참조가 생겨 메모리가 절대 해제되지 않습니다. 자식 쪽의 부모 참조는 소유가 아니라 단순 역참조이므로, Parent*라는 관찰 포인터로 표현하는 것이 정확한 소유권 모델입니다. addChild에서 child->setParent(this)를 호출할 때 넘기는 this 역시, Parent 자신이 그 자식들에 의해 소유되는 것이 아니므로 자연스럽게 관찰 포인터로 취급됩니다.
#include <memory>
#include <vector>
#include <iostream>
class Parent;
class Child {
Parent* parent; // 관찰 포인터 (부모 참조)
public:
Child() : parent(nullptr) {}
void setParent(Parent* p) {
parent = p;
}
void notifyParent() {
if (parent) {
std::cout << "부모에게 알림" << std::endl;
}
}
};
class Parent {
std::vector<std::unique_ptr<Child>> children; // 소유 포인터
public:
void addChild(std::unique_ptr<Child> child) {
child->setParent(this); // this는 관찰 포인터
children.push_back(std::move(child));
}
size_t childCount() const {
return children.size();
}
};
int main() {
Parent parent;
auto child1 = std::make_unique<Child>();
auto child2 = std::make_unique<Child>();
parent.addChild(std::move(child1));
parent.addChild(std::move(child2));
std::cout << "자식 수: " << parent.childCount() << std::endl;
return 0;
}
패턴 2: 콜백
버튼 같은 UI 컴포넌트가 클릭 이벤트를 처리할 때, 콜백 함수 안에 자기 자신(this)을 넘겨줘야 호출하는 쪽에서 “어떤 버튼이 클릭되었는지” 알 수 있습니다. 이때 Button은 콜백을 호출하는 시점에 이미 스스로 살아있는 상태이므로, 콜백에 넘기는 this는 그저 “지금 이 순간의 나 자신을 가리켜라”는 관찰용 정보이지 소유권 이전이 아닙니다. std::function으로 저장된 핸들러는 Button 객체보다 오래 살아남아 this를 나중에 사용할 수도 있으므로, 콜백이 버튼보다 더 오래 유지될 수 있는 구조라면 이 this 포인터의 유효성도 함께 고려해야 합니다.
#include <iostream>
#include <functional>
class Button {
public:
using ClickHandler = std::function<void(Button*)>;
void setOnClick(ClickHandler handler) {
onClick = handler;
}
void click() {
if (onClick) {
onClick(this); // this는 관찰 포인터
}
}
private:
ClickHandler onClick;
};
int main() {
Button btn;
btn.setOnClick([](Button*) {
std::cout << "버튼 클릭됨" << std::endl;
});
btn.click();
return 0;
}
패턴 3: 컨테이너 요소 접근
Container는 자신이 담고 있는 모든 Item을 unique_ptr로 소유하고 있지만, 특정 id에 해당하는 항목을 찾아 호출자에게 돌려줄 때는 소유권까지 넘길 필요가 없습니다. find가 소유권 있는 스마트 포인터를 반환하면 컨테이너의 소유권 모델이 애매해지고 실수로 이중 소유가 생길 위험이 있으므로, item.get()으로 얻은 관찰 포인터를 반환해 “이 항목은 여전히 컨테이너가 소유하고 있으니 함부로 해제하지 말라”는 의도를 명확히 전달합니다. const 오버로드를 함께 제공하면 const Container에서도 안전하게 항목을 조회할 수 있습니다.
#include <memory>
#include <vector>
#include <iostream>
class Item {
int id;
public:
Item(int i) : id(i) {}
int getId() const { return id; }
};
class Container {
std::vector<std::unique_ptr<Item>> items;
public:
void add(std::unique_ptr<Item> item) {
items.push_back(std::move(item));
}
// 소유권 유지, 관찰 포인터 반환
Item* find(int id) {
for (auto& item : items) {
if (item->getId() == id) {
return item.get(); // 관찰 포인터
}
}
return nullptr;
}
// const 버전
const Item* find(int id) const {
for (const auto& item : items) {
if (item->getId() == id) {
return item.get();
}
}
return nullptr;
}
};
int main() {
Container container;
container.add(std::make_unique<Item>(1));
container.add(std::make_unique<Item>(2));
container.add(std::make_unique<Item>(3));
Item* item = container.find(2);
if (item) {
std::cout << "찾음: " << item->getId() << std::endl;
}
return 0;
}
3. 자주 발생하는 문제
문제 1: 댕글링 포인터
관찰 포인터에서 가장 흔하고 위험한 실수는 관찰 대상이 관찰 포인터보다 먼저 소멸하는 상황입니다. bad() 함수는 owner라는 지역 변수의 내부 포인터를 전역 변수 observer에 저장해두는데, 함수가 끝나는 순간 owner가 소멸되면서 observer는 이미 해제된 메모리를 가리키는 댕글링 포인터가 됩니다. 이후 observer를 역참조하면 정의되지 않은 동작(운 좋으면 크래시, 운 나쁘면 조용히 잘못된 값을 반환)이 발생합니다. good()처럼 관찰 포인터의 사용 범위를 소유 포인터의 수명 안으로 한정하면 이 문제를 피할 수 있습니다.
#include <memory>
#include <iostream>
Widget* observer;
void bad() {
auto owner = std::make_unique<Widget>();
observer = owner.get();
} // owner 소멸 -> observer는 댕글링!
void good() {
auto owner = std::make_unique<Widget>();
Widget* localObserver = owner.get();
localObserver->use(); // owner 수명 내에서 사용
}
int main() {
bad();
// observer->use(); // 정의되지 않은 동작!
good(); // 안전
return 0;
}
해결책: 관찰 포인터는 소유 포인터의 수명 내에서만 사용하세요.
문제 2: nullptr 체크 누락
관찰 포인터는 소유권이 없다는 특성상 “이 포인터가 항상 유효한 객체를 가리킨다”는 보장이 소유 포인터보다 훨씬 약합니다. 값이 아직 설정되지 않았거나(Child의 parent가 초기값 nullptr인 경우처럼), 관찰 대상이 다른 경로로 이미 제거되었을 가능성을 항상 열어두어야 합니다. 관찰 포인터를 매개변수로 받는 함수는 사용하기 전에 반드시 nullptr 여부를 확인하는 것을 기본 습관으로 삼아야 하며, 이 검사를 생략하면 널 포인터 역참조로 인한 크래시가 발생합니다.
#include <iostream>
void process(Widget* ptr) {
// ❌ nullptr 체크 없음
// ptr->use(); // ptr이 nullptr이면 크래시!
// ✅ 항상 nullptr 체크
if (!ptr) {
std::cout << "널 포인터" << std::endl;
return;
}
ptr->use();
}
문제 3: 소유권 혼동
가장 근본적인 문제는 raw pointer 하나만으로는 그것이 소유권을 넘기는 것인지, 단순히 관찰만 하라는 것인지 코드 시그니처만 봐서는 전혀 구분할 수 없다는 점입니다. Widget* createWidget()처럼 함수가 new로 만든 객체를 raw pointer로 반환하면, 호출자는 이 포인터를 자신이 delete해야 하는지 아니면 다른 누군가가 관리하는지 알 방법이 없습니다. 소유권을 넘기는 함수라면 unique_ptr을 반환 타입으로 명시하고, 이미 다른 곳(예: Manager 내부의 컨테이너)이 소유하고 있는 객체에 대한 임시 접근만 제공하려는 함수라면 raw pointer(관찰 포인터)를 반환해 의도를 명확히 구분해야 합니다.
#include <memory>
// ❌ raw pointer로 소유권 이전 (불명확)
Widget* createWidget() {
return new Widget(); // 누가 delete?
}
// ✅ unique_ptr로 소유권 명확
std::unique_ptr<Widget> createWidget() {
return std::make_unique<Widget>();
}
// ✅ 관찰 포인터 반환 (소유권 유지)
class Manager {
std::vector<std::unique_ptr<Widget>> widgets;
public:
Widget* getWidget(size_t index) {
return widgets[index].get(); // 관찰만
}
};
문제 4: 컨테이너 저장
vector<Widget*>라는 선언만 봐서는 이 벡터가 각 Widget의 소유자인지, 아니면 다른 곳에서 소유하는 객체들을 단순히 모아둔 관찰용 목록인지 알 수 없습니다. 실제로 이 벡터가 소유자 역할을 해야 한다면 vector<unique_ptr<Widget>>로 선언해 소멸 시 자동으로 모든 원소가 해제되도록 만들어야 하고, 이미 다른 곳(예: 위 예제의 Manager)이 소유하는 객체들을 참조만 하려는 목적이라면 vector<Widget*>를 관찰용 목록으로 명확히 문서화(주석이나 변수명으로)해서 나중에 코드를 읽는 사람이 소유권을 착각하지 않도록 해야 합니다.
#include <vector>
#include <memory>
// ❌ raw pointer 컨테이너 (소유권 불명확)
std::vector<Widget*> widgets;
// 누가 delete? 메모리 누수 가능
// ✅ 소유 포인터 컨테이너
std::vector<std::unique_ptr<Widget>> owners;
// ✅ 관찰 포인터 컨테이너 (소유권은 다른 곳)
std::vector<Widget*> observers;
4. 실전 예제: 이벤트 시스템
관찰자 패턴(Observer Pattern)을 구현하는 이벤트 디스패처는 관찰 포인터가 등장하는 가장 전형적인 실무 사례입니다. EventDispatcher는 등록된 리스너들을 소유하지 않습니다. 리스너(Logger, Counter)는 main 함수의 지역 변수로 독립적으로 생성되고 소멸되며, 디스패처는 그저 이들의 주소만 vector<EventListener*>에 저장해두었다가 이벤트가 발생하면 순회하며 호출할 뿐입니다. 이런 구조 덕분에 하나의 리스너가 여러 디스패처에 등록되거나, 디스패처의 생명주기와 무관하게 리스너를 자유롭게 관리할 수 있습니다. 다만 이 설계는 리스너가 디스패처에 등록된 채로 먼저 소멸해버리면 댕글링 포인터가 남는다는 책임을 호출자에게 지우므로, 실무에서는 소멸자에서 자동으로 등록을 해제하는 안전장치를 추가하는 경우가 많습니다.
#include <memory>
#include <vector>
#include <iostream>
#include <algorithm>
class Event {
public:
std::string type;
Event(const std::string& t) : type(t) {}
};
class EventListener {
public:
virtual ~EventListener() = default;
virtual void onEvent(const Event& event) = 0;
};
class EventDispatcher {
std::vector<EventListener*> listeners; // 관찰 포인터
public:
// 리스너 등록 (소유권 없음)
void addListener(EventListener* listener) {
if (listener) {
listeners.push_back(listener);
}
}
// 리스너 제거
void removeListener(EventListener* listener) {
listeners.erase(
std::remove(listeners.begin(), listeners.end(), listener),
listeners.end()
);
}
// 이벤트 발송
void dispatch(const Event& event) {
for (EventListener* listener : listeners) {
if (listener) {
listener->onEvent(event);
}
}
}
};
class Logger : public EventListener {
public:
void onEvent(const Event& event) override {
std::cout << "[LOG] 이벤트: " << event.type << std::endl;
}
};
class Counter : public EventListener {
int count = 0;
public:
void onEvent(const Event& event) override {
count++;
std::cout << "[COUNT] 총 " << count << "개 이벤트" << std::endl;
}
};
int main() {
EventDispatcher dispatcher;
// 리스너 생성 (소유권 유지)
Logger logger;
Counter counter;
// 관찰 포인터로 등록
dispatcher.addListener(&logger);
dispatcher.addListener(&counter);
// 이벤트 발송
dispatcher.dispatch(Event{"UserLogin"});
dispatcher.dispatch(Event{"DataSaved"});
// 리스너 제거
dispatcher.removeListener(&logger);
dispatcher.dispatch(Event{"UserLogout"});
return 0;
}
정리
핵심 요약
- 관찰 포인터: 소유권 없는 포인터
- 용도: 부모 참조, 콜백, 임시 접근
- 위험: 댕글링 포인터 (수명 관리 주의)
- nullptr 체크: 필수
- 소유권 명확화:
unique_ptr/shared_ptrvs raw pointer
포인터 타입 비교
| 타입 | 소유권 | 수명 관리 | 사용 시기 |
|---|---|---|---|
unique_ptr | 단독 소유 | 자동 | 명확한 소유권 |
shared_ptr | 공유 소유 | 참조 카운트 | 여러 소유자 |
| raw pointer | 없음 (관찰) | 수동 | 참조만 |
weak_ptr | 없음 | shared_ptr 관찰 | 순환 참조 방지 |
실전 팁
사용 원칙:
- 소유권 있으면 스마트 포인터
- 소유권 없으면 raw pointer (관찰)
- 소유권 불명확하면 설계 재검토
안전성:
- 항상
nullptr체크 - 소유 포인터 수명 내에서만 사용
- 댕글링 포인터 주의
가독성:
- 함수 시그니처에 소유권 명시
- 주석으로 소유권 문서화
observer_ptr<T>타입 별칭 고려
다음 단계
관련 글
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Everything about C++ Observer Pointer : from basic concepts to practical applications.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ nullptr vs NULL | ‘널 포인터’ 가이드
- C++ Valgrind 완벽 가이드 | 메모리 누수 탐지와 디버깅 (실전 예제)
- C++ Chrono Literals | ‘시간 리터럴’ 가이드
이 글에서 다루는 키워드 (관련 검색어)
C++, observer-ptr, pointer, ownership, non-owning 등으로 검색하시면 이 글이 도움이 됩니다.