블로그로 돌아가기
C#

C# async await 데드락이 발생하는 원인과 해결 방법

C#에서 async 메서드를 .Result나 .Wait()로 동기 대기할 때 데드락이 발생하는 원리를 설명합니다. 호출 체인을 await로 연결하는 해결 방법과 ConfigureAwait(false)를 적용해야 하는 조건도 함께 정리합니다.

C#asyncawait데드락SynchronizationContextConfigureAwait
C# async await 데드락이 발생하는 원인과 해결 방법

C# async await 데드락이 발생하는 원인과 해결 방법

C# 비동기 메서드를 호출한 뒤 .Result.Wait()로 결과를 기다리면 프로그램이 멈춘 것처럼 보일 수 있습니다. 비동기 작업이 느려서가 아니라, 현재 스레드는 작업 완료를 기다리고 작업의 나머지 코드는 다시 현재 스레드에서 실행되기를 기다리는 순환 대기가 만들어졌기 때문입니다.

가장 먼저 적용할 해결책은 .Result.Wait()await로 바꾸는 것입니다. 호출 메서드만 수정해서 끝내지 않고, 비동기 작업을 시작하는 최상위 지점까지 호출 체인을 비동기로 연결해야 합니다.

적용 범위

  • 언어: C#
  • 주요 형식: Task, Task<T>
  • 주요 문법: async, await
  • 관련 개념: SynchronizationContext, 컨티뉴에이션
  • 주요 발생 환경: Windows Forms, WPF, .NET MAUI와 같은 단일 UI 스레드 환경
  • 추가 발생 환경: 이전 ASP.NET의 요청 컨텍스트
  • 별도 확인이 필요한 환경: ASP.NET Core, 콘솔 애플리케이션
  • 버전 조건: 특정 C# 또는 .NET 버전을 전제로 하지 않습니다.

콘솔 애플리케이션은 기본적으로 별도의 SynchronizationContext를 설치하지 않습니다. 따라서 UI 코드와 같은 예제를 콘솔에서 실행하면 데드락이 재현되지 않을 수 있습니다.

ASP.NET Core도 일반적인 UI 애플리케이션과 실행 환경이 다릅니다. 다만 .Result.Wait()로 스레드를 차단하면 스레드 풀 고갈과 응답 지연이 발생할 수 있으므로 비동기 호출 체인을 유지해야 합니다.

문제 상황

다음 Windows Forms 이벤트 처리기는 비동기 메서드의 결과를 .Result로 가져옵니다.

데드락이 발생할 수 있는 코드

private void loadButton_Click(object? sender, EventArgs e) { string message = LoadMessageAsync().Result; statusLabel.Text = message; } private static async Task<string> LoadMessageAsync() { await Task.Delay(1000); return "작업 완료"; }

버튼을 클릭하면 다음 현상이 발생할 수 있습니다.

  • 버튼 클릭 이벤트가 끝나지 않습니다.
  • 화면이 다시 그려지지 않습니다.
  • 창을 이동하거나 닫는 동작에 응답하지 않습니다.
  • statusLabel의 값이 변경되지 않습니다.
  • 예외나 오류 메시지가 표시되지 않을 수 있습니다.

데드락은 예외가 아니라 서로의 완료를 기다리는 상태입니다. 따라서 명확한 오류 메시지 없이 애플리케이션이 멈춘 것처럼 보일 수 있습니다.

async await 데드락이 발생하는 원리

데드락이 발생하려면 비동기 작업과 현재 스레드 사이에 순환 대기가 만들어져야 합니다.

1. UI 스레드가 이벤트 처리기를 실행합니다

Windows Forms와 WPF 같은 UI 프레임워크는 UI 컨트롤을 하나의 UI 스레드에서 관리합니다.

private void loadButton_Click(object? sender, EventArgs e) { string message = LoadMessageAsync().Result; }

이 이벤트 처리기도 UI 스레드에서 실행됩니다.

2. 비동기 메서드가 현재 컨텍스트를 캡처합니다

LoadMessageAsync()await Task.Delay(1000)에 도달합니다.

private static async Task<string> LoadMessageAsync() { await Task.Delay(1000); return "작업 완료"; }

Task.Delay()가 아직 끝나지 않았다면 LoadMessageAsync()는 완료되지 않은 Task<string>을 호출자에게 반환합니다.

