title: content-orchestration 자체교정 구현 플랜 (L2) date: 2026-02-25T20:15:00+09:00 type: implementation-plan layer: L2 status: draft tags: [content-orchestration, self-healing, L2] author: self-healing-impl-pl project: content-orchestration reviewed_by: "jarvis" reviewed_at: "2026-02-25T21:50:00+09:00" approved_by: "" approved_at: ""
content-orchestration 자체교정(Self-Healing) 구현 플랜
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 파이프라인 에러 발생 시 L1(즉시 재시도)/L2(백오프 재시도)/L5(에스컬레이션) 자동 교정이 동작하여, 일시적 오류의 80%+를 사람 개입 없이 자동 복구한다.
Architecture: self-healing.ts 핵심 모듈을 신규 생성하고, 기존 stage-collect/generate/publish에 래퍼 방식으로 자체교정을 통합한다. 기존 코드의 핵심 로직은 변경하지 않고, try-catch 블록에 자체교정 호출을 추가하는 방식으로만 구현한다. Cron 핸들러에 runSelfHealingCycle()을 선행 호출로 추가한다.
Tech Stack: Next.js 15 (App Router), Turso (LibSQL), @libsql/client/web, 기존 pipeline-logger.ts 활용
근거 문서:
- L1 자체교정 설계서:
content-orchestration-design-self-healing.md(approved) - L2 Phase 1 구현 플랜:
content-orchestration-impl-phase1.md(approved, 이미 구현됨)
전체 구현 범위 요약
| # | Task | 핵심 산출물 | 예상 규모 |
|---|---|---|---|
| 1 | self-healing.ts 핵심 모듈 생성 | src/lib/self-healing.ts — 타입 정의 + 6개 핵심 함수 | ~250줄 |
| 2 | stage-collect.ts 자체교정 통합 | RSS 수집 실패 시 L1 인라인 재시도 + error_logs 기록 | ~30줄 추가 |
| 3 | stage-generate.ts 자체교정 통합 | AI API 오류 시 L2 백오프 + QA 실패 시 L2 온도 조정 | ~40줄 추가 |
| 4 | stage-publish.ts 자체교정 통합 | 블로그 발행 실패 시 L1 재시도 + slug 충돌 L1 즉시 교정 | ~25줄 추가 |
| 5 | Cron 핸들러에 self-healing 선행 호출 | /api/cron/pipeline 시작 시 runSelfHealingCycle() 실행 | ~15줄 추가 |
| 6 | pipeline-logger.ts 타입 확장 | PipelineName에 'self-healing' 추가 | ~3줄 수정 |
| 7 | 통합 테스트 및 검증 | CLI 스크립트로 self-healing 동작 확인 | ~60줄 |
구현 위치: projects/content-pipeline/ (ai-blog 레포) 내부에서만 작업. content-orchestration 대시보드 레포는 변경 없음.
Task 1: self-healing.ts 핵심 모듈 생성
목적: L1 설계서의 6개 핵심 함수(detectErrors, classifyError, attemptAutoFix, escalateIfNeeded, logFixAttempt, runSelfHealingCycle)를 구현한다. Phase 1 범위인 L1/L2/L5만 구현하고, L3/L4는 Phase 2 placeholder로 남긴다.
Files:
- Create:
projects/content-pipeline/src/lib/self-healing.ts
Step 1: 타입 정의
// projects/content-pipeline/src/lib/self-healing.ts
import { createClient } from '@libsql/client/web';
import {
logError,
logAutoFix,
logPipelineStart,
logPipelineComplete,
type ErrorComponent,
type ErrorType,
} from './pipeline-logger';
// --- 타입 정의 ---
export type HealingLevel = 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
export interface FixResult {
success: boolean;
action: string;
nextLevel?: HealingLevel;
}
export interface HealingReport {
total: number;
fixed: number;
escalated: number;
skipped: number;
}
interface ErrorLogRow {
id: string;
occurred_at: number;
component: string;
error_type: string;
error_message: string;
content_id: string | null;
channel_id: string | null;
auto_fix_attempted: number;
auto_fix_result: string | null;
auto_fix_action: string | null;
escalated: number;
resolved_at: number | null;
resolution_type: string | null;
}
/** component + error_type → 교정 등급 매핑 (L1 설계서 섹션 3-3) */
interface ErrorClassification {
level: HealingLevel;
maxRetries: number;
baseDelayMs: number;
autoFixAction: string;
}
Step 2: DB 연결 + 에러 분류 매핑 테이블
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* component + error_type 조합으로 교정 등급을 결정한다.
* L1 설계서 섹션 3-3 매핑 테이블 기반.
*/
const ERROR_CLASSIFICATION_MAP: Record<string, ErrorClassification> = {
// rss_collector
'rss_collector:timeout': { level: 'L1', maxRetries: 1, baseDelayMs: 5000, autoFixAction: '타임아웃 확장 후 재시도' },
'rss_collector:api_error': { level: 'L1', maxRetries: 1, baseDelayMs: 5000, autoFixAction: '5초 후 재시도' },
'rss_collector:validation_fail':{ level: 'L1', maxRetries: 0, baseDelayMs: 0, autoFixAction: '해당 피드 skip' },
// ai_generator
'ai_generator:timeout': { level: 'L2', maxRetries: 2, baseDelayMs: 30000, autoFixAction: '30초 대기 후 재시도' },
'ai_generator:api_error': { level: 'L2', maxRetries: 2, baseDelayMs: 10000, autoFixAction: '지수 백오프 재시도' },
'ai_generator:auth_fail': { level: 'L5', maxRetries: 0, baseDelayMs: 0, autoFixAction: '즉시 에스컬레이션 (API 키 갱신 필요)' },
// qa_checker
'qa_checker:quality_fail': { level: 'L2', maxRetries: 2, baseDelayMs: 0, autoFixAction: '재생성 (온도 조정)' },
// publisher
'publisher:timeout': { level: 'L1', maxRetries: 1, baseDelayMs: 5000, autoFixAction: '5초 후 재시도' },
'publisher:api_error': { level: 'L1', maxRetries: 1, baseDelayMs: 5000, autoFixAction: '5초 후 재시도' },
'publisher:validation_fail': { level: 'L1', maxRetries: 1, baseDelayMs: 0, autoFixAction: 'slug 변경 후 재시도' },
'publisher:auth_fail': { level: 'L5', maxRetries: 0, baseDelayMs: 0, autoFixAction: '즉시 에스컬레이션 (DB 인증 확인)' },
// brevo
'brevo:rate_limit': { level: 'L2', maxRetries: 3, baseDelayMs: 60000, autoFixAction: '60초 대기 → 다음 날 재예약' },
'brevo:auth_fail': { level: 'L5', maxRetries: 0, baseDelayMs: 0, autoFixAction: '즉시 에스컬레이션 (Brevo API 키 확인)' },
'brevo:api_error': { level: 'L2', maxRetries: 2, baseDelayMs: 5000, autoFixAction: '재시도 → 파라미터 검증' },
'brevo:validation_fail': { level: 'L2', maxRetries: 1, baseDelayMs: 0, autoFixAction: '요청 파라미터 검증 후 재시도' },
// sns_publisher
'sns_publisher:auth_fail': { level: 'L3', maxRetries: 0, baseDelayMs: 0, autoFixAction: '해당 플랫폼 skip (Phase 2)' },
'sns_publisher:api_error': { level: 'L1', maxRetries: 1, baseDelayMs: 30000, autoFixAction: '30초 후 재시도' },
'sns_publisher:timeout': { level: 'L1', maxRetries: 1, baseDelayMs: 5000, autoFixAction: '재시도 → 해당 플랫폼 skip' },
// scheduler
'scheduler:api_error': { level: 'L2', maxRetries: 1, baseDelayMs: 0, autoFixAction: 'Cron 다음 실행 시 재시도' },
};
Step 3: 핵심 함수 6개 구현
/**
* [1] detectErrors — error_logs에서 미해결 에러를 조회한다.
* 조건: resolved_at IS NULL AND escalated = 0
* 정렬: occurred_at ASC (오래된 에러부터)
* 최대 20건
*/
export async function detectErrors(): Promise<ErrorLogRow[]> {
const db = getContentDb();
const result = await db.execute({
sql: `SELECT * FROM error_logs
WHERE resolved_at IS NULL
AND (auto_fix_attempted = 0 OR auto_fix_result = 'failed')
AND escalated = 0
ORDER BY occurred_at ASC
LIMIT 20`,
args: [],
});
return result.rows as unknown as ErrorLogRow[];
}
/**
* [2] classifyError — 에러의 component와 error_type 조합으로 교정 등급을 판단한다.
* auth_fail은 항상 L5.
*/
export function classifyError(error: ErrorLogRow): ErrorClassification {
// auth_fail은 항상 L5
if (error.error_type === 'auth_fail') {
return {
level: 'L5',
maxRetries: 0,
baseDelayMs: 0,
autoFixAction: `즉시 에스컬레이션 (${error.component} 인증 실패)`,
};
}
const key = `${error.component}:${error.error_type}`;
const classification = ERROR_CLASSIFICATION_MAP[key];
if (!classification) {
// 매핑에 없는 에러는 L2 기본 재시도로 처리
return {
level: 'L2',
maxRetries: 1,
baseDelayMs: 5000,
autoFixAction: `알 수 없는 에러 유형 (${key}), 기본 재시도`,
};
}
return classification;
}
/**
* [3] attemptAutoFix — 등급에 따라 자동 교정을 실행한다.
*
* Phase 1: L1(즉시 재시도), L2(백오프 재시도)만 실제 동작.
* L3/L4는 Phase 2 placeholder. L5는 에스컬레이션만.
*
* 주의: runSelfHealingCycle에서 호출하는 경우, "이전 실행에서 남은 에러"를 재시도하는 것이므로
* 실제 재시도 로직(원래 함수 재호출)은 없고, 에러 상태만 업데이트한다.
* 인라인 자체교정(각 Stage에서 직접 호출)에서는 실제 재시도를 수행한다.
*/
export async function attemptAutoFix(
error: ErrorLogRow,
classification: ErrorClassification
): Promise<FixResult> {
const { level } = classification;
switch (level) {
case 'L1': {
// L1: 즉시 재시도 — runSelfHealingCycle에서는 "재시도 가능" 표시만
// 실제 재시도는 다음 파이프라인 실행에서 자연스럽게 처리됨
return {
success: false,
action: `L1 ${classification.autoFixAction} — 다음 파이프라인 실행에서 재시도 예정`,
nextLevel: 'L2',
};
}
case 'L2': {
// L2: 백오프 재시도 — 동일하게 다음 실행 위임
// 3회 이상 실패한 에러는 L5로 에스컬레이션
return {
success: false,
action: `L2 ${classification.autoFixAction} — 다음 파이프라인 실행에서 백오프 재시도 예정`,
nextLevel: 'L5',
};
}
case 'L3': {
// Phase 2 placeholder
return {
success: false,
action: 'L3 대체 전략 — Phase 2에서 구현 예정',
nextLevel: 'L5',
};
}
case 'L4': {
// Phase 2 placeholder
return {
success: false,
action: 'L4 품질 강등 — Phase 2에서 구현 예정',
nextLevel: 'L5',
};
}
case 'L5': {
return {
success: false,
action: `L5 에스컬레이션: ${classification.autoFixAction}`,
};
}
default:
return { success: false, action: `알 수 없는 등급: ${level}` };
}
}
/**
* [4] escalateIfNeeded — 에스컬레이션 필요 여부를 판단하고, 필요 시 escalated=1 설정.
*
* 에스컬레이션 조건:
* 1. error_type === 'auth_fail' → 즉시
* 2. 동일 component 24시간 내 3회 교정 실패 → 에스컬레이션
*/
export async function escalateIfNeeded(error: ErrorLogRow): Promise<boolean> {
// 조건 1: auth_fail 즉시 에스컬레이션
if (error.error_type === 'auth_fail') {
await markEscalated(error.id);
return true;
}
// 조건 2: 동일 component 24시간 내 3회 자동 교정 실패
const db = getContentDb();
const result = await db.execute({
sql: `SELECT COUNT(*) as fail_count
FROM error_logs
WHERE component = ?
AND auto_fix_result = 'failed'
AND occurred_at > (? - 86400000)
AND resolved_at IS NULL`,
args: [error.component, Date.now()],
});
const failCount = Number((result.rows[0] as unknown as { fail_count: number }).fail_count) || 0;
if (failCount >= 3) {
await markEscalated(error.id);
return true;
}
return false;
}
/** error_logs.escalated = 1 설정 + auto_fix_result = 'skipped' */
async function markEscalated(errorId: string): Promise<void> {
const db = getContentDb();
await db.execute({
sql: `UPDATE error_logs
SET escalated = 1, auto_fix_result = 'skipped',
auto_fix_attempted = 1
WHERE id = ?`,
args: [errorId],
});
}
/**
* [5] logFixAttempt — 교정 시도/결과를 error_logs에 업데이트한다.
* pipeline-logger.ts의 logAutoFix를 래핑.
*/
export async function logFixAttempt(
errorId: string,
result: FixResult
): Promise<void> {
const fixResult = result.success ? 'success' : 'failed';
await logAutoFix(errorId, fixResult, result.action);
}
/**
* [6] runSelfHealingCycle — 전체 자체교정 사이클 실행.
* Cron 또는 파이프라인 시작 전에 호출한다.
*
* 플로우:
* 1. detectErrors() → 미해결 에러 목록
* 2. 각 에러: classifyError() → attemptAutoFix() → logFixAttempt()
* 3. 교정 실패 시 escalateIfNeeded()
* 4. 결과 리포트 반환 + pipeline_logs 기록
*/
export async function runSelfHealingCycle(): Promise<HealingReport> {
const report: HealingReport = { total: 0, fixed: 0, escalated: 0, skipped: 0 };
const pipelineLog = await logPipelineStart('self-healing' as any, 'scheduled');
try {
// 1. 미해결 에러 스캔
const errors = await detectErrors();
report.total = errors.length;
if (errors.length === 0) {
console.log('[self-healing] 미해결 에러 없음');
await logPipelineComplete(pipelineLog.id, 0, { message: 'no_unresolved_errors' });
return report;
}
console.log(`[self-healing] 미해결 에러 ${errors.length}건 발견`);
const details: Array<{ error_id: string; component: string; result: string; level: string }> = [];
// 2. 각 에러 처리
for (const error of errors) {
const classification = classifyError(error);
// L5는 바로 에스컬레이션
if (classification.level === 'L5') {
await escalateIfNeeded(error);
report.escalated++;
details.push({ error_id: error.id, component: error.component, result: 'escalated', level: 'L5' });
console.log(`[self-healing] ${error.id} (${error.component}:${error.error_type}) → L5 에스컬레이션`);
continue;
}
// L3/L4는 Phase 2 → skip
if (classification.level === 'L3' || classification.level === 'L4') {
report.skipped++;
details.push({ error_id: error.id, component: error.component, result: 'skipped', level: classification.level });
console.log(`[self-healing] ${error.id} (${error.component}:${error.error_type}) → ${classification.level} Phase 2 대기`);
continue;
}
// L1/L2 처리
const fixResult = await attemptAutoFix(error, classification);
await logFixAttempt(error.id, fixResult);
if (fixResult.success) {
report.fixed++;
details.push({ error_id: error.id, component: error.component, result: 'success', level: classification.level });
} else {
// 에스컬레이션 필요 여부 확인
const escalated = await escalateIfNeeded(error);
if (escalated) {
report.escalated++;
details.push({ error_id: error.id, component: error.component, result: 'escalated', level: classification.level });
} else {
report.skipped++;
details.push({ error_id: error.id, component: error.component, result: 'pending_retry', level: classification.level });
}
}
}
// 3. pipeline_logs 기록
await logPipelineComplete(pipelineLog.id, report.total, {
total_errors: report.total,
fixed: report.fixed,
escalated: report.escalated,
skipped: report.skipped,
details,
});
console.log(`[self-healing] 완료: ${report.total}건 스캔, ${report.fixed}건 교정, ${report.escalated}건 에스컬레이션, ${report.skipped}건 스킵`);
return report;
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error(`[self-healing] 사이클 오류: ${errMsg}`);
await logPipelineFailed(pipelineLog.id, errMsg);
return report;
}
}
여기서 logPipelineFailed도 import 필요:
// 파일 상단 import 수정
import {
logError,
logAutoFix,
logPipelineStart,
logPipelineComplete,
logPipelineFailed,
type ErrorComponent,
type ErrorType,
} from './pipeline-logger';
Step 4: 인라인 자체교정 헬퍼 함수 — Stage에서 직접 사용
각 Stage에서 에러 발생 시 인라인으로 L1/L2 교정을 수행하는 헬퍼 함수들.
// --- 인라인 자체교정 헬퍼 (Stage에서 직접 호출) ---
/**
* L1 즉시 재시도: 동일 함수를 1회 재실행한다.
* @param fn 재시도할 비동기 함수
* @param delayMs 재시도 전 대기 시간 (기본 5초)
* @param component 에러 컴포넌트 (error_logs 기록용)
* @param errorType 에러 타입 (error_logs 기록용)
* @param errorMessage 원래 에러 메시지
* @returns fn() 결과 또는 null (재시도 실패 시)
*/
export async function retryL1<T>(
fn: () => Promise<T>,
delayMs: number = 5000,
component: ErrorComponent,
errorType: ErrorType,
errorMessage: string
): Promise<{ result: T; errorLogId: string } | null> {
const errorLogId = await logError(component, errorType, errorMessage);
if (delayMs > 0) {
await sleep(delayMs);
}
try {
const result = await fn();
await logAutoFix(errorLogId, 'success', 'L1 즉시 재시도 성공');
console.log(`[self-healing:L1] ${component}:${errorType} 재시도 성공`);
return { result, errorLogId };
} catch (retryErr) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
await logAutoFix(errorLogId, 'failed', `L1 재시도 실패: ${retryMsg}`);
console.warn(`[self-healing:L1] ${component}:${errorType} 재시도 실패: ${retryMsg}`);
return null;
}
}
/**
* L2 백오프 재시도: 지수 백오프로 최대 maxRetries회 재시도한다.
* @param fn 재시도할 비동기 함수
* @param maxRetries 최대 재시도 횟수 (기본 3)
* @param baseDelayMs 기본 대기 시간 (기본 5초, 각 시도마다 2배)
* @param component 에러 컴포넌트
* @param errorType 에러 타입
* @param errorMessage 원래 에러 메시지
* @returns fn() 결과 또는 null (모든 재시도 실패 시)
*/
export async function retryL2<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelayMs: number = 5000,
component: ErrorComponent,
errorType: ErrorType,
errorMessage: string
): Promise<{ result: T; errorLogId: string } | null> {
const errorLogId = await logError(component, errorType, errorMessage);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const delay = baseDelayMs * Math.pow(2, attempt - 1);
console.log(`[self-healing:L2] ${component}:${errorType} 재시도 ${attempt}/${maxRetries} (${delay}ms 대기)`);
await sleep(delay);
try {
const result = await fn();
await logAutoFix(errorLogId, 'success', `L2 백오프 재시도 성공 (${attempt}/${maxRetries})`);
console.log(`[self-healing:L2] ${component}:${errorType} 재시도 ${attempt} 성공`);
return { result, errorLogId };
} catch (retryErr) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
if (attempt === maxRetries) {
await logAutoFix(errorLogId, 'failed', `L2 ${maxRetries}회 백오프 재시도 모두 실패: ${retryMsg}`);
console.warn(`[self-healing:L2] ${component}:${errorType} 최종 실패`);
}
}
}
return null;
}
/**
* L5 에스컬레이션: error_logs에 에스컬레이션 기록.
* Phase 1에서는 DB 기록만. Phase 2에서 Telegram 알림 추가.
*/
export async function escalateL5(
component: ErrorComponent,
errorType: ErrorType,
errorMessage: string,
options?: { contentId?: string; channelId?: string }
): Promise<string> {
const errorLogId = await logError(component, errorType, errorMessage, options);
await markEscalated(errorLogId);
console.error(`[self-healing:L5] 에스컬레이션: ${component}:${errorType} — ${errorMessage}`);
return errorLogId;
}
Step 5: Commit
git add projects/content-pipeline/src/lib/self-healing.ts
git commit -m "feat(content-pipeline): self-healing.ts 핵심 모듈 생성 — L1/L2/L5 자체교정 + 6개 핵심 함수"
Task 2: stage-collect.ts 자체교정 통합
목적: RSS 수집 실패 시 L1 인라인 재시도를 적용한다. 전체 수집 실패(catch 블록)에서 retryL1을 호출하여 1회 재시도한다.
Files:
- Modify:
projects/content-pipeline/src/pipeline/stage-collect.ts
기존 코드 보호:
collectNews(),saveCollectedNews()핵심 로직 변경 없음- 기존 catch 블록에 자체교정 호출 추가만
Step 1: import 추가
// stage-collect.ts 상단에 추가
import { retryL1, escalateL5 } from '../lib/self-healing';
Step 2: catch 블록에 L1 재시도 추가
현재 stage-collect.ts:57-72 catch 블록을 수정한다.
기존:
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const errorLogId = await logError('rss_collector', 'api_error', errMsg);
await logPipelineFailed(pipelineLog.id, errMsg, errorLogId);
console.error(`[stage-collect] 실패: ${errMsg}`);
return {
success: false,
itemsCollected: 0,
itemsSaved: 0,
feedsOk: 0,
feedsFail: 0,
pipelineLogId: pipelineLog.id,
};
}
수정:
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.warn(`[stage-collect] 1차 실패: ${errMsg}, L1 재시도 시도...`);
// L1: 5초 대기 후 전체 수집 1회 재시도
const retryResult = await retryL1(
async () => {
const items = await collectNews();
const saved = await saveCollectedNews(items);
return { items, saved };
},
5000,
'rss_collector',
'api_error',
errMsg
);
if (retryResult) {
const { items, saved } = retryResult.result;
await logPipelineComplete(pipelineLog.id, saved, {
raw_items: items.length,
saved_items: saved,
filter: 'pillar_keyword',
self_healing: 'L1_retry_success',
});
console.log(`[stage-collect] L1 재시도 성공: ${items.length}건 수집, ${saved}건 저장`);
return {
success: true,
itemsCollected: items.length,
itemsSaved: saved,
feedsOk: 0,
feedsFail: 0,
pipelineLogId: pipelineLog.id,
};
}
// L1 재시도도 실패 → auth_fail이면 L5, 아니면 에러 기록만
const isAuthFail = errMsg.toLowerCase().includes('auth') || errMsg.toLowerCase().includes('401') || errMsg.toLowerCase().includes('403');
if (isAuthFail) {
await escalateL5('rss_collector', 'auth_fail', errMsg);
}
const errorLogId = await logError('rss_collector', 'api_error', `L1 재시도 포함 최종 실패: ${errMsg}`);
await logPipelineFailed(pipelineLog.id, errMsg, errorLogId);
console.error(`[stage-collect] 최종 실패: ${errMsg}`);
return {
success: false,
itemsCollected: 0,
itemsSaved: 0,
feedsOk: 0,
feedsFail: 0,
pipelineLogId: pipelineLog.id,
};
}
Step 3: Commit
git add projects/content-pipeline/src/pipeline/stage-collect.ts
git commit -m "feat(content-pipeline): stage-collect L1 자체교정 통합 — RSS 수집 실패 시 5초 대기 후 1회 재시도"
Task 3: stage-generate.ts 자체교정 통합
목적: AI API 오류 시 L2 백오프 재시도를 적용한다. 기존 재시도 루프(MAX_RETRIES=2)는 유지하되, 최종 catch에서 L2 백오프를 추가하고, auth_fail 감지 시 L5 에스컬레이션한다.
Files:
- Modify:
projects/content-pipeline/src/pipeline/stage-generate.ts
기존 코드 보호:
generateBlogPost(),validateQuality(),getTodayPillar()핵심 로직 변경 없음- 기존 MAX_RETRIES 재시도 루프(L128~L157) 변경 없음
- 최종 catch 블록(L185~L190)에 자체교정 호출 추가만
Step 1: import 추가
// stage-generate.ts 상단에 추가
import { retryL2, escalateL5 } from '../lib/self-healing';
Step 2: 최종 catch 블록에 L2 백오프 + L5 에스컬레이션 추가
현재 stage-generate.ts:185-190 catch 블록을 수정한다.
기존:
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const errorLogId = await logError('ai_generator', 'api_error', errMsg);
await logPipelineFailed(pipelineLog.id, errMsg, errorLogId);
return { success: false, contentQueueId: null, title: null, qaScore: 0, pipelineLogId: pipelineLog.id };
}
수정:
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// auth_fail 감지 → L5 즉시 에스컬레이션
const isAuthFail = errMsg.toLowerCase().includes('auth') ||
errMsg.toLowerCase().includes('401') ||
errMsg.toLowerCase().includes('403') ||
errMsg.toLowerCase().includes('api_key');
if (isAuthFail) {
const escId = await escalateL5('ai_generator', 'auth_fail', errMsg);
await logPipelineFailed(pipelineLog.id, errMsg, escId);
return { success: false, contentQueueId: null, title: null, qaScore: 0, pipelineLogId: pipelineLog.id };
}
// L2: 지수 백오프 재시도 (10초 기본, 최대 2회)
console.warn(`[stage-generate] catch 진입, L2 백오프 재시도 시도...`);
const pillar = pillarOverride || getTodayPillar();
const finalTopic = topic || `${pillar || 'AI 활용'} 최신 트렌드 분석`;
const retryResult = await retryL2(
async () => {
const post = await generateBlogPost(finalTopic, pillar || undefined);
if (!post) throw new Error('생성 결과 null');
return post;
},
2,
10000,
'ai_generator',
'api_error',
errMsg
);
if (retryResult) {
const post = retryResult.result;
const quality = validateQuality(post);
const cqId = await saveToContentQueue(post, pillar, quality.score);
await logPipelineComplete(pipelineLog.id, 1, {
pillar: pillar || 'none',
qa_score: quality.score,
content_type: 'blog',
content_queue_id: cqId,
self_healing: 'L2_retry_success',
});
return {
success: true,
contentQueueId: cqId,
title: post.title,
qaScore: quality.score,
pipelineLogId: pipelineLog.id,
};
}
// L2 재시도도 실패 → 에러 기록
const errorLogId = await logError('ai_generator', 'api_error', `L2 백오프 포함 최종 실패: ${errMsg}`);
await logPipelineFailed(pipelineLog.id, errMsg, errorLogId);
return { success: false, contentQueueId: null, title: null, qaScore: 0, pipelineLogId: pipelineLog.id };
}
Step 3: Commit
git add projects/content-pipeline/src/pipeline/stage-generate.ts
git commit -m "feat(content-pipeline): stage-generate L2/L5 자체교정 통합 — AI API 오류 시 백오프 재시도, auth_fail 에스컬레이션"
Task 4: stage-publish.ts 자체교정 통합
목적: 블로그 발행 실패 시 기존 L1 재시도를 self-healing 모듈로 통합하고, auth_fail 감지 시 L5 에스컬레이션한다. 기존 stage-publish.ts에 이미 수동 L1 재시도가 구현되어 있으므로(L209~L238), 이를 self-healing 함수로 교체한다.
Files:
- Modify:
projects/content-pipeline/src/pipeline/stage-publish.ts
기존 코드 보호:
publishToBlog(),createDistribution(),getNextApproved(),updateContentQueueStatus()핵심 로직 변경 없음- 기존 수동 L1 재시도 블록(L206~L238)을 self-healing 함수 호출로 교체
Step 1: import 추가
// stage-publish.ts 상단에 추가
import { retryL1, escalateL5 } from '../lib/self-healing';
Step 2: 블로그 발행 실패 시 자체교정으로 교체
현재 stage-publish.ts:206-238 수동 재시도 블록을 수정한다.
기존:
if (!blogResult) {
const errId = await logError('publisher', 'api_error', 'blog_posts INSERT 실패', { contentId: item.id, channelId: 'ch-apppro-blog' });
// L1 자동 재시도 (1회)
console.log('[stage-publish] 블로그 발행 실패, 1회 재시도...');
const retryResult = await publishToBlog(item.contentBody, item.title);
if (!retryResult) {
await logAutoFix(errId, 'failed', '블로그 INSERT 재시도 실패');
await logPipelineFailed(pipelineLog.id, '블로그 발행 최종 실패', errId);
await updateContentQueueStatus(item.id, 'failed');
return { success: false, contentId: item.id, blogPostId: null, distributionId: null, pipelineLogId: pipelineLog.id };
}
await logAutoFix(errId, 'success', '블로그 INSERT 재시도 성공');
// retryResult를 blogResult로 사용
const distId = await createDistribution(
item.id, 'ch-apppro-blog', retryResult.postId,
`https://apppro.kr/blog/${retryResult.slug}`
);
await updateContentQueueStatus(item.id, 'published');
await logPipelineComplete(pipelineLog.id, 1, {
channels_ok: 1,
channels_fail: 0,
channels: ['apppro-blog'],
blog_post_id: retryResult.postId,
retry: true,
});
return { success: true, contentId: item.id, blogPostId: retryResult.postId, distributionId: distId, pipelineLogId: pipelineLog.id };
}
수정:
if (!blogResult) {
// L1 자체교정: 5초 대기 후 1회 재시도
const retryResult = await retryL1(
() => publishToBlog(item.contentBody, item.title),
5000,
'publisher',
'api_error',
`blog_posts INSERT 실패 (content_id: ${item.id})`
);
if (retryResult && retryResult.result) {
const retryBlog = retryResult.result;
const distId = await createDistribution(
item.id, 'ch-apppro-blog', retryBlog.postId,
`https://apppro.kr/blog/${retryBlog.slug}`
);
await updateContentQueueStatus(item.id, 'published');
await logPipelineComplete(pipelineLog.id, 1, {
channels_ok: 1,
channels_fail: 0,
channels: ['apppro-blog'],
blog_post_id: retryBlog.postId,
self_healing: 'L1_retry_success',
});
return { success: true, contentId: item.id, blogPostId: retryBlog.postId, distributionId: distId, pipelineLogId: pipelineLog.id };
}
// L1 재시도 실패 → 에러 기록
const errId = await logError('publisher', 'api_error', `블로그 발행 최종 실패 (L1 재시도 포함, content_id: ${item.id})`, { contentId: item.id, channelId: 'ch-apppro-blog' });
await logPipelineFailed(pipelineLog.id, '블로그 발행 최종 실패', errId);
await updateContentQueueStatus(item.id, 'failed');
return { success: false, contentId: item.id, blogPostId: null, distributionId: null, pipelineLogId: pipelineLog.id };
}
Step 3: 최종 catch에 auth_fail 감지 추가
현재 stage-publish.ts:276-281 catch 블록을 수정한다.
기존:
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const errorLogId = await logError('publisher', 'api_error', errMsg);
await logPipelineFailed(pipelineLog.id, errMsg, errorLogId);
return { success: false, contentId: contentId || '', blogPostId: null, distributionId: null, pipelineLogId: pipelineLog.id };
}
수정:
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// auth_fail 감지 → L5 에스컬레이션
const isAuthFail = errMsg.toLowerCase().includes('auth') || errMsg.toLowerCase().includes('401') || errMsg.toLowerCase().includes('403');
if (isAuthFail) {
const escId = await escalateL5('publisher', 'auth_fail', errMsg);
await logPipelineFailed(pipelineLog.id, errMsg, escId);
} else {
const errorLogId = await logError('publisher', 'api_error', errMsg);
await logPipelineFailed(pipelineLog.id, errMsg, errorLogId);
}
return { success: false, contentId: contentId || '', blogPostId: null, distributionId: null, pipelineLogId: pipelineLog.id };
}
Step 4: logAutoFix import 제거 (self-healing으로 대체)
stage-publish.ts에서 직접 logAutoFix를 호출하던 부분은 retryL1이 내부에서 처리하므로, import에서 logAutoFix를 제거한다 (사용처가 없어짐).
// 기존
import {
logPipelineStart,
logPipelineComplete,
logPipelineFailed,
logError,
logAutoFix,
type TriggerType,
} from '../lib/pipeline-logger';
// 수정 (logAutoFix 제거)
import {
logPipelineStart,
logPipelineComplete,
logPipelineFailed,
logError,
type TriggerType,
} from '../lib/pipeline-logger';
Step 5: Commit
git add projects/content-pipeline/src/pipeline/stage-publish.ts
git commit -m "feat(content-pipeline): stage-publish L1/L5 자체교정 통합 — 발행 실패 시 self-healing 모듈 활용, auth_fail 에스컬레이션"
Task 5: Cron 핸들러에 self-healing 선행 호출
목적: /api/cron/pipeline 시작 시 runSelfHealingCycle()을 먼저 실행하여, 이전 실행에서 남은 미해결 에러를 교정한다. L1 설계서 섹션 5-1 "파이프라인 실행 전 Self-Healing 선행" 구현.
Files:
- Modify:
projects/content-pipeline/src/app/api/cron/pipeline/route.ts
Step 1: import 추가
// route.ts 상단에 추가
import { runSelfHealingCycle } from '../../../../lib/self-healing';
Step 2: 파이프라인 실행 전 self-healing 호출 추가
현재 route.ts:27-30 (ensureSchema 후, Stage 1 전)에 삽입한다.
기존:
// DB 스키마 확인 (멱등)
await ensureSchema();
// Stage 1: RSS 수집
console.log('[cron/pipeline] Stage 1: 수집...');
수정:
// DB 스키마 확인 (멱등)
await ensureSchema();
// Self-Healing: 이전 실행 잔류 에러 교정
console.log('[cron/pipeline] Self-Healing 사이클 실행...');
const healingReport = await runSelfHealingCycle();
if (healingReport.total > 0) {
console.log(`[cron/pipeline] Self-Healing: ${healingReport.total}건 스캔, ${healingReport.fixed}건 교정, ${healingReport.escalated}건 에스컬레이션`);
}
// Stage 1: RSS 수집
console.log('[cron/pipeline] Stage 1: 수집...');
Step 3: 응답 JSON에 self-healing 결과 추가
현재 route.ts:52-68 응답 JSON에 healingReport를 추가한다.
기존:
return NextResponse.json({
success: true,
duration_ms: duration,
collect: { ... },
generate: { ... },
nextStep: ...
});
수정:
return NextResponse.json({
success: true,
duration_ms: duration,
selfHealing: healingReport.total > 0 ? healingReport : null,
collect: {
itemsCollected: collectResult.itemsCollected,
itemsSaved: collectResult.itemsSaved,
},
generate: {
success: generateResult.success,
contentQueueId: generateResult.contentQueueId,
title: generateResult.title,
qaScore: generateResult.qaScore,
},
nextStep: generateResult.success
? `CEO 승인 대기: POST /api/pipeline/approve { "contentId": "${generateResult.contentQueueId}" }`
: '생성 실패 — 수동 재실행 필요',
});
Step 4: Commit
git add projects/content-pipeline/src/app/api/cron/pipeline/route.ts
git commit -m "feat(content-pipeline): Cron에 self-healing 선행 실행 추가 — 파이프라인 시작 전 잔류 에러 교정"
Task 6: pipeline-logger.ts 타입 확장
목적: PipelineName 타입에 'self-healing'을 추가하여, self-healing.ts에서 logPipelineStart('self-healing' as any, ...) 대신 타입 안전하게 호출할 수 있도록 한다.
Files:
- Modify:
projects/content-pipeline/src/lib/pipeline-logger.ts
Step 1: PipelineName 타입 확장
현재 pipeline-logger.ts:11:
export type PipelineName = 'collect' | 'generate' | 'approve' | 'publish';
수정:
export type PipelineName = 'collect' | 'generate' | 'approve' | 'publish' | 'self-healing';
Step 2: self-healing.ts에서 as any 제거
self-healing.ts의 runSelfHealingCycle()에서:
기존:
const pipelineLog = await logPipelineStart('self-healing' as any, 'scheduled');
수정:
const pipelineLog = await logPipelineStart('self-healing', 'scheduled');
Step 3: Commit
git add projects/content-pipeline/src/lib/pipeline-logger.ts projects/content-pipeline/src/lib/self-healing.ts
git commit -m "chore(content-pipeline): PipelineName에 self-healing 추가 — 타입 안전성 확보"
Task 7: 통합 테스트 및 검증
목적: self-healing 모듈이 정상 동작하는지 검증하는 CLI 스크립트를 작성한다. 에러 시뮬레이션 → 자체교정 사이클 실행 → DB 상태 확인.
Files:
- Create:
projects/content-pipeline/scripts/test-self-healing.ts
Step 1: 테스트 스크립트 작성
// projects/content-pipeline/scripts/test-self-healing.ts
import { createClient } from '@libsql/client/web';
import { logError } from '../src/lib/pipeline-logger';
import {
detectErrors,
classifyError,
runSelfHealingCycle,
} from '../src/lib/self-healing';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
async function main() {
console.log('=== Self-Healing 통합 테스트 시작 ===\n');
// 1. 테스트 에러 생성
console.log('[테스트 1] 에러 로그 생성...');
const err1 = await logError('rss_collector', 'timeout', '테스트: RSS 타임아웃');
const err2 = await logError('ai_generator', 'api_error', '테스트: AI API 500 오류');
const err3 = await logError('publisher', 'auth_fail', '테스트: DB 인증 실패');
console.log(` 생성된 에러: ${err1}, ${err2}, ${err3}`);
// 2. detectErrors 테스트
console.log('\n[테스트 2] detectErrors()...');
const detected = await detectErrors();
console.log(` 감지된 미해결 에러: ${detected.length}건`);
for (const e of detected) {
console.log(` - ${e.id.slice(0, 8)}... ${e.component}:${e.error_type}`);
}
// 3. classifyError 테스트
console.log('\n[테스트 3] classifyError()...');
for (const e of detected) {
const classification = classifyError(e as any);
console.log(` ${e.component}:${e.error_type} → ${classification.level} (${classification.autoFixAction})`);
}
// 4. runSelfHealingCycle 테스트
console.log('\n[테스트 4] runSelfHealingCycle()...');
const report = await runSelfHealingCycle();
console.log(` 결과: total=${report.total}, fixed=${report.fixed}, escalated=${report.escalated}, skipped=${report.skipped}`);
// 5. DB 상태 확인
console.log('\n[테스트 5] DB 상태 확인...');
const db = getContentDb();
const errors = await db.execute({
sql: `SELECT id, component, error_type, auto_fix_attempted, auto_fix_result, escalated
FROM error_logs
WHERE id IN (?, ?, ?)`,
args: [err1, err2, err3],
});
for (const row of errors.rows) {
console.log(` ${(row.id as string).slice(0, 8)}... ${row.component}:${row.error_type} → attempted=${row.auto_fix_attempted}, result=${row.auto_fix_result}, escalated=${row.escalated}`);
}
// 6. 정리 (테스트 에러 삭제)
console.log('\n[정리] 테스트 에러 삭제...');
await db.execute({ sql: 'DELETE FROM error_logs WHERE id IN (?, ?, ?)', args: [err1, err2, err3] });
// self-healing pipeline_logs도 삭제
await db.execute({ sql: "DELETE FROM pipeline_logs WHERE pipeline_name = 'self-healing' AND created_at > ?", args: [Date.now() - 60000] });
console.log(' 완료');
console.log('\n=== Self-Healing 통합 테스트 완료 ===');
}
main().catch(console.error);
Step 2: 실행
cd projects/content-pipeline && npx tsx scripts/test-self-healing.ts
Expected:
- 테스트 1: 3건 에러 생성 (id 출력)
- 테스트 2: 3건 이상 감지 (기존 미해결 에러 포함 가능)
- 테스트 3: rss_collector:timeout → L1, ai_generator:api_error → L2, publisher:auth_fail → L5
- 테스트 4: auth_fail 1건 에스컬레이션, L1/L2는 pending_retry 또는 skipped
- 테스트 5: auth_fail의 escalated=1, 나머지 auto_fix_attempted=1
Step 3: Commit
git add projects/content-pipeline/scripts/test-self-healing.ts
git commit -m "test(content-pipeline): self-healing 통합 테스트 스크립트 — 에러 시뮬레이션 + 사이클 검증"
주의사항 및 보호 규칙
기존 코드 변경 최소화
collect.ts,generate-blog.ts,publishToBlog()등 핵심 비즈니스 로직 파일은 수정하지 않는다- Stage 래퍼(stage-collect/generate/publish)만 수정하며, 수정은 import 추가 + catch 블록 확장에 한정
- pipeline-logger.ts는 타입 1줄만 수정
Phase 1 범위 엄수
- L1(즉시 재시도) + L2(백오프 재시도) + L5(에스컬레이션 기록)만 구현
- L3(대체 전략), L4(품질 강등)는
Phase 2주석으로만 명시 - Telegram 에스컬레이션 알림은 Phase 2 (DB 기록만)
지연 시간 제한
- L1 재시도 대기: 최대 5초
- L2 백오프 재시도: 최대 10초 * 2^2 = 40초 (3회 시)
- Vercel Serverless 함수 타임아웃(60초) 내에서 완료 가능하도록 설계
- Brevo rate_limit 60초 대기는 Cron 환경에서만 사용 (Phase 2에서 별도 처리)
에러 로그 정리
- 테스트 스크립트에서 생성한 에러는 반드시 삭제
- 프로덕션에서는 error_logs를 주기적으로 정리하지 않음 (감사 로그 역할)
리뷰 로그
[self-healing-impl-pl 초안 작성] 2026-02-25 20:15
- 7개 Task로 분리: self-healing.ts 핵심 모듈 + 3개 Stage 통합 + Cron 연계 + 타입 확장 + 통합 테스트
- Phase 1 범위(L1+L2+L5) 엄수, L3/L4 Phase 2 placeholder
- 기존 코드 보호: Stage 래퍼의 catch 블록만 수정, 핵심 로직 미변경
- 인라인 자체교정 헬퍼(retryL1, retryL2, escalateL5): Stage에서 직접 호출 가능한 제네릭 함수
- runSelfHealingCycle: Cron 선행 실행용 배치 교정 사이클
- Vercel Serverless 타임아웃(60초) 고려하여 대기 시간 제한
- 자비스 검수 요청
[자비스 검수] 2026-02-25T21:50:00+09:00
- ✅ writing-plans 스킬 준수: REQUIRED SUB-SKILL 헤더, Goal/Architecture/TechStack 명시, bite-sized Task, 커밋 포함
- ✅ L1 설계서 완전 연계: 6개 핵심 함수(detectErrors/classifyError/attemptAutoFix/escalateIfNeeded/logFixAttempt/runSelfHealingCycle) 전부 구현
- ✅ Phase 1 범위 엄수: L1(즉시재시도)+L2(백오프)+L5(에스컬레이션)만 구현. L3/L4 Phase 2 placeholder 명시
- ✅ 기존 코드 보호 철저: collectNews/generateBlogPost/publishToBlog 핵심 로직 미변경. Stage 래퍼 catch 블록 추가 방식만 사용
- ✅ Vercel 타임아웃 고려: L1 5초, L2 최대 40초(10*2^2) — 60초 제한 내 완료 설계
- ✅ 인라인+배치 이중 구조: retryL1/retryL2/escalateL5 인라인 헬퍼 + Cron 선행 runSelfHealingCycle 배치 교정
- ✅ 타입 안전성: Task 6에서 PipelineName에 'self-healing' 추가 +
as any제거 - ✅ 통합 테스트 포함: Task 7에서 에러 시뮬레이션 3종(L1/L2/L5) + DB 상태 확인 + 테스트 데이터 정리까지 포함
- ⚠️ Task 3 주의: catch 블록에서
pillarOverride참조 — 실행 시 스코프 확인 필요. 실제 stage-generate.ts 파라미터 구조 확인 후 조정 가능 - 검수 결론: 승인 요청. VP 승인 시 executing-plans-pl 스폰하여 Task 1~7 순차 실행 권장