aster®

[타임시트] Express 5 API — 라우터와 오류, Vercel 진입점 (02)

마무리 · 2026-09

남색 윤곽선으로 그린 둥근 칸이 가로 일곱, 세로 다섯 줄로 늘어선 격자가 있다. 절반쯤은 초록으로 채워져 있고 세 칸은 빨간 윤곽선이다. 오른쪽에 납작한 판 세 장이 쌓여 있고 맨 위 판만 초록이다.

로컬에서는 타입 검사도, e2e 11건도 전부 통과했다. 그 앱을 Vercel 에 올리는 데 네 번 실패했다. 한 번은 네트워크, 두 번은 원격 빌드에서만 생긴 타입 에러였다. 마지막 한 번은 배포가 성공했는데 모든 요청이 500 이었다.

같은 코드가 로컬에서는 멀쩡하고 Vercel 에서는 깨졌다. 무엇이 달랐을까.

이 편은 API 서버의 뼈대를 본다. 라우터를 어떻게 나눴는지, 오류를 어디서 모아 어떤 모양으로 돌려주는지, 보안 미들웨어를 어느 순서로 걸었는지다. 그 뒤에 Vercel 이 이 앱을 어떻게 실행하는지와 거기서 난 실패를 원문대로 옮긴다.

1. API 가 맡은 일

server/ 는 Express 5.2.1 앱이다. 버전은 server/node_modules/express/package.json 에서 읽은 실제 설치값이다. Express 와 Prisma, zod, pino 는 작업을 시작하기 전부터 저장소 뼈대에 있었다. 로그인을 직접 구현하기로 정하면서 사용자가 인증·보안 패키지 설치를 한꺼번에 승인했다. argon2, jose, cookie-parser, helmet, express-rate-limit, zod-to-openapi, swagger-ui-express 가 그때 들어왔다.

라우터는 다섯 개이고 전부 /api 아래에 붙는다.

라우터파일맡는 것동작 수
authRoutersrc/auth/routes.tsCSRF 토큰, 가입, 로그인, 토큰 회전, 로그아웃, /users/me6
logoRoutersrc/stores/logo.ts매장 로고 올리기·지우기·읽기3
storeRoutersrc/stores/routes.ts매장, 초대 코드, 멤버 조건·시간표, 날짜별 변경, 사장 대시보드18
shiftRoutersrc/shifts/routes.ts멤버 본인 출근·퇴근·기록 조회3
requestsRoutersrc/requests/routes.ts기록 수정 요청, 휴가, 대타의 신청·승인·거절23

표 1. 라우터별 맡은 일. 동작 수 합계 53 은 docs/api/openapi.json 의 동작 수와 같다

권한 확인은 라우트마다 미들웨어를 줄 세워 건다. 사장 전용 라우트는 이 배열을 앞에 붙인다.

const master = [requireAuth, requireMembership, requireMaster];

requestsRouter.post("/stores/me/leaves/:id/approve", ...master, async (req, res) => {

requireAuth 는 access_token 쿠키를 검증한다. requireMembership 은 매장 소속을 읽어 req.membership 에 담고 requireMaster 는 역할이 master 인지 본다. 소속이 없으면 404 NO_STORE, 역할이 다르면 403 FORBIDDEN 이다.

2. 미들웨어를 거는 순서

src/app.ts 가 앱을 조립한다. 위에서 아래로 읽으면 요청 하나가 지나가는 길이 그대로 보인다.

export const app = express();

// Vercel 과 Next.js rewrites 뒤에 있다. X-Forwarded-For 의 첫 값을 클라이언트 IP 로 본다 (로그인 제한 키).
app.set("trust proxy", true);
app.use(helmet());
app.use(express.json({ limit: "32kb" }));
app.use(cookieParser());

app.get("/health", (_req, res) => {
  res.json({ ok: true });
});

if (env.SWAGGER_ENABLED) {
  const doc = openApiDocument();
  app.get("/api/docs-json", (_req, res) => res.json(doc));
  app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(doc));
}

// 인증이 쿠키라 브라우저가 자동으로 싣는다 — 상태를 바꾸는 모든 요청에 Origin + CSRF 토큰 검사를 건다.
const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]);
app.use("/api", (req, res, next) => (UNSAFE.has(req.method) ? csrfGuard(req, res, next) : next()));
app.use("/api", authRouter, logoRouter, storeRouter, shiftRouter, requestsRouter);
app.use(errorHandler);

