Holub on Patterns(2)

3장 라이프 게임

Clock 서브 시스템: Observer 디자인 패턴

Clock은 이벤트를 처리하는 객체들(Observer)에 주기적으로 클록 틱(clock tick) 이벤트를 통지한다.

Observer 의 주요 목적은 이벤트를 발생시키는 객체와 이벤트를 처리하는 객체 간의 결합도를 줄이는 것이다.

이벤트를 발생시키는 객체 이벤트를 처리하는 객체
Observable Observer
Subject Observer
Publish Subscribe

Observable 은 이벤트 메시지를 Observer 에 보내고, 이 메시지는 Observer 인터페이스의 메서드 인자를 통해 구체 Observer 에 통지된다.

1
2
3
4
5
class Subscriber implements ActionListener {
public void actionPerformed(ActionEvent e) {
... // 이벤트 발생 시 취할 행동
}
}

이제 사용자가 이벤트를 발생시키면 Observable 은 Observer 인터페이스의 하나 혹은 그 이상의 메서드를 호출함으로써 구체 Observer 에 통지한다. 이 때 실제로 Observer 에게 통지하는 코드는 Commend 객체에 캡슐화 되어 있다.

아래 예제와 같이 익명 내부 클래스를 사용하여 Observer 패턴을 실체화할 수도 있다. 이와 같은 구현으로 Observer 패턴의 세 부분 모두를 한 곳에 모을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Client {
volatile Boolean menuItemSelected = false;

public Client(JMenu topMenu) {
JMenuItem myItem = new JMenuItem("Hello");
myItem.addActionListener(
new ActionListener() {
public void actionPerformed(ActionEvent e) {
menuItemSelected = true;
}
}
);
topMenu.add(myItem);
}
}

Concrete Observer/Subscriber 역할은 Listener(Observer) 인터페이스를 구현하는 클래스가 맡게 된다.

1
2
3
4
5
6
7
Clock.instance().addClockListener(
new Clock.Listener() {
public void tick() {
// 클록 틱 이벤트를 처리하는 코드
}
}
);

구체 Observer/Subscriber 는 addClockListener() 를 호출해 자신을 Publish 에 등록하며, addClockListener 메서드는 Publish 객체에 Universe 인스턴스를 위임한다. Universe 객체는 Clock.instance().startTicking() 을 호출하여 클록을 시작시키고, 이후부터 리스너가 주기적으로 이벤트 통지를 받는다 (등록된 리스너의 tick() 메서드가 호출된다)

여기서 내가 자체적으로 정리한 것을 기록하면 아래와 같다.

Pub/Sub

  1. Pub 은
    a. Sub 를 등록
    b. Sub 에게 이벤트 통지
    c. Sub 를 해제

  2. Sub 은
    a. 이벤트를 받는 Listener 를 구현
    b. Pub 에게 자신을 등록

  3. Event (Interface)
    a. pub/sub 간의 연결. 이벤트

1.b. 와 2.a. 가 동일하다
1.b. 가 호출되면 2.a. 가 실행된다.

2 는 3 의 interface 를 구현하고 1 은 1.b. 를 호출할 때
2 의 3 interface 를 호출한다.

Observer 구현하기: Publish 클래스

지금까지 많은 개발자들의 경험을 통해 Observer 는 구현하기 매우 어렵다고 밝혀졌고 특히 스윙과 같이 여러 스레드가 상호 작용하는 환경에서는 더욱 그러하다. 스윙의 통지는 스윙 서브시스템에 의해 생성된 ‘이벤트’ 스레드에서 처리된다. 모든 실제 처리는 스윙 이벤트 스레드에서 일어나며 이 이벤트 스레드가 사용자 입력 액션에 반응한다. 이 때 이벤트 핸들러 상의 스윙 코드와 메인 스레드상의 코드가 동시에 같은 객체에 접근할 수 있기 때문에 동기화를 적절히 하지 않는다면 충돌은 피할 수 없다.

불행히도 아래와 같은 구현으로는 이러한 환경에 올바르게 대처할 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Publisher1 {
ArrayList subscribers = new ArrayList();

public synchronized void subscribe(Runnable subscriber) {
subscribers.add(subscriber);
}

public synchronized void cancelSubscription(Runnable subscriber) {
subscribers.remove(subscriber);
}

private synchronized void fireEvent() {
for (int i = 0; i < subscribers.size() ; i++)
((Runnable) subscribers.get(i)).run();
}
}

