C/C++ 디자인 패턴 (18) — 옵저버(Observer)

지역별 매출 데이터가 바뀌면 변경 로그, 막대 차트, 합계 표가 모두 갱신돼야 한다. 데이터 클래스가 이 화면들을 직접 호출하면 새 화면을 추가할 때마다 데이터 클래스를 고쳐야 하고, 화면 없이 데이터만 테스트할 수도 없다. 옵저버(Observer)는 일대다 의존 관계를 정의해서, 한 객체(Subject)의 상태가 바뀌면 그것을 구독한 객체(Observer)들에게 자동으로 알린다. 주체는 관찰자가 누구인지 구체적으로 모른다. 이 글에서는 GoF의 push·pull 모델, weak_ptr로 자동 정리되는 관찰자, 변경 묶음 통지, 관찰자 예외 격리, 그리고 OS 수준 옵저버인 리눅스 inotify를 다룬다.

이름 기반 게시·구독, 타입 안전한 이벤트, RAII 구독 해제, 통지 중 구독 변경, 잠금 상태 콜백 문제는 설계 기법 (14) 알림 센터에서 다뤘다.

이 시리즈의 다른 글

구조

참여자역할예제
Subject관찰자 등록·해제, 상태 변경 시 통지Sales, Temperature, 커널(inotify)
Observer통지를 받는 인터페이스Observer::onChanged()
ConcreteObserver통지에 반응해 자기 상태 갱신ChangeLog, BarChart, watch.c

push 모델과 pull 모델

// push_pull.cpp — 같은 데이터를 보는 두 뷰: push(변경값을 넘긴다) vs pull(알림만, 값은 직접 읽는다)
#include <algorithm>
#include <iostream>
#include <map>
#include <string>
#include <vector>

class Sales;

struct Observer {
	virtual ~Observer() = default;
	virtual void onChanged(const Sales &s, const std::string &region, int value) = 0;
};

class Sales {                                                   // Subject
public:
	void attach(Observer *o) { obs_.push_back(o); }
	void set(const std::string &region, int v)
	{
		data_[region] = v;
		for (auto *o : obs_)
			o->onChanged(*this, region, v);                 // push: 바뀐 것을 넘김
	}
	int total() const { int t = 0; for (auto &[k, v] : data_) t += v; return t; }
	const std::map<std::string, int> &all() const { return data_; }
private:
	std::map<std::string, int> data_;
	std::vector<Observer *> obs_;
};

struct ChangeLog : Observer {                                   // push 모델: 넘어온 값만 쓴다
	void onChanged(const Sales &, const std::string &r, int v) override
	{ std::cout << "  log  : " << r << " = " << v << '\n'; }
};

struct BarChart : Observer {                                    // pull 모델: 필요한 것을 주체에서 다시 읽는다
	void onChanged(const Sales &s, const std::string &, int) override
	{
		int mx = 1;
		for (auto &[k, v] : s.all()) mx = std::max(mx, v);
		std::cout << "  chart: ";
		for (auto &[k, v] : s.all())
			std::cout << k << '[' << std::string(v * 10 / mx, '#') << "] ";
		std::cout << "total=" << s.total() << '\n';
	}
};

