다음을 통해 공유


표준 .NET 이벤트 패턴

이전

.NET 이벤트는 일반적으로 몇 가지 알려진 패턴을 따릅니다. 이러한 패턴을 표준화하면 개발자가 이러한 표준 패턴에 대한 지식을 적용할 수 있으며 이는 모든 .NET 이벤트 프로그램에 적용할 수 있습니다.

표준 이벤트 원본을 만들고 코드에서 표준 이벤트를 구독하고 처리하는 데 필요한 모든 지식을 갖추도록 이러한 표준 패턴을 살펴보겠습니다.

이벤트 대리자 시그니처

.NET 이벤트 대리자에 대한 표준 시그니처는 다음과 같습니다.

void EventRaised(object sender, EventArgs args);

이 표준 서명은 이벤트가 사용되는 시기에 대한 인사이트를 제공합니다.

  • 반환 타입이 void입니다. 이벤트에는 0~많은 수신기가 있을 수 있습니다. 이벤트를 발생시키면 모든 수신기가 알림을 받습니다. 일반적으로 수신기는 이벤트에 대한 응답으로 값을 제공하지 않습니다.
  • 이벤트는 보낸 사람나타냅니다. 이벤트 서명에는 이벤트를 발생시킨 개체가 포함됩니다. 이는 모든 수신기에 보낸 사람과 통신하는 메커니즘을 제공합니다. sender의 컴파일 시간 형식은 System.Object입니다. 그러나 항상 올바를 수 있는 더 파생된 형식을 알고 있을 수도 있습니다. 규칙에 따라 object를 사용합니다.
  • 이벤트는 단일 구조로 추가 정보를 패키지합니다. args 매개 변수는 더 이상 필요한 정보를 포함하는 파생된 System.EventArgs 형식입니다. (다음 섹션에서는 이 규칙이 더 이상 적용되지 않는다는 것을 보게 될 것입니다.) 이벤트 유형에 더 이상 인수가 필요하지 않은 경우에도 두 인수를 모두 제공해야 합니다. 이벤트에 추가 정보가 포함되어 있지 않음을 나타내는 데 사용해야 하는 EventArgs.Empty 특별한 값이 있습니다.

디렉터리 또는 패턴을 따르는 모든 하위 디렉터리의 파일을 나열하는 클래스를 만들어 보겠습니다. 이 구성 요소는 검색된 각 파일에 대해 패턴과 일치하는 이벤트를 발생시킵니다.

이벤트 모델을 사용하면 몇 가지 디자인 장점이 있습니다. 검색된 파일을 찾으면 다른 작업을 수행하는 여러 이벤트 수신기를 만들 수 있습니다. 서로 다른 수신기를 결합하면 더 강력한 알고리즘을 만들 수 있습니다.

검색된 파일을 찾기 위한 초기 이벤트 인수 선언은 다음과 같습니다.

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

이 형식은 작은 데이터 전용 형식처럼 보이지만 규칙을 따르고 참조(class) 형식으로 만들어야 합니다. 즉, 인수 개체가 참조로 전달되고 데이터에 대한 모든 업데이트가 모든 구독자가 볼 수 있습니다. 첫 번째 버전은 변경할 수 없는 개체입니다. 이벤트 인수 형식에서 속성을 변경할 수 없도록 하는 것이 좋습니다. 이렇게 하면 한 구독자가 값을 변경한 후에 다른 구독자가 값을 볼 수 없습니다. (나중에 볼 수 있듯이 이 연습에는 예외가 있습니다.)

다음으로 FileSearcher 클래스에서 이벤트 선언을 만들어야 합니다. System.EventHandler<TEventArgs> 형식을 사용하면 다른 형식 정의를 만들 필요가 없습니다. 당신은 그냥 제네릭 특수화만 사용합니다.

FileSearcher 클래스를 입력하여 패턴과 일치하는 파일을 검색하고 일치하는 항목이 검색되면 올바른 이벤트를 발생시켜 보겠습니다.

public class FileSearcher
{
    public event EventHandler<FileFoundArgs>? FileFound;

    public void Search(string directory, string searchPattern)
    {
        foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
        {
            FileFound?.Invoke(this, new FileFoundArgs(file));
        }
    }
}

필드와 유사한 이벤트를 정의하고 발생시키기

이벤트를 클래스에 추가하는 가장 간단한 방법은 이전 예제와 같이 해당 이벤트를 public 필드로 선언하는 것입니다.

public event EventHandler<FileFoundArgs>? FileFound;

이 예제는 public 필드를 선언하는 것처럼 보이며, 잘못된 개체 지향 사례인 것 같습니다. 속성 또는 메서드를 통해 데이터 액세스를 보호하려고 합니다. 이 코드는 잘못된 사례처럼 보일 수 있지만 컴파일러에서 생성된 코드는 래퍼를 만들어 이벤트 개체에 안전한 방법으로만 액세스할 수 있도록 합니다. 필드와 유사한 이벤트에서 사용할 수 있는 유일한 작업은 추가제거 처리기입니다.

var fileLister = new FileSearcher();
int filesFound = 0;

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    filesFound++;
};

fileLister.FileFound += onFileFound;
fileLister.FileFound -= onFileFound;

처리기에 대한 지역 변수가 있습니다. 람다 remove 의 본문을 사용한 경우 처리기가 제대로 작동하지 않습니다. 대리자의 다른 인스턴스가 되어 조용히 아무 작업도 수행하지 않습니다.

클래스 외부의 코드는 이벤트를 발생하거나 다른 작업을 수행할 수 없습니다.

C# 14부터 이벤트를 부분 멤버로 선언할 수 있습니다. 부분 이벤트 선언에는 정의 선언구현 선언이 포함되어야 합니다. 정의 선언은 필드와 유사한 이벤트 구문을 사용해야 합니다. 구현 선언은 addremove 처리기를 선언해야 합니다.

이벤트 구독자에서 값 반환

간단한 버전은 제대로 작동하고 있습니다. 이제 또 다른 기능인 취소를 추가해 보겠습니다.

Found 이벤트를 발생시킬 때 수신기는 이 파일이 마지막으로 검색된 경우 추가 처리를 중지할 수 있어야 합니다.

이벤트 처리기는 값을 반환하지 않으므로 다른 방법으로 통신해야 합니다. 표준 이벤트 패턴은 EventArgs 개체를 사용하여 이벤트 구독자가 취소를 전달하는 데 사용할 수 있는 필드를 포함합니다.

취소 계약의 의미 체계에 따라 두 가지 다른 패턴을 사용할 수 있습니다. 두 경우 모두, 파일 찾기 이벤트에 대한 EventArguments에 불리언 필드를 추가합니다.

한 가지 패턴에서는 임의의 구독자 한 명이 작업을 취소할 수 있습니다. 이 패턴에서는 새 필드가 false로 초기화됩니다. 임의의 구독자가 이 값을 true로 변경할 수 있습니다. 모든 구독자에 대한 이벤트를 발생시키고 나면 FileSearcher 구성 요소는 부울 값을 검사하고 작업을 수행합니다.

두 번째 패턴에서는 모든 구독자가 작업 취소를 원하는 경우에만 작업을 취소합니다. 이 패턴에서는 작업이 취소되어야 함을 나타내도록 새 필드가 초기화되고 임의의 구독자는 작업이 계속되어야 함을 나타내도록 이 필드를 변경할 수 있습니다. 모든 구독자가 발생한 이벤트를 처리한 후, FileSearcher 구성 요소는 불리언 값을 검사하고 조치를 취합니다. 이 패턴에는 한 가지 추가 단계가 있습니다. 구성 요소는 구독자가 이벤트에 응답했는지 알아야 합니다. 구독자가 없으면 필드는 취소를 잘못 나타내게 됩니다.

이 샘플에 대한 첫 번째 버전을 구현해 보겠습니다. CancelRequested라는 부울 필드를 FileFoundArgs 형식에 추가해야 합니다.

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }
    public bool CancelRequested { get; set; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

이 새 필드는 실수로 취소하지 않도록 false 자동으로 초기화됩니다. 구성 요소에 대한 유일한 변경 내용은 이벤트를 발생 시키는 후 플래그를 확인하여 구독자 중 취소를 요청했는지 확인하는 것입니다.

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

이 패턴의 한 가지 장점은 새로운 변경 사항이 아니라는 점입니다. 이전에 취소를 요청한 구독자는 없으며 여전히 취소되지 않습니다. 새 취소 프로토콜을 지원하지 않으려면 구독자 코드에 업데이트가 필요하지 않습니다.

첫 번째 실행 파일을 찾으면 취소를 요청하도록 구독자를 업데이트해 보겠습니다.

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    eventArgs.CancelRequested = true;
};

다른 이벤트 선언 추가

기능을 하나 더 추가하고 이벤트에 대한 다른 언어 관용구를 보여 드리겠습니다. 파일 검색에서 모든 하위 디렉터리를 트래버스하는 Search 메서드의 오버로드를 추가해 보겠습니다.

이 메서드는 많은 하위 디렉터리가 있는 디렉터리에서 긴 작업이 될 수 있습니다. 각각의 새 디렉터리 검색이 시작될 때 발생되는 이벤트를 추가해 보겠습니다. 이 이벤트를 사용하면 구독자가 진행 상황을 추적하고 진행 상황을 업데이트할 수 있습니다. 지금까지 만든 모든 샘플은 공용입니다. 이 이벤트를 내부 이벤트로 만들어 보겠습니다. 즉, 인수 형식을 내부적으로 만들 수도 있습니다.

먼저 새 디렉터리 및 진행률을 보고하기 위한 새 EventArgs 파생 클래스를 만듭니다.

internal class SearchDirectoryArgs : EventArgs
{
    internal string CurrentSearchDirectory { get; }
    internal int TotalDirs { get; }
    internal int CompletedDirs { get; }

    internal SearchDirectoryArgs(string dir, int totalDirs, int completedDirs)
    {
        CurrentSearchDirectory = dir;
        TotalDirs = totalDirs;
        CompletedDirs = completedDirs;
    }
}

다시 권장 사항에 따라 이벤트 인수에 대해 변경할 수 없는 참조 형식을 만들 수 있습니다.

다음으로 이벤트를 정의합니다. 이번에는 다른 구문을 사용합니다. 필드 구문을 사용하는 것 외에도 처리기 추가 및 제거를 사용하여 이벤트 속성을 명시적으로 만들 수 있습니다. 이 샘플에서는 해당 처리기에 추가 코드가 필요하지 않지만 이를 만드는 방법을 보여 줍니다.

internal event EventHandler<SearchDirectoryArgs> DirectoryChanged
{
    add { _directoryChanged += value; }
    remove { _directoryChanged -= value; }
}
private EventHandler<SearchDirectoryArgs>? _directoryChanged;

여기서 작성하는 코드는 여러 가지 면에서 컴파일러가 앞에서 본 필드 이벤트 정의에 대해 생성하는 코드를 미러링합니다. 속성과 유사한 구문을 사용하여 이벤트를 만듭니다. 처리기의 이름은 addremove로 다릅니다. 이러한 접근자는 이벤트를 구독하거나 이벤트에서 구독을 취소하기 위해 호출됩니다. 또한 이벤트 변수를 저장하려면 private 지원 필드를 선언해야 합니다. 이 변수는 null로 초기화됩니다.

다음으로 하위 디렉터리를 트래버스하고 두 이벤트를 발생시키는 Search 메서드의 오버로드를 추가해 보겠습니다. 가장 쉬운 방법은 기본 인수를 사용하여 모든 디렉터리를 검색하도록 지정하는 것입니다.

public void Search(string directory, string searchPattern, bool searchSubDirs = false)
{
    if (searchSubDirs)
    {
        var allDirectories = Directory.GetDirectories(directory, "*.*", SearchOption.AllDirectories);
        var completedDirs = 0;
        var totalDirs = allDirectories.Length + 1;
        foreach (var dir in allDirectories)
        {
            _directoryChanged?.Invoke(this, new (dir, totalDirs, completedDirs++));
            // Search 'dir' and its subdirectories for files that match the search pattern:
            SearchDirectory(dir, searchPattern);
        }
        // Include the Current Directory:
        _directoryChanged?.Invoke(this, new (directory, totalDirs, completedDirs++));
        SearchDirectory(directory, searchPattern);
    }
    else
    {
        SearchDirectory(directory, searchPattern);
    }
}

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

이 시점에서 모든 하위 디렉터리를 검색하기 위해 오버로드를 호출하는 애플리케이션을 실행할 수 있습니다. 새 DirectoryChanged 이벤트에 대한 구독자는 없지만 ?.Invoke() 관용구를 사용하면 제대로 작동합니다.

콘솔 창에 진행률을 표시하는 줄을 작성하는 처리기를 추가해 보겠습니다.

fileLister.DirectoryChanged += (sender, eventArgs) =>
{
    Console.Write($"Entering '{eventArgs.CurrentSearchDirectory}'.");
    Console.WriteLine($" {eventArgs.CompletedDirs} of {eventArgs.TotalDirs} completed...");
};

.NET 에코시스템 전체에서 수행되는 패턴을 확인했습니다. 이러한 패턴 및 규칙을 학습하면 Idiomatic C# 및 .NET을 빠르게 작성할 수 있습니다.

추가 정보

다음으로, .NET의 최신 릴리스에서 이러한 패턴에 몇 가지 변경 내용이 표시됩니다.