미들웨어 완벽 가이드: Express와 Next.js에서의 활용

“코드가 너무 반복돼요. 매번 똑같은 검증 로직을 써야 하나요?”

API를 개발하다 보면 이런 코드를 자주 봅니다:

app.get('/api/profile', (req, res) => {
  // 토큰 확인
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  // 사용자 확인
  const user = verifyToken(token);
  if (!user) return res.status(401).json({ error: 'Invalid token' });

  // 로깅
  console.log(`${user.email} accessed profile`);

  // 드디어 실제 로직
  const profile = getProfile(user.id);
  res.json(profile);
});

app.get('/api/settings', (req, res) => {
  // 또 똑같은 코드...
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: 'Unauthorized' });
  // ...
});

10개, 20개의 API마다 이런 코드를 반복하면 유지보수가 어렵고, 실수로 하나라도 빠뜨리면 보안 취약점이 됩니다.

미들웨어는 이 문제를 해결합니다. 이 문서에서는 미들웨어의 개념부터 Express.js와 Next.js 15에서의 실전 활용법까지 다룹니다.

미들웨어란?

미들웨어(Middleware)는 요청(Request)과 응답(Response) 사이(Middle)에서 실행되는 함수입니다. 클라이언트 요청을 가로채서 처리한 후, 다음 단계로 넘기거나 응답을 종료할 수 있습니다.

왜 “미들웨어”인가?

클라이언트 → [미들웨어 1] → [미들웨어 2] → [미들웨어 3] → 실제 로직 → 응답

요청과 응답 사이(Middle)에 있는 소프트웨어(Software)라는 의미입니다.

미들웨어의 역할

비즈니스 로직과 공통 로직의 분리:

// ❌ 미들웨어 없이: 모든 것을 한 곳에
app.get('/api/users/:id', (req, res) => {
  // 1. 로깅
  console.log(`${req.method} ${req.url}`);

  // 2. 인증
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  // 3. 인가
  const user = verifyToken(token);
  if (req.params.id !== user.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  // 4. 입력 검증
  if (!isValidId(req.params.id)) {
    return res.status(400).json({ error: 'Invalid ID' });
  }

  // 드디어 실제 비즈니스 로직...
  const userData = getUserData(req.params.id);
  res.json(userData);
});

// ✅ 미들웨어 사용: 공통 로직 분리
app.get('/api/users/:id',
  logger,        // 1. 로깅
  authenticate,  // 2. 인증
  authorize,     // 3. 인가
  validateId,    // 4. 입력 검증
  (req, res) => {
    // 깔끔한 비즈니스 로직만!
    const userData = getUserData(req.params.id);
    res.json(userData);
  }
);

미들웨어의 장점:

  1. 재사용성: 한 번 작성하면 여러 곳에서 사용
  2. 가독성: 비즈니스 로직과 공통 로직 분리
  3. 유지보수성: 공통 로직 변경 시 한 곳만 수정
  4. 일관성: 모든 API에 동일한 정책 적용

Express.js 미들웨어

Express.js는 미들웨어 기반 프레임워크입니다. 모든 것이 미들웨어로 구성됩니다.

미들웨어의 구조

function middlewareName(req, res, next) {
  // req: 클라이언트 요청 정보
  // res: 서버 응답 객체
  // next: 다음 미들웨어로 넘기는 함수

  // 1. 요청 전처리
  console.log('Before request');

  // 2. 다음 미들웨어로 진행
  next();

  // 3. 응답 후처리 (optional)
  console.log('After response');
}

핵심 개념:

  • next() 호출: 다음 미들웨어로 제어 이동
  • next() 미호출: 요청 처리 중단 (응답을 보내야 함)
  • next(error): 에러 미들웨어로 이동

미들웨어 실행 순서

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

// 1번: 가장 먼저 실행
app.use((req, res, next) => {
  console.log('1: First middleware');
  next();
});

// 2번: 두 번째 실행
app.use((req, res, next) => {
  console.log('2: Second middleware');
  next();
});

// 3번: 특정 경로에만 적용
app.use('/api', (req, res, next) => {
  console.log('3: API middleware');
  next();
});

// 4번: 실제 라우트 핸들러
app.get('/api/users', (req, res) => {
  console.log('4: Route handler');
  res.json({ users: [] });
});

// GET /api/users 요청 시 실행 순서:
// 1 → 2 → 3 → 4

1. 로깅 미들웨어

// ✅ 간단한 로깅
function logger(req, res, next) {
  const start = Date.now();

  // 응답이 완료되면 실행
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(
      `${req.method} ${req.url} ${res.statusCode} - ${duration}ms`
    );
  });

  next();
}

