
타임시트는 파트타임 근무자의 출퇴근과 급여를 관리하는 웹 앱이다. 사장(마스터)이 매장을 만들고 초대 코드로 알바생(멤버)을 부르면, 알바생은 휴대폰 브라우저로 출퇴근을 찍고 급여를 본다. 2026-09-25 하루 동안 만들어 웹과 API 두 서버로 운영에 올렸다.
이 시리즈는 기능이 아니라 스택을 기준으로 나눈다. 편마다 그 스택이 이 프로젝트에서 무슨 일을 맡았는지, 왜 골랐는지, 코드가 어떻게 생겼는지, 어디서 막혔고 어떻게 고쳤는지를 적는다. 막힌 곳은 저장소 작업 히스토리에 남은 실패 기록에서 가져온다.
1. 스택 한 장
버전은 node_modules 에 실제로 설치된 값이다. package.json 에 적힌 범위(^5.1.0 같은)와 다를 수 있다.
| 스택 | 버전 | 맡은 일 | 다루는 편 |
|---|---|---|---|
| Next.js (App Router) | 16.3.6 | 멤버 화면 /, 사장 화면 /admin, 컴포넌트 가이드 /guide. /api/* 요청을 API 서버로 넘긴다 | 01 |
| React | 19.2.8 | 화면 | 01 |
| Express | 5.2.1 | API 전부 — 로그인·매장·초대·근무 기록·요청·로고·시간표 | 02 |
| zod | 4.6.5 | 요청 검증 스키마. server/src/contract.ts 가 계약 원본 | 03 |
| zod-to-openapi | 9.1.0 | zod 스키마에서 OpenAPI 3.1 문서를 뽑는다 | 03 |
| openapi-typescript · openapi-fetch | 7.13.0 · 0.17.0 | OpenAPI 문서에서 웹 타입을 만들고 그 타입을 입힌 HTTP 클라이언트 | 03 |
| Prisma (+ adapter-pg) | 7.10.0 | DB 스키마·마이그레이션·쿼리 | 04 |
| PostgreSQL · Neon | 로컬 18.6 · 운영 Neon | 모든 데이터. 매장 로고 이미지도 DB 에 둔다 | 04 |
| jose · argon2 · cookie-parser | 6.2.12 · 0.45.1 · 1.4.7 | 액세스 토큰 서명, 비밀번호 해시, 쿠키 읽기 | 05 |
| helmet · express-rate-limit | 8.3.0 · 8.7.0 | 보안 헤더, 로그인 시도 제한 | 05 |
| Tailwind CSS | 4.3.3 | 색·간격 토큰과 스타일 | 06 |
| shadcn (Base UI) | @base-ui/react 1.8.0 | 스크롤 영역·모달·버튼·스켈레톤 원본 | 06 |
| lucide-react · Pretendard | 1.47.0 · 1.3.9 | 아이콘, 본문 글꼴 | 06 |
| Vercel | — | 웹·API 두 프로젝트로 배포 | 07 |
| pnpm | 11.20.0 | 서버와 웹을 한 저장소에서 관리하는 워크스페이스 | 07 |
| node --test · tsx | Node 내장 · 4.23.15 | 웹 단위 테스트, 서버 e2e | 08 |
| TypeScript | 5.9.3 | 양쪽 타입 검사 | 전 편 |
표 1. 시리즈가 다루는 스택
Neon 쪽 Postgres 버전은 확인하지 못했다. 로컬은 Docker 의 postgres:18.6-alpine 이다.
2. 요청이 흘러가는 길
브라우저는 웹 주소 하나만 안다. API 주소는 따로 있지만 브라우저가 직접 부르지 않는다.
| 순서 | 어디서 | 무엇을 |
|---|---|---|
| 1 | 브라우저 | https://timesheet-brown-ten.vercel.app/api/users/me 처럼 웹 주소 아래 /api 로 요청한다 |
| 2 | 웹 (Next.js) | next.config.ts 의 rewrites 가 /api/* 를 API 서버로 넘긴다 |
| 3 | API (Express) | 쿠키로 로그인을 확인하고 zod 스키마로 요청을 검증한다 |
| 4 | DB (Neon) | Prisma 가 쿼리를 보내고 결과를 돌려준다 |
표 2. 요청 하나가 지나가는 길
rewrites 는 웹 서버가 받은 요청을 다른 주소로 대신 보내 주는 설정이다. 이렇게 하면 브라우저 입장에서 화면과 API 가 같은 출처(스킴·호스트·포트가 같은 주소)가 된다. 로그인 쿠키를 SameSite=Strict 로 걸어도 제대로 실리는 이유가 이것이다.
// web/next.config.ts
const API_URL = process.env.API_URL ?? "http://localhost:4200";
const nextConfig: NextConfig = {
async rewrites() {
return [{ source: "/api/:path*", destination: `${API_URL}/api/:path*` }];
},
};
API 계약은 한 방향으로만 흐른다. 서버의 zod 스키마가 원본이고, 웹은 거기서 생성된 타입만 본다.
| 순서 | 파일 | 만드는 명령 |
|---|---|---|
| 1 | server/src/contract.ts — zod 스키마 | 손으로 쓴다 |
| 2 | docs/api/openapi.json — OpenAPI 3.1 문서 | pnpm --filter timesheet-server openapi:export |
| 3 | web/src/api/schema.d.ts — 웹 타입 | pnpm --filter timesheet-web gen:api |
표 3. 계약이 흘러가는 순서
서버가 스키마를 바꾸고 2·3번을 다시 돌리면 웹의 타입 검사가 깨지는 자리가 생긴다. 그 자리가 웹에서 고칠 곳이다. 생성된 schema.d.ts 는 손으로 고치지 않는다.
3. 차례
| 편 | 제목 | 다루는 것 |
|---|---|---|
| 01 | Next.js 16 App Router — 같은 출처 쿠키와 정적 화면 | rewrites 로 만든 같은 출처, 빌드 때 정해지는 API_URL, 미리 만들어 두는 HTML, MDX 가이드 |
| 02 | Express 5 API — 라우터와 오류, Vercel 진입점 | 라우트 구성, 에러 응답, Vercel 이 진입점을 다른 파일로 골라 모든 요청이 500 이던 일 |
| 03 | zod 계약 하나로 검증·문서·FE 타입 | contract.ts 하나에서 검증·문서·웹 타입까지, nullable 이 웹 타입 11곳을 깬 일 |
| 04 | Prisma 7 과 Neon — 스키마, 마이그레이션, 운영 DB | 스키마와 마이그레이션, prisma.config.ts, 마이그레이션 폴더를 지웠다가 막힌 일 |
| 05 | 로그인 인증 — JWT, argon2, 쿠키 세션, CSRF | 액세스·리프레시 토큰을 모두 HttpOnly 쿠키에, 30일 슬라이딩, 전역 CSRF 검사 |
| 06 | UI — Tailwind 4 토큰, shadcn 원본, 스켈레톤 | 토큰 잇기, shadcn 원본 들이기, 같은 크기 스켈레톤, 버튼 규칙 |
| 07 | Vercel 배포 — 웹과 API 를 따로 올리고 리전을 재다 | 웹 prebuilt·API 원격 빌드, 업로드 한도, 요청 하나에 약 210ms 가 드는 이유 |
| 08 | 테스트와 검증 — 로직 단위 테스트부터 운영 측정까지 | 웹 단위 54개, 서버 e2e 17개, 운영 e2e 에서 드러난 방화벽과 429 |
표 4. 시리즈 목차
4. 저장소 구조
timesheet/
├── server/ Express API (timesheet-server)
│ ├── src/ contract.ts, app.ts, auth/, stores/, shifts/, requests/
│ └── prisma/ schema.prisma, migrations/
├── web/ Next.js (timesheet-web)
│ └── src/ app/, components/, auth/, api/, lib/
└── docs/ prd/, api/openapi.json, history/, verify/
급여 계산은 web/src/lib/pay.ts 한 곳에만 있다. 서버는 근무 기록과 멤버별 조건만 내려 주고 금액은 웹이 계산한다.
다음 편: [타임시트] Next.js 16 App Router — 같은 출처 쿠키와 정적 화면 (01)