aster®

[타임시트] 스택과 구조 안내 (00)

마무리 · 2026-09

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

타임시트는 파트타임 근무자의 출퇴근과 급여를 관리하는 웹 앱이다. 사장(마스터)이 매장을 만들고 초대 코드로 알바생(멤버)을 부르면, 알바생은 휴대폰 브라우저로 출퇴근을 찍고 급여를 본다. 2026-09-25 하루 동안 만들어 웹과 API 두 서버로 운영에 올렸다.

이 시리즈는 기능이 아니라 스택을 기준으로 나눈다. 편마다 그 스택이 이 프로젝트에서 무슨 일을 맡았는지, 왜 골랐는지, 코드가 어떻게 생겼는지, 어디서 막혔고 어떻게 고쳤는지를 적는다. 막힌 곳은 저장소 작업 히스토리에 남은 실패 기록에서 가져온다.

1. 스택 한 장

버전은 node_modules 에 실제로 설치된 값이다. package.json 에 적힌 범위(^5.1.0 같은)와 다를 수 있다.

스택버전맡은 일다루는 편
Next.js (App Router)16.3.6멤버 화면 /, 사장 화면 /admin, 컴포넌트 가이드 /guide. /api/* 요청을 API 서버로 넘긴다01
React19.2.8화면01
Express5.2.1API 전부 — 로그인·매장·초대·근무 기록·요청·로고·시간표02
zod4.6.5요청 검증 스키마. server/src/contract.ts 가 계약 원본03
zod-to-openapi9.1.0zod 스키마에서 OpenAPI 3.1 문서를 뽑는다03
openapi-typescript · openapi-fetch7.13.0 · 0.17.0OpenAPI 문서에서 웹 타입을 만들고 그 타입을 입힌 HTTP 클라이언트03
Prisma (+ adapter-pg)7.10.0DB 스키마·마이그레이션·쿼리04
PostgreSQL · Neon로컬 18.6 · 운영 Neon모든 데이터. 매장 로고 이미지도 DB 에 둔다04
jose · argon2 · cookie-parser6.2.12 · 0.45.1 · 1.4.7액세스 토큰 서명, 비밀번호 해시, 쿠키 읽기05
helmet · express-rate-limit8.3.0 · 8.7.0보안 헤더, 로그인 시도 제한05
Tailwind CSS4.3.3색·간격 토큰과 스타일06
shadcn (Base UI)@base-ui/react 1.8.0스크롤 영역·모달·버튼·스켈레톤 원본06
lucide-react · Pretendard1.47.0 · 1.3.9아이콘, 본문 글꼴06
Vercel—웹·API 두 프로젝트로 배포07
pnpm11.20.0서버와 웹을 한 저장소에서 관리하는 워크스페이스07
node --test · tsxNode 내장 · 4.23.15웹 단위 테스트, 서버 e2e08
TypeScript5.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 서버로 넘긴다
3API (Express)쿠키로 로그인을 확인하고 zod 스키마로 요청을 검증한다
4DB (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 스키마가 원본이고, 웹은 거기서 생성된 타입만 본다.

순서파일만드는 명령
1server/src/contract.ts — zod 스키마손으로 쓴다
2docs/api/openapi.json — OpenAPI 3.1 문서pnpm --filter timesheet-server openapi:export
3web/src/api/schema.d.ts — 웹 타입pnpm --filter timesheet-web gen:api

표 3. 계약이 흘러가는 순서

서버가 스키마를 바꾸고 2·3번을 다시 돌리면 웹의 타입 검사가 깨지는 자리가 생긴다. 그 자리가 웹에서 고칠 곳이다. 생성된 schema.d.ts 는 손으로 고치지 않는다.

3. 차례

편제목다루는 것
01Next.js 16 App Router — 같은 출처 쿠키와 정적 화면rewrites 로 만든 같은 출처, 빌드 때 정해지는 API_URL, 미리 만들어 두는 HTML, MDX 가이드
02Express 5 API — 라우터와 오류, Vercel 진입점라우트 구성, 에러 응답, Vercel 이 진입점을 다른 파일로 골라 모든 요청이 500 이던 일
03zod 계약 하나로 검증·문서·FE 타입contract.ts 하나에서 검증·문서·웹 타입까지, nullable 이 웹 타입 11곳을 깬 일
04Prisma 7 과 Neon — 스키마, 마이그레이션, 운영 DB스키마와 마이그레이션, prisma.config.ts, 마이그레이션 폴더를 지웠다가 막힌 일
05로그인 인증 — JWT, argon2, 쿠키 세션, CSRF액세스·리프레시 토큰을 모두 HttpOnly 쿠키에, 30일 슬라이딩, 전역 CSRF 검사
06UI — Tailwind 4 토큰, shadcn 원본, 스켈레톤토큰 잇기, shadcn 원본 들이기, 같은 크기 스켈레톤, 버튼 규칙
07Vercel 배포 — 웹과 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)