// Vercel 은 src/app.ts 를 server.ts 보다 먼저 진입점으로 고른다. 기본 내보내기가 앱이어야 한다.
export default app;

몇 줄은 이유를 짚고 넘어가야 한다.

CSRF 검사는 두 겹이다. 요청의 Origin(없으면 Referer)이 허용 목록 WEB_ORIGINS 에 있는지 본다. 그다음 X-CSRF-Token 헤더가 csrf_token 쿠키와 같은지, 서버 비밀키로 만든 서명이 맞는지 본다. 헤더 값과 쿠키 값을 비교하는 이 방식을 double-submit 이라고 부른다. 다른 사이트는 쿠키를 싣게 할 수는 있어도 그 값을 읽어 헤더에 넣을 수는 없다.

3. 오류는 AppError 하나로 던지고 한 곳에서 받는다

라우트 안에서 실패하면 AppError 를 던진다. 상태 코드와 오류 코드를 같이 들고 다니는 에러 클래스다.

export class AppError extends Error {
  constructor(
    readonly statusCode: number,
    readonly code: ErrorCode,
    message: string = code,
  ) {
    super(message);
  }
}

ErrorCode 는 ERROR_CODES 배열에서 나온다. VALIDATION_FAILED, CSRF_TOKEN_INVALID, REQUEST_CLOSED 같은 23개다. FE 는 사람이 읽는 message 가 아니라 이 code 로 분기한다. 같은 배열을 API 계약의 ErrorResponse 스키마도 쓴다. 코드 목록이 서버와 FE 타입에서 어긋날 수 없다(03편).

라우트는 async 함수 안에서 그냥 throw 한다. 따로 감싸는 코드가 없다.

shiftRouter.post("/shifts/clock-out", async (req, res) => {
  const [shift] = await prisma.shift.updateManyAndReturn({
    where: { userId: req.auth!.userId, end: null },
    data: { end: new Date() },
  });
  if (!shift) throw new AppError(409, "NOT_CLOCKED_IN");
  res.json(toShiftDto(shift));
});

이게 되는 건 Express 5 이기 때문이다. Express 4 는 async 핸들러가 던진 에러(거부된 Promise)를 잡지 못해서 try/catch 로 next(e) 를 불러 주거나 감싸는 헬퍼를 써야 했다. Express 5 는 거부된 Promise 를 받아 오류 처리기로 넘긴다.

오류 처리기는 에러 종류 셋을 같은 모양으로 바꾼다.

