우리 회사에서도 터진다 — 소규모 장애 생존기와 교훈

우리 회사에서도 터진다 — 소규모 장애 생존기와 교훈

AWS S3도 터지고 CrowdStrike도 터지는데, 우리 회사라고 안 터지겠냐. 흔한 장애 유형들과 살아남는 법


지금까지 세계적인 대형 장애들을 봤음. S3 타이핑 실수, left-pad 삭제, Cloudflare 정규식, Log4Shell, CrowdStrike BSOD. 하나같이 역사에 기록될 사건들인데, 사실 우리 대부분은 이런 규모의 장애는 겪지 않음.

우리가 겪는 건 이런 거임:

  • 새벽 3시에 PagerDuty 알림: "DB connection pool exhausted"
  • 배포 후 30분 만에 메모리 사용량이 하늘을 뚫음
  • 특정 API가 응답을 안 해서 확인해보니 무한 루프
  • "이거 어제까지 됐는데?" — 누군가가 환경변수를 바꿈

규모는 다르지만 교훈은 같음. 이번 편에서는 스타트업과 중소기업에서 실제로 자주 발생하는 장애 유형들을, 실제 코드와 함께 파헤친다.

이 글의 대상

대기업의 SRE팀이 아니라, 개발자 5명이 전부인 스타트업에서 장애 대응도 내 몫인 개발자를 위한 글. 화려한 도구 없이도 장애를 예방하고 대응하는 실전 가이드.


장애 유형 1: DB 연결 풀 고갈

증상

새벽에 알림이 옴: "Error: connect ECONNREFUSED" 또는 "ER_CON_COUNT_ERROR: Too many connections"

서비스가 DB에 연결하지 못해서 모든 요청이 실패. 근데 DB 서버 자체는 살아있음.

원인

typescript
// 흔한 원인 1: 연결을 반환하지 않는 코드

// 나쁜 예: 에러 발생 시 연결이 풀에 반환되지 않음
async function getUserBad(id: string) {
  const connection = await pool.getConnection();
  const result = await connection.query('SELECT * FROM users WHERE id = ?', [id]);
  // 만약 여기서 에러가 발생하면?
  // connection.release()가 호출되지 않음!
  // → 연결이 풀에 반환되지 않고 좀비가 됨
  connection.release();
  return result;
}

// 요청 100번 중 5번 에러 발생
// → 5개 연결이 좀비
// → 시간이 지나면 풀의 모든 연결이 좀비
// → 새 연결을 가져올 수 없음
// → "Too many connections"

// 좋은 예: try-finally로 연결 반환 보장
async function getUserGood(id: string) {
  const connection = await pool.getConnection();
  try {
    const result = await connection.query('SELECT * FROM users WHERE id = ?', [id]);
    return result;
  } finally {
    // 에러가 발생하든 안 하든 반드시 실행됨
    connection.release();
  }
}

// 더 좋은 예: pool.query() 사용 (연결 관리를 풀에 위임)
async function getUserBest(id: string) {
  // pool.query()는 내부적으로 연결을 가져오고 반환하는 것을 자동 처리
  const result = await pool.query('SELECT * FROM users WHERE id = ?', [id]);
  return result;
}
typescript
// 흔한 원인 2: 슬로우 쿼리가 연결을 오래 점유

// 연결 풀 크기: 10
// 정상 쿼리: 10ms
// 슬로우 쿼리: 30초

// 정상 상태: 10개 연결로 초당 1000 요청 처리 가능
// 슬로우 쿼리 발생: 10개 연결이 전부 30초짜리 쿼리에 점유됨
// → 새 요청은 연결을 기다려야 함
// → 요청 큐가 쌓이고 결국 타임아웃

