
.NET 스택 트레이스(Stack Traces)를 뚫어지게 쳐다보는 일을 멈추세요: Visual Studio 내부의 Claude로 디버깅하기
요약
Visual Studio 내부에 통합된 Claude를 활용하여 복잡한 .NET 스택 트레이스를 효율적으로 디버깅하는 방법을 소개합니다. 비동기 상태 머신이나 중첩된 예외로 인해 읽기 어려운 스택 트레이스를 AI가 분석하여 실제 원인을 찾아줍니다.
핵심 포인트
- 비동기 코드의 복잡한 상태 머신 프레임 분석 지원
- 중첩된 내부 예외(Inner Exceptions)의 핵심 원인 식별
- Visual Studio 내 Claude를 통한 논리적 해결책 제시
- 스택 트레이스 분석 시간 단축 및 디버깅 정확도 향상
.NET 스택 트레이스(Stack Traces)를 뚫어지게 쳐다보는 일을 멈추세요: Visual Studio 내부의 Claude로 디버깅하기
처리되지 않은 예외(unhandled exception)가 발생하면, Visual Studio는 40줄에 달하는 스택 트레이스(stack trace)를 당신의 앞에 던져줍니다. 그중 38줄은 Microsoft.EntityFrameworkCore.* 또는 System.Private.CoreLib에 속해 있고, 하나는 <<Main>$>b__0_0 같은 이름을 가진 비동기 상태 머신(async state-machine) 프레임이며, 정확히 단 한 줄만이 당신이 작성한 코드를 가리킵니다. 당신의 임무는 그 한 줄을 찾아내어, 그것이 왜 터졌는지 이해하고, 증상이 아닌 실제 원인을 수정하는 것입니다.
우리 모두는 스택 트레이스를 검색 엔진에 붙여넣었다가 2015년에 작성된 Stack Overflow 질문 하나를 발견한 적이 있습니다. 답변은 0개이고, 그저 _"이 문제를 해결하셨나요?"_라고 적힌 댓글 하나뿐인 그런 질문 말입니다. 더 나은 방법이 있으며, 이제 그것은 Visual Studio 내부에 바로 존재합니다. 스택 트레이스를 Claude에게 전달하면, 당신의 코드 라인과 연결된 순위가 매겨진 진단 결과와 실제로 논리적으로 이해할 수 있는 해결책을 돌려받을 수 있습니다.
.NET 스택 트레이스(Stack Traces)를 읽기 어려운 이유
스택 트레이스가 어려운 이유는 내용이 길기 때문이 아닙니다. 현대의 .NET 코드는 직선 형태로 실행되는 경우가 드물며, 트레이스는 당신의 의도가 아닌 내부 메커니즘을 반영하기 때문에 어려운 것입니다.
- 비동기 언와인딩 (Async unwinding).
await는 상태 머신 (state machine)으로 컴파일됩니다. 예외가await를 가로질러 발생할 때, 당신이 보게 되는 프레임은 당신이 작성한 깔끔한 호출 체인이 아니라MoveNext()호출과ExecutionContext.Run입니다. 메서드 이름은<SomethingAsync>d__4와 같이 표시되며, 논리적으로 예외를 던진 라인은 세 번의await위쪽에 있을 수도 있습니다. - 내부 예외 (Inner exceptions). 맨 위에 있는 메시지는 종종 가장 쓸모없는 메시지입니다. 진짜 원인은 중첩되어 있습니다. 예를 들어
SqliteException을 감싸고 있는DbUpdateException이나, 당신이 실제로 신경 써야 할 대상을 감싸고 있는TargetInvocationException같은 식입니다. 첫 줄만 읽는다면, 당신은 래퍼 (wrapper)를 디버깅하고 있는 것입니다. AggregateException.Task.WhenAll이나 병렬 처리 (parallelism)와 관련된 모든 것은 여러 개의 실패를 하나로 묶을 수 있습니다. 외부 메시지에는
Visual Studio 2022 (17.14+)에는 모델 선택기 (model picker) 기능이 포함된 GitHub Copilot이 탑재되어 있습니다. Copilot 배지를 클릭하고 채팅 프롬프트 상자의 모델 드롭다운을 열어 Claude 모델을 선택하세요. 일상적인 빠른 진단에는 Sonnet을, 버그가 정말 까다로울 때는 Opus를 선택하면 됩니다. 그 이후부터는 모든 Copilot Chat 응답이 Claude에 의해 생성됩니다.
이 방식이 디버깅에서 빛을 발하는 두 가지 이유가 있습니다:
- 디버깅 중 처리되지 않은 예외(unhandled exception)로 인해 **예외 도우미 (Exception Helper)**가 나타날 때, 해당 대화 상자에 바로 Copilot에게 질문하기 (Ask Copilot) 인라인 링크가 표시됩니다. Claude를 모델로 선택해 두었다면, 이 클릭 한 번으로 실행되는 분석은 Claude를 통해 수행됩니다.
- 채팅 상자에서
#참조를 사용하여 실제 컨텍스트를 가져올 수 있습니다 —#Program.cs,#solution, 또는 하이라이트된 선택 영역 등을 통해 Claude가 의역된 내용이 아닌 사용자의 실제 코드를 읽도록 할 수 있습니다.
Copilot의 모델을 사용하는 대신 본인의 Anthropic 키를 직접 사용하고 싶다면, 동일한 선택기에서 모델 관리 (Manage Models) → Anthropic 추가 → API 키 붙여넣기 과정을 거치면 됩니다. 이미 API 액세스 비용을 지불하고 있다면 매우 유용합니다.
2. 통합 터미널에서의 Claude Code (에이전트 방식)
아직 전체 Visual Studio를 위한 공식 Claude Code 확장 프로그램은 없지만, 굳이 필요하지도 않습니다. Claude Code는 VS 내부의 보기(View) → 터미널(Terminal) (Ctrl+``)을 포함한 모든 터미널에서 실행됩니다. claude`를 실행하고 스택 트레이스(trace)를 붙여넣으세요. Claude Code는 전체 저장소(repository)를 읽을 수 있기 때문에, 문제가 되는 파일을 직접 열고, 호출 경로(call path)를 추적하며, 사용자가 승인하거나 거부할 수 있는 diff 형태의 수정안을 제안할 것입니다.
트레이드오프(trade-off)는 간단합니다. Copilot Chat은 "이게 무슨 뜻인가요?"와 같은 빠른 질문에 더 빠르며, Claude Code는 수정 사항이 여러 파일에 걸쳐 있거나 버그를 재현하고 검증하기를 원할 때 더 적합합니다. 버그의 성격에 따라 적절한 도구를 사용하세요.
실제 사례: 잘못된 것을 지목하는 EF Core 트레이스
구체적인 사례를 디버깅해 봅시다. 여기 주문 요약(order summary)을 반환하는 .NET 10 minimal API 엔드포인트가 있습니다. 코드는 합리적으로 보이며, 컴파일도 문제없이 됩니다:
// Program.cs — .NET 10 minimal API
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext(o => o.UseSqlite("Data Source=shop.db"));
var app = builder.Build();
app.MapGet("/orders/{customerId:int}/summary", async (int customerId, ShopContext db) =>
{
// 속도를 높이기 위해 두 쿼리를 "병렬로" 실행합니다 — 맞죠?
var ordersTask = db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
var totalTask = db.Orders.Where(o => o.CustomerId == customerId).SumAsync(o => o.Total);
await Task.WhenAll(ordersTask, totalTask);
return Results.Ok(new { Orders = ordersTask.Result, Total = totalTask.Result });
});
app.Run();
엔드포인트를 호출하면 예외(exception)가 발생합니다. 다음은 Visual Studio에 표시된 트레이스(trace)입니다 (나타나는 프레임만 축약함):
System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913. at Microsoft.EntityFrameworkCore.Internal.ConcurrencyDetector.EnterCriticalSection() at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.ExecuteAsync[TResult](Expression query, CancellationToken ct) at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.ExecuteAsync[TResult](Expression expression, CancellationToken ct) at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync[TSource](IQueryable`1 source, CancellationToken ct)
at Program.<>c.<
$>b__0_0(Int32 customerId, ShopContext db) in C:\src\Shop.Api\Program.cs:line 12
그 다섯 개의 프레임 중 네 개는 EF Core 내부 코드입니다. 여러분의 코드인 Program.cs:line 12는 ToListAsync() 호출 부분이며, 이 때문에 마치 ToListAsync가 범인인 것처럼 보이게 만듭니다. 하지만 범인이 아닙니다.
Claude에게 올바르게 전달하는 방법
답변의 품질은 전적으로 여러분이 무엇을 붙여넣느냐에 달려 있습니다. 한 줄짜리 메시지를 보내면 한 줄짜리 추측만 돌아옵니다. Claude에게 전체 그림을 제공하세요:
`text
.NET 10, EF Core 10, SQLite. 이 엔드포인트는 일반적인 부하 상황(단순히 동시성 문제만이 아님)에서 모든 요청 시 예외를 발생시킵니다. 아래에 전체 예외(exception)와 엔드포인트 코드를 첨부합니다. 근본 원인(root cause)은 무엇이며, 관용적인(idiomatic) 해결 방법은 무엇인가요?
[ex.ToString()을 통해 전체 예외를 붙여넣으세요 — 메시지 + 모든 내부 예외(inner exceptions) + 스택 트레이스(trace)]
[MapGet 핸들러를 붙여넣으세요]
`
이 프롬프트가 좋은 이유는 세 가지입니다:
- 헤드라인이 아닌 전체 예외.
ex.ToString()은 모든 내부 예외와 완전한 트레이스(trace)를 포함합니다. Copilot Chat에서는#Program.cs를 사용하여 소스 코드를 자동으로 가져올 수 있습니다. - 여러분의 코드가 처음 등장하는 프레임. Claude에게
Program.cs:line 12와 그 주변 코드를 가리켜 주세요. 프레임워크 프레임은 문맥(context)일 뿐이며, 여러분의 프레임이 바로 범죄 현장입니다. - 조건. "모든 요청" vs "부하가 걸릴 때만" vs "간헐적으로"는 진단 결과를 완전히 바꿉니다. 관찰한 내용을 명확히 말하세요.
Claude가 알려주는 내용
"12번 라인이 실패했습니다"라는 말 대신, 실제 메커니즘을 알려줍니다: EF Core의 DbContext는 스레드 안전(thread-safe)하지 않으며, 동일한 인스턴스에서 동시에 두 개의 작업을 허용하지 않습니다. Task.WhenAll은 첫 번째 쿼리가 끝나기 전에 두 번째 쿼리를 시작하며, 두 쿼리 모두 주입된 동일한 db 인스턴스에서 실행되므로 EF의 ConcurrencyDetector가 예외를 던집니다. 줄 번호는 레드 헤링(red herring, 주의를 딴 데로 돌리는 것)이었으며, 버그는 호출 자체가 아니라 그 패턴에 있습니다.
그리고 Claude는 트레이드오프(trade-off)와 함께 두 가지 해결 방법을 모두 제시합니다:
csharp // 해결책 A — 가장 간단한 방법: 순차적으로 실행합니다. 한 번에 하나의 컨텍스트, 하나의 작업만 수행합니다. var orders = await db.Orders.Where(o => o.CustomerId == customerId).ToListAsync(); var total = await db.Orders.Where(o => o.CustomerId == customerId).SumAsync(o => o.Total); return Results.Ok(new { Orders = orders, Total = total });
`csharp
// 해결책 B — 진정한 병렬 처리: 팩토리(factory)를 통해 각 쿼리에 고유한 컨텍스트를 부여합니다.
builder.Services.AddDbContextFactory(o => o.UseSqlite("Data Source=shop.db"));
app.MapGet("/orders/{customerId:int}/summary",
async (int customerId, IDbContextFactory factory) =>
{
await using var ordersDb = await factory.CreateDbContextAsync();
await using var totalDb = await factory.CreateDbContextAsync();
var ordersTask = ordersDb.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
var totalTask = totalDb.Orders.Where(o => o.CustomerId == customerId).SumAsync(o => o.Total);
...
});
`
해결책 A는 90%의 상황에서 당신이 원하는 방식입니다. 두 개의 SQLite 쿼리는 빠르며, 병렬 처리를 위해 버그를 감수할 가치는 없었습니다. 해결책 B는 쿼리가 진정으로 느리고 독립적일 때의 정답입니다. 왜냐하면 IDbContextFactory<T>가 작업당 신선하고 수명이 짧은 컨텍스트를 제공하기 때문입니다. 당신이 실제로 놓치고 있었던 것은 줄 번호가 아니라 바로 그 차이점이며, 이것이 바로 좋은 설명이 제공하는 판단력의 종류이자 검색 결과는 줄 수 없는 것입니다.
.NET 비동기 스택 트레이스(async stack trace)에는 두 종류의 프레임(frame)이 있습니다. 런타임(runtime)에 속하는 40개의 프레임과, 당신의 잘못인 1개의 프레임입니다. Claude의 역할은 40개를 건너뛰고 그 1개를 설명하는 것입니다.
검증하세요 — 맹목적으로 믿지 마세요
Claude는 매우 빠른 시니어 동료이지만, 가끔 확신을 가지고 틀리기도 합니다. Claude의 진단을 판결이 아닌 강력한 가설로 취급하세요:
- 재현 및 확인 (Reproduce and confirm). 수정 사항을 적용하고 다시 실행하여, 예외가 실제로 사라졌는지 확인하세요. 가급적 예외를 유발했던 것과 동일한 조건에서 확인하는 것이 이상적입니다. Visual Studio에서는 수정된 라인에 중단점 (breakpoint)을 설정하고 상태를 관찰하세요.
- 단순히 '무엇(what)'이 아니라 '왜(why)'를 물으세요. "이것이 왜 문제를 해결하는지 설명해줘"라고 질문하면 잘못된 근본 원인 (root cause)을 빠르게 드러낼 수 있습니다. 만약 추론 과정이 타당하지 않다면, 수정 사항 또한 제대로 작동하지 않을 가능성이 높습니다.
- 그럴듯하지만 틀린 원인을 주의하세요. 특히 간헐적인 버그의 경우, Claude는 스택 트레이스 (trace)에는 부합하지만 실제 사용자의 상황과는 맞지 않는 원인을 제안할 수 있습니다. 후보군들의 순위를 매기게 하고, 이들을 어떻게 구별할 수 있는지 물어보세요.
디버거 (debugger)는 여전히 '상태 (state)'를 소유합니다. Claude는 '의미 (meaning)'를 소유합니다. 충돌이 발생한 순간 변수의 실제 값을 알아야 한다면, 그것은 채팅 프롬프트가 아니라 조사식 창 (watch window)과 중단점 (breakpoint)의 영역입니다.
Claude를 활용해야 할 때 — 그리고 활용하지 말아야 할 때
이럴 때 활용하세요:
- 예외 유형이나 메시지가 생소하거나, 스택 트레이스가 대부분 프레임워크 내부 코드인 경우.
- 실제 원인이 내부 예외 (inner exceptions)나
AggregateException안에 파묻혀 있는 경우. - 비동기 프레임 (async frames)을 들여다보고 있는데, 작성한 코드 중 어느 라인이 기점인지 알 수 없는 경우.
- 단순히 빨간 줄을 없애는 임시방편 (patch)이 아니라, 관용적 (idiomatic)인 해결책과 그에 따른 트레이드오프 (trade-offs)를 알고 싶은 경우.
이럴 때는 굳이 하지 마세요:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기