Next.js 미들웨어의 복잡성을 해결하는 파이프라인 라이브러리 개발기

문제의 시작 - 복잡해진 미들웨어 파일

처음 Next.js 미들웨어를 도입했을 때는 단순했다. 사용자 인증만 체크하면 되는 간단한 로직이었으니까.

typescript
// 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는 이렇게 변했다:

typescript
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에서는 이렇게 쓰잖아:

javascript
app.use(cors());
app.use(rateLimiter);
app.use(authenticate);
app.use(authorize);

각 미들웨어의 역할이 명확하고, 실행 순서도 직관적이다. 무엇보다 next() 함수를 통한 제어 흐름이 우아하다.

javascript
function authMiddleware(req, res, next) {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // 인증 성공 시 다음 미들웨어로
  next();
}

Next.js 미들웨어도 이런 패턴으로 만들 수 있지 않을까? 하지만 몇 가지 제약이 있었다:

  1. Edge Runtime 환경: Node.js API를 마음대로 쓸 수 없다
  2. 단일 함수 제약: middleware.ts에서 하나의 함수만 export할 수 있다
  3. 타입 안정성: TypeScript로 타입 추론이 제대로 동작해야 한다

첫 번째 시도 - 단순한 함수 분리

처음엔 단순하게 각 기능을 함수로 분리해봤다:

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의 특성을 고려한 설계가 필요했다.

핵심 설계 원칙

  1. 프레임워크 독립성: Next.js뿐만 아니라 다른 프레임워크에서도 사용할 수 있어야 한다
  2. 타입 안정성: TypeScript 제네릭을 활용한 완벽한 타입 추론
  3. 확장성: 플러그인 시스템으로 기능을 확장할 수 있어야 한다
  4. 성능: 순차 실행뿐만 아니라 병렬 실행도 지원해야 한다
  5. 안정성: 개별 미들웨어의 실패가 전체 서비스를 망가뜨리면 안 된다

어댑터 패턴 선택

가장 중요한 결정은 어댑터 패턴을 사용하는 것이었다. 각 프레임워크의 요청/응답 객체를 공통 인터페이스로 변환하는 방식이다:

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을 선택했다.

typescript
try {
  const response = await middleware.handler(request, next);
  return response;
} catch (error) {
  // 에러를 리포팅하되 서비스는 계속 진행
  await this.handleError(error, request);
  return next();
}

미들웨어의 버그 때문에 전체 서비스가 중단되는 것보다는, 일부 기능이 동작하지 않더라도 핵심 서비스는 유지되는 게 낫다고 판단했다.

핵심 구현 - 파이프라인 아키텍처

Express의 next() 패턴 구현

가장 핵심은 Express의 next() 패턴을 구현하는 것이었다:

typescript
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()를 호출해서 다음 미들웨어로 제어권을 넘길 수 있다.

컨텍스트 공유 메커니즘

미들웨어 간의 데이터 공유를 위해 컨텍스트 시스템을 만들었다:

typescript
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];
  }
}

이제 인증 미들웨어에서 사용자 정보를 저장하고, 다른 미들웨어에서 가져다 쓸 수 있다:

typescript
// 인증 미들웨어
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();
  }
});

순차/병렬 실행 지원

때로는 미들웨어들을 병렬로 실행하고 싶을 때가 있다. 예를 들어 로깅, 분석, 메트릭 수집 같은 독립적인 작업들은 동시에 실행해도 된다:

typescript
pipeline
  .use(authMiddleware)        // 순차 실행
  .useParallel([              // 병렬 실행
    loggingMiddleware,
    analyticsMiddleware,
    metricsMiddleware
  ])
  .use(businessLogicMiddleware);

병렬 실행은 Promise.allSettled를 사용해서 개별 미들웨어의 실패가 다른 미들웨어에 영향을 주지 않도록 했다.

플러그인 시스템

크로스 커팅 관심사(에러 리포팅, 로깅 등)는 플러그인으로 분리했다:

typescript
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>;
}

이제 에러 리포팅은 이렇게 설정할 수 있다:

typescript
const pipeline = new MiddlewarePipeline({
  adapter: createNextJsAdapter(),
  plugins: [
    createErrorReporter({
      service: 'sentry',
      enabled: process.env.NODE_ENV === 'production'
    })
  ]
});

