관찰자 디자인 패턴

관찰자 디자인 패턴을 사용하면 구독자가 공급자에 등록하고 공급자로부터 알림을 받을 수 있습니다. 푸시 기반 알림이 필요한 모든 시나리오에 적합합니다. 이 패턴은 공급자 ( 주체 또는 관찰 가능 개체라고도 함) 및 0개, 하나 이상의 관찰자를 정의합니다. 관찰자는 공급자에 등록하고 미리 정의된 조건, 이벤트 또는 상태 변경이 발생할 때마다 공급자는 대리자를 호출하여 모든 관찰자에게 자동으로 알깁니다. 이 메서드 호출에서 공급자는 관찰자에게 현재 상태 정보를 제공할 수도 있습니다. .NET에서 관찰자 디자인 패턴은 제네릭 System.IObservable<T>System.IObserver<T> 인터페이스를 구현하여 적용됩니다. 제네릭 형식 매개 변수는 알림 정보를 제공하는 형식을 나타냅니다.

패턴을 적용하는 경우

관찰자 디자인 패턴은 데이터 원본(비즈니스 논리) 계층과 사용자 인터페이스(표시) 계층과 같은 서로 다른 두 구성 요소 또는 애플리케이션 계층 간의 깨끗한 분리를 지원하기 때문에 분산 푸시 기반 알림에 적합합니다. 공급자가 콜백을 사용하여 클라이언트에 현재 정보를 제공할 때마다 패턴을 구현할 수 있습니다.

패턴을 구현하려면 다음 세부 정보를 제공해야 합니다.

  • 관찰자에게 알림을 보내는 개체인 공급자 또는 주체입니다. 공급자는 인터페이스를 구현하는 클래스 또는 구조체입니다 IObservable<T> . 공급자는 공급자로부터 알림을 받으려는 관찰자가 호출하는 단일 메서드 IObservable<T>.Subscribe를 구현해야 합니다.

  • 공급자로부터 알림을 받는 개체인 관찰자입니다. 관찰자는 인터페이스를 구현하는 클래스 또는 구조체입니다 IObserver<T> . 관찰자는 세 가지 메서드를 구현해야 하며, 이 메서드는 모두 공급자가 호출합니다.

  • 공급자가 관찰자를 추적할 수 있도록 하는 메커니즘입니다. 일반적으로 공급자는 알림을 구독한 System.Collections.Generic.List<T> 구현에 대한 참조를 저장하기 위해 IObserver<T> 객체와 같은 컨테이너 객체를 사용합니다. 이 목적을 위해 스토리지 컨테이너를 사용하면 공급자가 0부터 무제한 관찰자까지 처리할 수 있습니다. 관찰자가 알림을 받는 순서는 정의되지 않았습니다. 공급자는 모든 메서드를 사용하여 주문을 결정할 수 있습니다.

  • IDisposable 알림이 완료되면 공급자가 관찰자를 제거할 수 있도록 하는 구현입니다. 관찰자는 IDisposable 메서드를 통해 Subscribe 구현에 대한 참조를 받으므로, 공급자가 알림을 보내는 것을 완료하기 전에 IDisposable.Dispose 메서드를 호출하여 구독을 취소할 수도 있습니다.

  • 공급자가 관찰자에게 보내는 데이터를 포함하는 개체입니다. 이 개체의 형식은 IObservable<T>IObserver<T> 인터페이스의 제네릭 형식 매개 변수에 해당합니다. 이 개체는 IObservable<T> 구현과 같을 수도 있지만, 일반적으로는 별도의 타입입니다.

비고

관찰자 디자인 패턴을 구현하는 것 외에도 IObservable<T>IObserver<T> 인터페이스를 사용하여 구축된 라이브러리를 살펴보는 데 관심이 있을 수 있습니다. 예를 들어 Rx(.NET용 Reactive Extensions) 는 비동기 프로그래밍을 지원하는 일련의 확장 메서드와 LINQ 표준 시퀀스 연산자로 구성됩니다.

대안을 고려해야 하는 경우

