AgentSkillsCN

error-handling-philosophy

错误处理哲学——对用户友好,对开发者清晰

SKILL.md
--- frontmatter
name: error-handling-philosophy
description: 에러 처리 철학 - 사용자에게는 친절하게, 개발자에게는 명확하게

에러 처리 철학

핵심 원칙

"에러는 항상 발생한다. 어떻게 처리하느냐가 서비스 품질을 결정한다."

1. 빠르게 실패하라 (Fail Fast)

  • 문제를 발견하면 즉시 에러 발생
  • 잘못된 상태로 계속 실행하지 않기

2. 사용자는 친절한 메시지, 개발자는 상세한 로그

  • 사용자: "결제 처리 중 문제가 발생했습니다"
  • 로그: "Payment gateway timeout after 30s, orderId: 12345, userId: 678"

3. 복구 가능한 에러 vs 치명적 에러

  • 복구 가능: 재시도, 대체 방법 제공
  • 치명적: 즉시 중단, 알림 발송

에러 분류 체계

1. 클라이언트 에러 (4xx)

typescript
// 입력 검증 실패
class ValidationError extends Error {
  constructor(
    message: string,
    public field: string,
    public value: any
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

throw new ValidationError(
  '이메일 형식이 올바르지 않습니다',
  'email',
  'invalid-email'
);

// 사용자 메시지
{
  "error": {
    "type": "VALIDATION_ERROR",
    "message": "입력값을 확인해주세요",
    "field": "email",
    "userMessage": "올바른 이메일 주소를 입력하세요"
  }
}

2. 서버 에러 (5xx)

typescript
// 외부 서비스 장애
class ServiceUnavailableError extends Error {
  constructor(
    message: string,
    public service: string,
    public retryAfter?: number
  ) {
    super(message);
    this.name = 'ServiceUnavailableError';
  }
}

throw new ServiceUnavailableError(
  'Payment gateway timeout',
  'toss-payments',
  60
);

// 사용자 메시지
{
  "error": {
    "type": "SERVICE_UNAVAILABLE",
    "message": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요",
    "retryAfter": 60
  }
}

실전 패턴

패턴 1: Try-Catch는 명확한 목적이 있을 때만

typescript
// ❌ 나쁜 예: 모든 것을 catch
async function getUser(id: string) {
  try {
    const user = await db.user.findUnique({ where: { id } });
    return user;
  } catch (error) {
    console.error(error);  // 그냥 삼켜버림
    return null;
  }
}

// ✅ 좋은 예: 특정 에러만 처리
async function getUser(id: string) {
  try {
    const user = await db.user.findUnique({ where: { id } });

    if (!user) {
      throw new NotFoundError('사용자를 찾을 수 없습니다', 'User', id);
    }

    return user;
  } catch (error) {
    // PrismaClientKnownRequestError만 처리
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === 'P2025') {
        throw new NotFoundError('사용자를 찾을 수 없습니다', 'User', id);
      }
    }
    // 예상하지 못한 에러는 상위로 전파
    throw error;
  }
}

패턴 2: 에러 경계 (Error Boundaries)

typescript
// React Error Boundary
class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 로깅 서비스로 전송
    logger.error('React Error Boundary caught error', {
      error: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
    });
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h2>문제가 발생했습니다</h2>
          <p>페이지를 새로고침하거나 고객센터로 문의해주세요</p>
          <button onClick={() => window.location.reload()}>
            새로고침
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// 사용
<ErrorBoundary>
  <App />
</ErrorBoundary>

패턴 3: 중앙 집중식 에러 핸들러

typescript
// Express 에러 핸들러
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 에러 분류
  if (err instanceof ValidationError) {
    return res.status(400).json({
      error: {
        type: 'VALIDATION_ERROR',
        message: err.message,
        field: err.field,
      },
    });
  }

  if (err instanceof NotFoundError) {
    return res.status(404).json({
      error: {
        type: 'NOT_FOUND',
        message: err.message,
      },
    });
  }

  if (err instanceof UnauthorizedError) {
    return res.status(401).json({
      error: {
        type: 'UNAUTHORIZED',
        message: '로그인이 필요합니다',
      },
    });
  }

  // 예상하지 못한 에러
  logger.error('Unexpected error', {
    error: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    userId: req.user?.id,
  });

  // 운영 환경에서는 상세 정보 숨김
  res.status(500).json({
    error: {
      type: 'INTERNAL_SERVER_ERROR',
      message: process.env.NODE_ENV === 'production'
        ? '서버 오류가 발생했습니다'
        : err.message,
    },
  });
});

