스프레드시트를 떠올려 보자. B1 칸에 숫자를 하나 바꿨을 뿐인데, 그 값을 참조하던 막대그래프가 다시 그려지고, 합계 칸이 갱신되고, 원형 차트의 조각 비율이 움직인다. 데이터는 하나인데 그 데이터를 보여 주는 표현은 여럿이고, 하나가 바뀌면 나머지가 전부 일관되게 따라와야 한다. MVC에서 모델 하나에 뷰가 여럿 붙는 장면도 정확히 같은 문제다 — 모델의 상태가 바뀌면 모든 뷰가 최신 상태를 반영해야 한다.
문제는 이 “따라오게 하는 일”을 누가 책임지느냐다. 데이터를 가진 쪽이 자기를 보고 있는 표현들을 일일이 알아서 직접 갱신을 호출한다면, 데이터는 자신에게 매달린 모든 뷰의 존재와 타입을 알아야 한다. 그래프를 하나 더 붙이려면 데이터 클래스를 고쳐야 한다. 데이터와 표현이 서로를 훤히 아는 이 결합을 끊고, “값이 바뀌었다”는 사실 하나만 던지면 관심 있는 쪽이 알아서 반응하도록 만드는 것 — 그것이 감시자 패턴이다.
무엇이 문제인가 — 동기
온도 센서 하나가 있고, 그 값을 세 곳이 보여 준다고 하자. 디지털 표시창, 막대 게이지, 그리고 이상 온도를 감시하는 경보기. 패턴 없이 짜면 센서가 이 셋을 직접 붙든다.
class TemperatureSensor {
private double celsius;
private DigitalDisplay display;
private BarGauge gauge;
private AlarmMonitor alarm;
void setTemperature(double c) {
this.celsius = c;
display.update(c); // 표시창 갱신
gauge.redraw(c); // 게이지 갱신
alarm.check(c); // 경보 판단
}
}동작은 한다. 그러나 TemperatureSensor는 자신을 보고 있는 표현이 정확히 무엇이고 몇 개인지, 각각을 어떤 메서드로 갱신해야 하는지를 전부 알고 있다. update, redraw, check — 갱신 메서드 이름조차 제각각이라 센서 코드가 그 차이를 떠안는다. 여기에 웹 대시보드로 값을 보내는 표현을 하나 더 붙이려면? 센서 클래스에 필드를 추가하고 setTemperature 안에 호출 한 줄을 더 끼워야 한다. 센서는 온도를 재는 것이 본업인데, 자신을 구경하는 관객 명단을 관리하는 일에 발목이 잡힌다.
결합의 방향도 거꾸로다. 본래 “표시창이 센서를 본다”가 자연스러운데, 이 코드에서는 센서가 표시창을 안다. 저수준의 데이터 소유자가 고수준의 표현들에 의존하는 것이다. 이렇게 되면 센서는 표현 계층 없이 단독으로 재사용할 수 없다 — 센서를 다른 프로젝트에 가져가려면 DigitalDisplay, BarGauge 따위를 통째로 끌고 가야 한다.
판단 기준: “한 객체가 바뀌면 정해지지 않은 수의 다른 객체들이 갱신돼야 하고, 그 다른 객체들이 무엇인지 데이터 쪽이 몰라도 될 때” 감시자를 쓴다. 함정: 갱신될 대상이 늘 하나로 고정되어 있고 앞으로도 늘어날 일이 없다면, 감시자는 과한 장치다 — 그냥 직접 호출이 더 읽기 쉽다.
패턴의 구조 — 누가 무엇을 하나
감시자는 두 배역으로 문제를 가른다. 주체(Subject) 는 관심의 대상이 되는 데이터를 가진 쪽이다. 자신을 지켜보는 감시자들의 목록을 들고 있고, 등록(attach)과 해제(detach)를 받아 준다. 상태가 바뀌면 notify를 호출해 목록에 있는 모두에게 “변했다”고 방송한다. 결정적으로, 주체는 자기 목록에 든 것들이 어떤 구체 타입인지 모른다 — 오직 Observer라는 인터페이스로만 안다.
감시자(Observer) 는 주체의 변화에 반응하고 싶은 쪽이다. update라는 단일 창구를 가지며, 주체가 방송할 때 이 메서드가 불린다. 표시창이든 게이지든 경보기든, 주체 입장에서는 전부 똑같은 Observer일 뿐이다.
협력의 흐름은 이렇다. 감시자는 관심 있는 주체에게 스스로 attach해 명단에 이름을 올린다. 이후 주체의 상태가 바뀌면 주체는 명단을 훑어 각 감시자의 update를 부른다. update를 받은 감시자는 주체에게 필요한 값을 되물어 자신을 갱신한다. 핵심은 방향이다 — 주체는 “무엇이 바뀌었는지” 조차 자세히 말하지 않고 “바뀌었다”는 신호만 던지며, 그 신호에 누가 반응할지는 명단이 런타임에 결정한다. 이 일대다 의존이 컴파일 시점의 고정된 결합이 아니라 실행 시점의 느슨한 구독으로 바뀌는 것이 이 패턴의 전부다.
코드로 — 패턴이 자라나는 과정
앞의 온도 센서를 직접 호출 결합에서 출발해, 감시자 인터페이스와 구독 목록을 거쳐 자동 통지로 한 단계씩 풀어 나간다.
public class TemperatureSensor { private double celsius; private DigitalDisplay display; private BarGauge gauge; private AlarmMonitor alarm; public void setTemperature(double c) { this.celsius = c; display.update(c); gauge.redraw(c); alarm.check(c); }}// 센서가 표현들의 구체 타입과 제각각인 갱신 메서드를 전부 안다.// 표현을 하나 더 붙이려면 이 클래스를 고쳐야 한다 — 결합이 여기 몰려 있다.
무엇이 달라졌나. 마지막 상태에서 TemperatureSensor는 DigitalDisplay라는 이름을 한 번도 언급하지 않는다. 웹 대시보드를 붙이고 싶으면 Observer를 구현한 클래스를 만들어 sensor.attach(...) 한 줄을 호출처에 추가하면 그만이고, 센서 코드는 손대지 않는다. 저수준 데이터가 고수준 표현을 알던 거꾸로 된 결합이 끊겼고, 그 자리에 실행 시점에 맺어지는 구독 관계가 들어섰다.
판단 기준: push와 pull은 “주체가 무엇이 바뀌었는지 아는가”로 고른다. 바뀐 항목이 명확하고 감시자 대부분이 그것만 필요로 하면 push가 간결하고, 감시자마다 관심사가 제각각이면 pull이 유연하다. 함정: push 인터페이스에 “혹시 필요할까 봐” 인자를 계속 늘리면, 주체가 감시자들의 사정을 다 아는 꼴이 되어 애써 끊은 결합이 되살아난다.
결과 — 무엇을 얻고 무엇을 내주나
얻는 것. 주체와 감시자는 추상적으로만 결합된다. 주체는 Observer 인터페이스만 알 뿐 구체 타입을 모르므로, 둘은 서로 다른 계층에 살면서 독립적으로 확장·재사용될 수 있다. 감시자를 얼마든지 추가·제거해도 주체는 변하지 않는다. 그리고 이 통지는 방송(broadcast) 이다 — 주체는 수신자를 지정하지 않고 신호를 던질 뿐, 누가 받고 어떻게 처리할지는 각 감시자의 자유다. 명단은 런타임에 바뀌므로 결합이 실행 시점으로 미뤄진다.
내주는 것. 느슨함의 대가는 결코 가볍지 않다.
-
갱신 폭풍(cascade)과 성능. 방송이라 주체는 통지 비용을 통제하지 못한다. 감시자가 100개면 한 번의
setTemperature가 100번의update를 부른다. 더 나쁜 경우, 어떤 감시자가 반응하면서 다른 주체를 갱신하고 그것이 또 통지를 유발하면, 통지가 연쇄로 번지는 갱신 폭풍이 인다. 한 번의 변경이 얼마나 많은 갱신을 촉발하는지 코드만 봐서는 알기 어렵다. -
디버깅의 간접성. 직접 호출은 “누가 이걸 불렀나”를 콜스택에서 바로 읽지만, 감시자에서는 통지가 명단을 거쳐 흩어지므로 흐름이 코드에 드러나지 않는다. “이 뷰가 왜 지금 갱신됐지?”를 추적하려면 런타임에 명단에 무엇이 들었는지를 알아야 한다. 결합을 끊은 값으로 제어 흐름의 가시성을 지불한 것이다.
-
갱신 순서 의존. 감시자들에게 순서 보장은 없다. 그런데 어떤 감시자가 다른 감시자보다 먼저 갱신됐다고 은근히 가정하면, 명단 등록 순서에 따라 결과가 달라지는 미묘한 버그가 생긴다. 순서에 기대는 순간 “느슨한 결합”은 거짓말이 된다.
-
일관성 없는 중간 상태. 주체의 여러 속성을 한 번에 바꿔야 하는데 속성 하나 바꿀 때마다 통지가 나가면, 감시자는 절반만 바뀐 모순된 상태를 본다. 그래서 여러 변경을 묶어 마지막에 한 번만 통지하는 규율이 필요한데, 이 규율은 코드로 강제되지 않고 개발자의 주의에 맡겨진다.
판단 기준: 통지가 다시 통지를 부르는 연쇄(재진입)가 가능한 구조라면, 통지 도중 상태를 또 바꾸지 않도록 못박고 중간 상태 통지를 묶어라. 함정: 감시자 안에서 같은 주체를 다시 갱신하면 무한 통지 루프에 빠진다 — 방송은 편해 보여서 이 되먹임을 놓치기 쉽다.
판단 기준: 감시자 수가 많거나 update가 무겁다면 통지 비용을 반드시 재어 보고, 변경을 모아 통지 횟수를 줄여라. 함정: 잦은 setter마다 통지를 거는 습관은 갱신 폭풍의 씨앗이다 — “값이 바뀔 때마다 알린다”가 “의미 있는 변화가 끝났을 때 알린다”와 같지 않다.
언제 쓰고, 언제 과용인가
감시자가 빛나는 자리는 분명하다. 하나의 상태에 매달린 표현·의존자가 여럿이고 그 수가 유동적일 때, 그리고 데이터 쪽이 그들이 누구인지 몰라도 될 때다. MVC의 모델-뷰, 이벤트 시스템, 발행-구독 메시징이 모두 이 패턴의 얼굴이다. 데이터와 표현을 다른 계층으로 독립시켜 각자 재사용하고 싶을 때, 감시자는 그 사이의 결합을 끊는 정공법이다.
반대로 과용의 냄새도 뚜렷하다. 갱신 대상이 늘 하나이고 앞으로도 그럴 것이라면, 명단·등록·통지라는 장치는 직접 호출 한 줄이 할 일을 세 배로 부풀린 것에 불과하다. “언젠가 뷰가 늘 수도 있으니까”라는 막연한 예상만으로 감시자를 두르면, 오지 않을 변경에 대비하느라 지금의 흐름을 간접성 뒤로 숨기는 값을 매일 치른다. 특히 통지 흐름이 얽혀 디버깅이 어려운 시스템에서 감시자를 무분별하게 깔면, 어느 변경이 어느 갱신을 촉발하는지 아무도 그리지 못하는 지경에 이른다.
구독 관리에도 함정이 있다. 감시자가 주체보다 짧게 살아야 하는데 detach를 잊으면, 죽었어야 할 감시자가 명단에 남아 계속 통지를 받는다 — 이것이 흔한 구독 해제 누수다. 주체가 감시자에 대한 참조를 쥐고 있으니 감시자는 GC되지 못하고, 메모리와 성능이 조용히 샌다. 등록의 짝인 해제를 생명주기와 맞춰 반드시 걸어야 한다.
판단 기준: attach를 부르는 자리마다 대응하는 detach가 감시자의 소멸 시점에 확실히 불리는지를 점검하라. 짝이 맞지 않으면 누수다. 함정: 익명 클래스나 람다로 attach하면 해제할 참조를 잃어 detach 자체가 불가능해진다 — 등록한 핸들을 반드시 보관하거나, 약한 참조(weak reference) 명단을 고려하라.
주체를 애플리케이션 전역에서 하나만 공유하는 이벤트 버스로 두는 설계도 흔하다. 이때 주체는 대개 싱글턴으로 관리되는데, 전역 구독처가 되는 만큼 구독 누수와 통지 폭풍의 위험도 전역으로 커진다는 점을 잊지 말아야 한다. 한편 여러 주체와 여러 감시자가 서로 얽혀 통지가 사방으로 번진다면, 그 관계를 한 곳으로 모아 통제하는 중재자가 감시자보다 나은 선택일 수 있다 — 감시자가 일대다 방송이라면, 중재자는 다대다 관계를 중앙에서 조율한다.
다음으로 상태