IObservable<T> / IObserver<T> 인터페이스는 푸시 기반 알림 시나리오에 적합하지만 .NET 더 적합할 수 있는 다른 패턴을 제공합니다.

  • 표준 .NET 이벤트 — 단일 애플리케이션 내의 간단한 알림 시나리오의 경우 events는 보다 idiomatic이고 구현하기 쉽습니다.
  • IAsyncEnumerable<T> - 소비자가 속도를 제어하는 비동기 풀 기반 시퀀스의 경우 비동기 스트림을 사용합니다.
  • System.Threading.Channels — 백프레셔 및 비동기 지원이 필요한 생산자-소비자 패턴에는 System.Threading.Channels를 사용하세요.
  • Reactive Extensions(Rx.NET) — 복잡한 이벤트 조합, 필터링 및 변환에는 IObservable<T>를 직접 구현하는 대신 System.Reactive 패키지를 사용합니다.

.NET에서 IObservable<T>의 가장 두드러진 용도는 DiagnosticListener이며, 이를 통해 프레임워크 및 라이브러리 작성자는 소비자가 구독하는 구조화된 진단 이벤트를 내보낼 수 있습니다.

패턴 구현

다음 예제에서는 관찰자 디자인 패턴을 사용하여 공항 수하물 찾는 정보 시스템을 구현합니다. 클래스는 BaggageInfo 도착 항공편 및 각각의 항공편에서 수하물을 픽업할 수 있는 수하물 회전대에 대한 정보를 제공합니다. 다음 예제에 나와 있습니다.

namespace Observables.Example;

public readonly record struct BaggageInfo(
    int FlightNumber,
    string From,
    int Carousel);
Namespace Example

    Public Structure BaggageInfo
        Implements IEquatable(Of BaggageInfo)

        Public ReadOnly Property FlightNumber As Integer
        Public ReadOnly Property From As String
        Public ReadOnly Property Carousel As Integer

        Public Sub New(flightNumber As Integer, from As String, carousel As Integer)
            Me.FlightNumber = flightNumber
            Me.From = from
            Me.Carousel = carousel
        End Sub

        Public Overloads Function Equals(other As BaggageInfo) As Boolean Implements IEquatable(Of BaggageInfo).Equals
            Return FlightNumber = other.FlightNumber AndAlso
                   From = other.From AndAlso
                   Carousel = other.Carousel
        End Function

        Public Overrides Function Equals(obj As Object) As Boolean
            If TypeOf obj Is BaggageInfo Then
                Return Equals(DirectCast(obj, BaggageInfo))
            End If
            Return False
        End Function

        Public Overrides Function GetHashCode() As Integer
            Return HashCode.Combine(FlightNumber, From, Carousel)
        End Function

        Public Shared Operator =(left As BaggageInfo, right As BaggageInfo) As Boolean
            Return left.Equals(right)
        End Operator

        Public Shared Operator <>(left As BaggageInfo, right As BaggageInfo) As Boolean
            Return Not left.Equals(right)
        End Operator
    End Structure

End Namespace

BaggageHandler 클래스는 도착 항공편 및 수하물 회수대에 대한 정보를 수신할 책임이 있습니다. 내부적으로 다음 두 컬렉션을 유지 관리합니다.

  • _observers: 업데이트된 정보를 관찰하는 클라이언트의 컬렉션입니다.
  • _flights: 항공편과 할당된 수하물 컨베이어 벨트의 모음입니다.

클래스의 BaggageHandler 소스 코드는 다음 예제에 나와 있습니다.

namespace Observables.Example;

public sealed class BaggageHandler : IObservable<BaggageInfo>
{
    private readonly Lock _lock = new();
    private readonly HashSet<IObserver<BaggageInfo>> _observers = [];
    private readonly HashSet<BaggageInfo> _flights = [];

    public IDisposable Subscribe(IObserver<BaggageInfo> observer)
    {
        BaggageInfo[] snapshot;

        lock (_lock)
        {
            // Check whether observer is already registered. If not, add it.
            if (!_observers.Add(observer))
            {
                return new Unsubscriber<BaggageInfo>(_lock, _observers, observer);
            }

            // Snapshot existing data while holding the lock.
            snapshot = [.. _flights];
        }

        // Provide observer with existing data outside the lock.
        foreach (BaggageInfo item in snapshot)
        {
            observer.OnNext(item);
        }

        return new Unsubscriber<BaggageInfo>(_lock, _observers, observer);
    }