이벤트를 통지하는 도중에 객체 리스트를 수정하는 작업은 있을 수 있는 일이고, 이벤트 통지 사이클은 어느 정도의 시간을 소요할 수 있다. 이 때 run() 메서드의 구현은 클라이언트 클래스가 제공하는 것이기 때문에 얼마나 오랜 시간 동안 수행될지 알 수 없다. 이벤트 통지를 하는 동안 subscribe() 메서드를 잠구어 놓는 것은 이벤트 구독을 요청하는 스레드를 ‘굶겨 죽일 수도’ 있게 된다. 만약 이와 같은 ‘기아’ 를 없애기 위해 fireEvent() 에 synchronized 를 제거하면, 어떤 스레드에서 subscribe() 혹은 cancel() 메서드를 수행하는 동안, 다른 스레드에서 fireEvent() 를 수행시킬 수 있기 때문이다.

이번에는 관점을 달리하여 동기화된 방법의 장점을 살펴보자. fireEvent() 메서드가 비동기적인 상황에서는 통지 중에 추가한 구독자가 리스트의 마지막에 추가되어 구독하기 전에 발생한 통지를 받을 수도 있다. fireEvent() 메서드의 동기화 버전에서는 이러한 문제가 없다.

그렇다면 어떻게 해야 하는가? 몇 가지 방법이 있다. 첫 번째 방법은 구체적인 클래스 이름 대신에 Collection 인터페이스를 이용하고 리스트 탐색을 위해 Iterator 를 사용하는 것이다.

1
2
ArrayList subscribers = new ArrayList(); 를
Collection subscribers = new LinkedList() 로 변경

이렇게 하면 이터레이션 연산이 진행 중인 동안 add(..) 혹은 remove(..) 가 호출되면 예외가 발생하게 된다. 그러므로 이벤트 통지를 하고 있는 동안 리스너를 등록하려 시도하면 예외를 받게 되고, 리스너 등록을 다시 시도해야 한다. 하지만 이 방법은 Observer 에 너무 많은 짐을 떠넘기는 방법이다.

다른 접근 방법은 복사를 이용하는 것이다.

1
2
3
4
5
6
7
8
9
10
private void fireEvent() {
Collection localCopy;
synchronized(this) {
localCopy = subscribers.clone();
}

for (Iterator i = subscribers.iterator(); i.hasNext(); ) {
((Runnable) i.next()).run();
}
}

clone() 을 이용하여 구독 객체 리스트의 복사본을 만들었다. 그리고 복사본을 이용하여 구독 객체들에 통지를 한다. 통지를 하는 동안 원본 리스트는 사용되지 않기 때문에 통지 프로세스에 영향을 끼치지 않으면서도 리스트를 수정할 수 있게 된다. 이 접근 방법은 앞에서 논의했던 문제점은 해결하지만 새로운 문제를 야기한다.

첫째, 이벤트 통지가 복사본으로부터 이루어지므로 구독 객체가 구독을 취소한 후에도 이벤트 통지를 할 가능성이 있다. 물론 되게 드문 일이나 우리가 구현하는 애플리케이션에서 이 문제가 중요한 것이라면 이 점을 명심하고 방어적으로 작성하기 바란다.

둘째, 이벤트 통지는 자주 일어날 수 있는데 이때마다 복사본을 만드는 것은 효율적이지 못하다. 대신 드물게 일어나는 구독 취소가 일어났을 때에만 복사본을 만드는 것이 현명할 것이다. (어떻게 한다는 것인지는… 기술되지 않음. 스스로 고민해보자) 해당 이슈를 해결한 멋진 코드는 뒤에 기술할 예정이다.

아래의 코드는 Clock 클래스로부터 Publisher 가 Observer 를 어떻게 처리하는지를 보여주는 부분을 발췌한 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private Publisher publisher = new Publisher();

public interface Listener {
void tick();
}

public void addClockListener(Listener l) {
publisher.subscribe(l);
}

public void tick() {
publisher.publish(
new Publisher.Distributor() {
public void deliverTo(Object subscriber) {
((Listener) subscriber).tick();
}
}
);
}

addClockListener(…) 는 단순히 Publisher 객체로 메시지 처리를 위임한다 (Observable 에 Observer 를 등록하는 것) 클록이 ‘똑딱’ 할 때마다 호출되는 tick() 메서드는 모든 구독 객체에 이벤트가 발생했음을 통지한다. Clock 은 이를 Publisher 에 실제 통지를 수행하는 Command 객체(Publisher.Distributor()) 를 넘김으로써 수행한다. 그러면 Publisher 는 Distributor 객체의 deliverTo() 메서드를 여러 번 호출하게 되며, 호출 시마다 이 메서드에 다른 구독 객체를 넘겨준다. (어떻게 여러 번 호출하지?)