int main()
{
	Sales sales;
	ChangeLog log;
	BarChart chart;
	sales.attach(&log);
	sales.attach(&chart);

	sales.set("seoul", 120);
	sales.set("busan", 60);
	sales.set("seoul", 30);
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o push_pull push_pull.cpp && ./push_pull
  log  : seoul = 120
  chart: seoul[##########] total=120
  log  : busan = 60
  chart: busan[#####] seoul[##########] total=180
  log  : seoul = 30
  chart: busan[##########] seoul[#####] total=90
모델통지에 담는 것장점단점
push (ChangeLog)바뀐 값관찰자가 주체를 다시 조회하지 않음관찰자마다 필요한 정보가 다르면 인터페이스가 비대해짐
pull (BarChart)“바뀌었다”는 사실(+주체 참조)관찰자가 필요한 것만 읽음무엇이 바뀌었는지 알려면 전체를 다시 봐야 함

예제의 인터페이스는 둘을 섞었다. 바뀐 지역과 값을 넘기면서 주체 참조도 함께 넘겨, 로그는 넘어온 값만 쓰고 차트는 전체를 다시 읽었다.

weak_ptr 관찰자: 해제 호출 없는 정리

주체가 관찰자를 원시 포인터로 들고 있으면 관찰자가 먼저 사라질 때 댕글링이 된다. 관찰자를 shared_ptr로 관리하고 주체는 weak_ptr만 들면, 사라진 관찰자를 통지 시점에 걸러낼 수 있다.

// weak_observers.cpp — 주체가 weak_ptr만 들고 있어, 관찰자가 사라지면 자동으로 빠진다
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct View {
	std::string name;
	explicit View(std::string n) : name(std::move(n)) {}
	void update(int v) const { std::cout << "  " << name << " <- " << v << '\n'; }
};

class Temperature {
public:
	void attach(std::weak_ptr<View> v) { views_.push_back(std::move(v)); }
	void set(int v)
	{
		std::size_t before = views_.size();
		std::erase_if(views_, [](const auto &w) { return w.expired(); });   // 죽은 관찰자 정리
		for (const auto &w : views_)
			if (auto s = w.lock())                         // 통지 중에는 살아 있도록 잠깐 소유
				s->update(v);
		std::cout << "  (observers: " << before << " -> " << views_.size() << ")\n";
	}
private:
	std::vector<std::weak_ptr<View>> views_;
};

int main()
{
	Temperature t;
	auto panel = std::make_shared<View>("panel");
	t.attach(panel);
	{
		auto popup = std::make_shared<View>("popup");
		t.attach(popup);
		std::cout << "set 21:\n"; t.set(21);
	}                                                          // popup 소멸: 해제 호출 없음
	std::cout << "set 22:\n"; t.set(22);
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o weak_observers weak_observers.cpp && ./weak_observers
set 21:
  panel <- 21
  popup <- 21
  (observers: 2 -> 2)
set 22:
  panel <- 22
  (observers: 2 -> 1)

popup은 구독 해제를 부르지 않고 범위를 벗어났을 뿐인데, 다음 통지에서 목록이 2개에서 1개로 정리됐다. 통지 중에는 lock()으로 잠깐 소유해서, 콜백 실행 도중 관찰자가 파괴되지 않는다.

변경 묶음 통지

목록에 1000개를 넣을 때마다 화면을 다시 그리면 다시 그리기도 1000번이다. 갱신 구간을 RAII 객체로 표시하고, 구간 안의 변경은 “더러움” 표시만 했다가 가장 바깥 구간이 끝날 때 한 번 통지한다.

// batch.cpp — 변경 1000번에 통지 1000번 vs 묶어서 1번
#include <functional>
#include <iostream>
#include <vector>

class Model {
public:
	void subscribe(std::function<void(const Model &)> f) { subs_.push_back(std::move(f)); }
	void push(int v) { items_.push_back(v); notify(); }
	struct Batch {                                     // RAII: 범위가 끝날 때 한 번만 통지
		Model &m;
		explicit Batch(Model &mm) : m(mm) { ++m.depth_; }
		~Batch() { if (--m.depth_ == 0 && m.dirty_) { m.dirty_ = false; m.fire(); } }
	};
	std::size_t size() const { return items_.size(); }
private:
	void notify() { if (depth_ > 0) dirty_ = true; else fire(); }
	void fire() { for (auto &f : subs_) f(*this); }
	std::vector<int> items_;
	std::vector<std::function<void(const Model &)>> subs_;
	int depth_ = 0;
	bool dirty_ = false;
};

int main()
{
	Model m;
	int redraws = 0;
	m.subscribe([&](const Model &) { ++redraws; });   // 비싼 다시 그리기라고 가정

	for (int i = 0; i < 1000; ++i)
		m.push(i);
	std::cout << "unbatched: items=" << m.size() << " redraws=" << redraws << '\n';

	redraws = 0;
	{
		Model::Batch b(m);
		for (int i = 0; i < 1000; ++i)
			m.push(i);
		{ Model::Batch nested(m); m.push(-1); }       // 중첩해도 가장 바깥에서 한 번
	}
	std::cout << "batched  : items=" << m.size() << " redraws=" << redraws << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o batch batch.cpp && ./batch
unbatched: items=1000 redraws=1000
batched  : items=2001 redraws=1

관찰자 예외 격리

// throwing.cpp — 관찰자 하나가 예외를 던지면 뒤의 관찰자는 통지를 못 받는다
#include <functional>
#include <iostream>
#include <stdexcept>
#include <vector>

using Sub = std::function<void(int)>;

void notify_naive(const std::vector<Sub> &subs, int v)
{
	for (auto &s : subs) s(v);
}

void notify_isolated(const std::vector<Sub> &subs, int v)
{
	for (std::size_t i = 0; i < subs.size(); ++i) {
		try { subs[i](v); }
		catch (const std::exception &e) { std::cout << "  observer " << i << " failed: " << e.what() << '\n'; }
	}
}

int main()
{
	std::vector<Sub> subs = {
		[](int v) { std::cout << "  #0 got " << v << '\n'; },
		[](int)   { throw std::runtime_error("disk full"); },
		[](int v) { std::cout << "  #2 got " << v << '\n'; },
	};
	std::cout << "naive:\n";
	try { notify_naive(subs, 7); } catch (const std::exception &e) { std::cout << "  aborted: " << e.what() << '\n'; }
	std::cout << "isolated:\n";
	notify_isolated(subs, 7);
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o throwing throwing.cpp && ./throwing
naive:
  #0 got 7
  aborted: disk full
isolated:
  #0 got 7
  observer 1 failed: disk full
  #2 got 7

단순한 루프에서는 두 번째 관찰자의 예외 때문에 세 번째 관찰자가 통지를 받지 못했다. 관찰자끼리 독립적이어야 한다면 관찰자마다 예외를 격리하고 기록한다.

C 구현: inotify

리눅스 inotify는 운영체제가 제공하는 옵저버다. 프로세스가 디렉터리를 구독(inotify_add_watch)하면 커널이 파일 생성·쓰기·이동·삭제를 이벤트로 알려 준다. 관찰자는 파일 시스템을 주기적으로 확인(polling)할 필요가 없다.

/* watch.c — inotify: 커널이 주체, 프로세스가 관찰자. 디렉터리 변화를 구독한다 */
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/inotify.h>
#include <unistd.h>

static const char *what(uint32_t m)
{
	if (m & IN_CREATE)      return "CREATE";
	if (m & IN_CLOSE_WRITE) return "CLOSE_WRITE";
	if (m & IN_MOVED_TO)    return "MOVED_TO";
	if (m & IN_DELETE)      return "DELETE";
	return "OTHER";
}

int main(int argc, char **argv)
{
	int fd = inotify_init1(IN_CLOEXEC);
	char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
	int seen = 0, want = argc > 2 ? atoi(argv[2]) : 4;

	if (fd < 0 || inotify_add_watch(fd, argv[1], IN_CREATE | IN_CLOSE_WRITE | IN_MOVED_TO | IN_DELETE) < 0) {
		perror("inotify");
		return 1;
	}
	printf("watching %s\n", argv[1]);
	fflush(stdout);
	while (seen < want) {
		ssize_t n = read(fd, buf, sizeof(buf));        /* 이벤트가 올 때까지 잠든다 */
		if (n <= 0)
			break;
		for (char *p = buf; p < buf + n; ) {
			struct inotify_event *e = (struct inotify_event *)p;
			printf("  %-11s %s\n", what(e->mask), e->len ? e->name : "");
			fflush(stdout);
			seen++;
			p += sizeof(*e) + e->len;
		}
	}
	close(fd);
	return 0;
}
$ gcc -std=c11 -Wall -Wextra -O2 -o watch watch.c && rm -rf inbox && mkdir inbox && (./watch inbox 6 & W=$!; sleep 0.3; echo hi > inbox/a.txt; sleep 0.1; mv inbox/a.txt inbox/b.txt; sleep 0.1; rm inbox/b.txt; sleep 0.1; touch inbox/c; wait $W)
watching inbox
  CREATE      a.txt
  CLOSE_WRITE a.txt
  MOVED_TO    b.txt
  DELETE      b.txt
  CREATE      c
  CLOSE_WRITE c

셸에서 파일을 만들고, 이름을 바꾸고, 지운 순서대로 이벤트가 도착했다. 이름 변경은 감시 중인 디렉터리 쪽에서 MOVED_TO로 보인다.

주의사항

  • 통지 순서에 의존하지 않는다: 관찰자는 등록 순서대로 불리는 경우가 많지만, 그 순서에 의존하는 로직(차트가 로그보다 먼저 갱신돼야 한다 등)은 숨겨진 결합이다.
  • 통지 폭주: 관찰자가 통지 안에서 주체를 다시 바꾸면 통지가 연쇄된다. 묶음 통지나 “값이 실제로 바뀐 경우에만 통지”로 줄인다.
  • push 모델의 인터페이스 비대화: 관찰자가 늘 때마다 통지 인자를 추가하면 모든 관찰자가 영향을 받는다. 이벤트 구조체 하나로 묶거나 pull 모델을 섞는다.
  • weak_ptr 방식의 전제: 관찰자가 shared_ptr로 관리돼야 한다. 스택 객체나 unique_ptr 소유 객체라면 RAII 구독 토큰 방식을 쓴다.
  • inotify의 한계: 이벤트 큐가 넘치면 IN_Q_OVERFLOW가 오고 이벤트가 유실된다. 하위 디렉터리는 자동으로 감시되지 않아 따로 등록해야 하며, 감시 개수는 /proc/sys/fs/inotify/max_user_watches로 제한된다.

마무리

옵저버는 “누가 관심 있는지 모르는 채로 알린다”는 단순한 아이디어지만, 수명·순서·예외·통지 빈도를 정하지 않으면 디버깅하기 어려운 연쇄를 만든다. 다음 글에서는 객체의 상태에 따라 행동을 바꾸는 상태(State) 패턴을 다룬다.

참고

  • Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Observer
  • man7.org — inotify(7)

답글 남기기