    // Called to indicate all baggage is now unloaded.
    public void BaggageStatus(int flightNumber) =>
        BaggageStatus(flightNumber, string.Empty, 0);

    public void BaggageStatus(int flightNumber, string from, int carousel)
    {
        var info = new BaggageInfo(flightNumber, from, carousel);
        IObserver<BaggageInfo>[] snapshot;

        // Carousel is assigned, so add new info object to list.
        if (carousel > 0)
        {
            lock (_lock)
            {
                if (!_flights.Add(info))
                {
                    return;
                }

                snapshot = [.. _observers];
            }

            foreach (IObserver<BaggageInfo> observer in snapshot)
            {
                observer.OnNext(info);
            }
        }
        else if (carousel is 0)
        {
            // Baggage claim for flight is done.
            lock (_lock)
            {
                if (_flights.RemoveWhere(
                    flight => flight.FlightNumber == info.FlightNumber) == 0)
                {
                    return;
                }

                snapshot = [.. _observers];
            }

            foreach (IObserver<BaggageInfo> observer in snapshot)
            {
                observer.OnNext(info);
            }
        }
    }

    public void LastBaggageClaimed()
    {
        IObserver<BaggageInfo>[] snapshot;

        lock (_lock)
        {
            snapshot = [.. _observers];
            _observers.Clear();
        }

        foreach (IObserver<BaggageInfo> observer in snapshot)
        {
            observer.OnCompleted();
        }
    }
}
Namespace Example

    Public NotInheritable Class BaggageHandler
        Implements IObservable(Of BaggageInfo)

        Private ReadOnly _lock As New Object()
        Private ReadOnly _observers As New HashSet(Of IObserver(Of BaggageInfo))()
        Private ReadOnly _flights As New HashSet(Of BaggageInfo)()

        Public Function Subscribe(observer As IObserver(Of BaggageInfo)) As IDisposable Implements IObservable(Of BaggageInfo).Subscribe
            Dim snapshot As BaggageInfo()

            SyncLock _lock
                ' Check whether observer is already registered. If not, add it.
                If Not _observers.Add(observer) Then
                    Return New Unsubscriber(Of BaggageInfo)(_lock, _observers, observer)
                End If

                ' Snapshot existing data while holding the lock.
                snapshot = _flights.ToArray()
            End SyncLock

            ' Provide observer with existing data outside the lock.
            For Each item As BaggageInfo In snapshot
                observer.OnNext(item)
            Next

            Return New Unsubscriber(Of BaggageInfo)(_lock, _observers, observer)
        End Function

        ' Called to indicate all baggage is now unloaded.
        Public Sub BaggageStatus(flightNumber As Integer)
            BaggageStatus(flightNumber, String.Empty, 0)
        End Sub

        Public Sub BaggageStatus(flightNumber As Integer, from As String, carousel As Integer)
            Dim info As New BaggageInfo(flightNumber, from, carousel)
            Dim snapshot As IObserver(Of BaggageInfo)()

            ' Carousel is assigned, so add new info object to list.
            If carousel > 0 Then
                SyncLock _lock
                    If Not _flights.Add(info) Then
                        Return
                    End If

                    snapshot = _observers.ToArray()
                End SyncLock

                For Each observer As IObserver(Of BaggageInfo) In snapshot
                    observer.OnNext(info)
                Next
            ElseIf carousel = 0 Then
                ' Baggage claim for flight is done.
                SyncLock _lock
                    If _flights.RemoveWhere(
                        Function(flight) flight.FlightNumber = info.FlightNumber) = 0 Then
                        Return
                    End If

                    snapshot = _observers.ToArray()
                End SyncLock

                For Each observer As IObserver(Of BaggageInfo) In snapshot
                    observer.OnNext(info)
                Next
            End If
        End Sub

        Public Sub LastBaggageClaimed()
            Dim snapshot As IObserver(Of BaggageInfo)()

            SyncLock _lock
                snapshot = _observers.ToArray()
                _observers.Clear()
            End SyncLock

            For Each observer As IObserver(Of BaggageInfo) In snapshot
                observer.OnCompleted()
            Next
        End Sub
    End Class

