Node.js 인터뷰 질문 60

질문: Node.js 애플리케이션에서 효과적인, 예외 처리와 에러 관리 전략은 무엇인가요?

답변:

Node.js 애플리케이션에서 효과적인 예외 처리와 에러 관리는 애플리케이션의 안정성, 복원력, 유지보수성에 직접적으로 영향을 미치는 중요한 요소입니다. 특히 비동기 특성을 가진 Node.js 환경에서는 더욱 주의 깊은 에러 처리가 필요합니다.

1. Node.js에서 에러의 유형

Node.js에서 발생할 수 있는 에러는 크게 아래와 같이 분류할 수 있습니다:

1.1 표준 JavaScript 에러

// TypeError: 부적절한 타입으로 작업할 때 발생
const num = 123;
num.toUpperCase(); // TypeError: num.toUpperCase is not a function

// ReferenceError: 존재하지 않는 변수에 접근할 때 발생
console.log(undefinedVariable); // ReferenceError: undefinedVariable is not defined

// SyntaxError: 코드 구문이 잘못됐을 때 발생
const syntax Error = true; // SyntaxError: Unexpected identifier 'Error'

// RangeError: 허용 범위를 벗어난 값이 사용될 때 발생
const arr = new Array(-1); // RangeError: Invalid array length

1.2 시스템 에러

const fs = require("fs");

// 존재하지 않는 파일 읽기 시도
fs.readFile("/non-existent-file.txt", (err, data) => {
  if (err) {
    console.error(`시스템 에러 코드: ${err.code}`); // ENOENT
    console.error(`에러 메시지: ${err.message}`);
    return;
  }
  // 정상 처리 로직
});

1.3 사용자 정의 에러

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
    this.code = "ERR_VALIDATION_FAILED";
  }
}

function validateUserInput(input) {
  if (!input) {
    throw new ValidationError("입력값이 비어있습니다.");
  }
  // 유효성 검사 로직
}

2. 동기 코드에서의 에러 처리

동기 코드에서는 try/catch 블록을 사용하여 에러를 처리합니다.

function processData(data) {
  try {
    // 잠재적으로 에러가 발생할 수 있는 코드
    const result = JSON.parse(data);
    return result;
  } catch (err) {
    console.error("데이터 처리 중 에러 발생:", err.message);

    // 에러 로깅, 상위 호출자에게 알림, 또는 기본값 반환
    return { error: true, message: err.message };
  } finally {
    // 항상 실행되는 정리 코드
    console.log("데이터 처리 완료");
  }
}

3. 비동기 코드에서의 에러 처리

3.1 콜백 패턴

Node.js의 콜백은 전통적으로 error-first 패턴을 따릅니다.

const fs = require("fs");

// 콜백의 첫 번째 인자로 에러 객체 전달
fs.readFile("config.json", "utf8", (err, data) => {
  if (err) {
    console.error("파일 읽기 오류:", err);
    return;
  }

  try {
    const config = JSON.parse(data);
    processConfig(config);
  } catch (parseErr) {
    console.error("JSON 파싱 오류:", parseErr);
  }
});

function processConfig(config) {
  // 설정 처리 로직
}

3.2 프로미스와 에러 처리

const fs = require("fs").promises;

fs.readFile("config.json", "utf8")
  .then((data) => {
    const config = JSON.parse(data);
    return processConfig(config);
  })
  .then((result) => {
    console.log("처리 결과:", result);
  })
  .catch((err) => {
    // 체인 내 어느 단계에서든 발생한 에러를 처리
    console.error("작업 중 오류 발생:", err);
  })
  .finally(() => {
    console.log("작업 완료");
  });

function processConfig(config) {
  return new Promise((resolve, reject) => {
    // 비동기 처리 로직
    if (!config.apiKey) {
      reject(new Error("API 키가 없습니다."));
      return;
    }
    resolve({ success: true, data: config });
  });
}

3.3 Async/Await와 에러 처리

const fs = require("fs").promises;

