ASP.NET Core Blazor 동기화 컨텍스트

참고 항목

이 문서의 최신 버전은 아닙니다. 현재 릴리스는 이 문서의 .NET 8 버전을 참조 하세요.

Important

이 정보는 상업적으로 출시되기 전에 실질적으로 수정될 수 있는 시험판 제품과 관련이 있습니다. Microsoft는 여기에 제공된 정보에 대해 어떠한 명시적, 또는 묵시적인 보증을 하지 않습니다.

현재 릴리스는 이 문서의 .NET 8 버전을 참조 하세요.

Blazor는 동기화 컨텍스트(SynchronizationContext)를 사용하여 단일 논리적 실행 스레드를 적용합니다. 구성 요소의 수명 주기 메서드 및 Blazor에서 발생하는 이벤트 콜백은 동기화 컨텍스트에서 실행됩니다.

Blazor'의 서버 쪽 동기화 컨텍스트는 단일 스레드 환경이 단일 스레드인 브라우저의 WebAssembly 모델과 밀접하게 일치하도록 단일 스레드 환경을 에뮬레이트하려고 시도합니다. 이 에뮬레이션은 개별 회로로만 범위가 지정됩니다. 즉, 서로 다른 두 회로가 병렬로 실행될 수 있습니다. 회로 내의 지정된 특정 시점에서 작업은 정확히 하나의 스레드에서 수행되므로 단일 논리 스레드의 인상을 생성합니다. 두 작업이 동일한 회로 내에서 동시에 실행되지 않습니다.

스레드 차단 호출 방지

일반적으로 구성 요소에서 다음 메서드를 호출하지 마세요. 다음 메서드는 실행 스레드를 차단하므로 기본 Task가 완료될 때까지 앱이 작업을 다시 시작하지 못하게 차단합니다.

참고 항목

이 섹션에서 설명한 스레드 차단 메서드를 사용하는 Blazor 설명서 예제에서는 권장 코딩 지침이 아니라 데모용으로만 메서드를 사용합니다. 예를 들어 몇 가지 구성 요소 코드 데모에서는 Thread.Sleep을 호출하여 장기 실행 프로세스를 시뮬레이션합니다.

외부에서 구성 요소 메서드를 호출하여 상태 업데이트

외부 이벤트(예: 타이머 또는 다른 알림)를 기준으로 구성 요소를 업데이트해야 하는 경우 Blazor의 동기화 컨텍스트에 코드 실행을 디스패치하는 InvokeAsync 메서드를 사용합니다. 예를 들어 수신 대기하는 구성 요소에 업데이트된 상태를 알릴 수 있는 다음 ‘알림 서비스’를 살펴보세요. Update 메서드는 앱의 어디에서나 호출할 수 있습니다.

TimerService.cs:

namespace BlazorSample;