app.use(logger);

// ✅ 더 상세한 로깅
function advancedLogger(req, res, next) {
  const start = Date.now();

  // 요청 정보 로깅
  console.log({
    timestamp: new Date().toISOString(),
    method: req.method,
    url: req.url,
    ip: req.ip,
    userAgent: req.headers['user-agent'],
  });

  res.on('finish', () => {
    const duration = Date.now() - start;

    // 응답 정보 로깅
    console.log({
      timestamp: new Date().toISOString(),
      method: req.method,
      url: req.url,
      status: res.statusCode,
      duration: `${duration}ms`,
    });
  });

  next();
}

실무에서는 Winston이나 Pino 같은 로깅 라이브러리 사용:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

function requestLogger(req, res, next) {
  logger.info({
    method: req.method,
    url: req.url,
    ip: req.ip,
  });

  next();
}

app.use(requestLogger);

2. 인증 미들웨어

const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  // 1. Authorization 헤더 확인
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    return res.status(401).json({ error: 'No token provided' });
  }

  // 2. Bearer 토큰 추출
  const token = authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Invalid token format' });
  }

  try {
    // 3. 토큰 검증
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // 4. req.user에 사용자 정보 저장
    req.user = decoded;

    // 5. 다음 미들웨어로
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// 사용
app.get('/api/profile', authenticate, (req, res) => {
  // req.user는 authenticate에서 설정됨
  res.json({ user: req.user });
});

3. 인가 미들웨어

// ✅ 역할 기반 인가 (RBAC)
function requireRole(...allowedRoles) {
  return (req, res, next) => {
    // authenticate 미들웨어가 먼저 실행되었다고 가정
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    const userRole = req.user.role;

    if (!allowedRoles.includes(userRole)) {
      return res.status(403).json({
        error: `Access denied. Required roles: ${allowedRoles.join(', ')}`
      });
    }

    next();
  };
}

// 사용
app.get('/api/users',
  authenticate,
  requireRole('admin', 'moderator'),
  (req, res) => {
    // admin 또는 moderator만 접근 가능
    res.json({ users: getAllUsers() });
  }
);

app.delete('/api/users/:id',
  authenticate,
  requireRole('admin'),
  (req, res) => {
    // admin만 삭제 가능
    deleteUser(req.params.id);
    res.json({ success: true });
  }
);

// ✅ 리소스 소유자 확인
function requireOwnership(req, res, next) {
  const resourceOwnerId = req.params.userId;
  const currentUserId = req.user.id;

  if (resourceOwnerId !== currentUserId && req.user.role !== 'admin') {
    return res.status(403).json({
      error: 'You can only access your own resources'
    });
  }

  next();
}

app.get('/api/users/:userId/profile',
  authenticate,
  requireOwnership,
  (req, res) => {
    // 자기 프로필만 조회 가능
    const profile = getProfile(req.params.userId);
    res.json({ profile });
  }
);

4. 에러 핸들링 미들웨어

Express에서 에러 미들웨어는 4개의 매개변수를 받습니다:

// ✅ 에러 핸들링 미들웨어 (4개 매개변수)
function errorHandler(err, req, res, next) {
  console.error('Error:', err);

  // 1. JWT 에러
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({ error: 'Invalid token' });
  }

  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({ error: 'Token expired' });
  }

  // 2. Validation 에러
  if (err.name === 'ValidationError') {
    return res.status(400).json({
      error: 'Validation failed',
      details: err.details,
    });
  }

  // 3. Database 에러
  if (err.code === 'P2002') { // Prisma unique constraint
    return res.status(409).json({ error: 'Resource already exists' });
  }

  // 4. 기본 에러
  res.status(err.status || 500).json({
    error: err.message || 'Internal server error',
    // 프로덕션에서는 stack trace 숨기기
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  });
}

// ✅ 에러 미들웨어는 맨 마지막에!
app.use(errorHandler);

// 사용 예시
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await db.user.findUnique({
      where: { id: req.params.id },
    });

    if (!user) {
      // 에러 미들웨어로 전달
      const error = new Error('User not found');
      error.status = 404;
      throw error;
    }

    res.json({ user });
  } catch (error) {
    // next(error)로 에러 미들웨어로 전달
    next(error);
  }
});

