본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 22. 21:51

스택 트레이스에서 4초 만에 수정 제안까지: 자가 치유(Self-Healing) .NET API Gateway 구축하기

요약

.NET 환경에서 에러 발생 시 실시간으로 원인을 분석하고 코드 수정 초안을 제안하는 자가 치유(Self-Healing) API 게이트웨이 구축 방법을 소개합니다. Hangfire와 LLM을 활용하여 메인 요청 스레드에 영향을 주지 않고 백그라운드에서 분석을 수행하는 아키텍처를 다룹니다.

핵심 포인트

  • 에러 발생 시 30초 이내에 근본 원인 및 코드 패치 초안 제공
  • Hangfire를 활용해 AI 분석 작업을 백그라운드에서 비동기 처리
  • Groq 무료 티어 및 Ollama를 활용한 비용 효율적인 구현
  • 사용자 응답 속도 저하를 방지하는 미들웨어 설계 방식

지난 화요일, 나의 API 게이트웨이는 NullReferenceException을 포착하여 실시간으로 대시보드에 스트리밍했고, 내가 에러를 다 읽기도 전에 당직 엔지니어의 브라우저 탭으로 코드 수정 초안을 밀어 넣었습니다. 예전 같으면 벤더사의 마케팅 문구였을 이 문장이, 이제는 나의 Program.cs가 되었습니다.

이것은 아키텍처 사후 분석(post-mortem) 글입니다. 저는 주말을 이용해 이를 구축했습니다. Docker에서 실행됩니다. 개발 과정에서 LLM 크레딧 비용은 정확히 0달러가 들었는데, Groq의 무료 티어가 관대하고 Ollama를 대체제로 사용할 수 있었기 때문입니다. 저장소는 여기에 있으며, 이슈(issues)와 PR(Pull Requests)을 환영합니다.

대부분의 .NET 팀이 겪는 문제

운영(Production) 환경의 에러는 포착되어 파일에 로그로 남겨진 뒤 잊혀집니다. 엔지니어들은 20분 뒤에야 Slack 알림을 통해 에러를 알게 되거나, 아예 모르는 경우도 있습니다. 누군가 확인하려고 할 때쯤이면 원래의 요청 컨텍스트(request context)는 사라졌고, 사용자의 세션은 만료되었으며, 스택 트레이스(stack trace)는 System.* 호출의 네 단계 깊은 곳에 파묻혀 있습니다.

"자가 치유(Self-healing)"는 벤더사들이 "포드(pod)를 자동 재시작한다"는 의미로 사용하는 단어입니다. 저는 더 나은 것을 원했습니다. 실제 요구 사항은 다음과 같습니다:

서비스 A에서 예외(exception)가 발생했을 때, 엔지니어에게 (a) 명확한 근본 원인, (b) 제안된 수정 사항, (c) 코드 패치 초안을 30초 이내에 제공할 것.

마법 같은 블랙박스도 아니고, 자동으로 적용되는 패치도 아닙니다. 그저: 에러를 포착하고, 모델에 적절한 컨텍스트를 제공하며, 분석 내용을 실시간으로 사람에게 전달하여, 사람이 루프를 닫게(close the loop) 하는 것입니다.

아키텍처

하나의 .NET 솔루션, 4개의 프로젝트, 4개의 NuGet 패키지로 구성되며, 여러분이 이미 가지고 있을 법한 것 외에 새로운 인프라는 필요하지 않습니다.

[ HTTP request ]
       |
       v
...

결정적인 디테일은 AI 호출이 어디서 일어나는가 하는 점입니다. AI 호출은 요청 스레드(request thread)에서 발생하지 않습니다. 미들웨어(middleware)는 밀리초 단위로 500 에러를 반환하며, AI 작업은 Hangfire 백그라운드 작업(background job) 내부에서, 다른 프로세스에서, 혹은 어쩌면 다른 머신에서 실행됩니다. 두 개의 서로 다른 응답 시간, 하지만 사용자는 한 명입니다.

파트 1 — 캡처(the capture)

미들웨어는 using 문을 포함하여 50줄 정도입니다. 전체 코드는 다음과 같습니다.

using Hangfire;
using SmartLogAnalyzer.Core.Models;
using SmartLogAnalyzer.Core.Workers;
...

주의 깊게 봐야 할 두 가지 사항이 있습니다.

첫째, Enqueue 호출은 즉시 반환됩니다. Hangfire의 IBackgroundJobClient는 Hangfire 스토리지(이 경우에는 Redis)와 워커(worker) 픽업을 위한 얇은 프록시(thin proxy)입니다. 여기서 우리는 AI 호출을 await 하지 않습니다. 사용자는 한 자릿수 밀리초(ms) 내에 500 에러를 받게 됩니다.

둘째, 응답 본문인 "An internal error has been logged and is being analyzed."(내부 오류가 로그에 기록되었으며 분석 중입니다) 자체가 하나의 기능입니다. 이제 사용자(또는 호출하는 프론트엔드)는 오류가 처리되고 있음을 알 수 있습니다. 이것은 거짓말이 아니라 하나의 계약(contract)입니다.

파트 2 — 워커(the worker)

ErrorProcessingWorker는 평범한 C# 클래스입니다. Hangfire는 DI 컨테이너에서 이를 인스턴스화하고, ProcessErrorAsync를 호출하며, (만약 예외가 발생하면) 지수 백오프(exponential backoff)를 적용하여 최대 3번까지 재시도합니다.

[AutomaticRetry(Attempts = 3)]
public async Task ProcessErrorAsync(ErrorLog errorLog)
{
...

Redis를 이용한 중복 제거(dedupe) 단계는 0달러짜리 데모와 200달러짜리 Groq 청구서 사이의 차이를 만듭니다. /api/users/{id}에서 발생하는 NullReferenceException의 첫 번째 발생은 LLM 호출 한 번의 비용이 들지만, 이후 발생하는 10,000번의 발생은 비용이 들지 않습니다. 24시간 TTL(Time To Live)은 직접 조정해야 할 설정값(knob)입니다.

파트 3 — AI 호출(the AI call)

저는 이 프로젝트를 아주 화려한 설계로 시작했습니다. Semantic Kernel의 KernelPlugin을 사용하여 AI가 GitHub에서 문제가 된 소스 파일을 가져오고, 이를 커버하는 테스트를 살펴본 다음, 실제 코드에 기반한 디프(diff)를 제안하도록 하는 방식이었습니다. 영리한 설계였지만, v1 단계에서는 과잉 설계(over-engineered)였습니다.

실제로 배포된 버전은 15줄에 불과합니다.

var prompt = $@"`
You are a Senior .NET Engineer. Analyze the following error
and provide a JSON response with exactly three keys:
...

그다음 JsonDocument를 사용하여 응답을 세 개의 필드로 파싱하며, 실패할 경우 직접 구현한 문자열 파서(hand-rolled string parser)로 대체(fall back)합니다. 이 파서에 대해서는 다음 섹션에서 다시 다루겠습니다. 이 코드는 프로젝트 전체에서 가장 중요한 코드이자, 동시에 제가 가장 자랑스럽게 여기지 못하는 부분이기도 합니다.

호출 방식이 이렇게 간단한데 왜 Semantic Kernel을 사용할까요? 두 가지 이유가 있습니다.

  1. 공급자 교체 (Provider swap). Groq 연결은 단 한 줄이면 충분합니다: AddOpenAIChatCompletion(modelId: "llama-3.3-70b-versatile", apiKey: [your-groq-key], endpoint: new Uri("https://api.groq.com/openai/v1")) — 여기서 키는 시작 시 .env에서 로드됩니다. OpenAI, Azure OpenAI 또는 로컬 Ollama로 교체하는 것은 생성자 호출 한 번이면 됩니다. 만약 제가 HttpClient를 통해 Groq를 직접 호출했다면, 시도하는 모든 공급자마다 호출부를 다시 작성해야 했을 것입니다.
  2. 내장된 재시도(retries) 및 타임아웃(timeouts). Kernel.InvokePromptAsync는 기본 정책을 통해 429 및 5xx 오류를 처리합니다. 덕분에 잘못 처리할 가능성이 있는 작업이 하나 줄어듭니다.

물론 순수 HttpClientchat.completions.create()만으로도 충분히 구축할 수 있습니다. 다만 재시도 로직(retry logic)을 직접 작성해야 합니다. 저도 그렇게 해본 적이 있습니다. 하지만 권장하지는 않습니다.

4부 — 내가 실수한 것들

여러분이 기대했던 부분입니다. 저를 괴롭혔던 다섯 가지 사항을 비용(수고)이 많이 든 순서대로 나열하겠습니다.

4.1 출시될 뻔했던 JSON 파서

응답 파서의 첫 번째 버전은 JsonDocument.Parse를 사용했으며, 형식이 잘못된 출력이 나오면 예외를 발생시켰습니다. 프롬프트 끝에 "JSON Response:"라고 명시했음에도 불구하고, Groq 응답의 약 15%가

...

과 같은 마크다운 펜스(markdown fences)로 감싸져서 돌아왔습니다. 그래서 제거 로직(stripper)을 추가했습니다:

var cleaned = responseText.Trim();
if (cleaned.StartsWith("```json")) cleaned = cleaned.Substring(7);
if (cleaned.StartsWith("```"))    cleaned = cleaned.Substring(3);
if (cleaned.EndsWith("```"))      cleaned = cleaned.Substring(0, cleaned.Length - 3);
cleaned = cleaned.Trim();

이것으로 90%의 문제가 해결되었습니다. 나머지 10%는 문자열을 탐색하며 "RootCause": "..."를 찾고 백슬래시 이스케이프(backslash escapes)를 준수하는 직접 구현한 정규식 파서(regex parser)가 필요했습니다. 정규식 파서를 만드는 것을 너무 자존심 상해하지 마세요. 상위 시스템(upstream)이 LLM이고 계약 조건이 "JSON을 반환해 주세요"라면, LLM은 때때로 틀릴 수 있으며 여러분에게는 폴백(fallback)이 필요합니다.

...

csharp
private static readonly Regex BearerToken = new(@"Bearer\s+[A-Za-z0-9.-]+", RegexOptions.Compiled);
private static readonly Regex PasswordKV = new(@"(password|pwd|secret)\s*=\s*\S+", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex CreditCard = new(@"\b\d{16}\b", RegexOptions.Compiled);
private static readonly Regex EmailAddr = new(@"\b[\w.
%+-]+@[\w.-]+.[A-Za-z]{2,}\b", RegexOptions.Compiled);

public static string Redact(string input)
{
input = BearerToken.Replace(input, "Bearer [REDACTED]");
input = PasswordKV .Replace(input, "$1=[REDACTED]");
input = CreditCard .Replace(input, "[REDACTED-CC]");
input = EmailAddr .Replace(input, "[REDACTED-EMAIL]");
return input;
}


이 코드를 프롬프트가 생성되기 전에 실행하세요. 항상 AI 제공업체가 여러분의 데이터를 볼 것이라고 가정하세요. 언제나. 잊는 날은 고객의 JWT(JSON Web Token)가 다른 사람의 학습 세트에, 혹은 최소한 다른 사람의 로그에 들어가는 날입니다.

...

csharp
[AutomaticRetry(Attempts = 2, DelaysInSeconds = new[] { 30, 120 })]
public async Task ProcessErrorAsync(ErrorLog errorLog) { ... }


두 번의 시도와 각각 30초 및 2분 지연 시간입니다. 이는 무언가 잘못되었을 때 비용 급증(cost spiral)을 제한합니다. 정말로 고장 난 상태라도 여전히 오류당 2배의 비용이 들겠지만, 짧은 루프에서 5번 더 재시도하며 한 달 치 예산을 몇 시간 만에 소진하지는 않을 것입니다.

...

csharp
// 미들웨어(middleware) 내부
var correlationId = Guid.NewGuid().ToString("N");
context.Response.Headers["X-Correlation-Id"] = correlationId;
errorLog.CorrelationId = correlationId;


## Part 5 — 실시간 대시보드(live dashboard)

...

typescript
effect(() => {
const newConnection = new signalR.HubConnectionBuilder()
.withUrl(${API_URL}/errorHub)
.withAutomaticReconnect()
.build();
setConnection(newConnection);
}, []);

useEffect(() => {
if (!connection) return;
connection.start().then(() => {
setConnected(true);
connection.on('ReceiveErrorUpdate', (errorJson: string) => {
const error: ErrorLog = JSON.parse(errorJson);
setErrors(prev => {
const index = prev.findIndex(e => e.id === error.id);
if (index !== -1) {
const updated = [...prev];
updated[index] = error;
return updated;
}
return [error, ...prev];
});
});
});
return () => { connection.stop(); };
}, [connection]);

와이어는 JSON-over-SignalR입니다. 서버 측 허브(hub)가 `Clients.All.SendAsync(

만약 여러분도 이와 유사한 것을 구축하다가 동일한 다섯 가지 문제에 직면한다면, 꼭 알려주세요. 해당 리포지토리(repo)는 이슈(issues), 풀 리퀘스트(PRs), 그리고 여러분의 재시도 정책(retry policy)이 어떻게 LLM 예산을 파산시켰는지에 대한 불만 사항(rants)을 위해 열려 있습니다. 우리 모두 그런 경험이 있으니까요.

사용 기술: .NET 10 · ASP.NET Core · Hangfire · Semantic Kernel · Groq (llama-3.3-70b-versatile) · SignalR · MSSQL · Redis · React

리포지토리(Repo): ZalaAvinash/Smart-Log-Analyzer-Self-Healing-API-Gateway

저자 소개: Avinash Zala는 인도 수라트에 거주하는 시니어 .NET 엔지니어로, 7년 이상의 엔터프라이즈 웹 앱, API 및 ERP 시스템 구축 경험을 보유하고 있습니다. 그는 현재 자신의 기술 스택에 AI/LLM 역량을 추가하고 있으며, 그 과정에서 배우는 내용을 기록하고 있습니다. GitHub · LinkedIn

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0