디자인 패턴: 옵저버 패턴과 발행-구독 패턴
옵저버 패턴과 발행-구독 패턴의 구조와 차이를 정리했습니다.
개요
두 패턴은 모두 어떤 일이 일어났다는 사실을, 그 일에 관심 있는 객체들에게 알리는 구조이다. 알리는 쪽은 받는 쪽이 알림을 받고 무엇을 하는지 몰라도 된다. 차이는 알리는 쪽과 받는 쪽이 서로를 직접 아는지에 있다.
옵저버 패턴(Observer Pattern)
옵저버 패턴은 한 객체의 상태가 바뀌면, 그 객체에 등록된 객체들에게 자동으로 알리는 패턴이다. GoF가 정리한 행위 패턴이다.
알아야 할 역할은 둘이다.
- Subject : 상태를 가진 객체. 옵저버 목록을 갖고, 상태가 바뀌면 목록의 옵저버를 하나씩 호출한다.
- Observer : Subject에 자신을 등록하고, 알림을 받으면 정해진 함수가 호출되는 객체.
Subject는 옵저버가 구체적으로 어떤 클래스인지 모른다. 함수 형태만 맞으면 체력바든 사운드든 옵저버로 등록할 수 있고, 옵저버를 추가해도 Subject 코드는 바뀌지 않는다.
유니티 예제
유니티에서는 C#의 event가 옵저버 목록 역할을 한다. +=가 등록, -=가 해제이고, Invoke가 목록의 함수를 모두 호출한다. 등록은 OnEnable에서, 해제는 OnDisable에서 하는 것이 일반적이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Health.cs
using System;
using UnityEngine;
// Subject: event가 옵저버 목록 역할을 한다
public class Health : MonoBehaviour
{
public event Action<int> HealthChanged;
[SerializeField] private int hp = 100;
public void TakeDamage(int damage)
{
hp -= damage;
HealthChanged?.Invoke(hp);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// HealthBar.cs
using UnityEngine;
// Observer: 지켜볼 Health를 직접 알고 등록한다
public class HealthBar : MonoBehaviour
{
[SerializeField] private Health health;
private void OnEnable()
{
health.HealthChanged += OnHealthChanged;
}
private void OnDisable()
{
health.HealthChanged -= OnHealthChanged;
}
private void OnHealthChanged(int hp)
{
Debug.Log($"HealthBar: {hp}");
}
}
언리얼 예제
언리얼에서는 멀티캐스트 델리게이트가 옵저버 목록 역할을 한다. Broadcast가 바인딩된 함수를 모두 호출한다. 아래 예제는 블루프린트에서도 바인딩할 수 있도록 dynamic 델리게이트를 쓴다. dynamic 델리게이트는 AddDynamic으로 바인딩하고, 바인딩할 함수는 UFUNCTION으로 선언해야 한다.
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
// HealthComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "HealthComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, int32, NewHp);
// Subject: 델리게이트가 옵저버 목록 역할을 한다
UCLASS()
class UHealthComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintAssignable)
FOnHealthChanged OnHealthChanged;
void TakeDamage(int32 Damage)
{
Hp -= Damage;
OnHealthChanged.Broadcast(Hp);
}
private:
int32 Hp = 100;
};
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
// HealthBarWidget.h
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "HealthBarWidget.generated.h"
class UHealthComponent;
// Observer: 지켜볼 HealthComponent를 직접 알고 등록한다
UCLASS()
class UHealthBarWidget : public UUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
private:
UFUNCTION()
void HandleHealthChanged(int32 NewHp);
UPROPERTY()
TObjectPtr<UHealthComponent> Health;
};
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
// HealthBarWidget.cpp
#include "HealthBarWidget.h"
#include "GameFramework/Pawn.h"
#include "HealthComponent.h"
void UHealthBarWidget::NativeConstruct()
{
Super::NativeConstruct();
if (APawn* Pawn = GetOwningPlayerPawn())
{
Health = Pawn->FindComponentByClass<UHealthComponent>();
}
if (Health)
{
Health->OnHealthChanged.AddDynamic(this, &UHealthBarWidget::HandleHealthChanged);
}
}
void UHealthBarWidget::NativeDestruct()
{
if (Health)
{
Health->OnHealthChanged.RemoveDynamic(this, &UHealthBarWidget::HandleHealthChanged);
}
Super::NativeDestruct();
}
void UHealthBarWidget::HandleHealthChanged(int32 NewHp)
{
UE_LOG(LogTemp, Log, TEXT("HealthBar: %d"), NewHp);
}
예제라서 FindComponentByClass를 썼지만, 실제 게임이라면 위젯을 만드는 쪽이 HealthComponent를 넘겨주거나 하는 식으로 위젯이 게임 쪽 구조를 모르게 하는 방법이 좋다.
어느 쪽이든 두 엔진 모두 체력바는 Health나 HealthComponent를 직접 알고 있어야 등록할 수 있다. 옵저버 패턴에서 Observer는 Subject를 안다.
발행-구독 패턴(Publish-Subscribe Pattern)
발행-구독 패턴은 발행자가 메세지를 중개자에 보내고, 중개자가 그 메세지를 구독한 객체들에게 전달하는 패턴이다. GoF 책에서는 옵저버의 별칭으로 Dependents와 Publish-Subscribe를 말하지만, 오늘날에는 중개자가 있는 구조를 발행-구독 패턴이라고 따로 부른다.
알아야 할 역할은 셋이다.
- Publisher : 메세지를 중개자에 보낸다. 누가 받는지 모른다.
- Subscriber : 받고 싶은 메시지 종류를 중개자에 등록한다. 누가 보내는지 모른다.
- Broker (Event Bus) : 메세지 종류별로 구독자 목록을 갖고, 메세지가 오면 해당 구독자들에게 전달한다.
메시지 종류를 무엇으로 구분할지는 구현에 따라 다르다. 이 글의 유니티 예제는 메시지 타입으로, 언리얼 예제는 “PlayerDamaged” 같은 이름으로 정한 토픽으로 구분한다.
유니티 예제
유니티 예제의 EventBus<T>는 제네릭 static 클래스이다. EventBus<PlayerDamaged>와 EventBus<EnemyKilled>는 서로 다른 타입이라 static 필드도 따로 생긴다. 그래서 딕셔너리 없이도 메세지 타입마다 구독자 목록이 나뉜다.
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
// EventBus.cs
using System;
// 중개자: 메시지 타입마다 구독자 목록이 따로 생긴다
public static class EventBus<T>
{
private static Action<T> handlers;
public static void Subscribe(Action<T> handler)
{
handlers += handler;
}
public static void Unsubscribe(Action<T> handler)
{
handlers -= handler;
}
public static void Publish(T message)
{
handlers?.Invoke(message);
}
}
// 메시지 타입
public readonly struct PlayerDamaged
{
public readonly int Hp;
public PlayerDamaged(int hp)
{
Hp = hp;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Health.cs
using UnityEngine;
// 발행자: EventBus만 알고, 누가 받는지는 모른다
public class Health : MonoBehaviour
{
[SerializeField] private int hp = 100;
public void TakeDamage(int damage)
{
hp -= damage;
EventBus<PlayerDamaged>.Publish(new PlayerDamaged(hp));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// HealthBar.cs
using UnityEngine;
// 구독자: EventBus만 알고, 누가 보내는지는 모른다
public class HealthBar : MonoBehaviour
{
private void OnEnable()
{
EventBus<PlayerDamaged>.Subscribe(OnPlayerDamaged);
}
private void OnDisable()
{
EventBus<PlayerDamaged>.Unsubscribe(OnPlayerDamaged);
}
private void OnPlayerDamaged(PlayerDamaged message)
{
Debug.Log($"HealthBar: {message.Hp}");
}
}
언리얼 예제
언리얼 예제는 월드 서브시스템을 중개자로 쓴다. 월드 서브시스템은 월드마다 하나씩 엔진이 만들어 주는 객체라서, 어디서든 UWorld::GetSubsystem으로 찾을 수 있다. 구독자 목록에는 native 멀티캐스트 델리게이트를 썼다. native 델리게이트는 블루프린트에서 쓸 수 없지만, AddUObject로 UFUNCTION이 아닌 일반 C++ 함수도 바인딩할 수 있다. 바인딩하면 FDelegateHandle을 돌려주고, 해제할 때 이 핸들을 넘긴다.
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
// GameEventSubsystem.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "GameEventSubsystem.generated.h"
DECLARE_MULTICAST_DELEGATE_OneParam(FOnGameEvent, int32);
// 중개자: 토픽별로 구독자 목록을 가진다
UCLASS()
class UGameEventSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
FOnGameEvent& GetEvent(FName Topic)
{
return Events.FindOrAdd(Topic);
}
void Publish(FName Topic, int32 Value)
{
if (FOnGameEvent* Event = Events.Find(Topic))
{
Event->Broadcast(Value);
}
}
private:
TMap<FName, FOnGameEvent> Events;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HealthComponent.cpp
#include "HealthComponent.h"
#include "GameEventSubsystem.h"
// 발행자: 중개자만 알고, 누가 받는지는 모른다
void UHealthComponent::TakeDamage(int32 Damage)
{
Hp -= Damage;
if (UGameEventSubsystem* Events = UWorld::GetSubsystem<UGameEventSubsystem>(GetWorld()))
{
Events->Publish(TEXT("PlayerDamaged"), Hp);
}
}
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
// HealthBarWidget.cpp
#include "HealthBarWidget.h"
#include "GameEventSubsystem.h"
// 구독자: 중개자만 알고, 누가 보내는지는 모른다
void UHealthBarWidget::NativeConstruct()
{
Super::NativeConstruct();
if (UGameEventSubsystem* Events = UWorld::GetSubsystem<UGameEventSubsystem>(GetWorld()))
{
DamagedHandle = Events->GetEvent(TEXT("PlayerDamaged")).AddUObject(this, &UHealthBarWidget::HandlePlayerDamaged);
}
}
void UHealthBarWidget::NativeDestruct()
{
if (UGameEventSubsystem* Events = UWorld::GetSubsystem<UGameEventSubsystem>(GetWorld()))
{
Events->GetEvent(TEXT("PlayerDamaged")).Remove(DamagedHandle);
}
Super::NativeDestruct();
}
void UHealthBarWidget::HandlePlayerDamaged(int32 NewHp)
{
UE_LOG(LogTemp, Log, TEXT("HealthBar: %d"), NewHp);
}
옵저버 예제와 비교하면, 체력 코드와 체력바 코드 어디에도 서로의 이름이 나오지 않는다. 둘이 아는 것은 중개자와 메세지 종류뿐이다. 대신 체력바는 어느 객체가 데미지를 받았는지 구분하지 않는다. 같은 종류의 메세지라면 누가 보냈든 모두 받는다. 만약 어느 객체가 데미지를 받았는지 구분해야 한다면, 메세지에 객체 ID를 담고 받는 쪽에서 거를 수도 있지만, 이럴 바엔 옵저버 패턴을 사용하는 것이 더 적합할 수 있다.
위 예제의 Publish는 구독자를 바로 호출하는 동기 방식이다. 중개자가 메세지를 큐에 넣어 두었다가 나중에 한꺼번에 전달하면 비동기 방식이 된다. 비동기 방식에서는 발행자가 구독자의 처리가 끝나기를 기다리지 않는다.
두 패턴의 차이
| 옵저버 | 발행-구독 | |
|---|---|---|
| 중개자 | 없음 | 있음 (브로커, 이벤트 버스) |
| 서로 아는가 | Observer가 Subject를 알고 직접 등록한다 | Publisher와 Subscriber가 서로 모른다 |
| 받을 대상 구분 | Subject 단위 | 토픽, 메시지 타입 단위 |
| 전달 방식 | 보통 동기 | 동기, 비동기 구현하기 나름 |
어느 쪽을 쓸지는 받는 쪽이 특정 객체에 관심이 있는지, 특정 종류의 사건에 관심이 있는지로 정할 수 있다.
- 옵저버 패턴이 맞는 경우 : 이 플레이어의 체력바처럼, 받는 쪽이 어떤 객체를 지켜봐야 하는지 이미 정해져 있을 때
- 발행-구독 패턴이 맞는 경우 : “적 처치”처럼 게임 전체에서 일어나는 사건에 업적, 퀘스트, 사운드처럼 서로 관계없는 시스템 여러 개가 반응할 때. 발행하는 쪽이 이 시스템들을 하나하나 알 필요가 없다.
발행-구독의 장점인 “서로 모른다”는 단점이기도 하다. 코드만 봐서는 메세지를 누가 받는지 알 수 없다. 그래서 문제가 생겼을 때 흐름을 따라가기가 옵저버보다 어렵다.
구독 해제
두 패턴 모두 받는 쪽이 사라지기 전에 등록을 해제해야 한다. 해제하지 않았을 때 생기는 일은 엔진마다 다르다.
유니티에서 event나 델리게이트는 등록된 메서드의 객체를 참조한다. Destroy로 게임 오브젝트를 파괴해도 C# 객체는 이 참조 때문에 메모리에 남고, 알림이 오면 메서드가 그대로 호출된다. 이때 메서드 안에서 transform같은 엔진 쪽 데이터에 접근하면 MissingReferenceException이 발생한다. static인 EventBus<T>는 프로그램이 끝날 때까지 살아 있으므로, 해제하지 않은 구독자는 끝까지 회수되지 않는다. 그래서 유니티에서는 OnEnable에서 등록하고 OnDisable에서 해제하는 짝을 지킨다.
언리얼의 AddDynamic과 AddUObject는 바인딩한 객체를 약한 참조로 갖는다. 객체가 가비지 컬렉션으로 사라지면 Broadcast가 그 바인딩을 건너뛰고 목록에서 정리한다. 그래도 직접 해제해야 한다. 위젯을 화면에서 제거해도 가비지 컬렉션이 돌기 전까지는 객체는 살아 있고, 그동안은 알림을 계속 받는다. AddRaw나 this를 캡쳐한 AddLambda는 약한 참조가 아니므로, 객체가 사라진 뒤 호출되면 해제된 메모리에 접근한다.