Express 미들웨어의 실행 순서 이해하기

Express 미들웨어의 실행 순서 이해하기

한눈에 보기

Express 애플리케이션은 요청에 맞는 middleware와 route handler를 등록 순서대로 탐색한다. 각 함수는 응답을 끝내거나 next()로 다음 계층에 제어를 넘긴다. 따라서 request context와 body parser는 사용하는 코드보다 앞에, 인증은 보호할 router 앞에, 404는 모든 정상 route 뒤에, error handler는 마지막에 두어야 한다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

목차

문제가 되는 상황

다음 코드에서는 인증 middleware가 API router 뒤에 등록되어 있다.

app.use("/api", apiRouter);
app.use(authenticateRequest);

apiRouter 안의 handler가 응답을 끝내면 요청은 인증 middleware까지 내려오지 않는다.

apiRouter.get("/admin/users", async (req, res) => {
  const users = await userService.listAll();
  res.json({ users });
});

보호하려던 /api/admin/users가 인증 없이 호출될 수 있다. Express가 middleware 이름을 보고 인증을 먼저 실행해 주는 것이 아니다. 먼저 등록된 함수가 먼저 기회를 얻는다.

반대 문제도 있다.

app.use(errorHandler);
app.use("/api", apiRouter);

error handler가 router보다 앞에 있으면 뒤 route에서 발생한 오류가 앞쪽으로 되돌아가 처리될 것처럼 보이지만 Express stack은 기본적으로 다음 방향으로 진행한다. 뒤에서 발생한 error는 이후에 등록된 error middleware를 찾으므로 이 handler는 놓칠 수 있다.

Express에서는 코드의 위아래 순서가 곧 요청 파이프라인의 일부다.

Express 애플리케이션은 순서가 있는 Stack이다

Express 공식 문서는 애플리케이션을 request-response cycle에서 실행되는 일련의 middleware function 호출로 설명한다. 간단한 구성은 다음과 같다.

const app = express();

app.use(requestContext());
app.use(requestLogger());
app.use(express.json({ limit: "1mb" }));

app.get("/health/live", livenessHandler);
app.use("/api", apiRouter);

app.use(notFoundHandler);
app.use(errorHandler);

요청 GET /api/products/42가 들어오면 path와 method가 맞는 layer를 등록 순서로 만난다.

flowchart TD
    A[requestContext] --> B[requestLogger]
    B --> C[express.json]
    C --> D{route match?}
    D -->|/health/live 아님| E[/api router]
    E --> F[GET /products/:id handler]
    F --> G[Response]

없는 경로라면 router가 응답하지 않고 빠져나와 404까지 간다.

flowchart TD
    A[공통 middleware] --> B[/api router]
    B --> C{matched route?}
    C -->|아니오| D[notFoundHandler]
    D --> E[404 response]

오류가 next(error)로 전달되면 남은 일반 middleware를 건너뛰고 뒤쪽 error-handling middleware를 찾는다.

“전역”도 등록 위치 이후부터다

app.use(logger)를 파일 아래에 두면 그 앞에 이미 등록된 route가 응답한 요청에는 logger가 실행되지 않을 수 있다.

next를 호출한다는 것의 의미

middleware는 세 가지 행동 중 하나를 해야 한다.

  1. 응답을 보내 request-response cycle을 끝낸다.
  2. next()를 호출해 다음 matching middleware로 넘긴다.
  3. next(error) 또는 throw로 error flow에 넘긴다.
function requestContext(
  req: Request,
  res: Response,
  next: NextFunction,
) {
  req.context = {
    requestId: readOrCreateRequestId(req),
    startedAt: performance.now(),
  };

  next();
}

next()는 Node.js 자체 API가 아니라 Express router가 전달한 함수다. 호출하면 현재 middleware가 종료된다는 뜻은 아니다.

function surprisingMiddleware(req, res, next) {
  console.log("before");
  next();
  console.log("after");
}