// 방어: 쿼리 타임아웃 설정
const pool = createPool({
  host: 'localhost',
  user: 'root',
  database: 'myapp',
  connectionLimit: 10,
  // 쿼리 타임아웃: 5초 이상 걸리는 쿼리는 강제 중단
  queryTimeout: 5000,
  // 연결 획득 타임아웃: 3초 이내에 연결을 못 가져오면 에러
  acquireTimeout: 3000,
  // 유휴 연결 타임아웃: 사용하지 않는 연결은 60초 후 제거
  idleTimeout: 60000,
});
연결 풀 크기는 무작정 늘리지 마라

"Too many connections" 에러가 나면 풀 크기를 늘리고 싶은 유혹이 옴. 하지만 DB 서버의 최대 연결 수에는 한계가 있고, 연결 하나당 메모리를 소비함. 풀 크기를 100으로 올렸는데 슬로우 쿼리가 근본 원인이면, 100개 연결이 전부 슬로우 쿼리에 점유되어 결국 같은 상황이 됨. 근본 원인을 먼저 해결하라.

방어 체크리스트

typescript
// DB 연결 풀 고갈 방어 체크리스트

interface ConnectionPoolDefense {
  // 1. 연결 반환 보장
  alwaysReleaseInFinally: boolean;
  // 또는 pool.query() 같은 자동 관리 API 사용

  // 2. 타임아웃 설정
  queryTimeout: number;        // 개별 쿼리 타임아웃
  connectionTimeout: number;   // 연결 획득 타임아웃
  idleTimeout: number;         // 유휴 연결 제거

  // 3. 모니터링
  monitoring: {
    activeConnections: boolean;   // 현재 사용 중인 연결 수
    waitingRequests: boolean;     // 연결을 기다리는 요청 수
    slowQueries: boolean;         // 슬로우 쿼리 감지
  };

  // 4. 알림
  alerts: {
    poolUtilizationThreshold: number; // 풀 사용률 80% 이상이면 알림
    waitTimeThreshold: number;         // 연결 대기 시간 1초 이상이면 알림
  };
}

장애 유형 2: 메모리 릭

증상

배포 후 처음엔 멀쩡함. 몇 시간 지나면 메모리 사용량이 서서히 올라감. 하루 지나면 OOM(Out of Memory) Killer가 프로세스를 죽임. 재시작하면 또 괜찮다가 다시 올라감.

메모리 사용량 그래프:

100% |                                    * OOM Kill
     |                                *
     |                            *
     |                        *
     |                    *
     |                *
     |            *
 50% |        *
     |    *
     |  *
     | *
     |*
  0% +----------------------------------------→ 시간
     배포   2h    4h    6h    8h   10h   12h

흔한 원인들

typescript
// 원인 1: 이벤트 리스너 누적

class UserService {
  private emitter = new EventEmitter();

  // 이 메서드가 요청마다 호출됨
  handleRequest(userId: string) {
    // 문제: 이벤트 리스너가 요청마다 추가되지만 제거되지 않음!
    this.emitter.on('userUpdated', (data) => {
      console.log(`User ${userId} updated:`, data);
    });

    // 요청 1000번 → 리스너 1000개 등록
    // 요청 100만번 → 리스너 100만개 등록
    // 각 리스너가 userId를 클로저로 캡처 → 메모리 누적
  }
}

// 수정: 리스너를 적절히 관리
class UserServiceFixed {
  private emitter = new EventEmitter();

  constructor() {
    // 리스너는 한 번만 등록
    this.emitter.on('userUpdated', this.onUserUpdated.bind(this));
  }

  private onUserUpdated(data: any) {
    console.log('User updated:', data);
  }

  // 또는 once() 사용 — 한 번 실행 후 자동 제거
  handleRequest(userId: string) {
    this.emitter.once('userUpdated', (data) => {
      console.log(`User ${userId} updated:`, data);
    });
  }
}
typescript
// 원인 2: 전역 캐시의 무한 성장

// 문제: 캐시에 넣기만 하고 빼지 않음
const cache = new Map<string, any>();