기본 await는 현재 SynchronizationContext가 있으면 해당 컨텍스트를 캡처합니다. UI 스레드에서 호출했다면 await 이후의 코드도 UI 컨텍스트에서 이어서 실행하려고 합니다.

return "작업 완료";

이 코드는 지연 작업이 끝난 뒤 UI 스레드에서 실행될 차례를 기다립니다.

3. Result가 UI 스레드를 차단합니다

호출자는 반환된 작업에 .Result를 사용합니다.

string message = LoadMessageAsync().Result;

.Result는 작업이 완료될 때까지 현재 스레드를 동기적으로 차단합니다.

현재 스레드는 UI 스레드이므로 UI 메시지 처리도 함께 중단됩니다.

4. 비동기 메서드의 나머지 코드가 UI 스레드를 기다립니다

Task.Delay()가 완료되면 LoadMessageAsync()await 다음 코드를 실행해야 합니다.

하지만 해당 코드는 캡처한 UI 컨텍스트에서 실행되도록 예약되어 있습니다. UI 스레드는 .Result에서 작업 완료를 기다리느라 차단된 상태입니다.

두 대기는 다음과 같이 연결됩니다.

  1. UI 스레드는 LoadMessageAsync()의 완료를 기다립니다.
  2. LoadMessageAsync()는 UI 스레드에서 나머지 코드를 실행할 차례를 기다립니다.
  3. UI 스레드는 작업이 완료되기 전까지 차단을 풀지 않습니다.
  4. 비동기 작업은 UI 스레드가 비기 전까지 완료되지 않습니다.

이 순환 대기 때문에 데드락이 발생합니다.

해결 방법 1: 호출 체인을 끝까지 await로 연결하기

이 방법이 적합한 경우

다음 조건이라면 .Result.Wait()를 제거하고 await를 사용합니다.

  • 호출 메서드를 async로 변경할 수 있습니다.
  • UI 이벤트 처리기에서 비동기 작업을 호출합니다.
  • ASP.NET Core 컨트롤러나 엔드포인트를 수정할 수 있습니다.
  • 내부 서비스 메서드의 반환 형식을 Task 또는 Task<T>로 변경할 수 있습니다.
  • 호출 스택 전체를 직접 관리할 수 있습니다.

수정 코드

Windows Forms 이벤트 처리기는 프레임워크가 void 반환 형식을 요구합니다. 이벤트 처리기에 한해서 async void를 사용하고 내부 작업은 await로 기다립니다.

private async void loadButton_Click(object? sender, EventArgs e) { loadButton.Enabled = false; try { string message = await LoadMessageAsync(); statusLabel.Text = message; } finally { loadButton.Enabled = true; } } private static async Task<string> LoadMessageAsync() { await Task.Delay(1000); return "작업 완료"; }

await는 UI 스레드를 동기적으로 차단하지 않습니다. 비동기 작업이 진행되는 동안 이벤트 처리기는 제어권을 UI 메시지 루프에 반환합니다.

Task.Delay()가 완료되면 이벤트 처리기의 나머지 코드가 UI 스레드에서 다시 실행됩니다.

statusLabel.Text = message;

UI 스레드가 차단되지 않았으므로 컨티뉴에이션이 실행될 수 있습니다.

이벤트 처리기가 아닌 메서드 수정하기

이벤트 처리기가 아닌 일반 메서드는 async void가 아니라 Task 또는 Task<T>를 반환해야 합니다.

수정 전 코드는 다음과 같습니다.

public void RefreshData() { string data = LoadDataAsync().Result; SaveData(data); }

호출자가 작업 완료 여부와 예외를 추적할 수 있도록 Task를 반환합니다.

public async Task RefreshDataAsync() { string data = await LoadDataAsync(); SaveData(data); }

호출자도 해당 메서드를 await로 기다립니다.

await service.RefreshDataAsync();

중간 메서드에서 다시 .Result를 사용하면 같은 문제가 발생할 수 있습니다.

public void Run() { service.RefreshDataAsync().Wait(); }

비동기 호출 체인은 최상위 진입 지점까지 연결해야 합니다.

public async Task RunAsync() { await service.RefreshDataAsync(); }

동작 원리

await는 작업이 완료될 때까지 현재 스레드를 점유하지 않습니다.