async function loadAndProcessConfig() {
  try {
    // 비동기 작업을 동기적 스타일로 처리
    const data = await fs.readFile("config.json", "utf8");
    const config = JSON.parse(data);
    const result = await processConfig(config);
    return result;
  } catch (err) {
    // 모든 에러를 한 곳에서 처리
    console.error("설정 로드 및 처리 중 오류:", err);
    throw err; // 재전파 또는 기본값 반환 가능
  }
}

// 함수 호출 및 에러 처리
loadAndProcessConfig()
  .then((result) => {
    console.log("처리 완료:", result);
  })
  .catch((err) => {
    console.error("최상위 에러 처리:", err);
  });

4. 미처리 예외 처리

4.1 Node.js의 글로벌 예외 핸들러

// 처리되지 않은 rejection 이벤트 처리
process.on("unhandledRejection", (reason, promise) => {
  console.error("처리되지 않은 프로미스 거부:", promise, "이유:", reason);
  // 애플리케이션 종료 여부 결정 또는 로깅
  // process.exit(1);
});

// 처리되지 않은 예외 이벤트 처리
process.on("uncaughtException", (err) => {
  console.error("처리되지 않은 예외:", err);
  // 정상적인 종료를 위한 정리 작업 후 종료
  // process.exit(1);
});

// 명시적인 애플리케이션 종료 처리
process.on("SIGTERM", () => {
  console.log("프로세스 종료 시그널 수신");
  // 정리 작업 수행
  server.close(() => {
    console.log("HTTP 서버 종료");
    process.exit(0);
  });
});

5. Express.js 애플리케이션에서의 에러 처리

Express.js는 미들웨어 기반 에러 처리 메커니즘을 제공합니다.

const express = require("express");
const app = express();

// 일반 라우트 핸들러
app.get("/api/data", (req, res, next) => {
  try {
    // 동기 코드에서 발생하는 에러 처리
    const data = processData();
    res.json(data);
  } catch (err) {
    // next()로 에러 전달
    next(err);
  }
});

// 비동기 작업이 있는 라우트 핸들러
app.get("/api/users/:id", async (req, res, next) => {
  try {
    const user = await getUserById(req.params.id);
    if (!user) {
      // 404 에러 생성 및 전달
      const error = new Error("사용자를 찾을 수 없습니다");
      error.statusCode = 404;
      throw error;
    }
    res.json(user);
  } catch (err) {
    // 비동기 에러를 에러 핸들러 미들웨어로 전달
    next(err);
  }
});

// 커스텀 에러 클래스
class ApiError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = "ApiError";
    this.statusCode = statusCode || 500;
  }
}

// 존재하지 않는 라우트 처리 (404)
app.use((req, res, next) => {
  const error = new ApiError("요청한 리소스를 찾을 수 없습니다", 404);
  next(error);
});

// 에러 핸들러 미들웨어 (반드시 4개의 인자를 가짐)
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;

  // 개발 환경에서만 상세 에러 정보 제공
  const errorResponse = {
    message: err.message,
    status: statusCode,
    // 운영 환경에서는 스택 트레이스 노출 방지
    ...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
  };

  // 심각한 에러 로깅
  if (statusCode >= 500) {
    console.error("서버 오류:", err);
  }

  res.status(statusCode).json(errorResponse);
});

// 서버 시작
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`서버가 ${PORT} 포트에서 실행 중입니다`);
});

6. 효과적인 에러 관리 전략

6.1 의미 있는 에러 만들기

// 커스텀 에러 클래스 정의
class DatabaseError extends Error {
  constructor(message, operation, details = {}) {
    super(message);
    this.name = "DatabaseError";
    this.operation = operation;
    this.details = details;
    this.date = new Date();
    this.code = "ERR_DATABASE";
  }

  toString() {
    return `${this.name}: ${this.message} [Operation: ${this.operation}]`;
  }
}

// 에러 사용 예
function saveUser(user) {
  try {
    // 데이터베이스 작업...
    if (connectionError) {
      throw new DatabaseError("데이터베이스 연결 실패", "saveUser", {
        userId: user.id,
        connectionId: connectionId,
      });
    }
  } catch (err) {
    // 에러 로깅 및 처리
    console.error(`${err.name}: ${err.message}`);
    console.error(`작업: ${err.operation}, 세부 정보:`, err.details);
    throw err; // 필요에 따라 상위로 전파
  }
}