function getUser(id: string) {
  if (cache.has(id)) {
    return cache.get(id);
  }

  const user = fetchFromDB(id);
  cache.set(id, user); // 계속 추가만 됨
  return user;

  // 사용자가 100만 명이면 cache에 100만 개의 엔트리
  // 각 엔트리가 1KB라면 → 1GB 메모리 사용
}

// 수정 1: LRU 캐시 사용 (최대 크기 제한)
import { LRUCache } from 'lru-cache';

const cache = new LRUCache<string, any>({
  max: 10000,        // 최대 10,000개 엔트리
  ttl: 1000 * 60 * 5, // 5분 후 만료
  maxSize: 50 * 1024 * 1024, // 최대 50MB
  sizeCalculation: (value) => JSON.stringify(value).length,
});

// 수정 2: WeakMap 사용 (GC가 자동 정리)
// 단, WeakMap은 키가 객체여야 하고 iteration 불가
const weakCache = new WeakMap<object, any>();
typescript
// 원인 3: 닫지 않은 리소스

// 문제: 파일 핸들, 소켓, 스트림을 열고 닫지 않음
async function processFiles(paths: string[]) {
  for (const path of paths) {
    const stream = fs.createReadStream(path);

    stream.on('data', (chunk) => {
      // 처리...
    });

    // stream.on('end', ...) 에서 destroy()를 안 함
    // 또는 에러 발생 시 스트림이 닫히지 않음
  }
}

// 수정: pipeline 사용 (자동 정리)
import { pipeline } from 'stream/promises';

async function processFilesFixed(paths: string[]) {
  for (const path of paths) {
    const source = fs.createReadStream(path);
    const transform = new TransformStream(/* ... */);
    const dest = fs.createWriteStream('/dev/null');

    // pipeline은 완료/에러 시 모든 스트림을 자동으로 destroy
    await pipeline(source, transform, dest);
  }
}
Node.js 메모리 릭 디버깅 도구
  • --inspect 플래그로 Chrome DevTools 연결 → 힙 스냅샷 비교
  • process.memoryUsage() 주기적 로깅
  • clinic.js — Node.js 성능 진단 도구
  • heapdump 패키지 — 프로덕션에서 힙 덤프 생성

메모리 릭 감지 자동화

typescript
// 프로덕션 메모리 모니터링

class MemoryMonitor {
  private readings: number[] = [];
  private readonly MAX_READINGS = 60; // 최근 60개 샘플 유지

  start(intervalMs: number = 60000) { // 1분 간격
    setInterval(() => {
      const usage = process.memoryUsage();
      this.readings.push(usage.heapUsed);

      if (this.readings.length > this.MAX_READINGS) {
        this.readings.shift();
      }

      // 추세 분석
      if (this.readings.length >= 10) {
        const trend = this.calculateTrend();

        if (trend.increasing && trend.rate > 1024 * 1024) {
          console.warn(
            `메모리 릭 의심: 분당 ${(trend.rate / 1024 / 1024).toFixed(1)}MB 증가 중. ` +
            `현재: ${(usage.heapUsed / 1024 / 1024).toFixed(0)}MB`
          );
        }
      }

      // 절대값 체크
      const heapMB = usage.heapUsed / 1024 / 1024;
      if (heapMB > 512) {
        console.error(`메모리 사용량 경고: ${heapMB.toFixed(0)}MB`);
        // 힙 스냅샷 저장 (디버깅용)
        // require('heapdump').writeSnapshot('/tmp/heapdump-' + Date.now());
      }
    }, intervalMs);
  }

  private calculateTrend(): { increasing: boolean; rate: number } {
    const n = this.readings.length;
    let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;

    for (let i = 0; i < n; i++) {
      sumX += i;
      sumY += this.readings[i];
      sumXY += i * this.readings[i];
      sumXX += i * i;
    }

    // 선형 회귀의 기울기 (분당 메모리 변화량)
    const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);

