미들웨어 완벽 가이드: 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);
}
);
미들웨어의 장점:
- 재사용성: 한 번 작성하면 여러 곳에서 사용
- 가독성: 비즈니스 로직과 공통 로직 분리
- 유지보수성: 공통 로직 변경 시 한 곳만 수정
- 일관성: 모든 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;
}
정리
핵심 요약
- 미들웨어의 정의: 요청과 응답 사이에서 실행되는 함수
- Express 미들웨어:
(req, res, next) => { ... }형태,next()호출로 체이닝 - Next.js 미들웨어: Edge Runtime에서 실행, 페이지 렌더링 전 요청 가로채기
- 일반적인 용도: 인증, 로깅, 에러 핸들링, 입력 검증, Rate Limiting, CORS
- 2025년 주의사항: Next.js에서 미들웨어만으로 인증 처리는 불충분, 다층 방어 필요
실전 체크리스트
Express.js:
- 로깅 미들웨어 적용 (Winston, Pino)
- 에러 핸들링 미들웨어 (맨 마지막에)
- 인증/인가 미들웨어 분리
- Rate Limiting 적용 (특히 로그인)
- CORS 설정 (프로덕션은 특정 출처만)
- 입력 검증 미들웨어 (Joi, Zod)
Next.js 15:
- 미들웨어는 보조적으로만 사용
- 실제 인증은 Server Component/API에서
- matcher로 적용 경로 명확히 지정
- 보안 헤더 추가
- Edge Runtime 제약사항 숙지
다음 단계
- JWT 인증 가이드 - 인증 미들웨어 심화
- 데이터 유출 방지 - 보안 전반
- Express 공식 문서 - Middleware
- Next.js 공식 문서 - Middleware
참고 자료
공식 문서
추천 라이브러리
- morgan - HTTP 요청 로거
- helmet - 보안 헤더
- express-rate-limit - Rate limiting
- cors - CORS 설정
- express-validator - 입력 검증
댓글