End Namespace

업데이트된 정보를 수신하려는 클라이언트는 메서드를 호출합니다 BaggageHandler.Subscribe . 클라이언트가 이전에 알림을 구독하지 않은 경우 클라이언트의 IObserver<T> 구현에 대한 참조가 컬렉션에 _observers 추가됩니다.

오버로드된 메서드를 BaggageHandler.BaggageStatus 호출하여 항공편의 수하물이 언로드 중이거나 더 이상 언로드되지 않음을 나타낼 수 있습니다. 첫 번째 경우, 메서드는 항공편 번호와 항공편이 출발한 공항, 수하물이 내려지는 회전목마를 전달합니다. 두 번째 경우 메서드는 플라이트 번호만 전달됩니다. 언로드되는 수하물을 위해, 메서드는 메서드에 전달된 BaggageInfo 정보가 _flights 컬렉션에 존재하는지 확인합니다. 그렇지 않은 경우 메서드는 정보를 추가하고 각 관찰자의 OnNext 메서드를 호출합니다. 수하물이 더 이상 언로드되지 않는 항공편의 경우, 이 메서드는 해당 항공편의 정보가 컬렉션에 저장 _flights 되어 있는지 여부를 확인합니다. 이 경우 메서드는 각 관찰자의 OnNext 메서드를 호출한 후 BaggageInfo 객체를 _flights 컬렉션에서 제거합니다.

하루의 마지막 비행이 착륙하고 수하물이 처리되면 메서드가 BaggageHandler.LastBaggageClaimed 호출됩니다. 이 메서드는 각 관찰자의 OnCompleted 메서드를 호출하여 모든 알림이 완료되었음을 나타내고 컬렉션을 지웁니다 _observers .

공급자의 Subscribe 메서드는 관찰자가 IDisposable 메서드가 호출되기 전에 알림 수신을 중지할 수 있도록 하는 OnCompleted 구현을 반환합니다. 이 Unsubscriber 클래스의 소스 코드는 다음 예제에 나와 있습니다. BaggageHandler.Subscribe 메서드에서 클래스가 인스턴스화되면 _lock 개체에 대한 참조, _observers 컬렉션, 그리고 해당 컬렉션에 추가되는 관찰자에 대한 참조가 전달됩니다. 이러한 참조는 지역 변수에 할당됩니다. 개체의 Dispose 메서드가 호출되면 잠금 상태에서 _observers 컬렉션에서 관찰자를 제거합니다.

namespace Observables.Example;

internal sealed class Unsubscriber<T> : IDisposable
{
    private readonly Lock _lock;
    private readonly ISet<IObserver<T>> _observers;
    private readonly IObserver<T> _observer;

    internal Unsubscriber(
        Lock @lock,
        ISet<IObserver<T>> observers,
        IObserver<T> observer) => (_lock, _observers, _observer) = (@lock, observers, observer);

    public void Dispose()
    {
        lock (_lock)
        {
            _observers.Remove(_observer);
        }
    }
}
Namespace Example

    Friend NotInheritable Class Unsubscriber(Of T)
        Implements IDisposable

        Private ReadOnly _lock As Object
        Private ReadOnly _observers As ISet(Of IObserver(Of T))
        Private ReadOnly _observer As IObserver(Of T)

        Friend Sub New(lock As Object, observers As ISet(Of IObserver(Of T)), observer As IObserver(Of T))
            _lock = lock
            _observers = observers
            _observer = observer
        End Sub

        Public Sub Dispose() Implements IDisposable.Dispose
            SyncLock _lock
                _observers.Remove(_observer)
            End SyncLock
        End Sub
    End Class

End Namespace

다음 예제에서는 수하물 클레임 정보를 표시하는 기본 클래스인 IObserver<T> 구현인 ArrivalsMonitor을 제공합니다. 정보는 원래 도시의 이름으로 사전순으로 표시됩니다. 메서드 ArrivalsMonitor는 Visual Basic에서는 overridable으로, C#에서는 virtual로 표시되므로 파생 클래스에서 재정의할 수 있습니다.