    return {
      increasing: slope > 0,
      rate: slope, // bytes per interval
    };
  }
}

장애 유형 3: 무한 루프 / 무한 재귀

증상

특정 API가 응답을 안 함. CPU 사용률 100%. 다른 요청도 처리 못함.

typescript
// 원인 1: 재귀의 종료 조건 실수

interface TreeNode {
  id: string;
  children: TreeNode[];
  parent?: TreeNode;
}

// 순환 참조가 있는 트리 구조
function flattenTree(node: TreeNode): string[] {
  const result: string[] = [node.id];

  for (const child of node.children) {
    result.push(...flattenTree(child));
    // 만약 child.children에 node가 포함되어 있다면?
    // → 무한 재귀 → 스택 오버플로우
  }

  return result;
}

// 수정: 방문 추적으로 순환 참조 방지
function flattenTreeSafe(node: TreeNode, visited = new Set<string>()): string[] {
  if (visited.has(node.id)) {
    return []; // 이미 방문한 노드 — 순환 참조 차단
  }

  visited.add(node.id);
  const result: string[] = [node.id];

  for (const child of node.children) {
    result.push(...flattenTreeSafe(child, visited));
  }

  return result;
}
typescript
// 원인 2: 이벤트 기반 무한 루프

// 문제: A가 B를 트리거하고, B가 A를 트리거
class OrderService {
  async updateOrder(orderId: string, data: any) {
    await db.orders.update(orderId, data);

    // 주문 업데이트 이벤트 발행
    eventBus.emit('order:updated', { orderId, data });
  }
}

class InventoryService {
  constructor() {
    eventBus.on('order:updated', async ({ orderId }) => {
      // 재고 동기화
      const inventory = await this.syncInventory(orderId);

      // 재고 변경으로 주문 상태 업데이트 → 다시 order:updated 트리거!
      await orderService.updateOrder(orderId, {
        inventoryStatus: inventory.status,
      });
      // → 무한 루프: order:updated → syncInventory → updateOrder → order:updated → ...
    });
  }
}

// 수정 1: 이벤트에 컨텍스트 추가
eventBus.emit('order:updated', {
  orderId,
  data,
  source: 'user-action', // 이벤트의 출처
});

eventBus.on('order:updated', async ({ orderId, source }) => {
  // 자기가 트리거한 이벤트는 무시
  if (source === 'inventory-sync') return;

  const inventory = await this.syncInventory(orderId);
  await orderService.updateOrder(orderId, {
    inventoryStatus: inventory.status,
    _eventSource: 'inventory-sync', // 출처 표시
  });
});

// 수정 2: 최대 깊이 제한
function processWithDepthLimit(data: any, depth = 0) {
  if (depth > 10) {
    console.error('최대 처리 깊이 초과. 순환 참조 가능성.');
    return;
  }

  // 처리 로직...
  processWithDepthLimit(nextData, depth + 1);
}
무한 루프의 프로덕션 영향

Node.js는 싱글 스레드이므로, 무한 루프 하나가 전체 서버를 죽임. Express/Fastify 서버에서 하나의 요청 핸들러가 무한 루프에 빠지면, 다른 모든 요청도 처리 불가. CPU 100%로 서버가 먹통이 됨. 이것이 Node.js에서 CPU-intensive 작업이 위험한 이유.


장애 유형 4: 잘못된 환경변수/설정 변경

증상

"어제까지 됐는데 오늘 안 됨." 코드는 하나도 안 바뀌었는데 서비스가 비정상.

typescript
// 실화 기반 시나리오들

// 사례 1: 누군가 프로덕션 환경변수를 바꿈
// .env.production
// DATABASE_URL=postgres://user:pass@prod-db:5432/myapp
// ↓ 누군가 DB 마이그레이션 때문에 임시로 바꾸고 안 돌려놓음
// DATABASE_URL=postgres://user:pass@staging-db:5432/myapp
// → 프로덕션이 스테이징 DB를 바라봄
// → 데이터가 안 보인다는 CS 인입 폭주