프로덕션 적용 - 실전에서의 검증

라이브러리가 완성된 후, 기존 500줄짜리 미들웨어를 마이그레이션했다. 결과는 놀라웠다:

Before: 복잡한 단일 파일

typescript
// middleware.ts (500줄)
export function middleware(request: NextRequest) {
  // 1. 에러 추적
  // 2. CORS 처리
  // 3. Rate limiting
  // 4. 유지보수 모드
  // 5. 토큰 영속화
  // 6. 인증/인가
  // 7. 로깅
  // ... 500줄의 혼재된 로직
}

After: 명확히 분리된 미들웨어들

typescript
// 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) => {
    // 명확한 인증 로직만
  }
});

실질적인 개선 효과

  1. 가독성: 팀원들이 “이제 읽을 만하네요”라고 반응했다
  2. 유지보수성: 새로운 미들웨어 추가가 쉬워졌다
  3. 테스트: 각 미들웨어를 개별적으로 테스트할 수 있게 됐다
  4. 성능: 병렬 실행으로 약 15% 응답시간 개선
  5. 안정성: 개별 미들웨어 실패가 전체 서비스에 영향을 주지 않음

특히 인상적이었던 건 디버깅이 쉬워진 점이다. 문제가 생기면 특정 미들웨어만 비활성화하거나 로그를 확인하면 되니까.

예상치 못한 이슈들

물론 완벽하지는 않았다. 몇 가지 예상치 못한 이슈가 있었다:

  1. 메모리 사용량: 컨텍스트 객체와 미들웨어 체인 때문에 약간의 메모리 오버헤드가 있었다
  2. 타입 추론: 제네릭이 복잡해지면서 IDE에서 타입 힌트가 느려지는 경우가 있었다
  3. 학습 곡선: 새로운 팀원이 파이프라인 개념을 이해하는 데 시간이 좀 걸렸다

하지만 이런 단점들을 고려해도 얻는 이익이 훨씬 컸다.

배운 점과 앞으로

좋은 추상화란 무엇인가

이번 경험을 통해 좋은 추상화의 조건을 다시 생각해볼 수 있었다:

  1. 직관적: Express 미들웨어를 써본 개발자라면 쉽게 이해할 수 있어야 한다
  2. 확장 가능: 새로운 요구사항이 생겨도 기존 코드를 크게 바꾸지 않고 대응할 수 있어야 한다
  3. 타입 안전: 컴파일 시점에 많은 오류를 잡을 수 있어야 한다
  4. 성능: 추상화 때문에 성능이 크게 저하되면 안 된다

오버엔지니어링과 실용성의 경계

처음엔 “이게 오버엔지니어링 아닌가?”라는 생각도 들었다. 단순히 함수 몇 개 분리하면 되는 걸 왜 이렇게 복잡하게 만드나 싶기도 했다.

하지만 실제로 써보니 그 가치를 알 수 있었다. 특히 팀 단위로 개발할 때, 코드의 의도가 명확하게 드러나는 것의 중요성을 다시 깨달았다.

앞으로의 로드맵

현재 계획하고 있는 개선사항들:

  1. 더 많은 어댑터: Express, Fastify, Hono 등 다른 프레임워크 지원
  2. 플러그인 생태계: Rate limiting, Caching, A/B Testing 등의 플러그인들
  3. 성능 최적화: 메모리 사용량 최적화, 미들웨어 캐싱
  4. 개발 도구: 미들웨어 실행 순서 시각화, 디버깅 도구
  5. 문서화: 더 많은 실사용 예제와 모범 사례

마치며

결국 좋은 코드란 읽기 쉽고, 이해하기 쉽고, 수정하기 쉬운 코드라는 걸 다시 한번 확인했다. 기술적으로 복잡한 걸 만드는 것보다, 복잡한 문제를 단순하게 만드는 게 훨씬 어렵고 가치 있는 일이다.

이 라이브러리가 비슷한 고민을 하는 개발자들에게 도움이 되길 바란다. 그리고 더 많은 피드백을 받아서 계속 발전시켜 나가고 싶다.

전체 소스코드:

GitHub 저장소