5. 입력 검증 미들웨어

const Joi = require('joi');

// ✅ Joi 스키마 검증 미들웨어 팩토리
function validate(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false, // 모든 에러 반환
    });

    if (error) {
      return res.status(400).json({
        error: 'Validation failed',
        details: error.details.map(d => ({
          field: d.path.join('.'),
          message: d.message,
        })),
      });
    }

    // 검증된 데이터로 교체
    req.body = value;
    next();
  };
}

// 스키마 정의
const createUserSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  name: Joi.string().min(2).max(50).required(),
  age: Joi.number().integer().min(18).optional(),
});

// 사용
app.post('/api/users',
  validate(createUserSchema),
  async (req, res) => {
    // req.body는 이미 검증됨
    const user = await createUser(req.body);
    res.json({ user });
  }
);

6. Rate Limiting 미들웨어

const rateLimit = require('express-rate-limit');

// ✅ 일반 API 제한
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 100, // 최대 100번
  message: 'Too many requests, please try again later',
  standardHeaders: true, // RateLimit-* 헤더 포함
  legacyHeaders: false,
});

app.use('/api/', apiLimiter);

// ✅ 로그인 엔드포인트 제한 (더 엄격)
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 5, // 최대 5번
  skipSuccessfulRequests: true, // 성공한 요청은 카운트 안 함
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many login attempts, please try again later'
    });
  },
});

app.post('/api/login', loginLimiter, loginHandler);

// ✅ IP별 제한
const ipLimiter = rateLimit({
  windowMs: 60 * 1000, // 1분
  max: 10,
  keyGenerator: (req) => {
    return req.ip; // IP 주소로 제한
  },
});

7. CORS 미들웨어

const cors = require('cors');

// ✅ 간단한 CORS (모든 출처 허용 - 개발용)
app.use(cors());

// ✅ 프로덕션용 CORS (특정 출처만 허용)
const allowedOrigins = [
  'https://yourdomain.com',
  'https://www.yourdomain.com',
  process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean);

app.use(cors({
  origin: function (origin, callback) {
    // origin이 없는 경우 (같은 도메인 요청)는 허용
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true, // 쿠키 전송 허용
  optionsSuccessStatus: 200,
}));

// ✅ 특정 라우트에만 CORS 적용
const publicCors = cors({
  origin: '*', // 모든 출처 허용
});

app.get('/api/public', publicCors, (req, res) => {
  res.json({ message: 'Public data' });
});

8. 미들웨어 체이닝

// ✅ 미들웨어를 배열로 그룹화
const authMiddleware = [
  authenticate,
  requireRole('user'),
];

const adminMiddleware = [
  authenticate,
  requireRole('admin'),
];

app.get('/api/profile', ...authMiddleware, (req, res) => {
  res.json({ user: req.user });
});

app.delete('/api/users/:id', ...adminMiddleware, (req, res) => {
  deleteUser(req.params.id);
  res.json({ success: true });
});

// ✅ 조건부 미들웨어
function conditionalMiddleware(condition, middleware) {
  return (req, res, next) => {
    if (condition(req)) {
      return middleware(req, res, next);
    }
    next();
  };
}

// 프로덕션 환경에서만 Rate Limiting 적용
app.use(
  conditionalMiddleware(
    () => process.env.NODE_ENV === 'production',
    apiLimiter
  )
);

Next.js 15 미들웨어

Next.js에서 미들웨어는 Edge Runtime에서 실행되며, 페이지 렌더링 전에 요청을 가로챕니다.

Next.js 미들웨어의 특징

  • Edge Runtime: CDN 엣지에서 실행되어 빠름
  • 요청 전처리: 페이지 렌더링 전에 실행
  • 리다이렉트/Rewrite 가능: URL 조작 가능
  • 제한 사항: Node.js API 일부 사용 불가

기본 구조

// middleware.ts (프로젝트 루트)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 요청 처리
  console.log('Path:', request.nextUrl.pathname);

  // 요청을 그대로 통과
  return NextResponse.next();
}