// 사례 2: feature flag 실수
// 점검용으로 신규 가입을 막았는데 안 풀어놓음
// ENABLE_SIGNUP=false
// → "회원가입이 안 되는데요?" 인입 3시간 후에야 인지

// 사례 3: API 키 만료
// 3개월 전에 발급한 외부 API 키가 오늘 만료됨
// 아무도 모름. 갑자기 결제가 안 됨.

방어

typescript
// 설정 변경 관리 시스템

interface ConfigChange {
  key: string;
  oldValue: string;
  newValue: string;
  changedBy: string;
  changedAt: Date;
  reason: string;
  expiresAt?: Date; // 임시 변경의 경우 자동 복원 시간
}

// 설정 변경 시 자동 기록 + 알림
class ConfigManager {
  async setConfig(
    key: string,
    value: string,
    options: {
      changedBy: string;
      reason: string;
      temporary?: boolean;
      ttlMinutes?: number;
    }
  ) {
    const oldValue = await this.getConfig(key);

    // 변경 이력 기록
    await this.logChange({
      key,
      oldValue,
      newValue: value,
      changedBy: options.changedBy,
      changedAt: new Date(),
      reason: options.reason,
    });

    // 팀 채널에 알림
    await this.notifyTeam(
      `설정 변경: ${key} = ${value} (변경자: ${options.changedBy}, 사유: ${options.reason})`
    );

    // 임시 변경이면 자동 복원 스케줄링
    if (options.temporary && options.ttlMinutes) {
      setTimeout(async () => {
        await this.setConfig(key, oldValue, {
          changedBy: 'system-auto-revert',
          reason: `${options.changedBy}의 임시 변경 자동 복원`,
        });
      }, options.ttlMinutes * 60 * 1000);
    }

    await this.store.set(key, value);
  }
}
typescript
// 환경변수 유효성 검사 (앱 시작 시)

interface EnvConfig {
  DATABASE_URL: string;
  REDIS_URL: string;
  API_KEY: string;
  NODE_ENV: 'development' | 'staging' | 'production';
  PORT: number;
}

function validateEnvironment(): EnvConfig {
  const errors: string[] = [];

  // 필수 환경변수 존재 확인
  const required = ['DATABASE_URL', 'REDIS_URL', 'API_KEY', 'NODE_ENV'];
  for (const key of required) {
    if (!process.env[key]) {
      errors.push(`필수 환경변수 ${key}가 설정되지 않음`);
    }
  }

  // 값 유효성 검사
  if (process.env.DATABASE_URL && !process.env.DATABASE_URL.startsWith('postgres://')) {
    errors.push('DATABASE_URL이 올바른 형식이 아님');
  }

  if (process.env.NODE_ENV === 'production') {
    // 프로덕션 전용 검증
    if (process.env.DATABASE_URL?.includes('staging')) {
      errors.push('프로덕션인데 DATABASE_URL이 staging을 가리키고 있음!');
    }
    if (process.env.DATABASE_URL?.includes('localhost')) {
      errors.push('프로덕션인데 DATABASE_URL이 localhost를 가리키고 있음!');
    }
  }

  if (errors.length > 0) {
    console.error('환경변수 검증 실패:');
    errors.forEach(e => console.error(`  - ${e}`));
    process.exit(1); // 잘못된 설정으로 서비스 시작하는 것보다 안 시작하는 게 낫다
  }

  return {
    DATABASE_URL: process.env.DATABASE_URL!,
    REDIS_URL: process.env.REDIS_URL!,
    API_KEY: process.env.API_KEY!,
    NODE_ENV: process.env.NODE_ENV as EnvConfig['NODE_ENV'],
    PORT: parseInt(process.env.PORT || '3000'),
  };
}

장애 유형 5: 배포 직후 장애

증상

