← 목록으로
2026-02-25plans

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핵심 산출물예상 규모
1self-healing.ts 핵심 모듈 생성src/lib/self-healing.ts — 타입 정의 + 6개 핵심 함수~250줄
2stage-collect.ts 자체교정 통합RSS 수집 실패 시 L1 인라인 재시도 + error_logs 기록~30줄 추가
3stage-generate.ts 자체교정 통합AI API 오류 시 L2 백오프 + QA 실패 시 L2 온도 조정~40줄 추가
4stage-publish.ts 자체교정 통합블로그 발행 실패 시 L1 재시도 + slug 충돌 L1 즉시 교정~25줄 추가
5Cron 핸들러에 self-healing 선행 호출/api/cron/pipeline 시작 시 runSelfHealingCycle() 실행~15줄 추가
6pipeline-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 순차 실행 권장
plans/2026/02/25/content-orchestration-impl-self-healing.md