뒤 middleware가 동기적으로 실행되면 로그 순서는 다음이 될 수 있다.

before
route handler
after

Koa의 onion model처럼 await next()를 공식 흐름으로 가정해서는 안 된다. Express의 next()는 다음 layer로 제어를 전달할 뿐 Promise completion 계약으로 사용하지 않는다.

middleware가 응답도 보내지 않고 next()도 호출하지 않으면 요청이 끝나지 않는다.

function brokenMiddleware(req, res, next) {
  if (!req.headers["x-client-version"]) {
    return; // 응답도 next도 없어 요청이 hanging
  }

  next();
}

명시적으로 응답하거나 다음으로 넘긴다.

function requireClientVersion(req, res, next) {
  if (!req.headers["x-client-version"]) {
    return res.status(400).json({
      code: "CLIENT_VERSION_REQUIRED",
    });
  }

  return next();
}

응답 종료와 next를 동시에 하면 안 된다

다음 코드는 인증 실패 응답을 보낸 뒤에도 next()를 호출한다.

function authenticate(req, res, next) {
  const principal = verifyToken(req);

  if (!principal) {
    res.status(401).json({ code: "UNAUTHORIZED" });
  }

  next();
}

route handler가 다시 응답하려 하면 headers already sent 오류가 생기거나, 더 심각하게 인증되지 않은 요청이 보호된 로직까지 도달한다.

function authenticate(req, res, next) {
  const principal = verifyToken(req);

  if (!principal) {
    return res.status(401).json({ code: "UNAUTHORIZED" });
  }

  req.principal = principal;
  return next();
}

return res...는 Express가 return value를 사용하는 것이 아니라 이후 함수 코드가 실행되지 않게 하는 JavaScript 제어 흐름이다.

반대로 먼저 next()를 호출한 뒤 응답을 수정해도 위험하다.

function addHeaderTooLate(req, res, next) {
  next();
  res.setHeader("x-example", "value");
}

뒤 handler가 이미 body를 전송했다면 header를 추가할 수 없다. 응답 header와 context 설정은 next() 전에 끝낸다.

한 경로에서 소유권을 하나만 선택한다

응답을 보냈으면 종료하고, 다음으로 넘겼으면 현재 middleware에서 다시 응답하지 않는다.

전역 미들웨어의 권장 흐름

하나의 고정 정답은 아니지만 일반 API 서버에서는 다음 순서가 이해하기 쉽다.

1. proxy/trust 관련 애플리케이션 설정
2. request ID와 context
3. access log 시작·응답 완료 관측
4. 보안 header·CORS
5. body parser와 요청 크기 제한
6. public health/static route
7. 인증
8. API router
9. unmatched 404
10. error handler
const app = express();

app.set("trust proxy", proxyPolicy);

app.use(requestContext());
app.use(accessLogger());
app.use(securityHeaders());
app.use(cors(corsPolicy));
app.use(express.json({ limit: "1mb" }));

app.get("/health/live", livenessHandler);
app.get("/health/ready", readinessHandler);

app.use("/api/public", publicRouter);
app.use("/api", authenticateRequest, apiRouter);

app.use(notFoundHandler);
app.use(errorHandler);

health check를 인증 뒤에 두면 orchestrator가 token 없이 probe하지 못할 수 있다. 반대로 민감한 진단 정보를 public health endpoint에 노출해서도 안 된다.

rate limit의 위치도 목적에 따라 달라진다.

middleware 순서는 단순 스타일이 아니라 보안과 비용의 순서다.

Body Parser는 Route보다 먼저 등록한다

JSON parser가 route 뒤에 있으면 req.body가 준비되지 않는다.

app.post("/api/orders", createOrderHandler);
app.use(express.json());

의도한 순서:

app.use(express.json({ limit: "1mb" }));
app.post("/api/orders", createOrderHandler);

모든 route가 JSON을 쓰지 않는다면 router 범위에만 둘 수 있다.