배포 후 에러율이 급증. 또는 배포 후 특정 기능이 안 됨.

typescript
// 흔한 원인들과 방어

// 원인 1: DB 마이그레이션과 코드 배포 순서 불일치
// 새 코드가 새 컬럼을 참조하는데, 마이그레이션이 아직 안 돌아감
// → "column 'new_field' does not exist"

// 방어: 하위 호환성을 유지하는 배포 전략
// Step 1: 새 컬럼 추가 마이그레이션 (기존 코드에 영향 없음)
// Step 2: 새 코드 배포 (새 컬럼 사용 시작)
// Step 3: 이전 코드만 사용하던 필드 정리 (나중에)

// 원인 2: 환경별 차이
// "내 로컬에서는 되는데 프로덕션에서 안 됨"

interface EnvironmentDiff {
  local: {
    nodeVersion: '22.x';
    database: 'SQLite';
    fileSystem: 'case-insensitive (macOS)';
  };
  production: {
    nodeVersion: '20.x'; // 버전 차이!
    database: 'PostgreSQL'; // DB 엔진 차이!
    fileSystem: 'case-sensitive (Linux)'; // 파일 시스템 차이!
  };
}

// 방어: 환경 일치시키기
// - Docker로 개발 환경을 프로덕션과 동일하게
// - CI/CD에서 프로덕션과 동일한 환경으로 테스트
// - .nvmrc / .tool-versions로 런타임 버전 고정

롤백 전략

typescript
// 배포 후 장애 시 롤백 체크리스트

interface RollbackDecision {
  // 롤백 결정 기준
  shouldRollback: boolean;
  criteria: {
    errorRateIncrease: number;   // 에러율 증가 (%)
    latencyIncrease: number;     // 지연 시간 증가 (%)
    customerImpact: boolean;     // 고객 영향 여부
    timeElapsed: number;         // 배포 후 경과 시간 (분)
  };
}

// 자동 롤백 규칙
function shouldAutoRollback(metrics: {
  currentErrorRate: number;
  baselineErrorRate: number;
  currentP99Latency: number;
  baselineP99Latency: number;
}): boolean {
  // 에러율이 배포 전 대비 5배 이상이면 롤백
  if (metrics.currentErrorRate > metrics.baselineErrorRate * 5) {
    return true;
  }

  // P99 지연 시간이 배포 전 대비 3배 이상이면 롤백
  if (metrics.currentP99Latency > metrics.baselineP99Latency * 3) {
    return true;
  }

  return false;
}

// 롤백 시 주의사항
// 1. DB 마이그레이션이 포함된 배포는 롤백이 복잡함
//    → 마이그레이션은 항상 forward-compatible하게 작성
// 2. 캐시 무효화가 필요할 수 있음
// 3. 롤백 후에도 문제가 지속되면 데이터 오염 가능성 확인

장애 대응 프로세스 구축하기

대기업의 화려한 인시던트 관리 시스템 없이도, 최소한의 프로세스는 있어야 함.

5명 스타트업을 위한 최소한의 장애 대응 프로세스

typescript
// 장애 대응 5단계

interface IncidentResponse {
  // 1단계: 감지 (Detection)
  detection: {
    monitoring: '최소한의 모니터링 설정';
    tools: [
      'health check endpoint',  // GET /health → 200 OK
      'uptime monitoring',      // UptimeRobot (무료) 등
      'error tracking',         // Sentry (무료 티어 있음)
      'log aggregation',        // 최소한 구조화된 로깅
    ];
    alertChannel: 'Slack 또는 Discord 웹훅';
  };

  // 2단계: 대응 (Response)
  response: {
    acknowledgeWithin: '5분';
    firstAction: '영향 범위 파악';
    communication: '팀 채널에 상황 공유';
  };

