출퇴근 웹앱 개발일지 (4) — Vercel 함수 12개 한도 안에서 버티기
무료 플랜의 서버리스 함수 12개 제한 안에서 기능을 어떻게 다 욱여넣었는지. [action].js 한 파일로 여러 엔드포인트를 합치는 동적 라우트 패턴과 그 트레이드오프를 정리합니다.
title: "출퇴근 웹앱 개발일지 (4) — Vercel 함수 12개 한도 안에서 버티기" date: "2026-06-02" description: "무료 플랜의 서버리스 함수 12개 제한 안에서 기능을 어떻게 다 욱여넣었는지. [action].js 한 파일로 여러 엔드포인트를 합치는 동적 라우트 패턴과 그 트레이드오프를 정리합니다." tags: ["dev", "attendance-web", "vercel"] category: "dev"
3편까지 출퇴근 기록과 급여명세서를 다 붙이고 나니, 어느 날 배포가 막혔습니다. Vercel이 던진 에러는 이거였어요.
No more than 12 Serverless Functions can be added to a Deployment on the Hobby plan.
이 앱은 비용을 한 푼도 안 들이고 굴리는 게 목표였습니다. 그래서 Vercel 무료(Hobby) 플랜을 쓰는데, 여기엔 한 배포에 서버리스 함수 12개라는 제한이 있어요. 이번 편은 이 12개 한도 안에서 모든 API를 어떻게 우겨넣었는지에 대한 이야기입니다.
1. 함수 1개 = 파일 1개라는 함정
Vercel에서는 api/ 폴더 안의 파일 하나가 곧 함수 하나가 됩니다. 처음엔 아무 생각 없이
엔드포인트마다 파일을 만들었어요.
api/login.js
api/signup.js
api/logout.js
api/checkin.js
api/checkout.js
api/payroll-settings.js
api/payroll-compute.js
api/payslip.js
...
직관적이긴 한데, 기능이 늘어날수록 파일이 그대로 함수 개수로 쌓입니다. 로그인 관련 5개, 급여 관련 7~8개… 금방 12개를 넘겨버렸어요. 무료 플랜에서는 여기서 배포가 막힙니다.
2. 해법 — [action].js 한 파일로 묶기
Vercel은 동적 파일 라우트를 지원합니다. 파일명을 대괄호로 감싸면([action].js)
그 자리에 뭐가 들어오든 같은 파일이 받아요. api/auth/login, api/auth/checkin이
전부 api/auth/[action].js 한 파일(=함수 1개)로 들어옵니다.
그래서 관련 있는 엔드포인트를 주제별로 한 파일에 모으고, 파일 안에서 action 값으로
직접 분기했습니다. 급여 쪽을 예로 들면 이렇게 생겼어요.
// api/payroll/[action].js — 함수 1개로 급여 동작 11개를 처리
export default async function handler(req, res) {
await ensureReady();
const action = String(req.query.action || "").toLowerCase();
if (action === "settings") {
if (req.method === "GET") return await getSettings(req, res);
if (req.method === "PUT") return await putSettings(req, res);
return res.status(405).end();
}
if (action === "settings-list") return await listSettings(req, res);
if (action === "compute") return await computePreview(req, res);
if (action === "payslip") return await handlePayslip(req, res);
if (action === "payslips-month") return await listMonthPayslips(req, res);
// ... draft-month / recompute-month / track-open ...
}
이 파일 하나가 설정 조회·저장, 자동 계산, 명세서 CRUD, 월 단위 일괄 생성, 이메일 열람
추적 픽셀까지 11가지 동작을 다 받습니다. 그래도 Vercel 입장에선 함수 딱 1개예요.
인증(auth)·푸시(push)도 같은 방식으로 한 파일씩 묶었습니다.
3. 쿼리 파라미터로 합치기 — stats.js
라우트를 새로 안 파고, 그냥 쿼리 파라미터로 갈래를 트는 방법도 썼습니다.
통계(overview)와 감사 로그(audit)는 원래 파일이 따로였는데, 한 파일로 합치고
?type=으로 나눴어요.
// api/stats.js — overview + audit 를 한 함수로
const type = (req.query.type || "overview").toString();
if (type === "audit") return await getAudit(req, res);
return await getOverview(req, res);
audit.js라는 별도 함수를 없애고 stats.js?type=audit로 흡수한 거죠. 이거 하나로
함수 1칸을 아꼈습니다. 12칸짜리 살림에선 이 1칸이 큽니다.
4. 지금의 12칸 배치
이렇게 정리하고 나니 현재 정확히 12 / 12로 맞아떨어집니다.
| # | 함수 | 담당 |
|---|---|---|
| 1 | auth/[action].js | login·signup·logout·me·checkin·checkout |
| 2 | payroll/[action].js | 급여 설정·명세서·일괄·열람추적 (11종) |
| 3 | push/[action].js | 웹푸시 구독·발송 |
| 4 | record-requests/index.js | 수정 신청 목록·생성 |
| 5 | record-requests/[id].js | 신청 1건 승인·반려 |
| 6 | records/index.js | 출퇴근 기록 CRUD |
| 7 | sessions/index.js | 세션 목록 |
| 8 | sessions/[id].js | 세션 1건 강제 로그아웃 |
| 9 | users/index.js | 직원 목록·생성 |
| 10 | users/[id].js | 직원 1명 수정·삭제 |
| 11 | stats.js | 통계 + 감사로그(?type=audit) |
| 12 | cron/auto-checkout.js | 자정 미퇴근 자동 처리 |
새 기능이 필요하면 이제 파일을 새로 만들지 않고 관련 있는 [action].js에 분기를
한 줄 더 추가합니다. 12라는 천장이 오히려 "비슷한 건 한곳에 모은다"는 규칙을 강제해준
셈이에요.
5. 공짜의 대가 — 트레이드오프
물론 좋기만 한 건 아닙니다.
- 파일이 비대해집니다.
payroll/[action].js는 동작이 11개라 길어요. 대신 위쪽에 어떤action이 뭘 하는지 주석 표로 정리해두고 함수로 잘게 쪼개서 버팁니다. - 라우팅을 직접 분기해야 합니다. 프레임워크가 자동으로 안 나눠주니,
if (action === ...)사다리를 손으로 관리해야 해요. 빠뜨리면 405를 직접 돌려줘야 하고요. - 콜드스타트를 공유합니다. 한 함수가 여러 동작을 받으니, 한 번 깨어나면 그 안의 동작들은 같이 따뜻해집니다. 이건 오히려 장점에 가까웠어요.
작은 매장 하나 굴리는 앱에 월 사용료를 내긴 아까웠습니다. 무료 한도 안에서 버티려고 짜낸 구조인데, 결과적으로 코드도 주제별로 더 깔끔하게 모이더라고요.
정리 — 무엇이 핵심이었나
- Vercel 무료 플랜은 함수 12개가 한도. 파일 1개 = 함수 1개입니다.
- 관련 엔드포인트는
[action].js동적 라우트 한 파일로 묶어 함수 수를 줄입니다. - 라우트조차 아까우면
?type=쿼리 파라미터로 한 함수 안에서 갈래를 텄습니다. - 제약이 오히려 **"비슷한 건 한곳에"**라는 설계 규칙이 돼주었습니다.
다음 편 예고
Vol. 5 — Cron과 푸시 알림. 자정에 퇴근을 안 찍은 직원을 자동으로 잡아내는 Cron 잡과, 급여명세서를 직원 이메일로 보내는 발송 자동화 이야기로 시리즈를 마무리합니다.