6.2 구조화된 로깅

const winston = require("winston");

// 로거 구성
const logger = winston.createLogger({
  level: process.env.NODE_ENV !== "production" ? "debug" : "info",
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: "user-service" },
  transports: [
    // 콘솔에 로그 출력
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.simple()
      ),
    }),
    // 파일에 로그 저장
    new winston.transports.File({ filename: "error.log", level: "error" }),
    new winston.transports.File({ filename: "combined.log" }),
  ],
});

// 에러 로깅 함수
function logError(err, context = {}) {
  logger.error({
    message: err.message,
    errorType: err.name,
    stack: err.stack,
    context,
  });
}

// Express 에러 핸들러에 통합
app.use((err, req, res, next) => {
  // 요청 컨텍스트 정보 수집
  const context = {
    requestId: req.id,
    method: req.method,
    url: req.originalUrl,
    ip: req.ip,
    userId: req.user ? req.user.id : null,
  };

  // 에러 로깅
  logError(err, context);

  // 응답 처리
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    status: "error",
    message: statusCode < 500 ? err.message : "서버 오류가 발생했습니다",
  });
});

6.3 오류 감시 및 알림 시스템

const { SentryCaptureException } = require("@sentry/node");

// 중요 에러 발생 시 외부 모니터링 서비스로 전송
function reportCriticalError(err, context = {}) {
  // 로컬 로깅
  logError(err, context);

  // Sentry와 같은 모니터링 서비스에 에러 보고
  SentryCaptureException(err, {
    extra: context,
  });

  // 심각한 에러의 경우 즉시 알림
  if (isCriticalError(err)) {
    sendAlertToDevTeam(err, context);
  }
}

function isCriticalError(err) {
  // 심각한 에러를 판단하는 로직
  return (
    err.statusCode >= 500 ||
    ["DatabaseError", "SystemError"].includes(err.name) ||
    err.critical === true
  );
}

async function sendAlertToDevTeam(err, context) {
  // 슬랙, 이메일 등을 통한 알림 발송
  try {
    await slackClient.sendMessage({
      channel: "#alerts",
      text: `🚨 심각한 오류 발생: ${err.message}`,
      attachments: [
        {
          fields: [
            { title: "에러 타입", value: err.name },
            { title: "환경", value: process.env.NODE_ENV },
            { title: "컨텍스트", value: JSON.stringify(context) },
          ],
        },
      ],
    });
  } catch (alertErr) {
    // 알림 실패 시 로깅만 수행
    logger.error("경고 발송 실패:", alertErr);
  }
}

6.4 회복 전략 (Fallback 및 Circuit Breaker)

const CircuitBreaker = require("opossum");

// 외부 API 호출을 위한 함수
async function fetchUserData(userId) {
  // API 호출 구현
  const response = await axios.get(`https://api.example.com/users/${userId}`);
  return response.data;
}

// Circuit Breaker 패턴으로 외부 API 호출 래핑
const options = {
  timeout: 3000, // 3초 타임아웃
  errorThresholdPercentage: 50, // 50% 실패율에서 회로 개방
  resetTimeout: 10000, // 10초 후 반개방 상태로 전환
};

const userServiceBreaker = new CircuitBreaker(fetchUserData, options);

// 폴백 처리
userServiceBreaker.fallback((userId) => {
  // 캐시에서 기존 데이터 검색
  const cachedData = getUserFromCache(userId);

  if (cachedData) {
    // 캐시 데이터 반환
    return { ...cachedData, fromCache: true };
  }

  // 기본 데이터 반환
  return {
    id: userId,
    name: "알 수 없음",
    isDefault: true,
    message: "사용자 서비스를 일시적으로 이용할 수 없습니다",
  };
});

// 이벤트 리스너
userServiceBreaker.on("open", () => {
  logger.warn("사용자 서비스 회로 개방: 서비스 일시 중단");
});