const jsonApiRouter = express.Router();

jsonApiRouter.use(express.json({ limit: "1mb" }));
jsonApiRouter.post("/orders", createOrderHandler);

app.use("/api", jsonApiRouter);

webhook signature 검증은 raw body가 필요할 수 있다. 전역 JSON parser가 먼저 body를 변환하면 서명 검증이 실패할 수 있다.

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json", limit: "256kb" }),
  verifyWebhookSignature,
  handlePaymentWebhook,
);

app.use(express.json({ limit: "1mb" }));

특수 route를 parser 앞에 두거나 path별 parser를 명시한다. 실제 provider가 요구하는 서명 원문 규칙을 확인한다.

body size limit을 생략하지 않는다

큰 JSON을 모두 메모리에 읽고 parsing한 뒤 route에서 거부하면 이미 자원을 소비했다. content type과 크기 제한을 parser 단계에 둔다.

인증은 보호할 범위 앞에 둔다

전체 /api를 보호할 수 있다.

app.use("/api", authenticateRequest, apiRouter);

Express는 mount path가 맞는 요청에서 authenticateRequest를 실행하고 성공한 요청만 apiRouter로 넘긴다.

router 안에서 public과 protected route를 나눌 수도 있다.

const router = express.Router();

router.get("/catalog", listPublicCatalog);

router.use(authenticateRequest);
router.get("/me", getMyProfile);
router.post("/orders", createOrder);

인증 middleware보다 앞에 새 route를 추가하면 의도치 않게 public이 된다. public router와 protected router를 아예 분리하면 리뷰가 쉽다.

app.use("/api/public", publicRouter);
app.use("/api", authenticateRequest, protectedRouter);

권한 검사는 인증 다음, 대상 resource loading과의 순서를 의식한다.

router.delete(
  "/projects/:projectId",
  loadProject,
  authorize("project:delete"),
  deleteProject,
);

resource 존재와 권한 오류 순서는 정보 노출 정책에 영향을 준다. 다른 tenant의 project 존재 여부를 403/404 중 어떻게 표현할지 일관되게 정한다.

Router를 경계로 관심사를 나눈다

express.Router()는 독립적인 middleware stack을 만든다.

const orderRouter = express.Router();

orderRouter.use(requireFeature("orders"));
orderRouter.use(loadTenantContext);

orderRouter.get("/", listOrders);
orderRouter.post("/", validateCreateOrder, createOrder);
orderRouter.get("/:orderId", loadOrder, getOrder);

app.use("/api/orders", authenticateRequest, orderRouter);

요청 /api/orders/42가 router 내부에서는 mount path가 제거된 경로로 matching될 수 있다. logging과 URL 생성에서 다음 값의 차이를 확인한다.

req.originalUrl
req.baseUrl
req.path

중첩 router에서 request parameter를 상위에서 전달받으려면 mergeParams 옵션이 필요할 수 있다.

const itemRouter = express.Router({ mergeParams: true });

itemRouter.get("/:itemId", (req, res) => {
  const { orderId, itemId } = req.params;
  // ...
});

orderRouter.use("/:orderId/items", itemRouter);

Router boundary는 코드 파일 분리만을 위한 것이 아니다. 공통 인증, tenant context, feature flag와 validation이 어떤 route 집합에 적용되는지를 명시한다.

404 Handler는 일반 미들웨어다

Express에서 unmatched route가 자동으로 error를 throw하는 것은 아니다. 모든 route를 지나도 응답이 없으면 마지막 일반 middleware가 404를 만든다.

function notFoundHandler(req: Request, res: Response) {
  return res.status(404).json({
    code: "ROUTE_NOT_FOUND",
    method: req.method,
    path: req.path,
    requestId: req.context.requestId,
  });
}

등록 위치:

app.use("/api", apiRouter);
app.use(notFoundHandler);
app.use(errorHandler);