작업이 아직 완료되지 않았다면 비동기 메서드는 호출자에게 Task를 반환합니다. 현재 스레드는 다른 작업을 처리할 수 있습니다.

비동기 작업이 끝나면 런타임이 await 이후의 코드를 실행합니다. UI 애플리케이션에서 컨텍스트를 캡처했다면 UI 스레드에서 계속 실행합니다.

UI 스레드가 .Result.Wait()로 차단되지 않았으므로 순환 대기가 만들어지지 않습니다.

주의사항

이벤트 처리기 이외의 메서드에 async void를 사용하면 호출자가 작업을 기다릴 수 없습니다.

public async void RefreshDataAsync() { await LoadDataAsync(); }

호출자는 다음 내용을 확인하기 어렵습니다.

  • 작업이 언제 끝났는가
  • 작업이 실패했는가
  • 어떤 예외가 발생했는가
  • 테스트에서 작업 완료를 어떻게 기다려야 하는가

일반적인 비동기 메서드는 TaskTask<T>를 반환합니다.

public async Task RefreshDataAsync() { await LoadDataAsync(); }

해결 방법 2: 라이브러리 코드에서 ConfigureAwait(false) 사용하기

이 방법이 적합한 경우

ConfigureAwait(false)는 다음 조건을 만족하는 재사용 라이브러리 코드에서 검토합니다.

  • await 이후에 UI 컨트롤을 변경하지 않습니다.
  • 호출자의 SynchronizationContext로 돌아갈 필요가 없습니다.
  • 메서드가 특정 애플리케이션 프레임워크에 의존하지 않습니다.
  • 데이터 변환이나 I/O 결과 반환이 메서드의 주된 역할입니다.
  • 호출자가 UI, 서버, 테스트 환경 중 어디에서 실행할지 알 수 없습니다.

수정 코드

public static async Task<string> LoadMessageAsync() { await Task.Delay(1000).ConfigureAwait(false); return "작업 완료"; }

ConfigureAwait(false)를 적용하면 await 이후의 코드는 호출 당시의 SynchronizationContext로 복귀하도록 강제되지 않습니다.

컨티뉴에이션은 작업을 계속 실행할 수 있는 스레드에서 실행됩니다.

UI 코드와 라이브러리 코드를 분리하기

컨텍스트 복귀가 필요 없는 서비스 메서드에서는 ConfigureAwait(false)를 사용할 수 있습니다.

public sealed class MessageService { public async Task<string> LoadMessageAsync() { await Task.Delay(1000).ConfigureAwait(false); return "작업 완료"; } }

UI 이벤트 처리기는 서비스 메서드를 일반 await로 호출합니다.

private async void loadButton_Click(object? sender, EventArgs e) { string message = await messageService.LoadMessageAsync(); statusLabel.Text = message; }

서비스 메서드는 UI 컨텍스트에 의존하지 않습니다. 이벤트 처리기는 자신의 await에서 UI 컨텍스트를 캡처하므로 statusLabel을 UI 스레드에서 안전하게 변경할 수 있습니다.

ConfigureAwait(false)가 기본 해결책이 아닌 이유

다음처럼 호출자가 .Result를 계속 사용하는 상태에서 내부 메서드에 ConfigureAwait(false)를 추가하면 특정 데드락을 피할 수 있습니다.

private void loadButton_Click(object? sender, EventArgs e) { string message = LoadMessageAsync().Result; statusLabel.Text = message; } private static async Task<string> LoadMessageAsync() { await Task.Delay(1000).ConfigureAwait(false); return "작업 완료"; }

하지만 이 구조에는 다음 문제가 남습니다.

  • UI 스레드는 작업이 끝날 때까지 차단됩니다.
  • 화면이 비동기 작업 중 응답하지 않습니다.
  • 내부 호출 중 하나라도 컨텍스트를 다시 캡처하면 데드락이 재발할 수 있습니다.
  • 호출 스택이 변경되면 안전하다는 보장이 깨질 수 있습니다.
  • 동기 대기로 인한 예외 처리와 성능 문제가 남습니다.

따라서 애플리케이션 코드는 .Result를 유지한 채 ConfigureAwait(false)에 의존하지 않습니다. 호출 체인을 await로 변경하는 것이 우선입니다.

주의사항

UI 컨트롤을 직접 변경해야 하는 코드에 무조건 ConfigureAwait(false)를 붙이면 await 이후 코드가 UI 스레드가 아닌 곳에서 실행될 수 있습니다.

