architecture2025-04-22·9·111/348

Express.js 에러 핸들링 미들웨어 패턴

Building robust error handling middleware in Express.js with centralized error logging, response formatting, and async error catching.

Express.js 에러 핸들링 미들웨어 패턴

Introduction

Proper error handling is the difference between a production-ready application and one that crashes on unexpected input. Express.js error handling middleware provides a centralized way to catch, log, and respond to errors consistently. This post covers building a complete error handling system.

Environment

node --version
# v20.11.0

npm list express
# my-app@1.0.0
# +-- express@4.18.2

Problem

Without centralized error handling, error handling is scattered throughout the codebase:

app.get('/users/:id', async (req, res) => {
  try {
    const user = await db.users.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json(user);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal server error' });
  }
});

app.post('/users', async (req, res) => {
  try {
    const user = await db.users.create(req.body);
    res.status(201).json(user);
  } catch (err) {
    console.error(err);
    // Duplicate error handling code!
    res.status(500).json({ error: 'Internal server error' });
  }
});

This approach has several problems:

  1. Duplicate error handling code in every route
  2. Inconsistent error response formats
  3. Errors in middleware are not caught
  4. Async errors may crash the process

Analysis

Express error handling middleware follows a specific signature:

// Error middleware MUST have exactly 4 parameters
app.use((err, req, res, next) => {
  // This catches errors passed via next(err)
});

The error flow in Express:

Route throws error → next(err) → Error middleware → Response

For async functions, errors must be caught and passed to next():

// This does NOT catch async errors!
app.get('/users', async (req, res) => {
  const users = await db.users.findAll(); // If this throws, server crashes
  res.json(users);
});

Solution

Solution 1: Custom error classes

// errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code || 'INTERNAL_ERROR';
    this.isOperational = true;
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

class ValidationError extends AppError {
  constructor(errors) {
    super('Validation failed', 400, 'VALIDATION_ERROR');
    this.errors = errors;
  }
}

class AuthenticationError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'AUTHENTICATION_ERROR');
  }
}

module.exports = { AppError, NotFoundError, ValidationError, AuthenticationError };

Solution 2: Async error wrapper

// middleware/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = asyncHandler;

// Usage
const asyncHandler = require('./middleware/asyncHandler');

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    throw new NotFoundError('User');
  }
  res.json(user);
}));

Solution 3: Centralized error handler

// middleware/errorHandler.js
const { AppError } = require('../errors/AppError');

const errorHandler = (err, req, res, next) => {
  // Log error
  console.error('Error:', {
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    timestamp: new Date().toISOString()
  });
  
  // Handle known operational errors
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        ...(err.errors && { details: err.errors })
      }
    });
  }
  
  // Handle validation errors
  if (err.name === 'ValidationError') {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: err.message,
        details: err.errors
      }
    });
  }
  
  // Handle JWT errors
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      error: {
        code: 'INVALID_TOKEN',
        message: 'Invalid token'
      }
    });
  }
  
  // Handle unknown errors
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: process.env.NODE_ENV === 'production' 
        ? 'An unexpected error occurred' 
        : err.message
    }
  });
};

module.exports = errorHandler;

// server.js
const express = require('express');
const errorHandler = require('./middleware/errorHandler');

const app = express();

// ... routes ...

// Error handler MUST be last middleware
app.use(errorHandler);

Solution 4: 404 handler for unmatched routes

// middleware/notFound.js
const notFoundHandler = (req, res) => {
  res.status(404).json({
    error: {
      code: 'NOT_FOUND',
      message: `Route ${req.method} ${req.path} not found`
    }
  });
};

module.exports = notFoundHandler;

// server.js
app.use('/api', apiRoutes);
app.use(notFoundHandler); // After all routes
app.use(errorHandler);    // After 404 handler

Solution 5: Complete application setup

// server.js
const express = require('express');
const asyncHandler = require('./middleware/asyncHandler');
const errorHandler = require('./middleware/errorHandler');
const notFoundHandler = require('./middleware/notFound');
const { NotFoundError, ValidationError } = require('./errors/AppError');

const app = express();

app.use(express.json());

// Health check (no error handling needed)
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// API routes with asyncHandler
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json(user);
}));

app.post('/api/users', asyncHandler(async (req, res) => {
  const { email, name } = req.body;
  if (!email || !name) {
    throw new ValidationError(['Email and name are required']);
  }
  
  const user = await db.users.create({ email, name });
  res.status(201).json(user);
}));

// Error handling chain
app.use(notFoundHandler);
app.use(errorHandler);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Lessons Learned

  1. Always use async/await with asyncHandler - Never forget error handling for async routes
  2. Define error classes for different error types - It makes error handling more precise
  3. Centralize error logging - One place to log all errors consistently
  4. Hide error details in production - Do not leak stack traces to clients
  5. Test error paths - Errors should be tested as thoroughly as happy paths

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.