사용자 친화적 에러 메시지

❌ 개발자 중심 메시지

typescript
"Error: ECONNREFUSED 127.0.0.1:5432"
"Uncaught TypeError: Cannot read property 'name' of undefined"
"ValidationError: email must be a valid email"

✅ 사용자 친화적 메시지

typescript
const ERROR_MESSAGES = {
  NETWORK_ERROR: {
    title: '인터넷 연결을 확인해주세요',
    description: '네트워크 연결이 불안정합니다',
    action: '새로고침',
  },

  VALIDATION_EMAIL: {
    title: '이메일 주소를 확인해주세요',
    description: '올바른 이메일 형식이 아닙니다',
    action: '다시 입력',
  },

  PAYMENT_FAILED: {
    title: '결제 처리 중 문제가 발생했습니다',
    description: '카드사 오류이거나 한도를 초과했을 수 있습니다',
    action: '다시 시도',
  },

  SERVER_ERROR: {
    title: '일시적인 오류가 발생했습니다',
    description: '잠시 후 다시 시도해주세요',
    action: '고객센터 문의',
  },
};

// 컴포넌트에서 사용
<ErrorMessage>
  <h3>{ERROR_MESSAGES.PAYMENT_FAILED.title}</h3>
  <p>{ERROR_MESSAGES.PAYMENT_FAILED.description}</p>
  <button>{ERROR_MESSAGES.PAYMENT_FAILED.action}</button>
</ErrorMessage>

재시도 전략

지수 백오프 (Exponential Backoff)

typescript
async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  baseDelay = 1000
): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      // 마지막 시도에서는 에러 throw
      if (attempt === maxRetries - 1) {
        throw error;
      }

      // 재시도 가능한 에러인지 확인
      if (!isRetryableError(error)) {
        throw error;
      }

      // 지수 백오프 대기
      const delay = baseDelay * Math.pow(2, attempt);
      await sleep(delay);

      logger.warn('Retrying after error', {
        attempt: attempt + 1,
        maxRetries,
        delay,
        error: error.message,
      });
    }
  }

  throw new Error('Should not reach here');
}

function isRetryableError(error: any): boolean {
  // 네트워크 에러, 타임아웃, 일시적 서버 에러
  return (
    error.code === 'ECONNRESET' ||
    error.code === 'ETIMEDOUT' ||
    error.response?.status === 503 ||
    error.response?.status === 429
  );
}

// 사용
const data = await fetchWithRetry(() =>
  fetch('https://api.example.com/data').then(r => r.json())
);

에러 로깅 전략

로그 레벨

typescript
import winston from 'winston';

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.json(),
  transports: [
    // 에러 로그는 별도 파일
    new winston.transports.File({
      filename: 'error.log',
      level: 'error',
    }),
    // 모든 로그
    new winston.transports.File({
      filename: 'combined.log',
    }),
  ],
});

// 개발 환경에서는 콘솔 출력
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple(),
  }));
}

// 에러 레벨별 사용
logger.error('Payment failed', {
  orderId: '12345',
  userId: '678',
  amount: 50000,
  error: error.message,
  stack: error.stack,
});

logger.warn('Slow query detected', {
  query: 'SELECT * FROM users',
  duration: 3000,
});

logger.info('User logged in', {
  userId: '678',
  ip: req.ip,
});

logger.debug('Cache hit', {
  key: 'user:678',
});

민감 정보 필터링

typescript
// ❌ 민감 정보 로깅 금지
logger.error('Login failed', {
  email: 'user@example.com',
  password: 'user-password',  // ❌❌❌
  ssn: '123456-1234567',      // ❌❌❌
});

// ✅ 민감 정보 제거 후 로깅
const sanitized = {
  ...data,
  password: undefined,
  ssn: data.ssn ? '***-***-****' : undefined,
  creditCard: data.creditCard ? '****-****-****-' + data.creditCard.slice(-4) : undefined,
};

logger.error('Login failed', sanitized);

에러 모니터링

Sentry 연동

typescript
import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 1.0,
});

// Express 에러 핸들러 전에 추가
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.errorHandler());

// 수동으로 에러 전송
try {
  await processPayment();
} catch (error) {
  Sentry.captureException(error, {
    tags: {
      section: 'payment',
    },
    extra: {
      orderId: order.id,
      amount: order.amount,
    },
  });
  throw error;
}

API 에러 응답 표준

일관된 에러 형식