private async void loadButton_Click(object? sender, EventArgs e) { await Task.Delay(1000).ConfigureAwait(false); // UI 스레드가 아닐 수 있습니다. statusLabel.Text = "작업 완료"; }

Windows Forms와 WPF의 UI 컨트롤은 일반적으로 생성된 UI 스레드에서 접근해야 합니다.

UI 업데이트가 필요한 애플리케이션 계층에서는 기본 await를 사용합니다. 컨텍스트 의존성이 없는 라이브러리 계층에서만 ConfigureAwait(false)를 적용합니다.

해결 방법 3: 동기 호출 경계를 바꿀 수 없을 때 격리하기

이 방법이 적합한 경우

다음과 같이 동기 메서드 시그니처를 즉시 변경할 수 없는 경우가 있습니다.

  • 외부 프레임워크가 동기 인터페이스만 제공합니다.
  • 오래된 코드의 공개 API를 바로 변경할 수 없습니다.
  • 애플리케이션 시작 단계의 제한된 동기 경계입니다.
  • 마이그레이션 과정에서 임시 어댑터가 필요합니다.

가능하면 비동기 API와 동기 API를 분리합니다.

public Task<string> LoadMessageAsync() { return messageService.LoadMessageAsync(); }

동기 API를 반드시 유지해야 한다면 비동기 작업을 별도의 스레드 풀 컨텍스트에서 시작하는 어댑터를 검토할 수 있습니다.

public string LoadMessage() { return Task .Run(() => messageService.LoadMessageAsync()) .GetAwaiter() .GetResult(); }

Task.Run() 내부는 일반적으로 UI의 SynchronizationContext를 캡처하지 않습니다. 따라서 비동기 메서드가 UI 컨텍스트로 돌아오기를 기다리는 고전적인 순환 대기를 끊을 수 있습니다.

주의사항

이 방법은 비동기 작업을 진정한 동기 작업으로 바꾸지 않습니다. 호출 스레드는 작업이 끝날 때까지 계속 차단됩니다.

다음 환경에서는 일반적인 해결책으로 사용하지 않습니다.

  • UI 이벤트 처리기
  • ASP.NET Core 요청 처리 코드
  • 호출 빈도가 높은 서버 코드
  • 대량의 동시 요청을 처리하는 경로
  • await를 사용할 수 있는 메서드

ASP.NET Core에서 Task.Run()으로 비동기 코드를 감싸면 스레드 풀 예약만 추가될 수 있습니다. 서버 코드는 컨트롤러부터 데이터 접근 계층까지 비동기 호출 체인을 유지하는 편이 낫습니다.

이 방식은 동기 경계를 제거할 수 없는 제한된 위치에서만 사용하고, 장기적으로 비동기 API로 이전해야 합니다.

피해야 하는 해결 방법

Result를 GetAwaiter().GetResult()로만 바꾸기

다음 변경은 예외 전달 방식에는 차이가 있지만 데드락 원인을 제거하지 않습니다.

string message = LoadMessageAsync() .GetAwaiter() .GetResult();

GetAwaiter().GetResult()도 작업이 완료될 때까지 현재 스레드를 차단합니다.

호출 스레드와 컨티뉴에이션 사이에 순환 대기가 있다면 데드락 위험은 그대로 남습니다.

Wait에 타임아웃 추가하기

bool completed = LoadMessageAsync().Wait(3000);

타임아웃은 영구적으로 멈추는 현상을 제한할 수 있지만 비동기 호출 구조를 해결하지 않습니다.

작업이 타임아웃 이후에도 실행 중일 수 있으며 호출자는 작업 결과와 예외를 별도로 처리해야 합니다.

모든 비동기 호출을 Task.Run으로 감싸기

string message = await Task.Run( () => messageService.LoadMessageAsync() );

이미 비동기 I/O를 수행하는 메서드를 Task.Run()으로 감쌀 필요는 없습니다.

string message = await messageService.LoadMessageAsync();

Task.Run()은 CPU를 오래 사용하는 동기 작업을 UI 스레드에서 분리할 때 검토합니다. 비동기 I/O 호출의 데드락을 숨기는 용도로 반복해서 사용하지 않습니다.

UI 코드 전체에 ConfigureAwait(false) 적용하기

ConfigureAwait(false)는 호출자의 컨텍스트가 필요 없는 코드에 적용합니다.

UI 이벤트 처리기의 모든 await에 기계적으로 추가하면 UI 컨트롤 접근을 위해 다시 디스패치해야 합니다. 코드의 실행 위치를 추적하기도 어려워집니다.

일반 메서드에 async void 사용하기

public async void SaveAsync() { await repository.SaveAsync(); }

이벤트 처리기가 아닌 메서드는 Task를 반환해야 합니다.

public async Task SaveAsync() { await repository.SaveAsync(); }

해결 방법 비교

방법적합한 상황스레드 차단데드락 위험주의사항
호출 체인을 await로 연결수정 가능한 애플리케이션 코드없음가장 낮음최상위 진입 지점까지 비동기로 변경해야 합니다.
ConfigureAwait(false)컨텍스트가 필요 없는 라이브러리 코드없음호출자의 동기 대기 위험을 줄일 수 있음UI 접근이 필요한 코드에는 적용하지 않습니다.
Task.Run()으로 동기 경계 격리변경할 수 없는 제한된 동기 API있음고전적인 컨텍스트 데드락을 피할 수 있음서버와 UI의 일반 실행 경로에는 권장하지 않습니다.
.Result, .Wait() 유지권장하지 않음있음높음데드락과 스레드 풀 고갈이 발생할 수 있습니다.
GetAwaiter().GetResult()만 사용예외 래핑을 피해야 하는 동기 경계있음남아 있음데드락 해결책으로 사용하면 안 됩니다.

어떤 방법을 선택해야 하는가

UI 이벤트 처리기에서 비동기 메서드를 호출한다면 이벤트 처리기를 async로 변경하고 await를 사용합니다.

private async void loadButton_Click(object? sender, EventArgs e) { string message = await LoadMessageAsync(); statusLabel.Text = message; }

이벤트 처리기에서 호출하는 서비스와 저장소 메서드도 Task 또는 Task<T>를 반환하도록 연결합니다.

public async Task<string> LoadMessageAsync() { return await repository.LoadMessageAsync(); }

재사용 라이브러리에서 await 이후 호출자의 UI나 요청 컨텍스트가 필요하지 않다면 ConfigureAwait(false)를 검토합니다.

string data = await client .GetStringAsync(requestUri) .ConfigureAwait(false);

ASP.NET Core 컨트롤러나 엔드포인트에서는 .Result, .Wait()와 불필요한 Task.Run()을 제거합니다.

public async Task<IResult> GetMessageAsync() { string message = await messageService.LoadMessageAsync(); return Results.Ok(message); }

동기 인터페이스를 변경할 수 없다면 동기와 비동기의 경계를 한 곳으로 제한합니다. 해당 어댑터가 호출 스레드를 차단한다는 점을 문서화하고 호출 빈도가 높은 경로에서는 사용하지 않습니다.

자주 발생하는 추가 문제

Task.WaitAll을 사용한 코드가 멈추는 경우

다음 코드는 현재 스레드를 차단합니다.

Task.WaitAll(firstTask, secondTask);

비동기 메서드 안에서는 Task.WhenAll()await합니다.

await Task.WhenAll(firstTask, secondTask);

결과가 필요하다면 작업 완료 후 각각의 결과를 가져옵니다.

await Task.WhenAll(firstTask, secondTask); string first = await firstTask; string second = await secondTask;

생성자에서 비동기 초기화를 기다리는 경우

생성자는 async로 선언할 수 없습니다. 생성자에서 .Result를 사용하면 호출 환경에 따라 데드락이 발생할 수 있습니다.

public ReportService() { _template = LoadTemplateAsync().Result; }

비동기 팩터리 메서드를 사용해 생성과 초기화를 분리할 수 있습니다.

public sealed class ReportService { private readonly string _template; private ReportService(string template) { _template = template; } public static async Task<ReportService> CreateAsync() { string template = await LoadTemplateAsync(); return new ReportService(template); } private static async Task<string> LoadTemplateAsync() { await Task.Delay(100); return "template"; } }

호출자는 생성 결과를 await합니다.

ReportService service = await ReportService.CreateAsync();

lock 내부에서 비동기 작업을 기다리는 경우

lock 블록 안에서는 await를 사용할 수 없습니다. .Wait().Result로 대신하면 잠금을 보유한 채 스레드를 차단할 수 있습니다.

비동기 동기화가 필요하면 SemaphoreSlim.WaitAsync()를 검토합니다.

private readonly SemaphoreSlim _gate = new(1, 1); public async Task UpdateAsync() { await _gate.WaitAsync(); try { await SaveChangesAsync(); } finally { _gate.Release(); } }

SemaphoreSlim을 사용하는 동안에도 잠금 범위를 필요한 코드로 제한해야 합니다.

콘솔에서는 같은 코드가 멈추지 않는 경우

콘솔 애플리케이션은 기본적으로 UI와 같은 단일 스레드 SynchronizationContext가 없습니다.

다음 코드가 콘솔에서 완료됐다고 해서 UI 애플리케이션에서도 안전하다는 의미는 아닙니다.

string message = LoadMessageAsync().Result;

실제 배포 환경이 Windows Forms, WPF, .NET MAUI 또는 이전 ASP.NET이라면 해당 환경에서 다시 검증해야 합니다.

ASP.NET Core에서 데드락은 없지만 응답이 느려지는 경우

ASP.NET Core에서는 UI 컨텍스트와 같은 순환 대기가 재현되지 않을 수 있습니다. 그러나 다음 코드는 요청을 처리하는 스레드를 차단합니다.

string message = messageService .LoadMessageAsync() .Result;

동시 요청이 증가하면 사용할 수 있는 스레드가 줄어듭니다. 작업 완료를 처리할 스레드까지 부족해지면 응답 시간이 늘어나고 애플리케이션이 멈춘 것처럼 보일 수 있습니다.

컨트롤러와 서비스 호출을 비동기로 유지합니다.

string message = await messageService.LoadMessageAsync();

동작 확인

Windows 환경에서 Windows Forms 프로젝트를 생성합니다.

dotnet new winforms -n AsyncDeadlockSample cd AsyncDeadlockSample

폼에 loadButtonstatusLabel을 추가한 뒤 버튼 이벤트 처리기에 데드락 코드를 연결합니다.

private void loadButton_Click(object? sender, EventArgs e) { statusLabel.Text = "대기 중"; string message = LoadMessageAsync().Result; statusLabel.Text = message; } private static async Task<string> LoadMessageAsync() { await Task.Delay(1000); return "작업 완료"; }

예상되는 현상은 버튼을 클릭한 뒤 UI가 응답하지 않고 작업 완료가 표시되지 않는 것입니다.

프로세스를 종료한 뒤 이벤트 처리기를 다음 코드로 변경합니다.

private async void loadButton_Click(object? sender, EventArgs e) { statusLabel.Text = "대기 중"; loadButton.Enabled = false; try { string message = await LoadMessageAsync(); statusLabel.Text = message; } finally { loadButton.Enabled = true; } }

예상되는 현상은 버튼을 클릭한 뒤 UI가 계속 응답하며 약 1초 후 작업 완료가 표시되는 것입니다.

현재 작성 환경에는 .NET SDK가 설치되어 있지 않아 예제 코드를 직접 빌드하거나 실행하지 못했습니다. 발행 전 실제 Windows Forms 환경에서 다음 명령으로 확인해야 합니다.

dotnet build dotnet run

[실행 검증 필요]

정리

C# asyncawait 데드락은 비동기 작업을 시작한 스레드를 .Result.Wait()로 차단하고, 작업의 컨티뉴에이션이 다시 같은 스레드에서 실행되기를 기다릴 때 발생합니다.

애플리케이션 코드는 호출 체인을 최상위 진입 지점까지 await로 연결하는 방법을 우선합니다. 재사용 라이브러리에서 호출자의 컨텍스트가 필요하지 않을 때는 ConfigureAwait(false)를 적용할 수 있습니다.

동기 호출 경계를 변경할 수 없는 경우에만 별도의 어댑터를 검토합니다. GetAwaiter().GetResult()로 메서드 이름만 바꾸거나 모든 호출을 Task.Run()으로 감싸는 방식은 기본 해결책이 아닙니다.

콘솔에서 문제가 재현되지 않더라도 UI 애플리케이션과 서버 환경에서는 실행 구조가 다릅니다. 실제 사용하는 프레임워크에서 스레드 차단, 컨텍스트 캡처와 호출 체인을 함께 확인해야 합니다.