404 handler를 router 앞에 두면 모든 요청을 먼저 잡아 실제 route가 실행되지 않는다.

// 잘못된 순서
app.use(notFoundHandler);
app.use("/api", apiRouter);

resource가 없는 GET /users/42와 route 자체가 없는 GET /unknown도 구분한다.

route exists, entity missing
→ handler/service가 USER_NOT_FOUND

route not matched
→ final ROUTE_NOT_FOUND

둘 다 HTTP 404일 수 있지만 내부 error code와 관측 지표가 다르다.

Error Handler는 마지막에 둔다

error-handling middleware는 (err, req, res, next) 네 인자를 가진다.

function errorHandler(
  error: unknown,
  req: Request,
  res: Response,
  next: NextFunction,
) {
  if (res.headersSent) {
    return next(error);
  }

  const response = toErrorResponse(error, req.context.requestId);
  return res.status(response.status).json(response.body);
}

모든 정상 middleware와 route, 404 뒤에 등록한다.

app.use(notFoundHandler);
app.use(errorHandler);

error가 전달되면 Express는 뒤쪽에서 error handler signature를 가진 layer를 찾는다. 일반 middleware는 건너뛴다. 에러 handler가 네 인자를 받는 이유와 async error, headers sent 처리는 Express 에러 미들웨어가 네 개의 인자를 받는 이유에서 더 자세히 다룬다.

next route와 next router의 용도

next("route")는 현재 route의 남은 callback을 건너뛰고 다음 matching route로 넘기는 특수 제어 흐름이다. 공식 문서 기준으로 app.METHOD() 또는 router.METHOD()로 등록된 middleware에서 사용한다.

router.get(
  "/users/:id",
  (req, res, next) => {
    if (req.params.id === "me") {
      return next("route");
    }

    return next();
  },
  getUserById,
);

router.get("/users/me", getCurrentUser);

라우트 순서를 더 명확하게 바꾸는 편이 읽기 쉬울 수 있다.

router.get("/users/me", getCurrentUser);
router.get("/users/:id", getUserById);

next("router")는 현재 router의 나머지를 건너뛰고 상위 stack으로 빠져나간다.

const adminRouter = express.Router();

adminRouter.use((req, res, next) => {
  if (!req.headers["x-admin-token"]) {
    return next("router");
  }
  return next();
});

adminRouter.get("/dashboard", adminDashboard);

app.use("/admin", adminRouter);
app.use("/admin", (req, res) => res.sendStatus(401));

특수 문자열은 error처럼 보이지만 routerouter에는 별도 의미가 있다. 팀 코드에서 남용하면 실행 흐름을 추적하기 어려워질 수 있으므로 route 순서와 router 분리로 해결 가능한지 먼저 본다.

비동기 Handler와 Express 버전 차이

Express 5 공식 문서에서는 Promise를 반환하는 middleware/handler가 reject하거나 throw하면 Express가 자동으로 next(error)를 호출한다.

app.get("/api/products/:id", async (req, res) => {
  const product = await productService.getById(req.params.id);
  res.json({ product });
});

productService가 reject하면 error middleware로 전달된다.

Express 4에서는 async function의 rejected Promise를 자동으로 처리하지 않는 경로가 있어 wrapper 또는 명시적 catch가 필요하다.

const asyncHandler =
  (handler: RequestHandler): RequestHandler =>
  (req, res, next) =>
    Promise.resolve(handler(req, res, next)).catch(next);
router.get(
  "/products/:id",
  asyncHandler(async (req, res) => {
    const product = await productService.getById(req.params.id);
    res.json({ product });
  }),
);

프로젝트의 정확한 Express major version을 확인하고 패턴을 섞지 않는다. Express 5에서도 callback 기반 비동기 API가 나중에 throw하는 오류는 Promise 반환과 다른 경로일 수 있으므로 next(error)로 연결한다.

fs.readFile(filePath, (error, data) => {
  if (error) return next(error);
  res.type("text/plain").send(data);
});