public class TimerService(NotifierService notifier, 
    ILogger<TimerService> logger) : IDisposable
{
    private int elapsedCount;
    private readonly static TimeSpan heartbeatTickRate = TimeSpan.FromSeconds(5);
    private readonly ILogger<TimerService> logger = logger;
    private readonly NotifierService notifier = notifier;
    private PeriodicTimer? timer;

    public async Task Start()
    {
        if (timer is null)
        {
            timer = new(heartbeatTickRate);
            logger.LogInformation("Started");

            using (timer)
            {
                while (await timer.WaitForNextTickAsync())
                {
                    elapsedCount += 1;
                    await notifier.Update("elapsedCount", elapsedCount);
                    logger.LogInformation("ElapsedCount {Count}", elapsedCount);
                }
            }
        }
    }

    public void Dispose()
    {
        timer?.Dispose();

        // The following prevents derived types that introduce a
        // finalizer from needing to re-implement IDisposable.
        GC.SuppressFinalize(this);
    }
}
public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly static TimeSpan heartbeatTickRate = TimeSpan.FromSeconds(5);
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private PeriodicTimer? timer;

    public TimerService(NotifierService notifier,
        ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public async Task Start()
    {
        if (timer is null)
        {
            timer = new(heartbeatTickRate);
            logger.LogInformation("Started");

            using (timer)
            {
                while (await timer.WaitForNextTickAsync())
                {
                    elapsedCount += 1;
                    await notifier.Update("elapsedCount", elapsedCount);
                    logger.LogInformation($"elapsedCount: {elapsedCount}");
                }
            }
        }
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}
public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly static TimeSpan heartbeatTickRate = TimeSpan.FromSeconds(5);
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private PeriodicTimer? timer;

    public TimerService(NotifierService notifier,
        ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public async Task Start()
    {
        if (timer is null)
        {
            timer = new(heartbeatTickRate);
            logger.LogInformation("Started");

            using (timer)
            {
                while (await timer.WaitForNextTickAsync())
                {
                    elapsedCount += 1;
                    await notifier.Update("elapsedCount", elapsedCount);
                    logger.LogInformation($"elapsedCount: {elapsedCount}");
                }
            }
        }
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}
using System;
using System.Timers;
using Microsoft.Extensions.Logging;

public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private Timer timer;

    public TimerService(NotifierService notifier, ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public void Start()
    {
        if (timer is null)
        {
            timer = new();
            timer.AutoReset = true;
            timer.Interval = 10000;
            timer.Elapsed += HandleTimer;
            timer.Enabled = true;
            logger.LogInformation("Started");
        }
    }

    private async void HandleTimer(object source, ElapsedEventArgs e)
    {
        elapsedCount += 1;
        await notifier.Update("elapsedCount", elapsedCount);
        logger.LogInformation($"elapsedCount: {elapsedCount}");
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}
using System;
using System.Timers;
using Microsoft.Extensions.Logging;

public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private Timer timer;

    public TimerService(NotifierService notifier, ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public void Start()
    {
        if (timer is null)
        {
            timer = new Timer();
            timer.AutoReset = true;
            timer.Interval = 10000;
            timer.Elapsed += HandleTimer;
            timer.Enabled = true;
            logger.LogInformation("Started");
        }
    }

    private async void HandleTimer(object source, ElapsedEventArgs e)
    {
        elapsedCount += 1;
        await notifier.Update("elapsedCount", elapsedCount);
        logger.LogInformation($"elapsedCount: {elapsedCount}");
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}

NotifierService.cs:

namespace BlazorSample;

public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task>? Notify;
}
public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task>? Notify;
}
public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task>? Notify;
}
using System;
using System.Threading.Tasks;

public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task> Notify;
}
using System;
using System.Threading.Tasks;

public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task> Notify;
}

서비스를 등록합니다.

  • 클라이언트 쪽 개발의 경우 클라이언트 쪽 Program 파일에 서비스를 싱글톤으로 등록합니다.

    builder.Services.AddSingleton<NotifierService>();
    builder.Services.AddSingleton<TimerService>();
    
  • 서버 쪽 개발의 경우 서버 Program 파일에서 범위로 서비스를 등록합니다.

    builder.Services.AddScoped<NotifierService>();
    builder.Services.AddScoped<TimerService>();
    

NotifierService를 사용하여 구성 요소를 업데이트합니다.

Notifications.razor:

@page "/notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<PageTitle>Notifications</PageTitle>

<h1>Notifications Example</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        _ = Task.Run(Timer.Start);
    }

    public void Dispose() => Notifier.Notify -= OnNotify;
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        _ = Task.Run(Timer.Start);
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        _ = Task.Run(Timer.Start);
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        Timer.Start();
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key != null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        Timer.Start();
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

앞의 예에서:

  • 타이머는 '와 동기화 컨텍스트 _ = Task.Run(Timer.Start)외부에서 Blazor시작됩니다.
  • NotifierService 는 구성 요소의 메서드를 OnNotify 호출합니다. InvokeAsync는 올바른 컨텍스트로 전환하고 렌더링을 큐에 대기하는 데 사용됩니다. 자세한 내용은 ASP.NET Core Razor 구성 요소 렌더링을 참조하세요.
  • 구성 요소는 IDisposable을 구현합니다. OnNotify 대리자는 구성 요소가 삭제될 때 프레임워크에서 호출되는 Dispose 메서드에서 구독 취소됩니다. 자세한 내용은 ASP.NET Core Razor 구성 요소 수명 주기를 참조하세요.
  • NotifierService는 Blazor의 동기화 컨텍스트 외부에서 구성 요소의 OnNotify 메서드를 호출합니다. InvokeAsync는 올바른 컨텍스트로 전환하고 렌더링을 큐에 대기하는 데 사용됩니다. 자세한 내용은 ASP.NET Core Razor 구성 요소 렌더링을 참조하세요.
  • 구성 요소는 IDisposable을 구현합니다. OnNotify 대리자는 구성 요소가 삭제될 때 프레임워크에서 호출되는 Dispose 메서드에서 구독 취소됩니다. 자세한 내용은 ASP.NET Core Razor 구성 요소 수명 주기를 참조하세요.