namespace Observables.Example;

public class ArrivalsMonitor : IObserver<BaggageInfo>
{
    private readonly string _name;
    private readonly Lock _lock = new();
    private readonly List<string> _flights = [];
    private readonly string _format = "{0,-20} {1,5}  {2, 3}";
    private IDisposable? _cancellation;

    public ArrivalsMonitor(string name)
    {
        ArgumentException.ThrowIfNullOrEmpty(name);
        _name = name;
    }

    public virtual void Subscribe(BaggageHandler provider) =>
        _cancellation = provider.Subscribe(this);

    public virtual void Unsubscribe()
    {
        Interlocked.Exchange(ref _cancellation, null)?.Dispose();

        lock (_lock)
        {
            _flights.Clear();
        }
    }

    public virtual void OnCompleted()
    {
        lock (_lock)
        {
            _flights.Clear();
        }
    }

    // No implementation needed: Method is not called by the BaggageHandler class.
    public virtual void OnError(Exception e)
    {
        // No implementation.
    }

    // Update information.
    public virtual void OnNext(BaggageInfo info)
    {
        bool updated = false;

        lock (_lock)
        {
            // Flight has unloaded its baggage; remove from the monitor.
            if (info.Carousel is 0)
            {
                string flightNumber = $"{info.FlightNumber,5}";
                for (int index = _flights.Count - 1; index >= 0; index--)
                {
                    string flightInfo = _flights[index];
                    if (flightInfo.Substring(21, 5).Equals(flightNumber))
                    {
                        updated = true;
                        _flights.RemoveAt(index);
                    }
                }
            }
            else
            {
                // Add flight if it doesn't exist in the collection.
                string flightInfo = string.Format(_format, info.From, info.FlightNumber, info.Carousel);
                if (_flights.Contains(flightInfo) is false)
                {
                    _flights.Add(flightInfo);
                    updated = true;
                }
            }

            if (updated)
            {
                _flights.Sort();
                Console.WriteLine($"Arrivals information from {_name}");
                foreach (string flightInfo in _flights)
                {
                    Console.WriteLine(flightInfo);
                }

                Console.WriteLine();
            }
        }
    }
}
Imports System.Threading

Namespace Example

    Public Class ArrivalsMonitor
        Implements IObserver(Of BaggageInfo)

        Private ReadOnly _name As String
        Private ReadOnly _lock As New Object()
        Private ReadOnly _flights As New List(Of String)()
        Private ReadOnly _format As String = "{0,-20} {1,5}  {2, 3}"
        Private _cancellation As IDisposable

        Public Sub New(name As String)
            If String.IsNullOrEmpty(name) Then
                Throw New ArgumentException("Value cannot be null or empty.", NameOf(name))
            End If
            _name = name
        End Sub

        Public Overridable Sub Subscribe(provider As BaggageHandler)
            _cancellation = provider.Subscribe(Me)
        End Sub

        Public Overridable Sub Unsubscribe()
            Dim previous = Interlocked.Exchange(_cancellation, Nothing)
            previous?.Dispose()

            SyncLock _lock
                _flights.Clear()
            End SyncLock
        End Sub

        Public Overridable Sub OnCompleted() Implements IObserver(Of BaggageInfo).OnCompleted
            SyncLock _lock
                _flights.Clear()
            End SyncLock
        End Sub

        ' No implementation needed: Method is not called by the BaggageHandler class.
        Public Overridable Sub OnError([error] As Exception) Implements IObserver(Of BaggageInfo).OnError
            ' No implementation.
        End Sub

        ' Update information.
        Public Overridable Sub OnNext(info As BaggageInfo) Implements IObserver(Of BaggageInfo).OnNext
            Dim updated As Boolean = False

            SyncLock _lock
                ' Flight has unloaded its baggage; remove from the monitor.
                If info.Carousel = 0 Then
                    Dim flightNumber As String = String.Format("{0,5}", info.FlightNumber)
                    For index As Integer = _flights.Count - 1 To 0 Step -1
                        Dim flightInfo As String = _flights(index)
                        If flightInfo.Substring(21, 5).Equals(flightNumber) Then
                            updated = True
                            _flights.RemoveAt(index)
                        End If
                    Next
                Else
                    ' Add flight if it doesn't exist in the collection.
                    Dim flightInfo As String = String.Format(_format, info.From, info.FlightNumber, info.Carousel)
                    If Not _flights.Contains(flightInfo) Then
                        _flights.Add(flightInfo)
                        updated = True
                    End If
                End If

                If updated Then
                    _flights.Sort()
                    Console.WriteLine($"Arrivals information from {_name}")
                    For Each flightInfo As String In _flights
                        Console.WriteLine(flightInfo)
                    Next

                    Console.WriteLine()
                End If
            End SyncLock
        End Sub
    End Class

