들어가며: Express 미들웨어의 핵심 개념
Express.js는 Node.js 웹 애플리케이션 프레임워크로, 미들웨어 아키텍처를 기반으로 설계되었습니다.
이 아키텍처는 HTTP 요청과 응답 사이에서 다양한 기능을 수행할 수 있는 모듈식 접근 방식을 제공합니다.
미들웨어는 Express 애플리케이션의 심장부와 같습니다.
요청이 들어오면 미들웨어 함수들이 순차적으로 실행되며,
각 미들웨어는 요청 객체(req), 응답 객체(res), 그리고 다음 미들웨어 함수(next)에 접근할 수 있습니다.
이 블로그 포스트에서는 Express 미들웨어의 실제 동작 방식과 코드 흐름을 깊이 있게 살펴보겠습니다.
Express 미들웨어의 기본 구조와 실행 순서
Express 미들웨어는 간단히 말해 (req, res, next) => {}
형태의 함수입니다. 이 함수는 요청-응답 주기 동안 특정 작업을 수행하고, 필요에 따라 다음 미들웨어로 제어를 넘깁니다.
const express = require('express');
const app = express();
// 기본적인 미들웨어 구조
app.use((req, res, next) => {
console.log('첫 번째 미들웨어 실행');
next(); // 다음 미들웨어로 제어 이동
});
app.use((req, res, next) => {
console.log('두 번째 미들웨어 실행');
res.send('응답 완료');
});
app.listen(3000, () => {
console.log('서버가 3000번 포트에서 실행 중입니다.');
});
위 코드에서 핵심은 next()
함수입니다. 이 함수는 현재 미들웨어의 처리를 완료하고 다음 미들웨어로 제어를 넘기는 역할을 합니다.
미들웨어는 선언된 순서대로 실행됩니다. 즉, 코드에서 먼저 정의된 미들웨어가 먼저 실행되고, 그 다음에 정의된 미들웨어가 실행됩니다.
미들웨어 체인의 실제 작동 방식과 흐름 제어
미들웨어 체인은 요청이 들어올 때 어떻게 처리되는지를 결정하는 중요한 개념입니다. 이를 실제 코드로 살펴보겠습니다.
app.use((req, res, next) => {
console.log('요청 시간 기록');
req.requestTime = Date.now();
next();
});
app.use((req, res, next) => {
console.log(`요청 처리 시간: ${Date.now() - req.requestTime}ms`);
next();
});
app.get('/profile', (req, res, next) => {
console.log('프로필 라우트 핸들러');
res.send('프로필 페이지');
});
app.use((req, res) => {
res.status(404).send('페이지를 찾을 수 없습니다');
});
위 코드의 실행 흐름을 살펴보면:
- 첫 번째 미들웨어: 요청 시간을 기록합니다.
- 두 번째 미들웨어: 요청 처리 시간을 계산하고 로그를 남깁니다.
/profile
경로에 대한 GET 요청이 오면, 해당 라우트 핸들러가 실행됩니다.- 라우트가 일치하지 않으면, 마지막 미들웨어가 404 응답을 반환합니다.
미들웨어 체인에서 가장 중요한 점은 next()
함수의 호출 여부입니다. next()
가 호출되지 않으면 요청-응답 주기가 종료되며, 후속 미들웨어는 실행되지 않습니다.
경로 기반 미들웨어와 전역 미들웨어의 차이점
Express에서는 모든 요청에 적용되는 전역 미들웨어와 특정 경로에만 적용되는 경로 기반 미들웨어를 구분할 수 있습니다.
// 전역 미들웨어: 모든 요청에 적용
app.use((req, res, next) => {
console.log('모든 요청에 실행되는 미들웨어');
next();
});
// 경로 기반 미들웨어: '/api' 경로로 시작하는 요청에만 적용
app.use('/api', (req, res, next) => {
console.log('/api 경로 요청에만 실행되는 미들웨어');
next();
});
// 특정 HTTP 메소드와 경로에 대한 미들웨어
app.get('/users', (req, res, next) => {
console.log('GET /users 요청에만 실행되는 미들웨어');
next();
});
전역 미들웨어는 모든 요청에 대해 실행되므로, 로깅, 인증, CORS 설정 등 애플리케이션 전반에 적용해야 하는 기능에 유용합니다.
반면 경로 기반 미들웨어는 특정 경로나 경로 패턴에 대해서만 실행되므로, 특정 기능이나 API에 필요한 처리를 집중적으로 수행할 수 있습니다.
에러 처리 미들웨어의 특별한 역할과 구현 방법
Express에서는 일반 미들웨어와 별도로 에러 처리를 위한 특별한 미들웨어가 있습니다. 에러 처리 미들웨어는 4개의 매개변수 (err, req, res, next)
를 갖는 함수로 정의됩니다.
// 일반 미들웨어에서 에러 발생 시
app.use((req, res, next) => {
try {
// 에러 발생 가능한 코드
const result = someRiskyOperation();
next();
} catch (err) {
next(err); // 에러를 다음 미들웨어로 전달
}
});
// 비동기 함수에서 에러 처리
app.get('/async', async (req, res, next) => {
try {
const data = await fetchDataFromDB();
res.json(data);
} catch (err) {
next(err); // 비동기 에러를 에러 처리 미들웨어로 전달
}
});
// 에러 처리 미들웨어 (항상 마지막에 정의)
app.use((err, req, res, next) => {
console.error('에러 발생:', err.stack);
res.status(500).send('서버 에러가 발생했습니다');
});
에러 처리 미들웨어는 다음과 같은 특징이 있습니다:
- 4개의 매개변수를 가지며, 첫 번째는 항상 에러 객체입니다.
- 일반 미들웨어에서
next(err)
형태로 에러를 전달받습니다. - 애플리케이션의 다른 모든 미들웨어 뒤에 정의해야 합니다.
- 여러 개의 에러 처리 미들웨어를 연결할 수도 있습니다.
에러 처리 미들웨어는 애플리케이션의 안정성을 높이고, 예상치 못한 에러가 발생했을 때 사용자에게 적절한 응답을 제공하는 데 중요한 역할을 합니다.
실제 프로젝트에서의 미들웨어 구성과 모듈화 전략
대규모 Express 애플리케이션에서는 미들웨어를 효율적으로 관리하기 위한 모듈화 전략이 필요합니다. 아래는 실제 프로젝트에서 사용할 수 있는 미들웨어 구성 방법입니다.
// middlewares/logger.js
module.exports = function logger(options = {}) {
return (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();
};
};
// middlewares/auth.js
module.exports = function auth(options = {}) {
return (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ message: '인증이 필요합니다' });
}
try {
// 토큰 검증 로직
req.user = verifyToken(token);
next();
} catch (err) {
res.status(401).json({ message: '유효하지 않은 토큰입니다' });
}
};
};
// app.js
const express = require('express');
const logger = require('./middlewares/logger');
const auth = require('./middlewares/auth');
const app = express();
// 전역 미들웨어 적용
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(logger());
// API 라우트에만 인증 미들웨어 적용
app.use('/api', auth());
// 라우트 모듈 가져오기
app.use('/users', require('./routes/users'));
app.use('/posts', require('./routes/posts'));
// 에러 처리 미들웨어
app.use((err, req, res, next) => {
// 에러 로깅
console.error(err);
// 에러 응답
res.status(err.status || 500).json({
message: err.message || '서버 에러가 발생했습니다',
stack: process.env.NODE_ENV === 'production' ? undefined : err.stack
});
});
app.listen(3000);
위 코드는 다음과 같은 모듈화 전략을 보여줍니다:
- 각 미들웨어를 별도의 파일로 분리하여 재사용성을 높입니다.
- 미들웨어 팩토리 패턴을 사용하여 옵션을 통한 설정을 지원합니다.
- 전역 미들웨어와 특정 경로에만 적용되는 미들웨어를 구분합니다.
- 라우트도 모듈로 분리하여 관리합니다.
이러한 구조는 애플리케이션이 커질수록 코드의 유지보수성을 크게 향상시킵니다.
미들웨어 실행 순서와 성능 최적화 전략
미들웨어의 실행 순서는 애플리케이션의 성능과 보안에 큰 영향을 미칩니다. 따라서 미들웨어를 배치할 때는 다음과 같은 원칙을 고려해야 합니다.
// 1. 요청 본문 파싱 (가장 먼저 실행)
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// 2. 보안 관련 미들웨어
app.use(helmet()); // HTTP 헤더 보안
app.use(cors()); // CORS 설정
// 3. 로깅 미들웨어
app.use(morgan('dev'));
// 4. 세션/쿠키 처리
app.use(cookieParser());
app.use(session({ /* 설정 */ }));
// 5. 인증 미들웨어
app.use(passport.initialize());
app.use(passport.session());
// 6. 요청 전처리 커스텀 미들웨어
app.use((req, res, next) => {
// 요청 데이터 가공, 추가 정보 설정 등
next();
});
// 7. 라우트 핸들러
app.use('/api', apiRoutes);
app.use('/', webRoutes);
// 8. 404 처리
app.use((req, res, next) => {
res.status(404).send('페이지를 찾을 수 없습니다');
});
// 9. 에러 처리 (항상 마지막)
app.use((err, req, res, next) => {
// 에러 처리 로직
});
이러한 순서에는 명확한 이유가 있습니다:
- 요청 본문 파싱: 다른 미들웨어가 요청 본문에 접근할 수 있도록 가장 먼저 실행됩니다.
- 보안 미들웨어: 요청을 처리하기 전에 보안 검사를 수행합니다.
- 로깅: 요청 정보를 기록합니다.
- 세션/쿠키: 인증에 필요한 정보를 설정합니다.
- 인증: 사용자 인증을 처리합니다.
- 요청 전처리: 라우트 핸들러에서 필요한 추가 정보를 설정합니다.
- 라우트 핸들러: 실제 비즈니스 로직을 수행합니다.
- 404 처리: 일치하는 라우트가 없을 때 처리합니다.
- 에러 처리: 모든 과정에서 발생한 에러를 처리합니다.
성능 최적화를 위한 추가 팁은 다음과 같습니다:
- 무거운 처리(예: 파일 업로드, 대용량 데이터 처리)는 필요한 라우트에만 적용하세요.
- 모든 요청에 불필요한 미들웨어는 제거하거나 경로를 제한하세요.
- 비동기 작업은 가능한 병렬로 처리하세요(
Promise.all
활용). - 캐시 미들웨어를 적절히 사용하여 반복 요청의 부하를 줄이세요.
미들웨어 내부에서의 비동기 처리와 Promise 활용
Express 미들웨어에서 비동기 작업을 처리할 때는 특별한 주의가 필요합니다. Express 4.x 버전은 기본적으로 Promise를 자동으로 처리하지 않기 때문입니다.
// 잘못된 방식 - 에러가 캐치되지 않음
app.get('/users', async (req, res) => {
const users = await User.findAll(); // 에러 발생 시 Express가 처리하지 못함
res.json(users);
});
// 올바른 방식 1 - try/catch 사용
app.get('/users', async (req, res, next) => {
try {
const users = await User.findAll();
res.json(users);
} catch (err) {
next(err); // 에러를 Express 에러 핸들러로 전달
}
});
// 올바른 방식 2 - 미들웨어 래퍼 함수 사용
const asyncHandler = fn => (req, res, next) => {
return Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.findAll();
res.json(users);
}));
비동기 미들웨어를 처리하기 위한 유틸리티 함수를 만들면 코드를 더 깔끔하게 유지할 수 있습니다:
// utils/async-handler.js
module.exports = function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// 사용 예시
const asyncHandler = require('./utils/async-handler');
const User = require('./models/user');
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.findAll();
res.json(users);
}));
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findByPk(req.params.id);
if (!user) {
return res.status(404).json({ message: '사용자를 찾을 수 없습니다' });
}
res.json(user);
}));
이 패턴을 사용하면 모든 비동기 라우트 핸들러에서 try/catch 블록을 반복적으로 작성할 필요가 없어 코드가 간결해집니다.
Express 5.x(현재 알파 버전)에서는 Promise를 반환하는 미들웨어의 에러를 자동으로 처리할 예정이지만, 그때까지는 위와 같은 방식으로 비동기 에러를 명시적으로 처리해야 합니다.
커스텀 미들웨어 개발과 실전 활용 사례
실제 프로젝트에서 자주 사용되는 커스텀 미들웨어 몇 가지를 살펴보겠습니다.
1. 요청 속도 제한(Rate Limiting) 미들웨어
// middlewares/rate-limiter.js
module.exports = function rateLimiter(options = {}) {
const {
windowMs = 15 * 60 * 1000, // 15분
max = 100, // 15분 동안 최대 100개 요청
message = '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.',
statusCode = 429
} = options;
// 간단한 메모리 저장소 (실제로는 Redis 등을 사용하는 것이 좋음)
const requests = new Map();
// 만료된 요청 정리 간격 설정
const cleanup = setInterval(() => {
const now = Date.now();
requests.forEach((timestamps, ip) => {
// windowMs보다 오래된 타임스탬프 제거
while (timestamps.length && timestamps[0] < now - windowMs) {
timestamps.shift();
}
// 타임스탬프가 없으면 IP 삭제
if (timestamps.length === 0) {
requests.delete(ip);
}
});
}, 5 * 60 * 1000); // 5분마다 정리
// 서버 종료 시 인터벌 정리
if (cleanup.unref) cleanup.unref();
return (req, res, next) => {
const ip = req.ip;
// IP별 요청 타임스탬프 배열 가져오기
if (!requests.has(ip)) {
requests.set(ip, []);
}
const timestamps = requests.get(ip);
const now = Date.now();
// windowMs보다 오래된 타임스탬프 제거
while (timestamps.length && timestamps[0] < now - windowMs) {
timestamps.shift();
}
// 최대 요청 수 확인
if (timestamps.length >= max) {
return res.status(statusCode).json({
message: message,
retryAfter: Math.ceil((timestamps[0] + windowMs - now) / 1000)
});
}
// 현재 요청 시간 추가
timestamps.push(now);
next();
};
};
// 사용 예
app.use('/api', rateLimiter({
windowMs: 5 * 60 * 1000, // 5분
max: 50 // 5분 동안 최대 50개 요청
}));
2. 데이터 검증(Validation) 미들웨어
// middlewares/validate.js
const Joi = require('joi');
module.exports = function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // 모든 에러 반환
stripUnknown: true // 알 수 없는 필드 제거
});
if (error) {
const errorMessage = error.details.map(detail => detail.message).join(', ');
return res.status(400).json({ message: errorMessage });
}
// 검증된 데이터로 교체
req.body = value;
next();
};
};
// 사용 예
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required()
});
app.post('/users', validate(userSchema), (req, res) => {
// 검증된 데이터로 사용자 생성
const user = createUser(req.body);
res.status(201).json(user);
});
3. 응답 캐싱(Caching) 미들웨어
// middlewares/cache.js
module.exports = function cache(options = {}) {
const {
duration = 60 * 1000, // 기본 1분
cacheKey = req => req.originalUrl
} = options;
// 간단한 메모리 캐시 (실제로는 Redis 등을 사용하는 것이 좋음)
const cacheStore = new Map();
return (req, res, next) => {
const key = cacheKey(req);
const cachedItem = cacheStore.get(key);
// 캐시된 응답이 있으면 사용
if (cachedItem && cachedItem.expiresAt > Date.now()) {
res.set('X-Cache', 'HIT');
return res.status(cachedItem.status).send(cachedItem.body);
}
res.set('X-Cache', 'MISS');
// 원래 res.send 메서드 저장
const originalSend = res.send;
// res.send 메서드 오버라이드
res.send = function(body) {
// 성공 응답만 캐시 (상태 코드 200)
if (res.statusCode === 200) {
cacheStore.set(key, {
body,
status: res.statusCode,
expiresAt: Date.now() + duration
});
}
// 원래 메서드 호출
return originalSend.call(this, body);
};
next();
};
};
// 사용 예
app.get('/api/products', cache({ duration: 5 * 60 * 1000 }), (req, res) => {
// 데이터베이스에서 상품 목록 조회
const products = fetchProducts();
res.json(products);
});
이러한 커스텀 미들웨어는 애플리케이션의 다양한 요구사항을 해결하는 데 도움이 됩니다.
미들웨어를 재사용 가능한 모듈로 개발하면 여러 프로젝트에서 일관된 방식으로 기능을 구현할 수 있습니다.
결론: Express 미들웨어 설계의 모범 사례와 주의점
Express 미들웨어는 Node.js 웹 애플리케이션의 기능을 모듈화하고, 확장하며, 유지보수하기 쉽게 만드는 강력한 도구입니다.
이 블로그 포스트에서 살펴본 내용을 바탕으로 몇 가지 모범 사례와 주의점을 정리해 보겠습니다.
모범 사례
- 단일 책임 원칙 준수: 각 미들웨어는 한 가지 기능만 담당하도록 설계하세요.
- 재사용성 고려: 미들웨어를 팩토리 함수로 구현하여 옵션을 통한 설정을 지원하세요.
- 미들웨어 순서 최적화: 성능과 보안을 고려하여 미들웨어 순서를 신중하게 배치하세요.
- 비동기 작업 올바르게 처리: Promise 기반 미들웨어의 에러를 명시적으로 처리하세요.
- 에러 처리 미들웨어 활용: 중앙 집중식 에러 처리로 일관된 에러 응답을 제공하세요.
- 모듈화와 구조화: 복잡한 애플리케이션에서는 미들웨어와 라우트를 모듈로 분리하세요.
주의점
- next() 호출 잊지 않기:
next()
를 호출하지 않으면 요청이 중단됩니다. 의도적으로 응답을 끝내는 경우가 아니라면 항상next()
를 호출하세요. - 무한 루프 방지: 동일한 라우트 패턴에서 여러 번
next()
를 호출하면 무한 루프가 발생할 수 있습니다. - 응답 객체 수정 주의: 다른 미들웨어에서 사용할 가능성이 있는
res
객체의 메서드를 오버라이드할 때는 원래 기능을 보존하세요. - 미들웨어 성능 고려: 무거운 미들웨어는 애플리케이션 성능에 영향을 미칠 수 있으므로, 필요한 경로에만 적용하세요.
- 에러 전파 패턴 일관성 유지: 비동기 에러는 항상
next(err)
로 전달하여 일관된 에러 처리 패턴을 유지하세요.
Express 미들웨어는 웹 애플리케이션의 복잡성을 관리하는 데 중요한 역할을 합니다.
미들웨어의 흐름을 명확히 이해하고 올바르게 구성하면, 견고하고 유지보수하기 쉬운 Node.js 백엔드 애플리케이션을 개발할 수 있습니다.
이 글에서 다룬 내용이 여러분의 Express 애플리케이션 개발에 도움이 되길 바랍니다.
미들웨어의 실행 흐름을 이해하는 것은 Express 애플리케이션을 마스터하는 첫 번째 단계입니다.
'Node.js & 서버 개발' 카테고리의 다른 글
Node.js에서 비동기 처리 방식 총정리 – Callback, Promise, async/await (1) | 2025.05.21 |
---|---|
Node.js와 Express를 이용한 RESTful API 개발 - Todo List 구현 튜토리얼 (0) | 2025.02.19 |