본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 2. 2. · 3 Views
자가 치유 및 재시도 패턴 완벽 가이드
AI 에이전트와 분산 시스템에서 필수적인 자가 치유 패턴을 다룹니다. 에러 감지부터 서킷 브레이커까지, 시스템을 스스로 복구하는 탄력적인 코드 작성법을 배워봅니다.
목차
1. 에러 감지 및 분류
김개발 씨는 오늘 새벽 3시에 긴급 호출을 받았습니다. "서버가 다운됐어요!" 부랴부랴 로그를 확인해보니 수천 개의 에러 메시지가 쏟아지고 있었습니다.
그런데 문제는 모든 에러가 뒤섞여 있어서 어디서부터 손을 대야 할지 막막했습니다.
에러 감지 및 분류는 발생한 오류를 체계적으로 식별하고 유형별로 나누는 것입니다. 마치 병원 응급실에서 환자를 중증도에 따라 분류하는 트리아지 시스템과 같습니다.
이것을 제대로 구현하면 복구 가능한 에러와 치명적인 에러를 구분하여 적절한 대응 전략을 세울 수 있습니다.
다음 코드를 살펴봅시다.
// 에러 유형을 정의하는 열거형
enum ErrorType {
TRANSIENT = 'transient', // 일시적 오류 (재시도 가능)
PERMANENT = 'permanent', // 영구적 오류 (재시도 불가)
RATE_LIMIT = 'rate_limit', // 요청 제한
NETWORK = 'network', // 네트워크 오류
VALIDATION = 'validation' // 입력 검증 오류
}
// 에러를 분석하고 분류하는 함수
function classifyError(error: Error): ErrorType {
const message = error.message.toLowerCase();
// 네트워크 관련 에러 감지
if (message.includes('timeout') || message.includes('econnrefused')) {
return ErrorType.NETWORK;
}
// API 요청 제한 감지
if (message.includes('rate limit') || message.includes('429')) {
return ErrorType.RATE_LIMIT;
}
// 일시적 서버 에러 감지
if (message.includes('503') || message.includes('temporarily')) {
return ErrorType.TRANSIENT;
}
// 입력 검증 실패
if (message.includes('invalid') || message.includes('validation')) {
return ErrorType.VALIDATION;
}
// 기본값은 영구적 오류로 분류
return ErrorType.PERMANENT;
}
김개발 씨는 입사 6개월 차 백엔드 개발자입니다. AI 에이전트 서비스를 담당하게 된 후로 새벽 호출이 부쩍 늘었습니다.
오늘도 서버 장애 알람에 잠을 깨고 노트북을 열었습니다. 로그를 살펴보니 온갖 종류의 에러가 뒤섞여 있었습니다.
"Connection timeout", "Invalid API key", "Rate limit exceeded", "Internal server error"... 도대체 어디서부터 손을 대야 할까요?
선배 개발자 박시니어 씨가 슬랙으로 메시지를 보내왔습니다. "에러 분류부터 해봐요.
모든 에러를 똑같이 취급하면 안 됩니다." 그렇다면 에러 분류란 정확히 무엇일까요? 쉽게 비유하자면, 에러 분류는 마치 병원 응급실의 트리아지 시스템과 같습니다.
응급실에 환자가 몰려들면 의료진은 즉시 모든 환자를 동시에 치료하지 않습니다. 대신 중증도를 빠르게 파악하여 심정지 환자는 즉시 처치하고, 가벼운 찰과상 환자는 대기하게 합니다.
에러 분류도 마찬가지입니다. 모든 에러에 똑같이 대응하는 것이 아니라, 각 에러의 특성에 맞는 전략을 적용하는 것입니다.
에러 분류가 없던 시절에는 어땠을까요? 개발자들은 모든 에러를 단순히 "실패"로 처리했습니다.
네트워크가 잠깐 끊겼든, API 키가 잘못됐든 상관없이 그냥 에러 메시지를 던지고 끝이었습니다. 더 큰 문제는 복구 가능한 에러도 포기해버린다는 점이었습니다.
잠깐만 기다렸다가 다시 시도하면 성공할 수 있는 요청도 그냥 실패 처리되어 버렸습니다. 바로 이런 문제를 해결하기 위해 에러 분류 시스템이 필요합니다.
에러를 분류하면 크게 두 가지 카테고리로 나눌 수 있습니다. 첫째는 **일시적 오류(Transient Error)**입니다.
네트워크 타임아웃, 서버 과부하, 요청 제한 등이 여기에 해당합니다. 이런 에러는 시간이 지나면 해결될 가능성이 높으므로 재시도 전략을 적용합니다.
둘째는 **영구적 오류(Permanent Error)**입니다. 잘못된 API 키, 존재하지 않는 리소스 요청, 입력 검증 실패 등이 해당합니다.
이런 에러는 아무리 재시도해도 결과가 달라지지 않으므로 즉시 실패 처리하고 사용자에게 명확한 피드백을 제공합니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 ErrorType 열거형에서 다섯 가지 에러 유형을 정의합니다. 각 유형은 서로 다른 대응 전략을 필요로 합니다.
다음으로 classifyError 함수에서는 에러 메시지를 분석하여 적절한 유형으로 분류합니다. "timeout"이나 "econnrefused"가 포함되어 있으면 네트워크 오류로, "429"나 "rate limit"가 포함되어 있으면 요청 제한으로 판단합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 AI 챗봇 서비스를 운영한다고 가정해봅시다.
사용자가 질문을 보내면 외부 LLM API를 호출하는데, 이 API는 다양한 이유로 실패할 수 있습니다. 에러 분류 시스템이 있다면 네트워크 타임아웃은 자동 재시도하고, API 키 오류는 관리자에게 즉시 알림을 보내며, 요청 제한은 잠시 대기 후 재시도하는 식으로 지능적인 대응이 가능해집니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 에러 메시지만으로 분류하는 것입니다.
실제로는 HTTP 상태 코드, 에러 코드, 응답 헤더 등 다양한 정보를 종합적으로 활용해야 합니다. 또한 외부 서비스마다 에러 형식이 다르므로, 각 서비스에 맞는 분류 로직을 별도로 구현해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 에러 분류 시스템을 도입한 후, 김개발 씨는 로그에서 진짜 문제를 훨씬 빠르게 찾아낼 수 있게 되었습니다.
"아, 이건 일시적인 네트워크 문제였군요. 자동으로 복구됐네요!"
실전 팁
💡 - HTTP 상태 코드 5xx는 보통 일시적 오류, 4xx는 영구적 오류로 분류합니다
- 에러 분류 결과를 모니터링 시스템과 연동하여 패턴을 분석하세요
2. 지수 백오프 재시도 전략
김개발 씨가 에러 분류 시스템을 도입한 후, 일시적 오류에 대해 재시도 로직을 추가했습니다. 그런데 이상한 일이 벌어졌습니다.
서버 장애가 발생하자 모든 클라이언트가 동시에 재시도를 시작했고, 오히려 서버가 완전히 다운되어 버렸습니다.
**지수 백오프(Exponential Backoff)**는 재시도 간격을 점점 늘려가는 전략입니다. 마치 붐비는 식당에 전화했다가 통화 중이면, 1분 후, 2분 후, 4분 후...
점점 간격을 늘려서 다시 전화하는 것과 같습니다. 이렇게 하면 서버에 과부하를 주지 않으면서도 결국 연결에 성공할 수 있습니다.
다음 코드를 살펴봅시다.
interface RetryConfig {
maxRetries: number; // 최대 재시도 횟수
baseDelay: number; // 기본 대기 시간 (ms)
maxDelay: number; // 최대 대기 시간 (ms)
jitter: boolean; // 무작위 지연 추가 여부
}
async function retryWithBackoff<T>(
operation: () => Promise<T>,
config: RetryConfig
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt < config.maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
// 재시도 가능한 에러인지 확인
if (classifyError(lastError) === ErrorType.PERMANENT) {
throw lastError; // 영구적 오류는 즉시 실패
}
// 지수 백오프 계산: 2^attempt * baseDelay
let delay = Math.min(
config.baseDelay * Math.pow(2, attempt),
config.maxDelay
);
// 지터 추가로 동시 재시도 방지
if (config.jitter) {
delay = delay * (0.5 + Math.random());
}
console.log(`재시도 ${attempt + 1}/${config.maxRetries}, ${delay}ms 후 시도`);
await sleep(delay);
}
}
throw lastError!;
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
김개발 씨는 에러 분류 시스템에 자신감이 생겼습니다. "일시적 오류면 재시도하면 되겠지!" 그래서 간단한 재시도 로직을 추가했습니다.
실패하면 바로 다시 시도하고, 또 실패하면 바로 다시 시도하고... 그런데 예상치 못한 문제가 발생했습니다.
서버가 잠깐 느려지자 수천 개의 클라이언트가 동시에 재시도를 시작했습니다. 서버는 이 요청 폭탄을 감당하지 못하고 완전히 다운되었습니다.
이것이 바로 악명 높은 **재시도 폭풍(Retry Storm)**입니다. 박시니어 씨가 다가와 말했습니다.
"재시도는 무작정 하면 안 됩니다. 지수 백오프를 써야 해요." 그렇다면 지수 백오프란 정확히 무엇일까요?
쉽게 비유하자면, 지수 백오프는 마치 현명한 구직자와 같습니다. 원하는 회사에 전화했는데 담당자가 바쁘다고 합니다.
5분 후에 다시 전화합니다. 또 바쁘답니다.
이번엔 10분 후에, 그 다음엔 20분 후에... 이렇게 간격을 점점 늘리면 담당자를 괴롭히지 않으면서도 결국 연결될 수 있습니다.
반면 1분마다 계속 전화하면 담당자는 짜증이 나고, 블랙리스트에 오를 수도 있겠죠. 지수 백오프의 핵심은 2의 거듭제곱으로 대기 시간을 늘리는 것입니다.
첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후, 네 번째는 8초 후... 이런 식입니다.
이렇게 하면 서버가 회복할 시간을 충분히 확보할 수 있습니다. 하지만 지수 백오프만으로는 부족합니다.
여기에 **지터(Jitter)**라는 비밀 무기가 필요합니다. 1000개의 클라이언트가 동시에 실패했다고 가정해봅시다.
모두 지수 백오프를 사용하더라도, 1초 후에 1000개가 동시에 재시도하고, 2초 후에 또 1000개가 동시에 재시도합니다. 여전히 서버에는 부담이 됩니다.
지터는 대기 시간에 약간의 무작위성을 추가합니다. 어떤 클라이언트는 0.8초 후에, 어떤 클라이언트는 1.3초 후에 재시도합니다.
이렇게 요청이 분산되면 서버 부하가 크게 줄어듭니다. 위의 코드를 살펴보면, RetryConfig 인터페이스에서 재시도 설정을 정의합니다.
maxRetries는 최대 재시도 횟수, baseDelay는 기본 대기 시간, maxDelay는 대기 시간의 상한선입니다. 상한선이 없으면 대기 시간이 무한정 늘어날 수 있으므로 반드시 설정해야 합니다.
retryWithBackoff 함수의 핵심 로직을 보면, 먼저 에러 분류 함수를 호출하여 영구적 오류인지 확인합니다. 영구적 오류라면 재시도해봐야 소용없으므로 즉시 에러를 던집니다.
일시적 오류라면 지수 백오프 공식인 2^attempt * baseDelay로 대기 시간을 계산합니다. 그리고 지터가 활성화되어 있다면 대기 시간에 0.5~1.5 사이의 무작위 배수를 곱합니다.
실제 현업에서 AI 에이전트를 운영할 때, 외부 LLM API 호출은 거의 필수적으로 지수 백오프를 적용합니다. OpenAI나 Claude API는 요청 제한이 있고, 일시적으로 느려지는 경우도 많습니다.
지수 백오프가 없다면 한 번의 장애가 전체 서비스 마비로 이어질 수 있습니다. 주의할 점은 최대 재시도 횟수와 최대 대기 시간을 적절히 설정하는 것입니다.
너무 많이 재시도하면 사용자가 오래 기다려야 하고, 너무 적게 재시도하면 일시적 장애를 극복하지 못합니다. 일반적으로 35회 재시도, 최대 대기 시간 3060초 정도가 적당합니다.
김개발 씨는 지수 백오프를 도입한 후, 재시도 폭풍 문제가 깔끔하게 해결되었습니다. 서버 장애가 발생해도 클라이언트들이 점진적으로 재시도하며, 서버가 회복되면 자연스럽게 정상화됩니다.
실전 팁
💡 - 기본 대기 시간은 1초, 최대 대기 시간은 30초 정도로 시작하세요
- 지터는 거의 항상 활성화하는 것이 좋습니다 (동시 재시도 방지)
3. 컨텍스트 기반 복구
AI 에이전트가 복잡한 작업을 수행하다가 중간에 실패했습니다. 재시도를 해서 다시 연결은 됐는데, 문제는 이전에 어디까지 진행했는지 알 수가 없다는 것입니다.
결국 처음부터 다시 시작해야 했고, 사용자는 같은 대답을 두 번 듣게 되었습니다.
컨텍스트 기반 복구는 작업 상태를 저장해두고, 실패 시 마지막으로 성공한 지점부터 다시 시작하는 전략입니다. 마치 게임의 체크포인트 시스템과 같습니다.
보스전에서 죽어도 던전 입구부터 다시 시작하는 게 아니라, 보스 방 앞에서 다시 시작할 수 있습니다.
다음 코드를 살펴봅시다.
interface ExecutionContext {
taskId: string;
currentStep: number;
completedSteps: string[];
intermediateResults: Map<string, any>;
startedAt: Date;
lastCheckpoint: Date;
}
class ContextAwareExecutor {
private context: ExecutionContext;
private storage: ContextStorage;
async executeWithRecovery(
steps: TaskStep[],
taskId: string
): Promise<any> {
// 이전 컨텍스트 복원 시도
this.context = await this.storage.load(taskId) || {
taskId,
currentStep: 0,
completedSteps: [],
intermediateResults: new Map(),
startedAt: new Date(),
lastCheckpoint: new Date()
};
// 마지막 체크포인트부터 실행 재개
for (let i = this.context.currentStep; i < steps.length; i++) {
const step = steps[i];
try {
const result = await this.executeStep(step);
// 체크포인트 저장
this.context.currentStep = i + 1;
this.context.completedSteps.push(step.name);
this.context.intermediateResults.set(step.name, result);
this.context.lastCheckpoint = new Date();
await this.storage.save(this.context);
} catch (error) {
console.log(`Step ${step.name} 실패, 컨텍스트 저장됨`);
await this.storage.save(this.context);
throw error;
}
}
// 완료 후 컨텍스트 정리
await this.storage.delete(taskId);
return this.context.intermediateResults;
}
}
김개발 씨는 AI 에이전트의 멀티스텝 작업을 구현하고 있었습니다. 사용자가 "지난달 매출 보고서를 작성해줘"라고 요청하면, 에이전트는 여러 단계를 거칩니다.
데이터 조회, 분석, 차트 생성, 문서 작성, 최종 검토... 총 5단계의 작업입니다.
그런데 4단계에서 네트워크 오류가 발생했습니다. 지수 백오프로 재시도해서 연결은 복구됐는데, 에이전트가 1단계부터 다시 시작하는 것이었습니다.
이미 완료한 데이터 조회와 분석을 또 하고 있습니다. 사용자 입장에서는 "왜 이렇게 오래 걸리지?"라고 답답해합니다.
박시니어 씨가 지나가다 화면을 보더니 말했습니다. "컨텍스트를 저장하지 않으면 그렇게 돼요.
체크포인트 시스템을 만들어야 합니다." 그렇다면 컨텍스트 기반 복구란 무엇일까요? RPG 게임을 떠올려 보세요.
긴 던전을 탐험하다가 보스한테 죽으면 어떻게 될까요? 좋은 게임이라면 던전 입구가 아니라 보스 방 바로 앞에서 다시 시작하게 해줍니다.
이것이 바로 체크포인트 시스템입니다. 컨텍스트 기반 복구도 동일한 원리입니다.
각 작업 단계가 완료될 때마다 상태를 저장해두고, 실패하면 마지막 체크포인트부터 재개합니다. 이 패턴이 없다면 어떤 문제가 생길까요?
첫째, 자원 낭비입니다. 이미 완료한 작업을 반복하면 API 호출 비용, 컴퓨팅 자원, 시간이 모두 낭비됩니다.
둘째, 사용자 경험 저하입니다. 5분이면 끝날 작업이 실패와 재시도를 반복하며 20분이 걸릴 수 있습니다.
셋째, 일관성 문제입니다. 같은 데이터를 두 번 처리하면서 중복 결과가 생길 수 있습니다.
코드를 살펴보면, ExecutionContext 인터페이스가 작업의 현재 상태를 담고 있습니다. currentStep은 현재 진행 중인 단계 번호, completedSteps는 완료된 단계 목록, intermediateResults는 각 단계의 중간 결과물입니다.
executeWithRecovery 함수의 흐름을 따라가 봅시다. 먼저 이전에 저장된 컨텍스트가 있는지 확인합니다.
있다면 그 상태를 복원하고, 없다면 새로운 컨텍스트를 생성합니다. 그 다음 for 루프에서 주목할 점은 i = this.context.currentStep입니다.
0이 아니라 마지막 체크포인트부터 시작합니다. 각 단계가 성공할 때마다 체크포인트를 저장합니다.
현재 단계 번호를 업데이트하고, 완료된 단계 목록에 추가하고, 중간 결과물을 저장합니다. 이 모든 정보가 storage.save를 통해 영속화됩니다.
만약 중간에 실패하면, 현재까지의 컨텍스트를 저장한 후 에러를 던집니다. 다음 재시도 때는 이 컨텍스트를 복원해서 실패한 지점부터 재개합니다.
실제 AI 에이전트에서는 이 패턴이 매우 중요합니다. LLM API 호출은 비용이 들고, 복잡한 추론 작업은 시간이 오래 걸립니다.
중간에 실패했다고 처음부터 다시 하면 비용도 두 배, 시간도 두 배입니다. 주의할 점은 컨텍스트 저장의 원자성입니다.
컨텍스트를 저장하는 도중에 실패하면 불완전한 상태가 될 수 있습니다. 따라서 트랜잭션을 사용하거나, 저널링 기법을 적용해야 합니다.
또한 컨텍스트 만료 처리도 필요합니다. 너무 오래된 컨텍스트는 의미가 없으므로 TTL을 설정해야 합니다.
김개발 씨는 컨텍스트 기반 복구를 도입한 후, 에이전트가 훨씬 안정적으로 동작하게 되었습니다. 4단계에서 실패해도 4단계부터 다시 시작하니 사용자 대기 시간이 크게 줄었습니다.
실전 팁
💡 - 체크포인트 저장은 가벼운 키-값 저장소(Redis 등)를 사용하세요
- 컨텍스트에는 민감한 정보를 직접 저장하지 말고 참조만 저장하세요
4. 실패 이력 학습
같은 에러가 일주일째 반복되고 있습니다. 매번 재시도로 결국은 성공하지만, 왜 계속 실패하는지 원인을 모릅니다.
김개발 씨는 로그를 뒤지다가 문득 깨달았습니다. "우리 시스템은 실패에서 아무것도 배우지 않고 있구나."
실패 이력 학습은 과거 실패 데이터를 분석하여 미래의 실패를 예방하고 대응 전략을 개선하는 것입니다. 마치 의사가 환자의 병력을 참고하여 진단하는 것과 같습니다.
"이 환자는 페니실린 알레르기가 있군요"라는 정보가 있으면 다른 약을 처방할 수 있습니다.
다음 코드를 살펴봅시다.
interface FailureRecord {
timestamp: Date;
errorType: ErrorType;
errorMessage: string;
endpoint: string;
recoveryAction: string;
recoverySuccess: boolean;
recoveryTime: number;
}
class FailureHistoryAnalyzer {
private history: FailureRecord[] = [];
recordFailure(record: FailureRecord): void {
this.history.push(record);
// 최근 1000건만 유지
if (this.history.length > 1000) {
this.history.shift();
}
}
// 특정 엔드포인트의 실패율 계산
getFailureRate(endpoint: string, windowMs: number): number {
const cutoff = Date.now() - windowMs;
const recent = this.history.filter(
r => r.endpoint === endpoint && r.timestamp.getTime() > cutoff
);
if (recent.length === 0) return 0;
const failures = recent.filter(r => !r.recoverySuccess).length;
return failures / recent.length;
}
// 가장 효과적인 복구 전략 추천
recommendRecoveryStrategy(errorType: ErrorType): string {
const relevantRecords = this.history.filter(
r => r.errorType === errorType && r.recoverySuccess
);
// 복구 시간 기준으로 가장 빠른 전략 찾기
const strategyStats = new Map<string, number[]>();
for (const record of relevantRecords) {
const times = strategyStats.get(record.recoveryAction) || [];
times.push(record.recoveryTime);
strategyStats.set(record.recoveryAction, times);
}
let bestStrategy = 'default';
let bestAvgTime = Infinity;
for (const [strategy, times] of strategyStats) {
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
if (avgTime < bestAvgTime) {
bestAvgTime = avgTime;
bestStrategy = strategy;
}
}
return bestStrategy;
}
}
김개발 씨의 팀은 AI 에이전트 서비스를 운영한 지 3개월이 되었습니다. 그동안 수많은 에러가 발생하고 복구되었습니다.
하지만 이상한 점이 있었습니다. 똑같은 에러가 계속 반복되는데, 매번 같은 방식으로 대응하고 있었습니다.
어느 날 박시니어 씨가 물었습니다. "지난 달에 가장 많이 발생한 에러가 뭐였어요?" 김개발 씨는 대답하지 못했습니다.
"그리고 그 에러에 어떤 복구 전략이 가장 효과적이었어요?" 역시 모릅니다. 실패에서 아무것도 배우지 않고 있었던 것입니다.
실패 이력 학습은 이 문제를 해결합니다. 비유하자면, 이것은 마치 노련한 의사의 진료 기록과 같습니다.
처음 온 환자라면 의사는 일반적인 치료법을 시도합니다. 하지만 재방문 환자라면 다릅니다.
"이 환자는 지난번에 A 약이 안 들었고, B 약에는 부작용이 있었어요. C 약으로 가봅시다." 과거 기록이 있으면 훨씬 빠르고 정확하게 치료할 수 있습니다.
시스템도 마찬가지입니다. 과거 실패 데이터를 축적하면 패턴을 발견할 수 있습니다.
"매일 오전 9시에 네트워크 타임아웃이 급증하네요" - 출근 시간대 트래픽 폭증 때문일 수 있습니다. "이 외부 API는 지수 백오프보다 고정 간격 재시도가 더 효과적이네요" - API 특성에 맞는 전략을 적용할 수 있습니다.
코드를 살펴보면, FailureRecord 인터페이스는 하나의 실패 사례를 기록합니다. 언제 발생했는지, 어떤 유형의 에러인지, 어떤 복구 전략을 시도했는지, 성공했는지, 복구에 얼마나 걸렸는지 등의 정보를 담습니다.
getFailureRate 함수는 특정 엔드포인트의 최근 실패율을 계산합니다. 예를 들어 지난 1시간 동안 특정 API의 실패율이 50%라면, 서킷 브레이커를 작동시키는 신호로 활용할 수 있습니다.
가장 흥미로운 부분은 recommendRecoveryStrategy 함수입니다. 과거에 같은 유형의 에러가 발생했을 때 어떤 복구 전략이 가장 빠르게 성공했는지 분석합니다.
이 정보를 바탕으로 다음 실패 시 최적의 전략을 자동으로 선택할 수 있습니다. 실제로 대규모 AI 서비스에서는 이 데이터가 매우 가치 있습니다.
A/B 테스트처럼 여러 복구 전략을 시험하고, 가장 효과적인 것을 자동으로 채택할 수 있습니다. 시간이 지날수록 시스템이 스스로 최적화됩니다.
주의할 점은 데이터 저장 용량입니다. 모든 실패 기록을 무한정 보관할 수는 없습니다.
최근 N건만 유지하거나, 일정 기간이 지난 기록은 집계 데이터만 남기고 삭제하는 전략이 필요합니다. 또한 개인정보 보호에도 신경 써야 합니다.
에러 메시지에 사용자 정보가 포함될 수 있으므로 마스킹 처리가 필요합니다. 김개발 씨는 실패 이력 분석 대시보드를 만든 후, 놀라운 사실을 발견했습니다.
특정 시간대에 특정 에러가 집중된다는 패턴이 보였고, 근본 원인을 찾아 해결할 수 있었습니다.
실전 팁
💡 - 실패 기록은 시계열 데이터베이스에 저장하면 분석이 편리합니다
- 주간/월간 실패 리포트를 자동 생성하여 팀과 공유하세요
5. 서킷 브레이커 패턴
외부 결제 API가 완전히 다운되었습니다. 그런데 우리 시스템은 계속해서 그 API를 호출하고 있습니다.
재시도, 또 재시도... 사용자들은 무한 로딩 화면만 보고 있고, 서버 리소스는 바닥나고 있습니다.
"API가 죽었으면 그만 호출해야 하는 거 아니에요?"
서킷 브레이커는 연속된 실패가 감지되면 일정 시간 동안 요청을 차단하는 패턴입니다. 마치 가정집의 **두꺼비집(차단기)**과 같습니다.
과전류가 흐르면 차단기가 내려가서 집 전체의 화재를 막아줍니다. 서킷 브레이커도 장애가 전파되는 것을 막아줍니다.
다음 코드를 살펴봅시다.
enum CircuitState {
CLOSED = 'closed', // 정상 상태, 요청 허용
OPEN = 'open', // 차단 상태, 요청 거부
HALF_OPEN = 'half_open' // 테스트 상태, 일부 요청 허용
}
class CircuitBreaker {
private state: CircuitState = CircuitState.CLOSED;
private failureCount = 0;
private successCount = 0;
private lastFailureTime: number = 0;
constructor(
private failureThreshold: number = 5, // 실패 임계값
private resetTimeout: number = 30000, // 차단 해제 대기 시간
private halfOpenRequests: number = 3 // 반개방 상태 테스트 요청 수
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
// 차단 상태 확인
if (this.state === CircuitState.OPEN) {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.state = CircuitState.HALF_OPEN;
this.successCount = 0;
console.log('서킷 브레이커: HALF_OPEN 상태로 전환');
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
if (this.state === CircuitState.HALF_OPEN) {
this.successCount++;
if (this.successCount >= this.halfOpenRequests) {
this.state = CircuitState.CLOSED;
console.log('서킷 브레이커: CLOSED 상태로 복구');
}
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = CircuitState.OPEN;
console.log('서킷 브레이커: OPEN 상태로 전환');
}
}
}
김개발 씨의 AI 에이전트는 여러 외부 서비스에 의존합니다. LLM API, 벡터 데이터베이스, 검색 엔진 등...
어느 날 벡터 데이터베이스 서비스가 완전히 다운되었습니다. 지수 백오프가 열심히 재시도를 합니다.
1초 후 실패, 2초 후 실패, 4초 후 실패... 그런데 데이터베이스가 완전히 죽은 상태라면, 아무리 재시도해도 성공할 수 없습니다.
그 사이 수천 개의 요청이 쌓이고, 서버 리소스는 고갈되고, 다른 기능까지 영향을 받기 시작합니다. 박시니어 씨가 급하게 달려왔습니다.
"서킷 브레이커를 안 달았어요? 죽은 서비스한테 계속 요청하면 안 됩니다!" 서킷 브레이커란 무엇일까요?
가정집의 **두꺼비집(차단기)**을 떠올려 보세요. 평상시에는 전기가 잘 흐릅니다.
그런데 어딘가에서 누전이 발생하면 어떻게 될까요? 차단기가 자동으로 내려가서 전기를 끊어버립니다.
그래야 화재를 막을 수 있습니다. 불이 꺼진 후에 문제를 해결하고, 차단기를 다시 올리면 전기가 다시 흐릅니다.
서킷 브레이커 패턴도 동일합니다. 외부 서비스 호출이 연속으로 실패하면, 더 이상 호출하지 않고 즉시 실패를 반환합니다.
이렇게 하면 불필요한 대기 시간과 리소스 낭비를 막을 수 있습니다. 서킷 브레이커는 세 가지 상태를 가집니다.
CLOSED(닫힘)는 정상 상태입니다. 모든 요청이 외부 서비스로 전달됩니다.
OPEN(열림)은 차단 상태입니다. 모든 요청이 즉시 실패 처리됩니다.
HALF_OPEN(반개방)은 테스트 상태입니다. 일부 요청만 허용하여 서비스가 복구됐는지 확인합니다.
상태 전이 흐름을 따라가 봅시다. 정상 상태에서 시작합니다.
실패가 발생할 때마다 failureCount가 증가합니다. 연속 5번(임계값) 실패하면 OPEN 상태로 전환됩니다.
OPEN 상태에서는 모든 요청이 즉시 거부됩니다. 30초(resetTimeout)가 지나면 HALF_OPEN 상태로 전환됩니다.
HALF_OPEN 상태에서 3번 연속 성공하면 CLOSED 상태로 복구됩니다. 실패하면 다시 OPEN 상태로 돌아갑니다.
실제 코드에서 execute 함수를 보면, 먼저 현재 상태가 OPEN인지 확인합니다. OPEN이고 아직 resetTimeout이 지나지 않았다면 즉시 에러를 던집니다.
외부 서비스를 호출하지 않고도 빠르게 실패를 반환하는 것입니다. 이것을 Fail Fast 패턴이라고 합니다.
HALF_OPEN 상태가 중요한 이유는 점진적 복구를 가능하게 하기 때문입니다. 서비스가 복구됐는지 확인하기 위해 모든 요청을 한꺼번에 보내면 다시 과부하가 걸릴 수 있습니다.
대신 몇 개의 요청만 보내보고, 성공하면 조금씩 늘려갑니다. AI 에이전트에서 서킷 브레이커는 필수입니다.
외부 LLM API가 다운되면, 계속 재시도하는 것보다 빠르게 실패하고 대체 전략(로컬 캐시, 간소화된 응답 등)을 실행하는 것이 사용자 경험에 훨씬 좋습니다. 주의할 점은 임계값 설정입니다.
너무 낮으면 일시적인 오류에도 서킷이 열리고, 너무 높으면 장애 상황에서 늦게 반응합니다. 서비스 특성에 맞게 조정해야 합니다.
또한 서킷 상태 변화를 모니터링 시스템에 연동하여 즉시 알림을 받을 수 있어야 합니다.
실전 팁
💡 - 서킷 브레이커 상태 변화는 반드시 로그와 알림으로 기록하세요
- 외부 서비스별로 별도의 서킷 브레이커를 사용하세요
6. 그레이스풀 디그레이데이션
서킷 브레이커가 열렸습니다. LLM API를 호출할 수 없게 되었습니다.
그런데 사용자에게 그냥 "서비스 이용 불가"라고만 보여줘야 할까요? 김개발 씨는 고민했습니다.
"완벽하진 않더라도 뭔가 보여줄 수 있지 않을까?"
**그레이스풀 디그레이데이션(Graceful Degradation)**은 시스템 일부가 실패해도 핵심 기능은 유지하는 전략입니다. 마치 비행기의 이중 엔진 시스템과 같습니다.
한 엔진이 고장 나도 나머지 엔진으로 안전하게 착륙할 수 있습니다. 완벽하진 않지만, 추락하는 것보다는 훨씬 낫습니다.
다음 코드를 살펴봅시다.
interface FallbackConfig {
enableCache: boolean;
enableSimplifiedResponse: boolean;
enableQueueing: boolean;
maxQueueSize: number;
}
class GracefulDegradationManager {
private cache: Map<string, CachedResponse> = new Map();
private requestQueue: QueuedRequest[] = [];
constructor(private config: FallbackConfig) {}
async executeWithFallback<T>(
primaryOperation: () => Promise<T>,
request: { cacheKey: string; simplified?: () => T }
): Promise<T | DegradedResponse> {
try {
// 1차: 원래 작업 시도
const result = await primaryOperation();
// 성공 시 캐시 업데이트
if (this.config.enableCache) {
this.cache.set(request.cacheKey, {
data: result,
timestamp: Date.now()
});
}
return result;
} catch (error) {
console.log('주 작업 실패, 대체 전략 실행');
// 2차: 캐시에서 조회
if (this.config.enableCache) {
const cached = this.cache.get(request.cacheKey);
if (cached && this.isValidCache(cached)) {
console.log('캐시된 응답 반환');
return { ...cached.data, degraded: true, source: 'cache' };
}
}
// 3차: 단순화된 응답 반환
if (this.config.enableSimplifiedResponse && request.simplified) {
console.log('단순화된 응답 반환');
return { ...request.simplified(), degraded: true, source: 'simplified' };
}
// 4차: 큐에 저장하고 나중에 처리
if (this.config.enableQueueing) {
if (this.requestQueue.length < this.config.maxQueueSize) {
this.requestQueue.push({ request, timestamp: Date.now() });
return { degraded: true, source: 'queued', message: '요청이 대기열에 추가됨' };
}
}
// 모든 대체 전략 실패
throw new Error('All fallback strategies exhausted');
}
}
private isValidCache(cached: CachedResponse): boolean {
const maxAge = 5 * 60 * 1000; // 5분
return Date.now() - cached.timestamp < maxAge;
}
}
김개발 씨의 AI 에이전트는 사용자 질문에 답변하는 서비스입니다. 평소에는 GPT-4를 사용해서 고품질 답변을 제공합니다.
그런데 LLM API가 다운되면 어떻게 해야 할까요? 처음에 김개발 씨는 그냥 에러 메시지를 보여줬습니다.
"서비스를 일시적으로 이용할 수 없습니다." 사용자들은 불만을 쏟아냈습니다. "어제도 안 됐는데 오늘도 안 돼요?" "급한 건데 언제 되는 거예요?" 박시니어 씨가 조언했습니다.
"완벽한 답변을 못 줘도, 뭔가는 줄 수 있어야 합니다. 그게 바로 그레이스풀 디그레이데이션이에요." 그레이스풀 디그레이데이션이란 무엇일까요?
비행기를 생각해 보세요. 현대 비행기에는 엔진이 2개 이상 달려 있습니다.
한 엔진이 고장 나면 어떻게 될까요? 비행기가 곧장 추락하는 게 아닙니다.
남은 엔진으로 안전하게 착륙할 수 있습니다. 물론 정상 비행은 아니지만, 승객의 안전은 지킬 수 있습니다.
이것이 바로 그레이스풀 디그레이데이션입니다. 일부가 고장 나도 전체가 멈추지 않고, 핵심 기능은 유지하는 것입니다.
AI 에이전트에 적용하면 이렇습니다. LLM API가 다운되면 몇 가지 **대체 전략(Fallback)**을 순차적으로 시도합니다.
첫 번째 대체 전략은 캐시 활용입니다. 이전에 같은 질문에 대한 답변을 저장해뒀다면, 그것을 반환합니다.
최신 정보는 아닐 수 있지만, 아무것도 없는 것보다 낫습니다. "이 답변은 캐시된 데이터입니다"라고 표시해주면 사용자도 이해합니다.
두 번째 대체 전략은 단순화된 응답입니다. 복잡한 LLM 추론 없이 간단한 룰 기반 응답을 제공합니다.
예를 들어 "날씨 어때요?"라는 질문에 실시간 API를 호출하지 못하면, "죄송합니다. 현재 날씨 정보를 가져올 수 없습니다.
기상청 웹사이트를 확인해 주세요"라고 대답할 수 있습니다. 세 번째 대체 전략은 큐잉입니다.
즉시 처리할 수 없는 요청을 대기열에 넣어두고, 서비스가 복구되면 처리합니다. "요청이 접수되었습니다.
서비스가 복구되면 알림을 보내드리겠습니다"라고 안내하면 사용자는 기다릴 수 있습니다. 코드를 보면, executeWithFallback 함수가 이 로직을 구현합니다.
먼저 primaryOperation을 시도합니다. 성공하면 결과를 캐시에 저장하고 반환합니다.
실패하면 대체 전략을 순차적으로 시도합니다. 캐시 조회, 단순화된 응답, 큐잉 순서입니다.
각 대체 응답에는 degraded: true 플래그가 붙어서, 클라이언트가 이것이 완전한 응답이 아님을 알 수 있습니다. 실제 서비스에서 그레이스풀 디그레이데이션은 사용자 경험의 핵심입니다.
장애는 언제든 발생할 수 있습니다. 중요한 것은 장애 상황에서도 사용자에게 무언가를 제공하는 것입니다.
완벽하지 않아도 괜찮습니다. 아무것도 없는 것보다 훨씬 낫습니다.
주의할 점은 대체 응답의 품질 관리입니다. 캐시가 너무 오래되면 오히려 혼란을 줄 수 있습니다.
TTL(Time To Live)을 적절히 설정하고, 대체 응답임을 명확히 표시해야 합니다. 또한 큐 크기 제한도 중요합니다.
무한정 쌓이면 복구 후에 처리 폭탄을 맞을 수 있습니다. 김개발 씨는 그레이스풀 디그레이데이션을 도입한 후, 장애 상황에서도 사용자 불만이 크게 줄었습니다.
"완벽하진 않지만 적어도 뭔가 보여주니까 기다릴 수 있네요"라는 피드백을 받았습니다.
실전 팁
💡 - 대체 응답에는 항상 "제한된 서비스임"을 명시하세요
- 각 대체 전략의 사용 빈도를 모니터링하여 시스템 상태를 파악하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
UX와 협업 패턴 완벽 가이드
AI 에이전트와 사용자 간의 효과적인 협업을 위한 UX 패턴을 다룹니다. 프롬프트 핸드오프부터 인터럽트 처리까지, 현대적인 에이전트 시스템 설계의 핵심을 배웁니다.
AI 에이전트 신뢰성 완벽 가이드 - 가드레일과 평가 시스템
AI 에이전트가 예상치 못한 행동을 하지 않도록 안전장치를 설계하고, 품질을 체계적으로 평가하는 방법을 배웁니다. 실무에서 바로 적용할 수 있는 가드레일 패턴과 평가 프레임워크를 다룹니다.
Feedback Loops 컴파일러와 CI/CD 완벽 가이드
컴파일러 피드백 루프부터 CI/CD 파이프라인, 테스트 자동화, 자가 치유 빌드까지 현대 개발 워크플로우의 핵심을 다룹니다. 초급 개발자도 쉽게 이해할 수 있도록 실무 예제와 함께 설명합니다.
실전 MCP 통합 프로젝트 완벽 가이드
Model Context Protocol을 활용한 실전 통합 프로젝트를 처음부터 끝까지 구축하는 방법을 다룹니다. 아키텍처 설계부터 멀티 서버 통합, 모니터링, 배포까지 운영 레벨의 MCP 시스템을 구축하는 노하우를 담았습니다.
MCP 동적 도구 업데이트 완벽 가이드
AI 에이전트의 도구를 런타임에 동적으로 로딩하고 관리하는 방법을 알아봅니다. 플러그인 시스템 설계부터 핫 리로딩, 보안까지 실무에서 바로 적용할 수 있는 내용을 다룹니다.