공식 동작은 Express 5 Writing MiddlewareError Handling 문서를 기준으로 확인한다.

응답 이후 작업과 완료 시점 관측

route handler가 res.json()을 호출했다고 TCP 전송과 request lifecycle의 모든 작업이 끝난 것은 아니다. access log는 요청 시작이 아니라 response 완료 또는 connection close 시점에 남기는 것이 정확하다.

function accessLogger() {
  return (req: Request, res: Response, next: NextFunction) => {
    const startedAt = performance.now();
    let finished = false;

    res.once("finish", () => {
      finished = true;
      logCompletedRequest(req, res, performance.now() - startedAt);
    });

    res.once("close", () => {
      if (!finished) {
        logAbortedRequest(req, res, performance.now() - startedAt);
      }
    });

    next();
  };
}

finish는 응답이 전송 계층에 넘겨진 완료 흐름, close는 연결이 일찍 끊긴 경우를 구분하는 데 활용할 수 있다. 실제 Node.js 버전의 event 의미를 확인한다.

응답 뒤 fire-and-forget 작업을 그냥 시작하면 종료와 오류가 추적되지 않는다.

res.status(202).json({ accepted: true });
sendEmail(); // 오류 유실, 프로세스 종료 시 중단

내구성이 필요한 후속 작업은 DB outbox 또는 queue에 기록하고 worker가 처리한다.

await database.transaction(async (tx) => {
  await tx.jobs.insert(emailJob);
});

res.status(202).json({ accepted: true });

실행 순서를 테스트로 고정하기

순서는 코드 리뷰만으로 놓치기 쉽다. 통합 테스트로 보안과 error flow를 고정한다.

인증보다 route가 먼저 실행되지 않는지

it("보호된 route는 token 없이 실행되지 않는다", async () => {
  const response = await request(app).get("/api/orders");

  expect(response.status).toBe(401);
  expect(orderService.list).not.toHaveBeenCalled();
});

없는 route가 404 handler에 도달하는지

it("등록되지 않은 API는 구조화된 404를 반환한다", async () => {
  const response = await request(app).get("/api/not-existing");

  expect(response.status).toBe(404);
  expect(response.body).toMatchObject({
    code: "ROUTE_NOT_FOUND",
  });
});

context가 error log에도 존재하는지

it("route 오류 응답에는 request ID가 포함된다", async () => {
  productService.getById.mockRejectedValue(new Error("sample failure"));

  const response = await request(app)
    .get("/api/products/42")
    .set("x-request-id", "test-request-id");

  expect(response.status).toBe(500);
  expect(response.body.requestId).toBe("test-request-id");
});

응답을 두 번 보내지 않는지

인증 실패 후 service가 호출되지 않는 테스트가 double response와 authorization bypass를 함께 막는다.

middleware stack을 직접 introspection하는 비공개 내부 API에 의존하기보다 HTTP 요청 결과를 검증한다.

결론

Express middleware 순서는 코드 정리 취향이 아니라 요청이 어떤 보안·파싱·라우팅·오류 경로를 통과하는지를 결정하는 실행 모델이다. 먼저 등록된 matching middleware가 먼저 실행되고, 각 함수는 응답을 끝내거나 next()로 다음 layer에 제어를 넘겨야 한다. 응답과 next()를 동시에 실행하면 보호된 route 실행과 double response 문제가 생길 수 있다.

request context와 logger는 모든 소비자보다 앞에, body parser는 body를 읽는 route 앞에, 인증은 보호할 router 앞에 둔다. 정상 route 뒤에는 unmatched 404, 마지막에는 네 인자의 error handler를 둔다. Express 5는 반환된 Promise rejection을 error flow로 연결하지만 Express 4와 동작이 다르므로 major version을 확인한다. 최종 순서는 인증 우회, 404, async error와 request ID를 실제 HTTP 통합 테스트로 고정해야 한다.

관련 노트

참고 자료