  // 3단계: 완화 (Mitigation)
  mitigation: {
    options: [
      '롤백 (가장 빠르고 안전)',
      'feature flag로 문제 기능 비활성화',
      '핫픽스 배포',
      '인프라 스케일업 (임시 조치)',
    ];
    principle: '"완벽한 해결"보다 "빠른 완화"가 먼저';
  };

  // 4단계: 해결 (Resolution)
  resolution: {
    rootCauseAnalysis: '근본 원인 분석';
    permanentFix: '영구적 수정';
    verification: '수정 후 재발 방지 확인';
  };

  // 5단계: 사후 분석 (Post-mortem)
  postMortem: {
    writeUp: '포스트모템 작성';
    actionItems: '재발 방지 액션 아이템';
    sharing: '팀 전체 공유';
  };
}

최소한의 health check

typescript
// 앱에 반드시 있어야 하는 헬스 체크 엔드포인트

interface HealthStatus {
  status: 'ok' | 'degraded' | 'down';
  timestamp: string;
  uptime: number;
  checks: Record<string, {
    status: 'ok' | 'error';
    latency?: number;
    message?: string;
  }>;
}

// Express 예시
app.get('/health', async (req, res) => {
  const health: HealthStatus = {
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    checks: {},
  };

  // DB 연결 체크
  try {
    const start = Date.now();
    await db.query('SELECT 1');
    health.checks.database = {
      status: 'ok',
      latency: Date.now() - start,
    };
  } catch (err) {
    health.checks.database = {
      status: 'error',
      message: 'DB 연결 실패',
    };
    health.status = 'down';
  }

  // Redis 연결 체크
  try {
    const start = Date.now();
    await redis.ping();
    health.checks.redis = {
      status: 'ok',
      latency: Date.now() - start,
    };
  } catch (err) {
    health.checks.redis = {
      status: 'error',
      message: 'Redis 연결 실패',
    };
    health.status = 'degraded';
  }

  // 메모리 체크
  const memUsage = process.memoryUsage();
  const heapMB = memUsage.heapUsed / 1024 / 1024;
  health.checks.memory = {
    status: heapMB > 512 ? 'error' : 'ok',
    message: `Heap: ${heapMB.toFixed(0)}MB`,
  };

  const statusCode = health.status === 'down' ? 503 : 200;
  res.status(statusCode).json(health);
});

포스트모템 작성법

장애가 해결되면 끝이 아님. 같은 실수를 반복하지 않기 위해 포스트모템을 작성해야 함.

비난 없는 포스트모템 (Blameless Post-mortem)

포스트모템의 목적은 "누구의 잘못인가"를 밝히는 게 아니라 "왜 이런 일이 일어났고 어떻게 방지할 수 있는가"를 분석하는 것. "홍길동이 환경변수를 잘못 바꿔서"가 아니라 "환경변수 변경에 대한 검증 프로세스가 없었다"가 올바른 근본 원인 분석. 개인을 비난하면 다음번에 장애가 발생했을 때 사람들이 숨기게 됨.

포스트모템 템플릿

markdown
# 장애 포스트모템: [장애 제목]

## 요약
- 발생 일시: YYYY-MM-DD HH:MM ~ HH:MM (KST)
- 영향 범위: [어떤 서비스/기능에 영향]
- 영향 사용자: [대략적인 수치]
- 심각도: SEV-1 / SEV-2 / SEV-3
- 인시던트 커맨더: [이름]

## 타임라인 (KST)
| 시간 | 이벤트 |
|------|--------|
| HH:MM | [장애 시작] |
| HH:MM | [감지] |
| HH:MM | [첫 번째 대응] |
| HH:MM | [완화 조치] |
| HH:MM | [해결] |

## 근본 원인
[기술적 원인 상세 설명]

## 영향
- [영향 1]
- [영향 2]

## 잘 된 것
- [빠르게 감지됨]
- [롤백이 매끄러웠음]

## 개선할 것
- [모니터링 부재]
- [카나리 배포 없었음]

