본문 바로가기
Tech Notes

언제 try-catch, throw를 써야할까?

by miracle-tech 2025. 10. 11.
320x100
반응형

🎯 핵심 개념: 역할 분담

비유로 이해하기 (학교)

 
 
학생 (Route Handler)
  ↓ 문제 발생!
  "선생님! 숙제를 못 찾겠어요!" (throw)
  ↓
담임 선생님 (Async Handler)
  ↓ 일단 받아줌
  "알았어, 교장 선생님께 보고할게" (catch → next)
  ↓
교장 선생님 (Error Handler)
  ↓ 최종 판단
  "이건 단순 실수구나" → 학생에게 친절하게 설명
  "이건 심각한 문제네" → 학부모(Slack) 연락

📋 규칙: 언제 throw? 언제 try-catch?

✅ Route Handler: throw만!

 
 
javascript
// ✅ 좋은 코드 (throw만 사용)
app.post('/login', asyncHandler(async (req, res) => {
  const { email, password } = req.body;
  
  // 검증 실패 → throw
  if (!email) {
    throw new BadRequestError('이메일을 입력하세요');
  }
  
  // 사용자 없음 → throw
  const user = await User.findOne({ email });
  if (!user) {
    throw new UnauthorizedError('이메일 또는 비밀번호가 틀렸습니다');
  }
  
  // 비밀번호 틀림 → throw
  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    throw new UnauthorizedError('이메일 또는 비밀번호가 틀렸습니다');
  }
  
  // 성공!
  res.json({ token: generateToken(user) });
}));

// ❌ 나쁜 코드 (try-catch 사용)
app.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    if (!email) {
      return res.status(400).json({ error: '이메일을 입력하세요' });  // ← 중복!
    }
    
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: '틀렸습니다' });  // ← 중복!
    }
    
    // ... 계속 반복
    
  } catch (error) {
    res.status(500).json({ error: '서버 오류' });  // ← 이것도 중복!
  }
});

→ Route Handler에서는 그냥 throw만 하세요!


✅ Async Handler: 자동 catch

 
 
javascript
// asyncHandler가 자동으로 해줌
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next))
      .catch(next);  // ← 여기서 catch!
  };
}

// 사용:
app.get('/user/:id', asyncHandler(async (req, res) => {
  // 여기서 throw하면 asyncHandler가 자동으로 catch
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('없음');  // ← throw
  res.json(user);
}));

// → 자동으로 Error Handler로 전달됨!

→ try-catch 직접 안 써도 됨!


✅ Error Handler: 모든 에러를 받아서 처리

 
 
javascript
// 최종 에러 처리
function errorHandler(err, req, res, next) {
  // 1. 로그 기록
  logger.error(err.message, {
    error: err,
    request: {
      method: req.method,
      url: req.url,
    },
  });
  
  // 2. Slack 알림 (심각한 에러만)
  if (err.statusCode >= 500) {
    slackNotify(err);
  }
  
  // 3. 사용자에게 응답
  const statusCode = err.statusCode || 500;
  const message = err.isOperational 
    ? err.message  // 예상된 에러 → 그대로 보여줌
    : '서버 오류가 발생했습니다';  // 예상 못한 에러 → 숨김
  
  res.status(statusCode).json({
    error: {
      message,
      code: err.code,
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
    },
  });
}

app.use(errorHandler);

→ 한 곳에서 모든 에러를 처리!


🎬 실제 흐름 시각화

시나리오: 사용자 조회 API

 
 
javascript
// 1. Route Handler (throw만)
app.get('/user/:id', asyncHandler(async (req, res) => {
  console.log('1️⃣ Route Handler 시작');
  
  const user = await User.findById(req.params.id);
  
  if (!user) {
    console.log('2️⃣ 에러 발생! throw');
    throw new NotFoundError('사용자를 찾을 수 없습니다');
  }
  
  res.json(user);
}));

// 2. Async Handler (자동 catch)
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next))
      .catch((error) => {
        console.log('3️⃣ Async Handler가 잡음');
        next(error);  // Error Handler로 전달
      });
  };
}

// 3. Error Logger (기록)
function errorLogger(err, req, res, next) {
  console.log('4️⃣ Error Logger 기록');
  logger.error(err.message);
  next(err);  // 다음으로 전달
}

app.use(errorLogger);

