
로컬에서는 타입 검사도, 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 아래에 붙는다.
| 라우터 | 파일 | 맡는 것 | 동작 수 |
|---|---|---|---|
authRouter | src/auth/routes.ts | CSRF 토큰, 가입, 로그인, 토큰 회전, 로그아웃, /users/me | 6 |
logoRouter | src/stores/logo.ts | 매장 로고 올리기·지우기·읽기 | 3 |
storeRouter | src/stores/routes.ts | 매장, 초대 코드, 멤버 조건·시간표, 날짜별 변경, 사장 대시보드 | 18 |
shiftRouter | src/shifts/routes.ts | 멤버 본인 출근·퇴근·기록 조회 | 3 |
requestsRouter | src/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;
몇 줄은 이유를 짚고 넘어가야 한다.
trust proxy— API 앞에는 Next rewrites 와 Vercel 이 있다. Express 가X-Forwarded-For헤더의 값을 클라이언트 IP(req.ip)로 쓰게 하는 설정이다. 로그인 제한이 이 IP 를 키로 쓴다.- helmet — 응답에 보안 헤더를 한꺼번에 붙이는 미들웨어다. 기본값 그대로 쓴다.
- 본문 32kb 제한 — JSON 본문이 이보다 크면 body-parser 가 에러를 던지고 오류 처리기가 413 으로 바꾼다.
- Swagger — 로컬에서만 켠다. 운영은
SWAGGER_ENABLED=false라/api/docs가 404 다. - 전역 CSRF — 처음에는 라우트마다
csrfGuard를 붙였다. 사용자가 로그인을 계속 유지하고 싶다며 액세스 토큰까지 쿠키에 담기로 정한 뒤로 바뀌었다. 쿠키는 브라우저가 자동으로 싣기 때문에 상태를 바꾸는 모든 요청을 검사해야 한다. 그래서 라우트별 가드를 걷어내고app.ts한 곳에 걸었다.
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 의 엔진 바이너리도 같은 사정이다. 그래서 원격 빌드를 쓴다.
원격 빌드에는 준비가 둘 필요했다.
- pnpm 11 의 빌드 스크립트 허용 — pnpm 11 은 설치 때 빌드 스크립트를 돌리는 패키지를 명시해서 허용해야 한다. 처음 설치는
ERR_PNPM_IGNORED_BUILDS로 멈췄다.pnpm-workspace.yaml의allowBuilds에argon2: true를 두고 swagger-ui 가 끌고 온 사용량 수집 스크립트"@scarf/scarf"는false로 막았다. .vercelignore— 원격 빌드는 저장소를 통째로 올린다. 로컬server/.env의 개발용 비밀값이 섞이지 않게 루트.vercelignore를 먼저 만들었다.
저장소 루트의 .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 가 listen | src/app.ts 의 기본 내보내기를 함수로 감쌈 |
| 타입 검사 | 로컬 tsc 통과 | 빌더의 타입 검사가 helmet 을 모듈 객체로 봄 (원인 미확인) |
| 클라이언트 IP | 테스트가 보낸 X-Forwarded-For 를 그대로 씀 | Vercel 이 덮어써서 실제 IP 하나 |
| 로그인 제한 카운트 | 프로세스 하나 | 인스턴스마다 따로 |
| 네이티브 바이너리 | macOS | Linux — 그래서 원격 빌드 |
표 2. 같은 앱이 로컬과 Vercel 에서 다르게 동작한 곳
docs/deploy.md 에 두 줄로 남겼다. "Vercel 은 src/app.ts 를 진입점으로 고른다." "Vercel 빌더의 타입 검사는 로컬 tsc 와 결과가 다를 수 있다." 배포 실패 순서는 전역 스킬 vercel-monorepo-deploy 의 함정 표로도 옮겼다.
남은 것은 셋이다. 로그인 제한은 공유 저장소 없이 인스턴스 메모리에서 센다. 폐기·만료된 리프레시 토큰 행을 지우는 주기 작업이 아직 없다. helmet 타입 에러의 원인은 확인하지 못한 채 단언으로 덮어 두었다.
이전 편: [타임시트] Next.js 16 App Router — 같은 출처 쿠키와 정적 화면 (01)
다음 편: [타임시트] zod 계약 하나로 검증·문서·FE 타입 (03)