## 액션 아이템
| 항목 | 담당 | 우선순위 | 기한 |
|------|------|---------|------|
| [액션 1] | [이름] | P1 | [날짜] |
| [액션 2] | [이름] | P2 | [날짜] |
typescript
// 포스트모템에서 자주 나오는 액션 아이템 유형

type ActionItemCategory =
  | 'monitoring'    // "이 메트릭에 대한 알림을 추가하자"
  | 'testing'       // "이 시나리오에 대한 테스트를 추가하자"
  | 'process'       // "배포 프로세스에 이 단계를 추가하자"
  | 'automation'    // "이 수동 작업을 자동화하자"
  | 'documentation' // "이 절차를 문서화하자"
  | 'architecture'; // "이 구조를 개선하자"

// 좋은 액션 아이템: 구체적이고 측정 가능하고 기한이 있음
const goodActionItem = {
  description: 'DB 연결 풀 사용률이 80%를 넘으면 Slack 알림을 보내도록 설정',
  category: 'monitoring' as const,
  assignee: '김개발',
  priority: 'P1',
  deadline: '2026-03-24', // 1주일 이내
  doneWhen: '알림이 실제로 동작하는 것을 확인',
};

// 나쁜 액션 아이템: 모호하고 기한 없음
const badActionItem = {
  description: '모니터링을 강화한다',
  // assignee 없음, deadline 없음, 구체적이지 않음
  // → 영원히 "할 일 목록"에 남아있다가 사라짐
};

장애 예방을 위한 최소한의 도구 세트

5명 스타트업을 위한 장애 예방 체크리스트

무료/저비용으로 할 수 있는 것들:

1. 모니터링

  • UptimeRobot (무료): 5분 간격 업타임 체크
  • Sentry (무료 티어): 에러 트래킹
  • /health 엔드포인트: 앱 내부 상태 체크

2. 알림

  • Slack/Discord 웹훅: 장애 알림
  • PagerDuty 또는 Opsgenie (무료 티어): 온콜 로테이션

3. 로깅

  • 구조화된 로깅 (JSON 형태)
  • 요청 ID 추적 (correlation ID)
  • 최소한 에러 로그는 중앙 집중

4. 배포

  • package-lock.json / bun.lockb 커밋
  • 환경변수 유효성 검사 (앱 시작 시)
  • 롤백 가능한 배포 (이전 버전으로 즉시 복원)

5. 문화

  • 포스트모템 작성 (비난 없이)
  • 장애 대응 런북 (최소한의 절차 문서)
  • 액션 아이템 추적 (JIRA, Linear 등에 등록)

마치며

대형 장애든 소규모 장애든, 결국 패턴은 같음:

  1. 방심 — "이 정도는 괜찮겠지"
  2. 가시성 부재 — "뭐가 문제인지 모르겠는데"
  3. 프로세스 부재 — "일단 서버 재시작해보자"
  4. 재발 — "이거 저번에도 이러지 않았나?"

AWS S3 장애, left-pad 사태, Cloudflare 정규식 장애, Log4Shell, CrowdStrike BSOD — 이 모든 사건의 포스트모템에서 반복되는 교훈이 있음:

  • 단일 장애점을 제거하라
  • 변경사항은 점진적으로 배포하라
  • 모니터링을 독립적으로 유지하라
  • 복구 프로세스를 미리 테스트하라
  • 포스트모템으로 배우고, 같은 실수를 반복하지 마라

이게 스타트업이든 AWS든 CrowdStrike든 똑같이 적용되는 원칙임. 규모만 다를 뿐.

마지막으로, 장애는 반드시 일어남. 완벽한 시스템은 없고, 인간은 실수를 하고, 소프트웨어는 버그를 가지고 있음. 중요한 건 장애가 일어났을 때 얼마나 빨리 감지하고, 얼마나 빨리 복구하고, 얼마나 잘 배우는가임. 장애를 두려워하지 말고, 장애로부터 배우지 않는 것을 두려워하라.