userServiceBreaker.on("halfOpen", () => {
  logger.info("사용자 서비스 회로 반개방: 복구 시도 중");
});

userServiceBreaker.on("close", () => {
  logger.info("사용자 서비스 회로 폐쇄: 서비스 정상화");
});

userServiceBreaker.on("fallback", (result) => {
  logger.info("폴백 전략 사용 중");
});

// 사용 예시
app.get("/api/users/:id", async (req, res, next) => {
  try {
    // Circuit Breaker를 통한 API 호출
    const user = await userServiceBreaker.fire(req.params.id);

    // 캐시 데이터인 경우 헤더 설정
    if (user.fromCache) {
      res.setHeader("X-Data-Source", "cache");
    }

    res.json(user);
  } catch (err) {
    next(err);
  }
});

6.5 재시도 메커니즘

const { promisify } = require("util");
const sleep = promisify(setTimeout);

/**
 * 재시도 로직을 구현한 함수
 * @param {Function} fn - 실행할 비동기 함수
 * @param {Object} options - 재시도 옵션
 * @param {number} options.retries - 최대 재시도 횟수
 * @param {number} options.initialDelay - 초기 지연 시간 (ms)
 * @param {number} options.maxDelay - 최대 지연 시간 (ms)
 * @param {Function} options.shouldRetry - 재시도 여부를 결정하는 함수
 */
async function withRetry(fn, options = {}) {
  const defaults = {
    retries: 3,
    initialDelay: 1000,
    maxDelay: 30000,
    factor: 2,
    shouldRetry: (err) => true, // 기본적으로 모든 에러에 대해 재시도
  };

  const opts = { ...defaults, ...options };
  let lastError = null;

  for (let attempt = 0; attempt <= opts.retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;

      // 재시도 여부 확인
      if (attempt >= opts.retries || !opts.shouldRetry(err)) {
        throw err;
      }

      // 재시도 지연 시간 계산 (지수 백오프)
      const delay = Math.min(
        opts.initialDelay * Math.pow(opts.factor, attempt),
        opts.maxDelay
      );

      // 재시도 로깅
      console.log(
        `작업 실패, ${delay}ms 후 재시도 (${attempt + 1}/${opts.retries})`,
        err.message
      );

      // 지연 후 재시도
      await sleep(delay);
    }
  }

  // 재시도 모두 실패
  throw lastError;
}

// 사용 예시: 데이터베이스 연결
async function connectToDatabase() {
  return await withRetry(
    async () => {
      // 데이터베이스 연결 시도
      const client = await mongodb.MongoClient.connect(process.env.DB_URI);
      return client;
    },
    {
      retries: 5,
      initialDelay: 500,
      shouldRetry: (err) => {
        // 네트워크 관련 오류에만 재시도
        return ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND"].includes(err.code);
      },
    }
  );
}

7. 오류 모니터링 및 디버깅

7.1 종합적인 모니터링 시스템

// 다양한 모니터링 도구 통합 예시
const Sentry = require("@sentry/node");
const prometheus = require("prom-client");
const express = require("express");
const app = express();

// Sentry 초기화
Sentry.init({
  dsn: "https://your-sentry-dsn",
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1,
});

// 미들웨어로 Sentry 요청 핸들러 추가
app.use(Sentry.Handlers.requestHandler());

// Prometheus 메트릭 설정
const httpRequestDurationMicroseconds = new prometheus.Histogram({
  name: "http_request_duration_seconds",
  help: "HTTP 요청 처리 시간",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10],
});

// 메트릭 수집 미들웨어
app.use((req, res, next) => {
  const start = process.hrtime();

  res.on("finish", () => {
    const durationInSeconds = getDurationInSeconds(start);
    const route = req.route ? req.route.path : req.path;

    httpRequestDurationMicroseconds
      .labels(req.method, route, res.statusCode)
      .observe(durationInSeconds);
  });

  next();
});

// 메트릭 엔드포인트
app.get("/metrics", async (req, res) => {
  res.set("Content-Type", prometheus.register.contentType);
  res.end(await prometheus.register.metrics());
});