// 4. Error Handler (응답)
function errorHandler(err, req, res, next) {
  console.log('5️⃣ Error Handler 응답');
  
  res.status(err.statusCode || 500).json({
    error: {
      message: err.message,
    },
  });
}

app.use(errorHandler);

실행 결과:

 
 
1️⃣ Route Handler 시작
2️⃣ 에러 발생! throw
3️⃣ Async Handler가 잡음
4️⃣ Error Logger 기록
5️⃣ Error Handler 응답

→ 사용자에게:
{
  "error": {
    "message": "사용자를 찾을 수 없습니다"
  }
}

🤔 언제 try-catch를 써야 할까?

규칙: 특별한 처리가 필요할 때만!

 
 
javascript
// ✅ Case 1: DB 에러를 변환할 때
app.post('/user', asyncHandler(async (req, res) => {
  try {
    const user = await User.create(req.body);
    res.json(user);
  } catch (error) {
    // MongoDB 중복 키 에러를 우리 에러로 변환
    if (error.code === 11000) {
      throw new ConflictError('이미 사용 중인 이메일입니다');
    }
    
    // 다른 에러는 그대로 던짐
    throw error;
  }
}));

// ✅ Case 2: 외부 API 호출 시
app.get('/weather', asyncHandler(async (req, res) => {
  try {
    const response = await fetch('https://weather-api.com/...');
    const data = await response.json();
    res.json(data);
  } catch (error) {
    // 외부 API 에러를 우리 에러로 변환
    throw new ServiceUnavailableError('날씨 정보를 가져올 수 없습니다');
  }
}));

// ✅ Case 3: 여러 작업을 한 번에 처리
app.post('/order', asyncHandler(async (req, res) => {
  try {
    // 1. 재고 확인
    const product = await Product.findById(req.body.productId);
    if (product.stock < 1) {
      throw new BadRequestError('재고가 부족합니다');
    }
    
    // 2. 주문 생성
    const order = await Order.create(req.body);
    
    // 3. 재고 감소
    await product.updateStock(-1);
    
    // 4. 결제 처리
    await processPayment(order);
    
    res.json(order);
    
  } catch (error) {
    // 롤백이 필요한 경우
    if (error.name === 'PaymentError') {
      await order.cancel();  // 주문 취소
      await product.updateStock(+1);  // 재고 복구
    }
    
    throw error;  // 다시 던져서 Error Handler가 처리
  }
}));

→ try-catch는 "특별한 처리"가 필요할 때만!


📊 코드 패턴 비교

❌ 안티패턴 (모든 곳에서 try-catch)

 
 
javascript
// Route 1
app.get('/user/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: '없음' });  // 중복 1
    }
    res.json(user);
  } catch (error) {
    logger.error(error);  // 중복 2
    res.status(500).json({ error: '서버 오류' });  // 중복 3
  }
});

// Route 2
app.post('/user', async (req, res) => {
  try {
    const user = await User.create(req.body);
    res.json(user);
  } catch (error) {
    logger.error(error);  // 중복 2 (또!)
    res.status(500).json({ error: '서버 오류' });  // 중복 3 (또!)
  }
});

// Route 3, 4, 5... 계속 반복 😰

문제점:

  • 😰 코드 중복 (try-catch가 모든 라우트에)
  • 😰 일관성 없음 (에러 메시지가 제각각)
  • 😰 유지보수 어려움 (수정할 곳이 너무 많음)

✅ 좋은 패턴 (중앙 집중식)

 
 
javascript
// Route 1
app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('없음');  // ← throw만!
  res.json(user);
}));

// Route 2
app.post('/user', asyncHandler(async (req, res) => {
  const user = await User.create(req.body);
  res.json(user);
}));

// Route 3, 4, 5... 계속 간단! 😊

// 에러 처리는 한 곳에서!
app.use(errorLogger);
app.use(errorHandler);

장점:

  • ✅ 코드 간결 (try-catch 없음)
  • ✅ 일관성 있음 (한 곳에서 처리)
  • ✅ 유지보수 쉬움 (수정할 곳 하나)

🎯 최종 정리: 당신의 질문 답변

Q: 언제 throw? 언제 try-catch?

A: 간단한 규칙!

 
 
javascript
// 1. Route Handler에서는: throw만!
app.get('/api', asyncHandler(async (req, res) => {
  if (문제) throw new CustomError('메시지');
  res.json(data);
}));

