Next.js 미들웨어의 복잡성을 해결하는 파이프라인 라이브러리 개발기
문제의 시작 - 복잡해진 미들웨어 파일
처음 Next.js 미들웨어를 도입했을 때는 단순했다. 사용자 인증만 체크하면 되는 간단한 로직이었으니까.
// middleware.ts (초기 버전)
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}하지만 프로덕션 서비스는 그렇게 단순하지 않았다. 요구사항은 점점 늘어났다:
- CORS 처리가 필요해졌다
- Rate limiting을 추가해야 했다
- 에러 추적을 위한 로깅이 필요했다
- 유지보수 모드 체크 로직이 들어갔다
- 토큰 영속화 기능까지…
몇 개월 후, 우리의 middleware.ts는 이렇게 변했다:
export function middleware(request: NextRequest) {
// 1. 에러 추적 설정
const requestId = generateRequestId();
// 2. CORS 처리
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 200,
headers: corsHeaders
});
}
// 3. Rate limiting 체크
const ip = getClientIP(request);
if (await isRateLimited(ip)) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
// 4. 유지보수 모드 체크
if (await isMaintenanceMode()) {
return NextResponse.redirect(new URL('/maintenance', request.url));
}
// 5. 토큰 영속화
const token = request.headers.get('authorization') || request.cookies.get('auth-token');
if (token) {
// 토큰 저장 로직...
}
// 6. 인증 체크
if (protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
if (!token || !await validateToken(token)) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// 7. 응답 헤더 설정
const response = NextResponse.next();
response.headers.set('X-Request-ID', requestId);
response.headers.set('X-Frame-Options', 'DENY');
return response;
}500줄이 넘어가는 파일을 보며 한숨이 나왔다. 새로운 팀원이 와서 “이 미들웨어가 뭘 하는 건가요?”라고 물어보면 설명하기가 곤란했다. 각 로직이 언제 실행되는지, 에러가 발생하면 어떻게 처리되는지 파악하기 어려웠다.
“Express였으면 이렇게 깔끔하게 분리했을 텐데…”라는 생각이 계속 들었다.
Express의 미들웨어 패턴을 다시 보다
Express에서는 이렇게 쓰잖아:
app.use(cors());
app.use(rateLimiter);
app.use(authenticate);
app.use(authorize);각 미들웨어의 역할이 명확하고, 실행 순서도 직관적이다. 무엇보다 next() 함수를 통한 제어 흐름이 우아하다.
function authMiddleware(req, res, next) {
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Unauthorized' });
}
// 인증 성공 시 다음 미들웨어로
next();
}Next.js 미들웨어도 이런 패턴으로 만들 수 있지 않을까? 하지만 몇 가지 제약이 있었다:
- Edge Runtime 환경: Node.js API를 마음대로 쓸 수 없다
- 단일 함수 제약:
middleware.ts에서 하나의 함수만 export할 수 있다 - 타입 안정성: TypeScript로 타입 추론이 제대로 동작해야 한다
첫 번째 시도 - 단순한 함수 분리
처음엔 단순하게 각 기능을 함수로 분리해봤다:
async function handleCORS(request: NextRequest) {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 200, headers: corsHeaders });
}
return null;
}
async function handleAuth(request: NextRequest) {
const token = request.cookies.get('auth-token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return null;
}
export async function middleware(request: NextRequest) {
const corsResponse = await handleCORS(request);
if (corsResponse) return corsResponse;
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
return NextResponse.next();
}조금 나아졌지만 여전히 문제가 있었다:
- 각 함수 간의 데이터 공유가 어렵다 (예: 인증된 사용자 정보)
- 에러 처리가 일관되지 않다
- 새로운 미들웨어를 추가할 때마다 메인 함수를 수정해야 한다
- 조건부 실행이나 병렬 실행을 구현하기 어렵다
근본적인 해결책이 아니었다.
설계 고민 - 진짜 미들웨어 파이프라인을 만들자
그때 든 생각이 “그냥 Express 스타일의 미들웨어 시스템을 Next.js에 맞게 만들어보자”였다. 하지만 단순히 Express를 복사하는 게 아니라, Next.js의 특성을 고려한 설계가 필요했다.
핵심 설계 원칙
- 프레임워크 독립성: Next.js뿐만 아니라 다른 프레임워크에서도 사용할 수 있어야 한다
- 타입 안정성: TypeScript 제네릭을 활용한 완벽한 타입 추론
- 확장성: 플러그인 시스템으로 기능을 확장할 수 있어야 한다
- 성능: 순차 실행뿐만 아니라 병렬 실행도 지원해야 한다
- 안정성: 개별 미들웨어의 실패가 전체 서비스를 망가뜨리면 안 된다
어댑터 패턴 선택
가장 중요한 결정은 어댑터 패턴을 사용하는 것이었다. 각 프레임워크의 요청/응답 객체를 공통 인터페이스로 변환하는 방식이다:
interface BaseRequest {
url: string;
method: string;
headers: Record<string, string | string[]>;
}
interface FrameworkAdapter<TRawRequest, TRawResponse, TRequest, TResponse> {
createRequest(rawRequest: TRawRequest): TRequest;
createResponse(response: TResponse): TRawResponse;
createNextResponse(): TResponse;
createErrorResponse(error: Error, statusCode?: number): TResponse;
}이렇게 하면 미들웨어 로직은 프레임워크에 독립적으로 작성할 수 있고, 어댑터만 바꾸면 다른 프레임워크에서도 사용할 수 있다.
fail-open 전략
또 하나 중요한 결정은 에러 처리 전략이었다. fail-close(에러 시 모든 요청 차단) vs fail-open(에러 시에도 서비스 지속) 중에서 fail-open을 선택했다.
try {
const response = await middleware.handler(request, next);
return response;
} catch (error) {
// 에러를 리포팅하되 서비스는 계속 진행
await this.handleError(error, request);
return next();
}미들웨어의 버그 때문에 전체 서비스가 중단되는 것보다는, 일부 기능이 동작하지 않더라도 핵심 서비스는 유지되는 게 낫다고 판단했다.
핵심 구현 - 파이프라인 아키텍처
Express의 next() 패턴 구현
가장 핵심은 Express의 next() 패턴을 구현하는 것이었다:
private createChain(middlewares: Middleware[]): (request: TRequest) => Promise<TResponse> {
return async (request: TRequest) => {
let index = 0;
const next: NextFunction<TResponse> = async () => {
if (index >= middlewares.length) {
return this.adapter.createNextResponse();
}
const current = middlewares[index++];
if (this.shouldExecute(current, request)) {
return current.handler(request, next);
}
return next();
};
return next();
};
}이 방식으로 각 미들웨어가 next()를 호출해서 다음 미들웨어로 제어권을 넘길 수 있다.
컨텍스트 공유 메커니즘
미들웨어 간의 데이터 공유를 위해 컨텍스트 시스템을 만들었다:
class Context<T = Record<string, any>> {
private data: T;
set<K extends keyof T>(key: K, value: T[K]): void {
this.data[key] = value;
}
get<K extends keyof T>(key: K): T[K] | undefined {
return this.data[key];
}
}이제 인증 미들웨어에서 사용자 정보를 저장하고, 다른 미들웨어에서 가져다 쓸 수 있다:
// 인증 미들웨어
const authMiddleware = createMiddleware({
name: 'auth',
handler: async (request, next) => {
const user = await validateToken(request.headers.authorization);
request.context.set('user', user);
return next();
}
});
// 권한 확인 미들웨어
const authzMiddleware = createMiddleware({
name: 'authorization',
handler: async (request, next) => {
const user = request.context.get('user');
if (!user.roles.includes('admin')) {
return { status: 403, body: 'Forbidden' };
}
return next();
}
});순차/병렬 실행 지원
때로는 미들웨어들을 병렬로 실행하고 싶을 때가 있다. 예를 들어 로깅, 분석, 메트릭 수집 같은 독립적인 작업들은 동시에 실행해도 된다:
pipeline
.use(authMiddleware) // 순차 실행
.useParallel([ // 병렬 실행
loggingMiddleware,
analyticsMiddleware,
metricsMiddleware
])
.use(businessLogicMiddleware);병렬 실행은 Promise.allSettled를 사용해서 개별 미들웨어의 실패가 다른 미들웨어에 영향을 주지 않도록 했다.
플러그인 시스템
크로스 커팅 관심사(에러 리포팅, 로깅 등)는 플러그인으로 분리했다:
interface MiddlewarePlugin {
name: string;
version: string;
onInit?(): void | Promise<void>;
beforeMiddleware?(middleware: Middleware, request: BaseRequest): void | Promise<void>;
afterMiddleware?(middleware: Middleware, request: BaseRequest, response: BaseResponse): void | Promise<void>;
onError?(error: Error, middleware: Middleware, request: BaseRequest): void | Promise<void>;
}이제 에러 리포팅은 이렇게 설정할 수 있다:
const pipeline = new MiddlewarePipeline({
adapter: createNextJsAdapter(),
plugins: [
createErrorReporter({
service: 'sentry',
enabled: process.env.NODE_ENV === 'production'
})
]
});프로덕션 적용 - 실전에서의 검증
라이브러리가 완성된 후, 기존 500줄짜리 미들웨어를 마이그레이션했다. 결과는 놀라웠다:
Before: 복잡한 단일 파일
// middleware.ts (500줄)
export function middleware(request: NextRequest) {
// 1. 에러 추적
// 2. CORS 처리
// 3. Rate limiting
// 4. 유지보수 모드
// 5. 토큰 영속화
// 6. 인증/인가
// 7. 로깅
// ... 500줄의 혼재된 로직
}After: 명확히 분리된 미들웨어들
// middleware.ts
export const middleware = createNextJsMiddleware((pipeline) => {
pipeline
.use(requestIdMiddleware)
.use(corsMiddleware)
.use(tokenPersistenceMiddleware)
.use(authMiddleware)
.use(authorizationMiddleware)
.useIf(
process.env.NODE_ENV === 'production',
rateLimitMiddleware
)
.useParallel([
performanceMiddleware,
analyticsMiddleware
]);
});
// middlewares/auth.ts
export const authMiddleware = createMiddleware({
name: 'authentication',
matcher: ['/api/*', '/dashboard/*'],
handler: async (request, next) => {
// 명확한 인증 로직만
}
});실질적인 개선 효과
- 가독성: 팀원들이 “이제 읽을 만하네요”라고 반응했다
- 유지보수성: 새로운 미들웨어 추가가 쉬워졌다
- 테스트: 각 미들웨어를 개별적으로 테스트할 수 있게 됐다
- 성능: 병렬 실행으로 약 15% 응답시간 개선
- 안정성: 개별 미들웨어 실패가 전체 서비스에 영향을 주지 않음
특히 인상적이었던 건 디버깅이 쉬워진 점이다. 문제가 생기면 특정 미들웨어만 비활성화하거나 로그를 확인하면 되니까.
예상치 못한 이슈들
물론 완벽하지는 않았다. 몇 가지 예상치 못한 이슈가 있었다:
- 메모리 사용량: 컨텍스트 객체와 미들웨어 체인 때문에 약간의 메모리 오버헤드가 있었다
- 타입 추론: 제네릭이 복잡해지면서 IDE에서 타입 힌트가 느려지는 경우가 있었다
- 학습 곡선: 새로운 팀원이 파이프라인 개념을 이해하는 데 시간이 좀 걸렸다
하지만 이런 단점들을 고려해도 얻는 이익이 훨씬 컸다.
배운 점과 앞으로
좋은 추상화란 무엇인가
이번 경험을 통해 좋은 추상화의 조건을 다시 생각해볼 수 있었다:
- 직관적: Express 미들웨어를 써본 개발자라면 쉽게 이해할 수 있어야 한다
- 확장 가능: 새로운 요구사항이 생겨도 기존 코드를 크게 바꾸지 않고 대응할 수 있어야 한다
- 타입 안전: 컴파일 시점에 많은 오류를 잡을 수 있어야 한다
- 성능: 추상화 때문에 성능이 크게 저하되면 안 된다
오버엔지니어링과 실용성의 경계
처음엔 “이게 오버엔지니어링 아닌가?”라는 생각도 들었다. 단순히 함수 몇 개 분리하면 되는 걸 왜 이렇게 복잡하게 만드나 싶기도 했다.
하지만 실제로 써보니 그 가치를 알 수 있었다. 특히 팀 단위로 개발할 때, 코드의 의도가 명확하게 드러나는 것의 중요성을 다시 깨달았다.
앞으로의 로드맵
현재 계획하고 있는 개선사항들:
- 더 많은 어댑터: Express, Fastify, Hono 등 다른 프레임워크 지원
- 플러그인 생태계: Rate limiting, Caching, A/B Testing 등의 플러그인들
- 성능 최적화: 메모리 사용량 최적화, 미들웨어 캐싱
- 개발 도구: 미들웨어 실행 순서 시각화, 디버깅 도구
- 문서화: 더 많은 실사용 예제와 모범 사례
마치며
결국 좋은 코드란 읽기 쉽고, 이해하기 쉽고, 수정하기 쉬운 코드라는 걸 다시 한번 확인했다. 기술적으로 복잡한 걸 만드는 것보다, 복잡한 문제를 단순하게 만드는 게 훨씬 어렵고 가치 있는 일이다.
이 라이브러리가 비슷한 고민을 하는 개발자들에게 도움이 되길 바란다. 그리고 더 많은 피드백을 받아서 계속 발전시켜 나가고 싶다.
전체 소스코드: