옵저버 패턴에 대해 학습하며 작성된 글입니다. 학습 과정에서 작성된 글이기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적이나 피드백은 언제든 환영입니다.
1. 옵저버 패턴
옵저버 패턴(observer pattern)은 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴입니다. 주로 분산 이벤트 핸들링 시스템을 구현하는 데 사용되며, 발행/구독 모델로 알려져 있습니다.
이는 대표적으로 한 개념의 두 부분 중 하나가 다른 부분에 의존적일 때, 한 객체에 가해진 변경으로 다른 객체를 변경해야 하며, 얼마나 많은 객체가 변경되어야 하는지 몰라도 될 때, 어떤 객체가 다른 객체에 자신의 변화를 통보할 수 있는데, 그 변화에 관심 있어 하는 객체들이 누구인지에 대한 가정 없이도 그러한 통보가 될 때 사용할 수 있습니다.
말이 어려울 수 있는데 한 객체가 다른 객체에 의존적이거나, 한 객체의 변형이 다른 객체에 영향을 미칠 때정도로 볼 수 있습니다.
다이어그램으로 보면 다음과 같습니다. 이는 하나의 시스템을 서로 연동되는 클래스 집합으로 나눴을 때 발생하는 공통적 부작용은 관련된 객체 간 일관성을 유지하게 하려고 등장했습니다. 이는 느슨한 결합을 추구하며, 이를 통해 각 클래스의 재사용성을 높입니다.
옵저버 패턴에는 주체(Subject), 감시자(Observer)라는 개념이 등장하며, 이들은 각 구현체를 가지고 있습니다. 이를 통해 주체와 감시자 모두 독립적으로 변화될 수 있으며, 각 클래스를 재사용할 수 있습니다. 또한 주체나 감시자의 수정 없이도 감시자를 추가할 수 있습니다.
플로우(Flow)는 다음과 같습니다. 주체가 상태 변경을 알리기 위해 등록된 모든 옵저버에게 알림을 보내는 것입니다. 이를 통해 주체와 옵저버 사이의 결합도를 최소화할 수 있습니다.
2. 구현
채널과 구독자로 간단한 예제를 만들어 보겠습니다. 구현의 단순화를 위해 한 명의 사용자는 하나의 채널만 등록할 수 있다고 가정하겠습니다. 우선 주체를 구현하면 이는 채널에 해당합니다. 이는 채널명을 가지고 있으며, 내부에서 구독자를 관리(등록, 수정, 공지)할 수 있습니다.
1
2
3
4
5
6
7
8
9
abstract class Subject {
abstract fun register(observer: Observer)
abstract fun remove(observer: Observer)
abstract fun notifyToAll()
}
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
class Channel(
val name: String
) : Subject() {
private val observers: MutableList<Observer> = ArrayList()
override fun register(observer: Observer) {
if (!observers.contains(observer)) {
observers.add(observer)
}
}
override fun remove(observer: Observer) {
if (observers.contains(observer)) {
observers.remove(observer)
}
}
override fun notifyToAll() {
for (observer: Observer in observers) {
observer.update()
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is YoutubeChannel) return false
if (name != other.name) return false
if (observers != other.observers) return false
return true
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + observers.hashCode()
return result
}
override fun toString(): String {
return "name: ${name}, channel: ${observers}"
}
}
감시자는 사용자에 대응됩니다. 사용자는 이름과 채널을 가지고 있으며, 자신에 대한 상태가 업데이트되었음을 공지 받는 update( ) 메서드를 가지고 있습니다.
1
2
3
4
interface Observer {
fun update()
}
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
class User(
val name: String,
private val channel: Subject
) : Observer {
private val log = LoggerFactory.getLogger(User::class.java)
init {
channel.register(this)
}
override fun update() {
log.info("${name}님이 구독자로 등록되었습니다.")
}
val subject: Subject
get() = channel
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is User) return false
if (name != other.name) return false
return true
}
override fun hashCode(): Int {
return name.hashCode()
}
override fun toString(): String {
return name
}
}
아래 테스트를 실행하면 옵저버 패턴이 어떻게 동작하는지 알 수 있는데, 관찰자가 주체를 등록하면 주체에도 해당 사항이 반영됩니다. 또한 주체가 상태를 공지하면 그 알림이 관찰자에게 가게 됩니다. 이는 구체 클래스가 아닌 인터페이스에 의존하며, 이를 통해 결합을 느슨하게 할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@DisplayName("[UnitTest] 옵저버 패턴 예제 단위 테스트")
class ObserverExampleUnitTest {
@Test
@DisplayName("구독자가 채널을 등록하면, 채널의 구독 목록에 들어온다.")
fun observer_update__notify_test() {
val channel = Channel("Champions League")
User("Youl", channel)
val observers = channel.observers
observers.notifyToAll()
assertEquals(1, observers.size) // TRUE
}
}
3. 고려 사항
옵저버 패턴이 결합을 느슨하게 하지만 이 과정에서도 고려해야 할 사항들이 몇 가지 존재합니다. 대표적으로 알림을 누가 트리거 할지, 주체가 삭제되었을 때 감시자는 어떻게 대응해야 할지, 의도치 않은 연산이 발생할 경우입니다. 각 케이스에 대해 살펴보겠습니다.
3-1. Trigger 주체
주체와 감시자가 각각 자신의 값을 정확하게 유지하기 위해서는 통보 메커니즘에 의존해야 합니다. 여기서 값을 갱신하면 어떤 객체가 이를 알릴지 정해야 하는데, 주체가 감시자에게 알리는 방법과 감시자가 주체에게 알리는 방법 두 가지가 있습니다. 우선 첫 번째는 주체가 상태를 변경한 후 이를 지정하는 연산에서 Notify를 하는 것입니다. 예를 들어, 채널의 상태를 업데이트한 후 모든 사용자에게 알리는 것입니다. 이를 통해 사용자가 직접 Notify를 호출하지 않아도 된다는 장점이 있지만, 반대로 연산을 여러 번 수행해야 하는 비효율적인 단점이 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Channel(
val name: String
) : Subject() {
private val _observers: MutableList<Observer> = ArrayList()
......
override fun notifyToAll() {
for (observer: Observer in _observers) {
observer.sendMessage("${NAME}님이 새로운 게시글을 등록했습니다.")
}
}
......
}
반대로 사용자가 Notify를 호출하는 책임을 부여할 수도 있습니다. 이를 통해 사용자가 상태 변경을 할 때까지 갱신의 시작을 미룰 수 있으며, 이를 통해 중간중간 불필요한 수정이 일어나지 않습니다. 하지만 사용자가 자신의 상태를 수정하는 추가 행동을 정의해야 하므로 이를 호출하는 것을 잊어버릴 수도 있으며, 관리 포인트가 늘어나게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User(
val name: String,
private val channel: Subject?
) : Observer {
......
override fun notify(
subscribe: Subscribe,
notification: Notification
) {
this._subscribe = subscribe
this._notification = notification
// 자신의 상태 업데이트 공지
channel.notify(this)
}
......
}
여기서 푸시 모델(Push Model)과 풀 모델(Pull Model)을 고려할 수 있는데, update( ) 연산의 주최를 누가 가져가는지에 대한 것입니다. 푸시 모델에서 주체는 감시자의 요청이 무엇인지 알아야 하며, 자신의 변경에 대한 상세한 정보를 감시자에게 전달합니다. 이를 사용하면 감시자 클래스의 재사용성이 떨어지는데, 주체 클래스가 감시자 클래스에 대한 어떤 가정을 하면 이것이 항상 맞지는 않기 때문입니다. 예를 들어 구독자에게 알림 중복 전송, 메시지 유실의 경우가 있습니다.
요약하면 관찰자는 자신에게 푸시된 정보만 처리하면 되기에 효율적입니다. 반면 주체는 관찰자가 어떤 정보를 필요로 하는지 알아야 하며, 주체와 관찰자의 결합도가 높아질 수 있습니다. 이 과정에서 메시지 유실과 같은 케이스가 발생할 수 있습니다.
반면 풀 모델은 주체가 최소한의 정보만을 전달하고 감시자가 다시 상세 정보를 요청합니다. 주체가 감시자를 몰라도 되지만 감시자 클래스가 주체 클래스와 상관 없이 무엇이 변했는지를 확인해야 하기에 비효율적일 수 있습니다. 예를 들어 유튜브에서 특정 인플루언서의 새로운 동영상 업로드에 대해 구독자들에게 알림을 보낸다고 가정해보겠습니다. 이때 구독자가 접속했을 경우에만 새로운 동영상에 대한 알림을 받도록 하는 것입니다. 즉 실제 접속하지 않거나, 장기간 접속하지 않은 사용자들에게 알림을 보낼 필요가 없어 비효율적인 메시지 전송을 막을 수 있습니다.
하지만 이 경우 사용자가 접속할 때 서버에게 자신의 접속을 알리고, 변경된 내용에 대한 업데이트를 요청하여 받아야 합니다. 코드 레벨의 구현으로 내려가면 이벤트 리스너(Event Listener)를 구현해줘야 합니다.
만약 갱신 과정이 복잡하다면 주체 클래스에서 관심 있는 이벤트에 대한 감시자를 등록하는 인터페이스를 정의해 갱신 과정을 조금 더 효율화할 수 있습니다. 관심 있는 이벤트가 발생할 때 주체는 등록된 감시자에게만 알려주면 되기 때문입니다.
1
2
3
interface NotificationValidateable {
fun agreeWithNotification(observer: Observer, message: Message)
}
여기서 더 나아가 복잡한 갱신의 의미 구조를 캡슐화할 수도 있습니다. 주체와 감시자 간 일어나는 관련성이 복잡하다면 이들 관련성을 관리하는 별도의 객체를 만드는 것입니다. 이 객체는 감시자가 처리해야 하는 주체의 변경 처리를 최소화하는 것입니다. 이를 다이어그램으로 보면 다음과 같습니다.
이 과정을 요약하면 다음과 같습니다. ChangeManager를 통해 인터페이스로만 메시지를 전송하며, 각 구현체가 복잡한 로직을 처리합니다. ChangeManager는 일종의 중재자 패턴의 예시입니다.
- 주체와 감시자를 매핑하고 이를 유지하는 인터페이스 정의
- ChangeManager는 특별한 갱신 전략 정의
- 주체에 요청이 있을 때 모든 독립적 감시자들을 다시 수정
3-2. Dangling 참조
주체의 삭제로 감시자가 무효 참조자를 가지면 안 됩니다. 즉 채널이 삭제됐는데 사용자는 이를 모른 채 계속해서 자신의 상태를 알리는 것입니다. 이를 피하기 위해서는 주체가 감시자에게 자신이 삭제되었을 때 이에 대한 통보를 보내줘야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User(
val name: String,
private val channel: Subject? // 삭제된 채널을 구독하면 X
) : Observer {
......
override fun notify(
subscribe: Subscribe,
notification: Notification
) {
this._subscribe = subscribe
this._notification = notification
// 자신의 상태 업데이트 공지
channel.notify(this)
}
......
}
3-3. 의도치 않는 연산
감시자는 자신의 상태를 변경하기 위해 주체에 자신의 상태를 조회합니다. 하지만 이 과정에서 추상 클래스를 사용하면 예기치 못한 문제가 발생할 수 있습니다. 예를 들어 아래와 같이 Parent의 연산을 호출하고 Child의 오퍼레이션을 호출하면 부모 동작이 먼저 수행되기 때문에 이 과정에서 원하는 결과가 나오지 않을 수 있습니다. 따라서 이런 경우를 주의해야 하며, 추상 클래스를 사용한다면 템플릿 메서드(Template Method) 패턴을 통해 이를 구현하도록 합니다.
1
2
3
4
5
6
7
abstract class Parent {
protected void print() {
System.out.println("Parent");
}
}
1
2
3
4
5
6
7
8
9
public class Child extends Parent {
@Override
public void print() {
super.print();
// Other Operation
}
}
4. 정리
옵저버 패턴은 객체 지향 디자인 패턴 중 하나로, 어떤 객체의 상태 변화가 발생했을 때 그 변화를 관찰하는 다른 객체들(옵저버)에 자동으로 알림을 전달하는 구조를 말합니다. 이는 결합을 느슨하게 해서 객체 간의 의존성을 낮출 수 있지만 이 과정에서 고려해야 하는 점들이 있습니다. 이에 대해 잘 숙지하고 자신이 처한 문제 상황에 잘 적용하도록 합니다.
- GOF Design Pattern
- Observer pattern