// 2. 특별한 처리 필요할 때만: try-catch
app.post('/api', asyncHandler(async (req, res) => {
  try {
    const result = await externalAPI();
    res.json(result);
  } catch (error) {
    // 특별히 변환이 필요한 경우만
    throw new ServiceError('외부 서비스 오류');
  }
}));

// 3. 최종 처리는: Error Handler가 알아서!
app.use(errorLogger);
app.use(errorHandler);

Q: 예상된 에러 vs 예상 못한 에러 구분?

A: Custom Error에 플래그 추가!

 
 
javascript
// Custom Error 베이스
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;  // ← 예상된 에러!
  }
}

// 예상된 에러들
class NotFoundError extends AppError {
  constructor(message) {
    super(message, 404);
    this.code = 'NOT_FOUND';
  }
}

class BadRequestError extends AppError {
  constructor(message) {
    super(message, 400);
    this.code = 'BAD_REQUEST';
  }
}

// Error Handler에서 구분
function errorHandler(err, req, res, next) {
  // 예상된 에러 (isOperational = true)
  if (err.isOperational) {
    logger.info('예상된 에러:', err.message);  // info 레벨
    slackNotify = false;  // Slack 안 보냄
    message = err.message;  // 사용자에게 그대로
  } 
  // 예상 못한 에러 (isOperational = undefined/false)
  else {
    logger.error('예상 못한 에러!', err);  // error 레벨
    slackNotify = true;  // Slack 보냄!
    message = '서버 오류가 발생했습니다';  // 숨김
  }
  
  res.status(err.statusCode || 500).json({
    error: { message }
  });
}

Q: 로그와 Slack은 어디서?

A: Error Logger와 Error Handler에서!

 
 
javascript
// 1. Error Logger Middleware
function errorLogger(err, req, res, next) {
  // Winston 로그
  logger.error(err.message, {
    error: {
      message: err.message,
      stack: err.stack,
      code: err.code,
    },
    request: {
      method: req.method,
      url: req.url,
      ip: req.ip,
    },
  });
  
  next(err);  // 다음으로 전달
}

// 2. Error Handler
function errorHandler(err, req, res, next) {
  // Slack 알림 (심각한 것만)
  if (err.statusCode >= 500 || !err.isOperational) {
    slackNotify({
      text: `🔥 서버 에러 발생!`,
      error: err.message,
      url: req.url,
    });
  }
  
  // 사용자 응답
  res.status(err.statusCode || 500).json({
    error: { message: err.message }
  });
}

// 순서가 중요!
app.use(errorLogger);    // 먼저 로그
app.use(errorHandler);   // 그 다음 응답

📝 완벽한 템플릿

 
 
javascript
// ========== 1. Custom Errors ==========
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(message = '리소스를 찾을 수 없습니다') {
    super(message, 404);
    this.code = 'NOT_FOUND';
  }
}

class UnauthorizedError extends AppError {
  constructor(message = '인증이 필요합니다') {
    super(message, 401);
    this.code = 'UNAUTHORIZED';
  }
}

// ========== 2. Async Handler ==========
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// ========== 3. Routes (throw만!) ==========
app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('사용자 없음');
  res.json(user);
}));

app.post('/login', asyncHandler(async (req, res) => {
  const { email, password } = req.body;
  
  if (!email) throw new BadRequestError('이메일 필요');
  
  const user = await User.findOne({ email });
  if (!user) throw new UnauthorizedError('틀림');
  
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) throw new UnauthorizedError('틀림');
  
  res.json({ token: generateToken(user) });
}));

// ========== 4. Error Logger ==========
function errorLogger(err, req, res, next) {
  logger.error(err.message, {
    error: err,
    request: { method: req.method, url: req.url },
  });
  next(err);
}

// ========== 5. Error Handler ==========
function errorHandler(err, req, res, next) {
  const statusCode = err.statusCode || 500;
  const message = err.isOperational 
    ? err.message 
    : '서버 오류';
  
  // Slack 알림 (심각한 것만)
  if (statusCode >= 500) {
    slackNotify(err);
  }
  
  res.status(statusCode).json({
    error: {
      message,
      code: err.code,
      ...(isDevelopment && { stack: err.stack }),
    },
  });
}

// ========== 6. 미들웨어 등록 ==========
app.use(errorLogger);
app.use(errorHandler);
320x100