
타임시트는 하루 동안 기능이 계속 붙은 앱이다. 로그인, 매장과 초대 코드, 출퇴근 기록으로 시작해 수정 요청·휴가·대타, 매장 로고, 근무 시간표가 차례로 들어왔다. 기능이 들어올 때마다 DB 스키마도 바뀌었고 마이그레이션 다섯 개가 전부 운영 DB 까지 올라갔다. 뒤의 셋은 테스트 계정이 아닌 실제 사용자가 이미 있는 DB 에 적용됐다.
실제 사용자 데이터가 있으면 스키마를 바꿀 때마다 DB 를 비우고 다시 만들 수는 없다. 이 글은 스키마가 다섯 번 바뀌는 동안 운영 DB 를 한 번도 초기화하지 않고 따라간 방법을 다룬다. Prisma 7 설정부터 마이그레이션 원칙, 운영 적용 순서, 막힌 곳까지 차례로 본다. 마지막에 DB 위치가 성능에 준 영향도 짚는다.
1. Prisma 와 Neon 이 맡은 일
Prisma 는 TypeScript 에서 DB 를 다루는 도구다. schema.prisma 파일에 테이블 구조를 적으면 두 가지를 만들어 준다. 하나는 그 구조대로 타입이 붙은 쿼리 클라이언트고 다른 하나는 DB 를 그 구조로 바꾸는 SQL 파일(마이그레이션)이다. Neon 은 서버를 직접 띄우지 않고 쓰는 호스팅 Postgres 다.
| 구성 | 값 | 누가 정했나 |
|---|---|---|
| ORM | prisma 7.10.0, @prisma/client 7.10.0 | 첫 커밋(워크스페이스 뼈대)부터 있었다. 고른 이유는 기록에 없다 |
| 드라이버 | @prisma/adapter-pg 7.10.0 + pg 8.23.0 | 코드에 있는 구성이다 |
| 로컬 DB | Docker postgres:18.6-alpine, 포트 5434 | server/compose.yaml |
| 운영 DB | Neon Postgres timesheet-db, us-east-1, Vercel Marketplace 로 API 프로젝트에 연결 | 사용자가 "Neon + Vercel" 을 골랐다. 에이전트가 Supabase 와 함께 후보로 물었다 |
| 모델 | 10개 | server/prisma/schema.prisma |
| 마이그레이션 | 5개 | server/prisma/migrations/ |
표 1. 타임시트의 DB 구성
로컬 Postgres 포트가 5434 인 데는 이유가 있다. 같은 PC 에서 다른 프로젝트가 5432·5433 을 이미 쓰고 있었다. compose.yaml 에는 name: timesheet 도 박아 두었다. 이름을 안 적으면 docker-compose 가 디렉터리 이름(server)을 프로젝트 이름으로 쓰는데, 그러면 다른 저장소의 server/ 에 있는 compose 와 컨테이너·볼륨을 같이 쓰게 된다.
2. 연결 주소는 스키마 밖에 둔다
Prisma 7 부터는 DB 연결 주소를 schema.prisma 에 적지 않는다. 대신 prisma.config.ts 라는 설정 파일에 둔다. 그래서 스키마의 datasource 에는 DB 종류만 남는다.
// server/prisma.config.ts
// Prisma 7 부터 연결 주소는 스키마가 아니라 여기에 둔다.
// .env 는 파일이 있을 때만 읽는다. 배포 환경에는 없는 것이 정상이다.
import { existsSync } from "node:fs";
import { defineConfig, env } from "prisma/config";
if (existsSync(".env")) process.loadEnvFile(".env");
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: { url: env("DATABASE_URL") },
});
// server/prisma/schema.prisma
// 연결 주소는 prisma.config.ts 에 있다.
datasource db {
provider = "postgresql"
}
.env 는 파일이 있을 때만 읽는다. 로컬에서는 .env 가 있고 Vercel 에서는 환경변수가 직접 들어오니 파일이 없다. 이렇게 두면 두 환경 모두 같은 설정 파일로 돌아간다.
3. 드라이버 어댑터와 전역 omit
앱 코드는 PrismaClient 를 만들 때 adapter 로 PrismaPg 를 넘긴다. adapter-pg 는 Node 의 pg 드라이버로 Postgres 에 붙는 어댑터다.
// server/src/db.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { env } from "./env.js";
// 로고 바이트(최대 1MB)가 매장을 읽는 모든 조회에 딸려 오지 않게 기본으로 뺀다. 로고 라우트만 omit: { logo: false } 로 읽는다.
export const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: env.DATABASE_URL }),
omit: { store: { logo: true } },
});
omit 은 조회 결과에서 특정 컬럼을 빼는 옵션이다. 여기서는 클라이언트 전체에 걸었다. 매장 로고를 따로 파일 저장소에 두지 않고 stores 테이블의 bytea 컬럼(바이트를 그대로 담는 Postgres 타입)에 넣기로 했기 때문이다. 매장당 하나이고 1MB 이하라 새 외부 저장소를 붙이지 않았다.
매장을 읽는 곳이 많은 게 문제다. 로그인한 사용자 정보(/users/me)도 소속 매장을 같이 읽는다. 전역 omit 이 없으면 이 모든 조회에 최대 1MB 짜리 바이트가 딸려 온다. 그래서 기본값을 "빼기" 로 두고 로고를 내려주는 라우트 한 곳만 다시 켠다.
// server/src/stores/logo.ts
const store = await prisma.store.findUniqueOrThrow({ where: { id: req.membership!.storeId }, omit: { logo: false } });
로고 기능을 붙인 뒤 /users/me 응답에 logo 가 없는 것을 e2e 에서 확인했다.
4. 주요 모델
모델은 10개다. 인증용 User·RefreshToken, 매장용 Store·Membership·Invite, 근무용 Shift, 요청용 ShiftCorrection·Leave·Substitution, 시간표용 ScheduleException 이다.
| 모델 | 테이블 | 무엇을 담나 | 눈여겨볼 제약 |
|---|---|---|---|
User | users | 이메일·닉네임·비밀번호 해시 | email 유니크. 소문자로 바꿔 저장한다 |
Membership | memberships | 사용자와 매장의 소속, 역할(master·member), 시급, 근무 요일·시각 | 기본 키가 user_id 다. 한 사용자는 매장 하나에만 속한다 |
Shift | shifts | 출근·퇴근 시각 | end 가 null 이면 아직 퇴근하지 않은 열린 기록. 사용자당 하나만 허용 (5절) |
Leave | leaves | 휴가 기간·사유·상태·유급 여부 | 유급 여부는 사장님이 승인할 때 정한다 |
Substitution | substitutions | 대타를 부탁한 사람·맡은 사람·날짜 | 상태가 6가지(requested → accepted → approved 등) |
ScheduleException | schedule_exceptions | 특정 날짜 하루만 쉬거나 시각을 바꾸는 변경 | @@unique([userId, date]) — 한 멤버·한 날짜에 하나 |
표 2. 기능을 떠받치는 주요 모델
Membership 의 기본 키를 user_id 로 둔 것이 "한 사람은 매장 하나" 라는 규칙을 DB 가 지키게 하는 방법이다. 코드에서 검사하지 않아도 같은 사용자로 두 번째 소속 행을 넣으면 DB 가 거부한다. 멤버를 퇴사처리하면 이 행만 지우고 근무 기록은 남긴다.
Leave 의 paid 컬럼에는 사연이 있다. 처음에는 멤버가 신청할 때 유급 여부를 골랐는데, 사용자가 "사장님이 알아서 판단하도록" 바꾸라고 했다. 이때 컬럼을 nullable 로 바꾸지 않았다. 기존 행의 뜻이 흔들리기 때문이다. 대신 신청할 때는 false 로 저장하고 화면에는 "미정" 으로 보이게 했다. 승인 API 에서는 paid 를 필수로 받는다.
5. 부분 유니크 인덱스 — 열린 기록은 하나
출근 버튼을 두 번 누르거나 두 기기에서 동시에 누르면 열린 기록이 두 개 생길 수 있다. 그러면 퇴근을 어느 기록에 찍을지 알 수 없다. 이 규칙을 앱 코드가 아니라 DB 가 지키게 했다.
부분 유니크 인덱스는 조건을 만족하는 행에만 유니크 제약을 거는 인덱스다. end IS NULL 인 행끼리만 user_id 가 겹치지 않으면 된다. 닫힌 기록은 사용자당 몇 개든 괜찮다.
-- server/prisma/migrations/20260925035604_init/migration.sql
-- 열린 근무 기록은 사용자당 하나 (docs/prd/06-store-invite.md I-8). Prisma 스키마로 표현할 수 없어 직접 넣는다.
CREATE UNIQUE INDEX "shifts_one_open_per_user" ON "shifts"("user_id") WHERE "end" IS NULL;
주석에 적힌 대로 이 인덱스는 Prisma 스키마에 없다. 마이그레이션 SQL 에 손으로 넣었다. 스키마 파일의 Shift 모델 위에는 "마이그레이션 SQL 의 부분 유니크 인덱스가 강제한다" 는 주석을 달아 두었다. 스키마만 읽는 사람도 이 제약이 있다는 걸 알 수 있게 하려는 것이다.
출근 API 는 이 인덱스에 걸리면 Prisma 가 내는 에러 코드 P2002(유니크 제약 위반)를 409 로 바꾼다.
// server/src/shifts/routes.ts
shiftRouter.post("/shifts/clock-in", async (req, res) => {
try {
const shift = await prisma.shift.create({
data: { userId: req.auth!.userId, storeId: req.membership!.storeId, start: new Date() },
});
res.status(201).json(toShiftDto(shift));
} catch (e) {
// 열린 기록이 이미 있으면 부분 유니크 인덱스(shifts_one_open_per_user)가 막는다.
if ((e as { code?: string }).code === "P2002") throw new AppError(409, "ALREADY_CLOCKED_IN");
throw e;
}
});
먼저 조회하고 없으면 넣는 방식은 두 요청이 거의 동시에 오면 둘 다 "없음" 을 보고 둘 다 넣는다. DB 제약은 그 틈이 없다. 수정 요청에도 같은 방식을 썼다. 한 기록에 대기 중인 수정 요청은 하나만 둔다.
-- server/prisma/migrations/20260925051339_requests_leave_substitution/migration.sql
CREATE UNIQUE INDEX "shift_corrections_one_pending_per_shift" ON "shift_corrections"("shift_id") WHERE "status" = 'pending' AND "shift_id" IS NOT NULL;
6. 마이그레이션 다섯 개 — 추가만 한다
마이그레이션은 DB 구조를 바꾸는 SQL 을 순서대로 쌓아 둔 폴더다. Prisma 는 _prisma_migrations 테이블에 어디까지 적용했는지 기록하고 아직 적용하지 않은 것만 실행한다.
| 마이그레이션 | 무엇을 바꿨나 | 기능 |
|---|---|---|
20260925035604_init | 테이블 전부 생성, 열린 기록 부분 유니크 인덱스 | 로그인·매장·초대·근무 기록 |
20260925035843_invite_used_by_fk | invites.used_by 에 외래 키 추가 (2줄) | 초대 코드를 쓴 사용자 연결 |
20260925051339_requests_leave_substitution | 테이블 3개 추가, 대기 요청 부분 유니크 인덱스 | 수정 요청·휴가·대타 |
20260925062253_store_logo | stores 에 컬럼 3개 추가 (logo, logo_type, logo_updated_at) | 매장 로고 |
20260925075132_work_schedule | memberships 에 컬럼 3개, schedule_exceptions 테이블 추가 | 근무 시간표·날짜별 변경 |
표 3. 운영까지 올라간 마이그레이션
첫 마이그레이션 뒤의 넷은 모두 무언가를 더하기만 한다. DROP 도, 컬럼 타입 변경도, 이름 변경도 없다. 히스토리에는 "테이블 3개 추가만(DROP 0)", "컬럼 추가만", "추가만: 열 3개 + schedule_exceptions 표" 가 마이그레이션마다 적혀 있다.
추가만 하면 이미 있는 행이 새 구조에서도 뜻을 잃지 않는다. 옛 API 가 잠깐 새 DB 를 읽어도 모르는 컬럼이 늘었을 뿐이라 깨지지 않는다. 근무 시간표 마이그레이션이 좋은 예다. 새 컬럼 schedule_days 는 기본값이 빈 배열이고 비어 있으면 "시간표 없음" 으로 읽는다. 시간표를 정하지 않은 기존 멤버는 옛 주 시간·일수 값을 그대로 쓴다.
이 원칙은 프로젝트 결정 목록(docs/decisions.md)에도 올라 있다. "파괴적 DB 변경·새 외부 리소스·비용은 묻는다." 추가만 하는 변경은 에이전트가 진행하고 무언가를 지우거나 뜻을 바꾸는 변경은 사용자에게 먼저 묻는다.
7. 운영에 적용한 절차
Neon 붙이기
Neon 은 Vercel Marketplace 로 API 프로젝트(timesheet-api)에 연결했다. Marketplace 로 붙이면 Vercel 이 DB 를 만들고 연결 주소 환경변수를 프로젝트에 넣어 준다. 이 배포를 정리한 스킬(vercel-monorepo-deploy)에 남은 명령은 이렇다.
vercel --non-interactive integration add neon --name <name>-db
처음 설치하면 integration_terms_acceptance_required 로 멈춘다. Neon 약관에 동의해야 한다는 뜻이다. 약관 동의는 사용자가 브라우저에서 했다. 에이전트가 대신 동의할 수 있는 일이 아니라서 동의 주소를 전하고 기다린 뒤 명령을 다시 돌렸다.
설치가 끝나면 DATABASE_URL(연결 풀러 경유)과 DATABASE_URL_UNPOOLED(풀러를 거치지 않는 직접 연결) 등이 API 프로젝트 환경변수에 들어간다. 연결 풀러는 짧게 오가는 여러 연결을 적은 수의 실제 DB 연결로 묶어 주는 중간 서버다. 앱은 풀러 주소를 쓴다.
migrate deploy
운영에는 migrate dev 가 아니라 migrate deploy 를 쓴다. migrate dev 는 스키마와 DB 를 비교해 새 마이그레이션을 만들고 상황에 따라 DB 를 초기화하자고 한다. migrate deploy 는 이미 만들어진 마이그레이션 중 적용 안 된 것만 실행한다. 새로 만들지도, 지우지도 않는다.
vercel env pull # timesheet-api 에 연결된 폴더에서. 값은 커밋하지 않는다
cd server && DATABASE_URL="<DATABASE_URL_UNPOOLED>" pnpm exec prisma migrate deploy
마이그레이션은 풀러 없는 주소로 돌린다(docs/deploy.md). 배포와도 따로 한다. 순서는 매번 같았다. DB → API → 웹. 새 API 가 새 컬럼을 읽으려면 DB 가 먼저 바뀌어 있어야 한다. 추가만 하는 마이그레이션이라 DB 를 먼저 바꿔도 옛 API 는 그대로 돈다.
| 시각 | 적용한 마이그레이션 | 뒤이어 한 일 |
|---|---|---|
| 첫 API 배포 전 | init, invite_used_by_fk (2건) | API 첫 배포 |
| 14:33 | requests_leave_substitution | API → 웹 연달아 배포, 운영 e2e 8/8 |
| 15:29 | store_logo | API 배포, 운영 e2e LG-1~LG-7 통과 |
| 16:57 이후 | work_schedule | API 원격 빌드 → 웹 배포, 운영 e2e WS-1 1/1 |
표 4. 운영 DB 에 마이그레이션을 적용한 순서 (시각은 히스토리 기록 기준)
work_schedule 을 올린 직후 새 API 경로가 404 를 돌려준 적이 있다. 배포가 반영되기 전에 요청한 것이었고 곧 401(로그인 필요 — 정상)로 바뀌었다.
8. 막힌 곳
Prisma 가 AI 의 migrate reset 을 막았다
Invite 에 사용자 관계를 추가하는 중이었다. 세션이 먼저 prisma/migrations 폴더를 지운 다음, 로컬 DB 를 초기화하려고 prisma migrate reset --force 를 불렀다. Prisma 는 이렇게 응답하고 멈췄다.
If the user explicitly consents, you may rerun this command with the
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION environment variable ...
Prisma 7 은 AI 에이전트가 DB 를 지우는 명령을 부르면 사용자 동의를 요구한다. 환경변수 하나만 넣으면 통과하지만 우회하지 않았다. docker exec psql 로 스키마를 직접 날리는 길도 있었는데, 그것도 같은 가드를 돌아가는 방법이라 쓰지 않았다.
대신 지운 init 마이그레이션을 체크섬까지 똑같이 다시 만들었다. 체크섬은 Prisma 가 마이그레이션 파일 내용으로 계산해 _prisma_migrations 에 저장해 둔 값이다. 파일이 한 글자라도 다르면 DB 기록과 맞지 않는다.
- 관계를 빼서 이전 스키마로 되돌리고
prisma migrate diff --from-empty --to-schema로 SQL 을 만든다 - 생성기가 앞에 붙인
CREATE SCHEMA IF NOT EXISTS "public";줄을 뗀다 - 끝 개행을 바꿔 가며 SHA-256 을 DB 의 체크섬(
1c4ad119…)과 비교해 같은 것을 찾는다 - 그 위에 외래 키 추가를 두 번째 마이그레이션(
invite_used_by_fk)으로 쌓는다
DB 는 한 번도 지우지 않았다. 처음에 마이그레이션 폴더를 지운 것부터가 실수였다. 새 마이그레이션은 migrate dev --name 으로 쌓으면 되는 일이었다. 히스토리에 남은 교훈은 한 줄이다. "마이그레이션 폴더를 지우지 않는다."
migrate dev 뒤에 클라이언트가 옛날 그대로였다
근무 시간표 마이그레이션을 만든 뒤 tsc 를 돌리니 scheduleException 이 없다는 에러가 났다. Prisma 7 의 migrate dev 는 클라이언트를 새로 만들지 않는다. prisma generate(서버 패키지의 pnpm generate)를 따로 돌려야 새 모델의 타입이 생긴다.
배포 빌드는 처음부터 "build": "prisma generate && tsc" 로 되어 있어 이 문제가 없었다. 로컬 개발 흐름에서만 걸렸다.
orderBy 에 붙인 as const 가 반환 타입을 망가뜨렸다
요청 목록 API 에서 findMany 결과에 include 로 붙인 관계가 타입에 없다는 에러가 났다. 원인은 정렬 조건이었다.
// ❌ 읽기 전용 튜플이 되어 Prisma 인자 추론이 깨진다 → 기본 반환 타입으로 떨어진다
orderBy: [{ createdAt: "desc" }] as const
// ✅ 객체로 두고 값에만 as const
const recentFirst = { createdAt: "desc" as const };
prisma.leave.findMany({ where: { userId }, include: { user: true }, orderBy: recentFirst, take: 100 });
배열 전체에 as const 를 붙이면 읽기 전용 튜플이 된다. Prisma 는 인자 타입으로 반환 타입을 계산하는데, 이 튜플이 기대한 인자 모양과 맞지 않아 추론이 실패했다. 그러면 include 가 없는 기본 반환 타입으로 떨어진다. 값 "desc" 에만 as const 를 붙인 객체로 바꾸니 풀렸다.
9. DB 가 us-east-1 이라서 생긴 일
운영에서 성능을 쟀더니 요청 하나에 약 210~250ms 가 들었다(docs/verify/performance.md, 서울의 개발 PC 에서 측정). 대부분은 DB 가 아니라 거리였다.
| 무엇 | p50 | 뜻 |
|---|---|---|
/health (DB 를 읽지 않음) | 214ms | 서울 ↔ 미국 동부 왕복과 함수 기본 시간 |
| 멤버 기록 API | 226~247ms | /health 보다 10~40ms 더 걸린다. 이만큼이 DB 쿼리 몫이다 |
| 사장 대시보드 (가장 무거운 API, 웹 경유) | 237ms | 무거운 조회도 왕복 시간에 묻힌다 |
| 멤버 앱 부팅 합계 (웹 경유) | 563ms | /users/me 뒤에 4개를 병렬로 부르니 왕복 두 번 |
표 5. 운영 API 응답 시간 (10회, 단위 ms)
응답 헤더 x-vercel-id: icn1::iad1 은 요청이 서울 엣지(icn1)로 들어와 미국 동부(iad1) 함수에서 처리됐다는 뜻이다. Neon DB 도 us-east-1 에 있다. 함수와 DB 는 가까우니 쿼리는 빠르다. 느린 것은 서울에서 미국 동부까지 한 번 다녀오는 시간이다.
그래서 쿼리를 다듬어도 줄일 수 있는 것은 요청당 수십 ms 뿐이다. 성능 문서의 병목 순서도 1번이 "서울 ↔ 미국 동부 왕복" 이고 2번이 "로그인 확인 뒤에야 데이터 요청 시작" 이다. 둘 다 DB 가 아니라 배치와 요청 순서의 문제다. 함수와 DB 를 서울 가까이 옮기는 작업은 하지 않았다.
10. 그래서 운영 DB 를 지우지 않고 스키마를 다섯 번 바꿀 수 있었나
바꿀 수 있었다. 장치는 세 가지다.
- 첫 마이그레이션 뒤로는 추가만 했다. 테이블·컬럼·인덱스를 더하기만 해서 기존 행의 뜻이 바뀌지 않았다. 유급 여부처럼 뜻을 바꾸고 싶은 컬럼도 구조는 두고 쓰는 방식을 바꿨다
- 운영에는
migrate deploy만 썼다. 새 마이그레이션을 만들거나 DB 를 초기화하는 명령은 로컬에서만 돌았다. 순서는 DB → API → 웹으로 고정했다 - 초기화 가드를 우회하지 않았다. 로컬에서 이력이 꼬였을 때도 DB 를 지우지 않고 마이그레이션 파일을 체크섬까지 맞춰 복구했다
트레이드오프
| 선택 | 얻은 것 | 대가 |
|---|---|---|
| 추가만 하는 마이그레이션 | 기존 행과 옛 API 가 깨지지 않는다. DB 를 먼저 바꿔도 된다 | 쓰지 않게 된 컬럼이 남는다. Leave.paid 는 승인 전 값이 의미 없는데도 false 로 채워진다 |
로고를 bytea 로 DB 에 | 새 외부 저장소 없이 끝난다 | 전역 omit 을 잊고 새 클라이언트를 만들면 모든 매장 조회에 바이트가 딸려 온다 |
| 부분 유니크 인덱스를 SQL 로 | 동시 출근에도 열린 기록이 하나로 지켜진다 | Prisma 스키마에 보이지 않는다. 주석으로만 알린다 |
| Neon us-east-1 | Vercel 함수(iad1)와 가까워 쿼리가 빠르다 | 서울 사용자는 요청마다 약 210ms 의 왕복을 치른다 |
표 6. DB 쪽 선택과 대가
확인하지 못한 것
- Neon 쪽 Postgres 메이저 버전은 확인하지 못했다. 로컬은 18.6 이다
- Prisma 를 뼈대에 넣은 이유와 adapter-pg 를 고른 이유는 기록에 없다
- 폐기된 리프레시 토큰 행을 지우는 주기 작업이 없다.
refresh_tokens는 계속 쌓인다 - 성능은 개발 PC 의 유선 회선에서만 쟀다. 모바일 회선에서의 왕복 시간은 재지 않았다
이전 편: [타임시트] zod 계약 하나로 검증·문서·FE 타입 (03)
다음 편: [타임시트] 로그인 인증 — JWT, argon2, 쿠키 세션, CSRF (05)