// ... 라우트 핸들러 ...

// 에러 핸들러 (Sentry 통합)
app.use(Sentry.Handlers.errorHandler());

// 최종 에러 처리 미들웨어
app.use((err, req, res, next) => {
  // 커스텀 에러 처리 (위의 예제와 유사)
});

// 유틸리티 함수
function getDurationInSeconds(start) {
  const diff = process.hrtime(start);
  return (diff[0] * 1e9 + diff[1]) / 1e9;
}

7.2 고급 디버깅 기법

// 디버깅 정보 향상을 위한 Error 객체 확장
Error.stackTraceLimit = 25; // 스택 트레이스 라인 수 증가

// 개발 환경에서만 활성화되는 디버그 로거
const debug = require("debug")("app:server");

// 오류 발생 지점에 컨텍스트 추가
function enhanceError(err, context = {}) {
  err.context = {
    ...err.context,
    ...context,
    timestamp: new Date().toISOString(),
  };
  return err;
}

// 디버그 로깅이 가능한 비동기 함수 래퍼
function debugAsync(fn, category = "function") {
  const debugLogger = require("debug")(`app:${category}`);

  return async function (...args) {
    const functionName = fn.name || "익명 함수";
    const start = Date.now();

    debugLogger(`${functionName} 시작: 인자=`, args);

    try {
      const result = await fn(...args);
      const duration = Date.now() - start;

      debugLogger(`${functionName} 완료: 소요시간=${duration}ms`);
      return result;
    } catch (err) {
      const duration = Date.now() - start;

      debugLogger(`${functionName} 실패: 소요시간=${duration}ms, 에러=`, err);
      throw enhanceError(err, { functionName, args });
    }
  };
}

// 사용 예시
const getUserDetails = debugAsync(async (userId) => {
  // 사용자 정보 조회 로직
}, "database");

app.get("/api/users/:id", async (req, res, next) => {
  try {
    const user = await getUserDetails(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
});

요약: 효과적인 Node.js 에러 처리 전략

  1. 적절한 에러 타입 사용:

    • 표준 JavaScript 에러 클래스 활용
    • 의미 있는 커스텀 에러 클래스 정의
    • 시스템 에러 코드 및 메시지 표준화
  2. 상황에 맞는 에러 처리 패턴 적용:

    • 동기 코드: try/catch 블록
    • 콜백 패턴: Error-first 콜백
    • 프로미스: .catch() 및 .finally()
    • Async/Await: try/catch 블록
  3. 미처리 예외 관리:

    • uncaughtException 및 unhandledRejection 이벤트 처리
    • 그러나 미처리 예외 후 애플리케이션 정상 실행 보장은 어려움
    • 안전한 종료 전략 구현
  4. Express.js와 같은 프레임워크의 에러 처리 활용:

    • 중앙 집중식 에러 핸들러 미들웨어
    • 상황에 맞는 HTTP 상태 코드 반환
    • 개발/운영 환경에 따른 상세 정보 노출 제어
  5. 구조화된 로깅 및 모니터링:

    • 일관된 로그 형식 및 레벨
    • 컨텍스트 정보 포함
    • 외부 모니터링 서비스 통합
  6. 견고한 복구 메커니즘:

    • 적절한 폴백 전략
    • Circuit Breaker 패턴
    • 재시도 로직 구현
    • 우아한 성능 저하
  7. 디버깅 용이성 향상:

    • 풍부한 컨텍스트 정보 제공
    • 개발 환경에서 상세 에러 정보 노출
    • 추적 가능한 에러 ID 생성

Node.js 애플리케이션에서 효과적인 에러 처리는 단순히 애플리케이션 충돌을 방지하는 것을 넘어, 견고하고 유지보수 가능한 시스템을 구축하는 데 필수적입니다. 에러 처리는 개발 초기 단계부터 고려되어야 하며, 지속적인 모니터링과 개선을 통해 프로덕션 환경에서도 안정적으로 작동하는 시스템을 구축할 수 있습니다.

results matching ""

    No results matching ""