typescript
interface ErrorResponse {
  error: {
    // 에러 타입 (기계 판독용)
    type: string;

    // 사용자 메시지 (사람 판독용)
    message: string;

    // 상세 정보 (선택)
    details?: Array<{
      field: string;
      message: string;
    }>;

    // 요청 ID (디버깅용)
    requestId: string;

    // 문서 링크 (선택)
    docUrl?: string;
  };
}

// 예시
{
  "error": {
    "type": "VALIDATION_ERROR",
    "message": "입력값을 확인해주세요",
    "details": [
      {
        "field": "email",
        "message": "올바른 이메일 주소를 입력하세요"
      },
      {
        "field": "password",
        "message": "비밀번호는 8자 이상이어야 합니다"
      }
    ],
    "requestId": "req_abc123",
    "docUrl": "https://docs.example.com/errors/validation"
  }
}

실전 시나리오

시나리오 1: 결제 실패

typescript
async function processPayment(orderId: string) {
  try {
    // 1. 주문 조회
    const order = await getOrder(orderId);
    if (!order) {
      throw new NotFoundError('주문을 찾을 수 없습니다', 'Order', orderId);
    }

    // 2. 재고 확인
    const hasStock = await checkStock(order.items);
    if (!hasStock) {
      throw new BusinessError(
        '일부 상품의 재고가 부족합니다',
        'OUT_OF_STOCK'
      );
    }

    // 3. 결제 처리 (재시도 가능)
    const payment = await fetchWithRetry(
      () => paymentGateway.charge(order.amount),
      3,
      1000
    );

    // 4. 주문 완료 처리
    await completeOrder(orderId, payment.id);

    return { success: true, paymentId: payment.id };

  } catch (error) {
    // 에러 분류 및 처리
    if (error instanceof BusinessError) {
      // 비즈니스 로직 에러 - 사용자에게 친절한 메시지
      logger.warn('Business error during payment', {
        orderId,
        errorCode: error.code,
      });
      throw error;
    }

    if (error.response?.status === 402) {
      // 결제 거부
      logger.error('Payment declined', {
        orderId,
        reason: error.response.data.reason,
      });
      throw new BusinessError(
        '결제가 거부되었습니다. 카드사로 문의해주세요',
        'PAYMENT_DECLINED'
      );
    }

    // 예상치 못한 에러
    logger.error('Unexpected error during payment', {
      orderId,
      error: error.message,
      stack: error.stack,
    });

    // Sentry로 알림
    Sentry.captureException(error, {
      tags: { section: 'payment' },
      extra: { orderId },
    });

    throw new Error('결제 처리 중 문제가 발생했습니다');
  }
}

시나리오 2: 외부 API 호출

typescript
async function fetchWeatherData(city: string) {
  try {
    const response = await axios.get(
      `https://api.weather.com/forecast`,
      {
        params: { city },
        timeout: 5000,  // 5초 타임아웃
      }
    );

    return response.data;

  } catch (error) {
    // Axios 에러 처리
    if (axios.isAxiosError(error)) {
      if (error.code === 'ECONNABORTED') {
        // 타임아웃
        throw new ServiceUnavailableError(
          '날씨 정보를 불러올 수 없습니다',
          'weather-api',
          60
        );
      }

      if (error.response) {
        // 서버 응답 있음
        if (error.response.status === 404) {
          throw new NotFoundError('도시를 찾을 수 없습니다', 'City', city);
        }
        if (error.response.status === 429) {
          throw new RateLimitError('요청 한도를 초과했습니다', 60);
        }
      } else if (error.request) {
        // 요청은 보냈지만 응답 없음
        throw new ServiceUnavailableError(
          '날씨 서비스에 연결할 수 없습니다',
          'weather-api',
          60
        );
      }
    }

    // 예상치 못한 에러
    throw error;
  }
}

체크리스트

에러 처리 전

  • 이 에러는 복구 가능한가?
  • 사용자에게 어떤 메시지를 보여줄 것인가?
  • 재시도가 필요한가?
  • 로그에 어떤 정보를 남길 것인가?
  • 개발자에게 알림이 필요한가?

배포 전

  • 모든 예상 에러가 처리되었는가?
  • 사용자 메시지가 친절한가?
  • 민감 정보가 로그에 없는가?
  • 에러 모니터링이 설정되었는가?
  • 에러 응답 형식이 일관적인가?

마무리 원칙

"에러는 적이 아니라 피드백이다"

  • 숨기지 마라: 에러를 무시하면 더 큰 문제가 된다
  • 명확하게: 사용자도, 개발자도 이해할 수 있게
  • 일관되게: 같은 패턴을 유지하라
  • 준비하라: 예상 가능한 에러는 모두 처리하라
  • 배우라: 에러 로그에서 개선점을 찾아라