End Namespace

클래스에는 ArrivalsMonitorSubscribe 메서드가 포함됩니다. Subscribe 메서드를 사용하면, 클래스는 IDisposable 호출에서 반환된 구현을 프라이빗 변수에 저장할 Subscribe 수 있습니다. 이 Unsubscribe 메서드를 사용하면 공급자의 Dispose 구현을 호출하여 클래스가 알림에서 구독을 취소할 수 있습니다. ArrivalsMonitorOnNext, OnError, 및 OnCompleted 메서드의 구현도 제공합니다. 구현에 OnNext 만 상당한 양의 코드가 포함됩니다. 이 방법은 도착 항공편의 출발지 공항과 수하물을 받을 수 있는 회전식 컨베이어에 대한 정보를 유지하는 개인 정렬된 일반 List<T> 객체와 함께 작동합니다. 클래스가 BaggageHandler 새 플라이트 도착을 보고하는 경우 메서드 구현은 OnNext 해당 플라이트 정보를 목록에 추가합니다. 클래스가 BaggageHandler 항공편의 수하물이 언로드되었다고 OnNext 보고하는 경우, 이 메서드는 해당 항공편을 목록에서 제거합니다. 변경될 때마다 목록이 정렬되어 콘솔에 표시됩니다.

다음 예제에서는 BaggageHandler 클래스와 ArrivalsMonitor 클래스의 두 인스턴스를 인스턴스화하는 애플리케이션 진입점을 포함하고, BaggageHandler.BaggageStatus 메서드를 사용하여 도착 항공편에 대한 정보를 추가하고 제거합니다. 각 경우에 관찰자는 업데이트를 받고 수하물 클레임 정보를 올바르게 표시합니다.

using Observables.Example;

BaggageHandler provider = new();
ArrivalsMonitor observer1 = new("BaggageClaimMonitor1");
ArrivalsMonitor observer2 = new("SecurityExit");

provider.BaggageStatus(712, "Detroit", 3);
observer1.Subscribe(provider);

provider.BaggageStatus(712, "Kalamazoo", 3);
provider.BaggageStatus(400, "New York-Kennedy", 1);
provider.BaggageStatus(712, "Detroit", 3);
observer2.Subscribe(provider);

provider.BaggageStatus(511, "San Francisco", 2);
provider.BaggageStatus(712);
observer2.Unsubscribe();

provider.BaggageStatus(400);
provider.LastBaggageClaimed();
Imports Observables.Example
Imports System.Threading

Module Program
    Sub Main(args As String())
        Dim provider As New BaggageHandler()
        Dim observer1 As New ArrivalsMonitor("BaggageClaimMonitor1")
        Dim observer2 As New ArrivalsMonitor("SecurityExit")

        provider.BaggageStatus(712, "Detroit", 3)
        observer1.Subscribe(provider)

        provider.BaggageStatus(712, "Kalamazoo", 3)
        provider.BaggageStatus(400, "New York-Kennedy", 1)
        provider.BaggageStatus(712, "Detroit", 3)
        observer2.Subscribe(provider)

        provider.BaggageStatus(511, "San Francisco", 2)
        provider.BaggageStatus(712)
        observer2.Unsubscribe()

        provider.BaggageStatus(400)
        provider.LastBaggageClaimed()
    End Sub
End Module