C# 최소 API의 전역 오류 처리
[[academy-video-youtube({"vid": "B5NsgtdwOlg", "start_time": "0", "title": "C# 최소 API에서의 글로벌 오류 처리", "creator": "Tim Corey", "length": "13m 30s"})]]
처리되지 않은 예외를 발생시키는 웹 API는 기본적으로 개발자가 로컬에서 디버그하는 데 도움이 되는 오류 페이지와 외부인이 호출 스택을 매핑하는 데 도움이 되는 오류 페이지를 반환합니다. 줄 번호, 유형 이름, 소스 파일 경로는 요청한 사람에게 모두 돌아갑니다. 오류가 발생할 수 있는 최종 지점에서 모든 오류를 잡는 것이 올바른 접근 방식이지만, 이는 다음에 잊힌 try/catch까지 작동할 뿐입니다. 글로벌 핸들러는 엔드포인트가 놓친 것을 잡아내는 안전망입니다.
그의 비디오 "C# Minimal API에서의 전역 오류 처리"에서, Tim Corey는 고의적으로 잘못된 엔드포인트가 있는 작은 최소 API를 구축하고, 보호 없이 반환되는 개발자 오류 페이지를 시연한 후, app.UseExceptionHandler을 연결하여 잡히지 않은 예외를 인터셉트하고 일반적인 500 응답을 보낸다. 또한 엔드포인트 수준의 처리가 왜 선호되는 경로로 남아 있는지 강화한다: 전역 핸들러는 백업 수단이지 전략이 아니다. 최소 API를 배포하면서 어떤 스택 트레이스도 서버를 벗어나지 않도록 하고자 하는 사람은 아래에서 미들웨어 설정 및 그에 따른 설계 논리를 찾을 수 있습니다.
고장난 엔드포인트를 가진 최소 API 빌드하기
[1:08 - 3:01] 팀은 ErrorDemoApp이라는 새로운 .NET 8 ASP.NET Core Web API 프로젝트로 시작합니다. 프로젝트 템플릿 옵션은 기본 설정에 가깝게 유지됩니다: HTTPS 켜짐, OpenAPI 켜짐, 인증 없음, 상위 수준 문장은 활성화된 상태로 남기고, 최소 API이기 때문에 컨트롤러 체크박스는 체크 해제 상태입니다. 생성된 Program.cs는 Swagger를 유지하지만, 날씨 예보 샘플 엔드포인트와 그 레코드는 삭제되어 파일에는 기본 사항만 표시됩니다.
샘플 대신, 그는 실패하는 것이 목적 전체인 /demo에 단일 엔드포인트를 추가합니다:
app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});Ctrl+F5(디버깅 없이 시작)로 프로젝트를 실행하면 Visual Studio 디버거가 throw를 가로채지 않으므로 실제 HTTP 호출자에게 나타나는 방식으로 실패가 드러납니다. Swagger가 열리고, /demo 엔드포인트만 사용 가능하며, 이를 실행하면 500 응답이 반환됩니다. 응답 본문에는 예외 유형, 메시지, 그리고 Program.cs의 18번 라인에 대한 참조가 포함되어 있습니다.
기본 오류 페이지가 구현 세부사항을 누출하는 이유
[3:01 - 5:00] 브라우저에서 직접 /demo를 입력하면 (?message= Swagger 래퍼 없이) JSON 응답 대신 개발자 예외 페이지가 표시됩니다. 페이지는 예외 이름, 메시지, throw가 발생한 파일 경로 및 줄 번호, 원시 예외 세부 정보, 그리고 throw 위의 스택 프레임을 렌더링합니다. 로컬에서 작업하는 개발자에게 이것은 금과 같습니다. 다른 모든 사람에게는 코드베이스의 무료 지도입니다.
팀의 요점은 꾸미지 않고 전달됩니다: 이 페이지는 개발자들을 돕기 위해 존재하며, 절대로 최종 사용자에게 도달해서는 안 됩니다. 그것이 가끔 발생한다는 사실이 글로벌 핸들러가 중요한 이유입니다. 모든 엔드포인트를 예외 처리로 꼼꼼하게 감싸는 팀조차 결국 하나를 놓치게 되며, 그 하나를 놓침으로 인해 발생하는 비용은 요청한 사람에게 전체 스택 추적이 제공되는 것입니다.
종단점에서 먼저 오류 잡기
[5:00 - 6:30] 글로벌 핸들러를 설치하기 전에, Tim은 선호되는 경로를 설명하기 위해 데모 엔드포인트를 try/catch로 감쌉니다. 핸들러는 발생된 모든 예외에 대해 Results.BadRequest(ex.Message)을 반환한다:
app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});결과는 메시지 문자열만 포함한 400입니다. 스택 트레이스 없음, 파일 경로 없음, 줄 번호 없음. 메시지 자체를 노출할지 여부는 응용 프로그램에 따라 다릅니다; 공용 API의 경우 메시지조차도 팀이 원하는 것보다 더 많이 노출될 수 있으므로 핸들러가 일반 문자열로 대체하게 됩니다. 로컬 캐치로 엔드포인트는 호출자가 보는 내용을 완전히 제어할 수 있습니다. 여기에는 장애 모드가 실제로 알려져 있을 때 500보다 더 구체적인 상태 코드를 반환하는 선택이 포함됩니다.
이 패턴이 할 수 없는 것은 엔드포인트가 감싸는 것을 잊은 것을 잡아내는 것이다. 새로운 코드 경로, 더 깊은 층에서의 재투척, 별도의 스레드에서 던지는 Task는 모두 엔드포인트의 try/catch를 우회한다. 그것이 미들웨어가 채우는 격차입니다.
UseExceptionHandler 미들웨어 연결하기
[6:30 - 10:00] app.UseHttpsRedirection() 줄 바로 아래에서, 핸들러가 app.UseExceptionHandler에 등록된다. 빌더 액션을 받는 오버로드는 기본 파이프라인을 노출하여 핸들러가 응답 형태를 명시적으로 설정할 수 있게 합니다:
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});해당 블록의 몇 가지 선택 사항은 중요합니다. 상태 코드를 500으로 강제 설정하는 것은 호출자가 응답 번호로부터 아무것도 유추할 수 없음을 의미합니다; 내부 예외 유형이 무엇이든 표면은 동일해 보입니다. API의 응답과 일치하도록 콘텐츠 유형을 application/json으로 강제 설정하면 클라이언트가 하나의 파서를 사용할 수 있습니다. IExceptionHandlerFeature은 실제 핸들러가 로그할 수 있도록 원본 예외를 노출한다; Tim은 여기서 프로젝트가 실제로 사용할 로거 대신 Console.WriteLine을 사용한다.
최종 WriteAsJsonAsync 호출은 상태 코드와 일반 메시지가 있는 익명 객체를 반환한다. 본문에는 무엇이 실패했는지에 대한 내용이 전혀 없고 단지 무언가가 실패했다는 것만 언급되어 있습니다. 이는 핵심입니다. 내부 진단은 응답이 아닌 로그에 포함되어야 합니다.
처리된 경로와 미처리된 경로 테스트
[10:00 - 13:14] try/catch가 여전히 있는 상태에서, 엔드포인트는 로컬 경로를 실행합니다: Swagger는 "이것은 데모 예외입니다"라는 400을 표시합니다. 미들웨어는 throw를 보지 못합니다. 왜냐하면 catch 블록이 먼저 이를 해결하기 때문입니다. 이것은 Tim이 기본적으로 원하는 설계입니다. 로컬 핸들러는 그들의 작업을 수행하고, 글로벌 핸들러는 대기 상태에 있습니다.
try-catch를 제거하고 다시 실행하면 폴백이 작동합니다. 이제 같은 요청이 JSON 본문 { "statusCode": 500, "message": "Internal Server Error" }을 포함한 500을 반환한다. 응답에서는 예외가 발생한 위치나 예외 유형에 대한 정보를 제공하지 않습니다. 그러나 Visual Studio 콘솔 창은 파일 경로 및 줄 번호를 포함하여 Console.WriteLine 플레이스홀더를 통해 로그된 원본 예외 텍스트를 보여준다. 전체 진단은 개발자가 읽을 수 있는 곳에 남아 있습니다. 응답이 유출될 수 없는 곳에 머무릅니다.
이 패턴은 최소 API에서 유효성 검사 미들웨어, 사용자 지정 인증 또는 기타 파이프라인 구성 요소로 이어집니다. 예외 처리기는 파이프라인의 초기에 위치하며, 이후 단계에서 발생하는 모든 사항을 잡아냅니다.
마무리: 다층 방어
[13:14 - 13:30] 로컬 처리와 글로벌 처리 방식은 대체 가능한 것이 아닙니다; 그들은 레이어입니다. 로컬 핸들러는 실패 모드를 알 때 엔드포인트가 의미 있는 응답을 할 수 있는 기회를 제공합니다. 전역 핸들러는 로컬 레이어에서 놓친 모든 사항에 대해 일관되고 일반적이며 안전한 응답을 생성하도록 보장합니다. 컨트롤러 기반 API는 약간의 구문 조정으로 동일한 아이디어를 사용하지만, 최소 API 형식이 표면적이 충분히 작아 하나의 파일에서 전체 그림을 볼 수 있기 때문에 먼저 익숙해질 가치가 있습니다.
결론
[13:14 - 13:30] 최소 API에서 전역 오류 핸들러를 설정하는 것은 세 가지 단계다: 파이프라인 초기에 UseExceptionHandler를 등록하고, 핸들러 내부에서 응답 상태 및 콘텐츠 유형을 설정하며, 구현 세부 사항이 밖으로 나오지 않도록 고의적으로 일반적인 본문을 작성한다. 가장 실패할 가능성이 높은 코드 경로 주위에 try/catch 블록을 추가하면, 글로벌 핸들러가 전략이 아니라 안전망 역할을 하는 심층 방어 모델을 갖추게 됩니다.
예제 팁: 핸들러가 실제 로거에 호출할 때 전체 예외 객체(메시지만이 아닌)를 전달하여 구조화된 로깅 파이프라인이 유형, 스택, 그리고 내포된 모든 예외를 캡처하도록 하십시오. Serilog과 같은 로거는 모든 것을 쿼리 가능한 속성으로 보존하여, 프로덕션 500에 대한 알림이 발생하면 누구든지 요청을 다시 실행하지 않고도 로컬에서 재현할 수 있는 충분한 컨텍스트를 제공합니다.
전체 비디오를 그의 YouTube 채널에서 시청하고 10-Minute Training 시리즈에서 실전에 준비된 미니멀 API를 구축하는 방법에 대한 더 많은 인사이트를 얻으세요.