Command 객체가 Observer 에 어떻게 통지할 것인가에 대한 정보를 캡슐화하기 때문에, Publisher는 통지 메커니즘을 Command 객체에 위임할 수 있다. 즉, Publisher 는 실제로 구독 객체에 어떻게 통지 (어떻게 처리) 하는지에 대한 정보를 가지고 있지 않다. 구독과 관련된 정보는 Publisher 가 아닌 Command 객체가 갖고 있다. Publisher 는 구독 객체의 리스트를 관리하고 Distributer 의 구현체가 구독 객체들에 통지를 어떻게 할지 결정한다는 것을 알고 있다.

이제 본격적으로 위에서 이야기한 이슈를 해결하는 코드를 살펴본다. Publisher 객체는 구독 객체들을 연결 리스트를 이용하여 관리한다. 이 때 단일 연결 리스트를 직접 구현하였다. 리스트의 각 노드는 구독 객체와 다음 Node 에 대한 레퍼런스를 지니고 있다. 생성자는 새로운 노드를 생성하고, 이 노드를 리스트에 연결시킨다. 이 때 새로 생성된 노드가 리스트의 헤드가 된다. 생성자에 새로운 구독 객체와 리스트 헤드의 레퍼런스를 넘겨주면, 노드는 next 레퍼런스를 이전의 헤드 레퍼런스를 가리키면서 스스로 리스트의 헤드가 된다. subscribe() 메서드는 리스트의 헤드에 대한 레퍼런스를 새로 생성된 노드 객체에 대한 레퍼런스로 교체한다. Node 의 모든 필드는 final 이므로 Node 는 불변(immutable) 객체이며 한 번 생성하면 수정할 수 없다. 결과적으로 여러 스레드가 Node 객체에 접근하더라도 안전하며 동기화를 하지 않아도 된다.

이벤트가 발생하면 클라이언트 클래스는 publish() 메서드를 호출하고, publish() 메서드는 리스트의 머리에서 꼬리까지 순회하면서 각 구독 객체가 publish(…) 의 인자로 넘어온 deliveryAgent Command 객체를 받아들이도록(accept) 요청한다. Node 의 accept 메서드를 보면 하는 일은 단지 deliverTo(…) 메서드를 호출하여 deliveryAgent 가 이벤트 통지 작업을 하도록 요청하는 것 밖에 없다. 그러므로 실제로는 deliveryAgent 가 구독 객체에 이벤트가 발생했음을 알리게 된다. (자세한 건 Visitor 패턴에서 논의)

이런 구조를 가질 경우 어떤 스레드가 publish 호출 직후 다른 스레드가 subscribe(b) 를 호출한다고 하면, 새로운 노드는 통지가 시작될 때는 리스트에 없었고 통지를 받는 첫 번째 구독 객체부터 연결 리스트를 타고 꼬리 객체까지 통지가 전달된다. 또한 구독 해지를 한다면 재귀를 이용해 삭제할 노드를 찾은 후에 다시 원 호출로 돌아오게 되고, 이 과정에서 실제 노드 삭제가 일어나지는 않지만 삭제할 노드 왼쪽에 있는 모든 노드를 새로 생성하게 된다. 물론 새로 생성된 노드는 기존의 구독 객체들에 대한 레퍼런스를 갖도록 초기화된다.

이제 해당 코드 전체를 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/*
*<PRE>
*class EventGenerator {
* interface Listener {
* notify(String why);
* }
*
* private Publisher publisher = new Publisher();
*
* public void addEventListener(Listener l) {
* publisher.subscribe(l);
* }
*
* public void removeEventListener(Listener l) {
* publisher.cancelSubscription(l);
* }
*
* public void someEventHasHappend(final String reason) {
* publisher.publish(
* new Publisher.Distributor() {
* public void deliverTo(Object subscriber) {
* ((Listener)subscriber).notify(reason);
* }
* }
* );
* }
*}
*</PRE>
*/

public class Publisher {
public interface Distributor {
void deliverTo(Object subscriber); // Visitor 패턴의 visit 메서드
}

private class Node {
public final Object subscriber;
public final Node next;

private Node(Object subscriber, Node next) {
this.subscriber = subscriber;
this.next = next;
}

public Node remove(Object target) {
if (target == subscriber)
return next;

if (next == null)
throw new java.util.NoSuchElementException(target.toString());

return new Node(subscriber, next.remove(target));
}

public void accept(Distributor deliveryAgent) {
deliveryAgent.deliverTo(subscriber); // deliveryAgent 는 visitor 이다.
}
}

private volatile Node subscribers = null;

public void publish(Distributor deliveryAgent) {
for (Node cursor = subscribers; cursor != null ; cursor = cursor.next)
cursor.accept(deliveryAgent);
}

public void subscribe(Object subscriber) {
subscribers = new Node(subscriber, subscribers);
}

public void cancelSubscription(Object subscriber) {
subscriber = subscribers.remove(subscriber);
}
}