export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({ statusCode: err.statusCode, code: err.code, message: err.message });
    return;
  }
  // body-parser(express.raw·json) 가 한도를 넘긴 본문에 던지는 에러
  if ((err as { type?: string }).type === "entity.too.large") {
    res.status(413).json({ statusCode: 413, code: "PAYLOAD_TOO_LARGE", message: "PAYLOAD_TOO_LARGE" });
    return;
  }
  if (err instanceof ZodError) {
    res.status(400).json({ statusCode: 400, code: "VALIDATION_FAILED", message: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") });
    return;
  }
  logger.error({ err, path: req.path }, "unhandled");
  res.status(500).json({ statusCode: 500, code: "INTERNAL", message: "Internal Server Error" });
};

ZodError 가 400 으로 바뀌는 덕에 라우트는 LoginBody.parse(req.body) 처럼 검증을 한 줄로 쓴다. 검증에 실패하면 zod 가 던지고 오류 처리기가 받는다. 라우트 파일 전체에서 .parse( 가 26번 나온다.

다른 매장의 데이터를 요청하면 403 이 아니라 404 를 돌려준다. assertMemberOf 의 주석이 이렇다. "같은 매장 멤버여야 한다. 아니면 존재를 드러내지 않고 404." 요청 상태 전이도 비슷하게 나눈다. 조건부 UPDATE 가 0건이면 대상이 없을 때 404, 있는데 이미 처리됐으면 409 REQUEST_CLOSED 다.

4. 로그인 제한 — express-rate-limit

express-rate-limit 8.7.0 으로 로그인과 초대 코드 등록에 1분 5회 제한을 건다.

const loginLimiter = rateLimit({
  windowMs: 60_000,
  limit: 5,
  skipSuccessfulRequests: true,
  standardHeaders: "draft-8",
  legacyHeaders: false,
  // trust proxy 를 켠 것은 알고 한 일이다 (app.ts). 경고를 끈다.
  validate: { trustProxy: false },
  handler: (_req, _res, next) => next(new AppError(429, "TOO_MANY_REQUESTS")),
});

skipSuccessfulRequests 라 성공한 로그인은 세지 않는다. 틀린 비밀번호만 센다. 한도를 넘기면 handler 가 AppError 로 넘겨서 다른 오류와 같은 모양의 429 가 나간다. 초대 코드 등록 제한은 로그인한 뒤의 동작이라 IP 대신 사용자 id 를 키로 쓴다(keyGenerator: (req) => req.auth!.userId).

운영에서 e2e 를 돌리자 매장·초대 테스트가 첫 로그인부터 429 였다. 테스트는 클라이언트마다 다른 X-Forwarded-For 를 보내 제한 카운트가 섞이지 않게 해 두었고 로컬에서는 그게 동작했다. 운영 DB 의 refresh_tokens.ip 를 조회하니 값이 실제 IP 하나뿐이었다. Vercel 이 X-Forwarded-For 를 덮어쓴다. 앞선 테스트의 실패한 로그인이 같은 IP 의 한도를 채워 뒤 테스트까지 막은 것이다. 1분 쉬고 단독으로 돌려 통과했다.

제한에는 한계가 하나 더 있다. 카운트를 함수 인스턴스의 메모리에 센다. Vercel 이 인스턴스를 여러 개 띄우면 인스턴스마다 따로 세니 제한이 느슨해진다. 엄격히 하려면 Redis 같은 공유 저장소가 필요하다. docs/deploy.md 의 알려진 한계에 적혀 있고 아직 그대로다.

5. pino 로깅

로거는 pino 10.3.1 이고 설정 없이 만든다.

import pino from "pino";

export const logger = pino();

쓰는 곳은 두 군데뿐이다. 로컬 서버가 뜰 때 listening 한 줄, 그리고 오류 처리기가 AppError·ZodError·본문 크기 에러가 아닌 에러를 받았을 때 unhandled 한 줄이다. 요청마다 남기는 접근 로그는 없다. 운영에서 모든 요청이 500 을 낸 원인(6절)도 이 로그가 아니라 vercel logs 로 찾았다.

6. Vercel 은 src/app.ts 를 진입점으로 골랐다

로컬에서는 src/server.ts 가 앱을 띄운다.

// 로컬 실행용. Vercel 은 이 파일이 아니라 src/app.ts 의 기본 내보내기를 쓴다.
if (!process.env.VERCEL) {
  app.listen(env.PORT, () => {
    logger.info({ port: env.PORT }, "listening");
  });
}

Vercel 은 다르다. 프로젝트 설정을 framework=express, rootDirectory=server 로 두면 Vercel 이 Express 앱 파일을 찾아 함수로 감싼다. 서버를 직접 listen 하지 않고 요청이 올 때마다 기본 내보내기된 앱에 요청을 넘긴다.

처음에는 server.ts 가 export default app 을 했고 app.ts 에는 export const app 만 있었다. 배포는 성공했는데 모든 요청이 500 FUNCTION_INVOCATION_FAILED 였다. vercel logs 에 이렇게 남았다.

Invalid export found in module "/var/task/server/src/app.js".
The default export must be a function or server.

Vercel 의 진입점 탐색이 src/app.ts 를 src/server.ts 보다 먼저 골랐다. 그 파일에는 기본 내보내기가 없었다. app.ts 에 export default app 을 두고 server.ts 는 VERCEL 환경변수가 없을 때만 listen 하는 로컬용으로 남겼다. 배포 뒤 /health 200, /api/users/me 401, /api/docs 404 를 확인했다.

7. 원격 빌드에서만 난 helmet TS2349

그 전 단계에서 두 번 실패했다. 원격 빌드의 타입 검사에서다.

src/app.ts(16,9): error TS2349: This expression is not callable.
  Type 'typeof import("/vercel/path0/node_modules/.pnpm/helmet@8.3.0/node_modules/helmet/index")' has no call signatures.

로컬 tsc 는 통과했다. Vercel 의 Express 빌더가 타입 검사를 따로 하는데, helmet 의 기본 내보내기를 함수가 아니라 모듈 객체로 잡았다.

로컬에서 재현하려고 module·moduleResolution·esModuleInterop·package.json 의 "type" 조합 여섯 가지를 돌렸다. 어느 조합에서도 재현되지 않았다. 첫 수정으로 import * as helmet 뒤 helmet.default() 를 불렀더니 원격에서 같은 에러가 또 났다.

두 번째로는 타입만 단언했다.

// Vercel 빌더의 타입 검사는 helmet 의 기본 내보내기를 모듈 객체로 잡는다(로컬 tsc 는 아니다).
// 런타임은 ESM·CJS 모두 함수다 — helmet 의 index.cjs 가 module.exports.default 를 자기 자신으로 둔다.
const helmet = helmetImport as unknown as () => RequestHandler;

런타임은 걱정할 필요가 없다. helmet 의 index.cjs 가 module.exports.default = module.exports 로 끝나서 어느 방식으로 읽어도 기본 내보내기는 함수다. 다만 원인은 끝내 확인하지 못했다. 재현 없이 우회한 수정이라 코드 주석에 "로컬 tsc 는 아니다" 를 남겼다.

8. argon2 때문에 원격에서 빌드한다

웹은 로컬에서 빌드해 결과만 올린다(01편). API 는 반대로 소스를 올려 Vercel 에서 빌드한다.

비밀번호 해시에 쓰는 argon2 0.45.1 은 네이티브 애드온이다. C 로 짠 코드를 설치할 때 그 운영체제용 바이너리로 컴파일한다. 로컬 macOS 에서 빌드하면 macOS 용 바이너리가 들어가고 Linux 인 Vercel 함수에서는 돌지 않는다. Prisma 의 엔진 바이너리도 같은 사정이다. 그래서 원격 빌드를 쓴다.

원격 빌드에는 준비가 둘 필요했다.

저장소 루트의 .vercel/ 은 웹 프로젝트에 연결돼 있어서 API 는 환경변수로 대상 프로젝트를 바꿔 배포한다.

VERCEL_ORG_ID=<팀 id> VERCEL_PROJECT_ID=<API 프로젝트 id> vercel deploy --prod

업로드 중 fetch failed 는 하루 동안 여러 번 났고 전부 재시도로 넘어갔다. 17:20 에는 무료 요금제의 하루 파일 업로드 5000개 한도(api-upload-free)에 걸렸다. 웹은 --archive=tgz 로 묶어 올려 피했지만 API 는 묶음으로도 같은 한도에 걸려 24시간 뒤로 미뤘다. 그때 밀린 API 변경은 문서 요약과 오류 글자뿐이라 동작 차이는 없다.

9. 그래서 로컬과 Vercel 에서 무엇이 달랐나

코드는 같았다. 달랐던 것은 앱을 실행하는 쪽이다.

무엇로컬Vercel
진입점server.ts 가 listensrc/app.ts 의 기본 내보내기를 함수로 감쌈
타입 검사로컬 tsc 통과빌더의 타입 검사가 helmet 을 모듈 객체로 봄 (원인 미확인)
클라이언트 IP테스트가 보낸 X-Forwarded-For 를 그대로 씀Vercel 이 덮어써서 실제 IP 하나
로그인 제한 카운트프로세스 하나인스턴스마다 따로
네이티브 바이너리macOSLinux — 그래서 원격 빌드

표 2. 같은 앱이 로컬과 Vercel 에서 다르게 동작한 곳

docs/deploy.md 에 두 줄로 남겼다. "Vercel 은 src/app.ts 를 진입점으로 고른다." "Vercel 빌더의 타입 검사는 로컬 tsc 와 결과가 다를 수 있다." 배포 실패 순서는 전역 스킬 vercel-monorepo-deploy 의 함정 표로도 옮겼다.

남은 것은 셋이다. 로그인 제한은 공유 저장소 없이 인스턴스 메모리에서 센다. 폐기·만료된 리프레시 토큰 행을 지우는 주기 작업이 아직 없다. helmet 타입 에러의 원인은 확인하지 못한 채 단언으로 덮어 두었다.

이전 편: [타임시트] Next.js 16 App Router — 같은 출처 쿠키와 정적 화면 (01)

다음 편: [타임시트] zod 계약 하나로 검증·문서·FE 타입 (03)