// 미들웨어를 적용할 경로 지정
export const config = {
  matcher: [
    /*
     * 다음 경로를 제외한 모든 경로:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization)
     * - favicon.ico
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

1. 인증 미들웨어 (2025년 주의사항)

중요: Next.js 15에서는 미들웨어만으로 인증을 처리하는 것이 권장되지 않습니다. 보조적으로만 사용하세요.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  const { pathname } = request.nextUrl;

  // ✅ 첫 번째 방어선: 로그인 페이지로 리다이렉트
  // (실제 인증은 각 페이지/API에서 수행)
  const isProtectedRoute =
    pathname.startsWith('/dashboard') ||
    pathname.startsWith('/admin');

  if (isProtectedRoute && !token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('from', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

2. 로깅 미들웨어

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const start = Date.now();

  // 요청 정보 로깅
  console.log({
    timestamp: new Date().toISOString(),
    method: request.method,
    url: request.url,
    pathname: request.nextUrl.pathname,
    userAgent: request.headers.get('user-agent'),
  });

  const response = NextResponse.next();

  // 응답 시간 측정
  const duration = Date.now() - start;
  response.headers.set('X-Response-Time', `${duration}ms`);

  return response;
}

3. 헤더 추가 미들웨어

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // ✅ 보안 헤더 추가
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

  // ✅ CORS 헤더 추가
  response.headers.set('Access-Control-Allow-Origin', 'https://yourdomain.com');
  response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');

  return response;
}

4. 지역화(i18n) 미들웨어

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const locales = ['en', 'ko', 'ja'];
const defaultLocale = 'en';

function getLocale(request: NextRequest): string {
  // 1. URL에서 locale 확인
  const pathname = request.nextUrl.pathname;
  const pathnameLocale = locales.find(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameLocale) return pathnameLocale;

  // 2. 쿠키에서 locale 확인
  const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
  if (cookieLocale && locales.includes(cookieLocale)) {
    return cookieLocale;
  }

  // 3. Accept-Language 헤더 확인
  const acceptLanguage = request.headers.get('accept-language');
  if (acceptLanguage) {
    const browserLocale = acceptLanguage.split(',')[0].split('-')[0];
    if (locales.includes(browserLocale)) {
      return browserLocale;
    }
  }

  return defaultLocale;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // 이미 locale이 있으면 통과
  const pathnameHasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) return NextResponse.next();

  // locale 추가하여 리다이렉트
  const locale = getLocale(request);
  request.nextUrl.pathname = `/${locale}${pathname}`;

  return NextResponse.redirect(request.nextUrl);
}

export const config = {
  matcher: ['/((?!api|_next|favicon.ico).*)'],
};

5. A/B 테스팅 미들웨어

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 쿠키에서 variant 확인
  let variant = request.cookies.get('ab-test-variant')?.value;

  if (!variant) {
    // 50/50 A/B 테스트
    variant = Math.random() < 0.5 ? 'A' : 'B';
  }

  const response = NextResponse.next();

  // variant를 쿠키에 저장
  response.cookies.set('ab-test-variant', variant, {
    maxAge: 7 * 24 * 60 * 60, // 7일
  });

  // 헤더에 variant 추가 (Server Component에서 사용 가능)
  response.headers.set('x-ab-test-variant', variant);

  return response;
}

정리

핵심 요약

  1. 미들웨어의 정의: 요청과 응답 사이에서 실행되는 함수
  2. Express 미들웨어: (req, res, next) => { ... } 형태, next() 호출로 체이닝
  3. Next.js 미들웨어: Edge Runtime에서 실행, 페이지 렌더링 전 요청 가로채기
  4. 일반적인 용도: 인증, 로깅, 에러 핸들링, 입력 검증, Rate Limiting, CORS
  5. 2025년 주의사항: Next.js에서 미들웨어만으로 인증 처리는 불충분, 다층 방어 필요

실전 체크리스트

Express.js:

  • 로깅 미들웨어 적용 (Winston, Pino)
  • 에러 핸들링 미들웨어 (맨 마지막에)
  • 인증/인가 미들웨어 분리
  • Rate Limiting 적용 (특히 로그인)
  • CORS 설정 (프로덕션은 특정 출처만)
  • 입력 검증 미들웨어 (Joi, Zod)

Next.js 15:

  • 미들웨어는 보조적으로만 사용
  • 실제 인증은 Server Component/API에서
  • matcher로 적용 경로 명확히 지정
  • 보안 헤더 추가
  • Edge Runtime 제약사항 숙지

다음 단계

참고 자료

공식 문서

추천 라이브러리

댓글