Important

Razor 구성 요소가 백그라운드 스레드에서 트리거되는 이벤트를 정의하는 경우, 처리기가 등록될 때 실행 컨텍스트(ExecutionContext)를 캡처하고 복원해야 할 수 있습니다. 자세한 정보는 InvokeAsync(StateHasChanged)를 호출하면 페이지가 기본 문화권으로 대체됩니다(dotnet/aspnetcore #28521)를 참조하세요.

백그라운드 TimerService 에서 catch된 예외를 구성 요소로 디스패치하여 일반적인 수명 주기 이벤트 예외와 같은 예외를 처리하려면 구성 요소의 수명 주기 섹션 외부에서 catch된 예외 핸들을 Razor 참조하세요.

구성 요소의 수명 주기 외부에서 catch된 Razor 예외 처리

구성 요소에서 Razor 구성 요소의 수명 주기 호출 스택 외부에서 throw된 예외를 처리하는 데 사용합니다ComponentBase.DispatchExceptionAsync. 이렇게 하면 구성 요소의 코드가 수명 주기 메서드 예외인 것처럼 예외를 처리할 수 있습니다. 그 후 Blazor오류 경계와 같은 오류 처리 메커니즘이 예외를 처리할 수 있습니다.

참고 항목

ComponentBase.DispatchExceptionAsync 는 .에서 Razor 상속되는 구성 요소 파일(.razor)에서 ComponentBase사용됩니다. 구성 요소를 만들 때는 . implement IComponent directly를 사용합니다 RenderHandle.DispatchExceptionAsync.

구성 요소의 수명 주기 외부에서 catch된 예외를 Razor 처리하려면 예외 DispatchExceptionAsync 를 전달하고 결과를 기다립니다.

try
{
    ...
}
catch (Exception ex)
{
    await DispatchExceptionAsync(ex);
}

이전 접근 방식의 일반적인 시나리오는 구성 요소가 비동기 작업을 시작하지만 대기하지 않는 Task경우이며, 메서드가 실행되고(시작됨) 메서드의 결과가 잊혀지고(버려짐) 때문에 종종 fire 및 forget 패턴이라고도 합니다. 작업이 실패하는 경우 구성 요소가 다음 목표 중 하나로 오류를 구성 요소 수명 주기 예외로 처리하도록 할 수 있습니다.

  • 예를 들어 오류 경계를 트리거하려면 구성 요소를 오류 상태로 전환합니다.
  • 오류 경계가 없으면 회로를 종료합니다.
  • 수명 주기 예외에 대해 발생하는 동일한 로깅을 트리거합니다.

다음 예제에서 사용자는 보고서 보내기 단추를 선택하여 보고서를 보내는 백그라운드 메서드 ReportSender.SendAsync를 트리거합니다. 대부분의 경우 구성 요소는 비동기 호출을 기다리고 Task 작업이 완료되었음을 나타내도록 UI를 업데이트합니다. 다음 예제에서는 메서드가 SendReport 대기 Task 하지 않고 사용자에게 결과를 보고하지 않습니다. 구성 요소가 의도적으로 in을 카드 TaskSendReport때문에 일반적인 수명 주기 호출 스택에서 비동기 오류가 발생하므로 다음에서 볼 Blazor수 없습니다.

<button @onclick="SendReport">Send report</button>

@code {
    private void SendReport()
    {
        _ = ReportSender.SendAsync();
    }
}

수명 주기 메서드 예외와 같은 오류를 처리하려면 다음 예제와 같이 예외를 구성 요소로 DispatchExceptionAsync명시적으로 다시 디스패치합니다.

<button @onclick="SendReport">Send report</button>

@code {
    private void SendReport()
    {
        _ = SendReportAsync();
    }

    private async Task SendReportAsync()
    {
        try
        {
            await ReportSender.SendAsync();
        }
        catch (Exception ex)
        {
            await DispatchExceptionAsync(ex);
        }
    }
}

대체 방법은 다음을 활용합니다.Task.Run

private void SendReport()
{
    _ = Task.Run(async () =>
    {
        try
        {
            await ReportSender.SendAsync();
        }
        catch (Exception ex)
        {
            await DispatchExceptionAsync(ex);
        }
    });
}

작업 데모의 경우 상태를 업데이트하기 위해 외부적으로 Invoke 구성 요소 메서드에서 타이머 알림 예제를 구현합니다. Blazor 앱에서 타이머 알림 예제에서 다음 파일을 추가하고 섹션에서 설명하는 대로 파일에 서비스를 Program 등록합니다.

  • TimerService.cs
  • NotifierService.cs
  • Notifications.razor

이 예제에서는 구성 요소의 수명 주기 외부에서 타이머를 Razor 사용합니다. 여기서 처리되지 않은 예외는 일반적으로 오류 경계와 같은 오류 처리 메커니즘에 의해 Blazor처리되지 않습니다.

먼저 코드를 TimerService.cs 변경하여 구성 요소의 수명 주기 외부에서 인위적인 예외를 만듭니다. 루프TimerService.cs에서 while 다음 두 값에 elapsedCount 도달하면 예외를 throw합니다.

if (elapsedCount == 2)
{
    throw new Exception("I threw an exception! Somebody help me!");
}

앱의 기본 레이아웃에 오류 경계를 배치합니다. 태그를 <article>...</article> 다음 태그로 바꿉다.

MainLayout.razor의 경우

<article class="content px-4">
    <ErrorBoundary>
        <ChildContent>
            @Body
        </ChildContent>
        <ErrorContent>
            <p class="alert alert-danger" role="alert">
                Oh, dear! Oh, my! - George Takei
            </p>
        </ErrorContent>
    </ErrorBoundary>
</article>

오류 경계가 정적 구성 요소에만 적용되는 Web Apps에서는 Blazor 정적 MainLayout 서버 쪽 렌더링(정적 SSR) 단계 중에만 경계가 활성화됩니다. 구성 요소 계층 구조 아래의 구성 요소가 대화형이기 때문에 경계가 활성화되지 않습니다. 구성 요소 계층 구조에서 구성 요소 및 나머지 구성 요소에 대해 MainLayout 광범위하게 대화형 작업을 사용하도록 설정하려면 구성 요소(Components/App.razor)의 구성 요소 인스턴스 및 Routes 구성 요소 인스턴스에 App 대해 HeadOutlet 대화형 렌더링을 사용하도록 설정합니다. 다음 예제에서는 대화형 서버(InteractiveServer) 렌더링 모드를 채택합니다.

<HeadOutlet @rendermode="InteractiveServer" />

...

<Routes @rendermode="InteractiveServer" />

이 시점에서 앱을 실행하면 경과된 수가 2의 값에 도달하면 예외가 throw됩니다. 그러나 UI는 변경되지 않습니다. 오류 경계에 오류 내용이 표시되지 않습니다.

타이머 서비스의 예외를 다시 구성 요소로 디스패치하기 위해 Notifications 구성 요소에 다음과 같은 변경 내용이 적용됩니다.

  • 문에서 타이머를 시작합니다try-catch. 블록의 catchtry-catch 절에서 예외는 결과를 전달 ExceptionDispatchExceptionAsync 하고 대기하여 구성 요소로 다시 디스패치됩니다.
  • StartTimer 메서드에서 반환Task된 타이머의 대리 Task.Run 자에서 Action 비동기 타이머 서비스를 시작하고 의도적으로 디스카드.

StartTimer 구성 요소의 Notifications 메서드(Notifications.razor):

private void StartTimer()
{
    _ = Task.Run(async () =>
    {
        try
        {
            await Timer.Start();
        }
        catch (Exception ex)
        {
            await DispatchExceptionAsync(ex);
        }
    });
}

타이머 서비스가 실행되고 2의 개수에 도달하면 예외가 구성 요소로 Razor 디스패치되고, 그러면 오류 경계가 트리거되어 구성 요소에 있는 MainLayout 오류 내용이 <ErrorBoundary> 표시됩니다.

이런! 오, 내! - 조지 테이크