aster®

[타임시트] Prisma 7 과 Neon — 스키마, 마이그레이션, 운영 DB (04)

마무리 · 2026-09

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

타임시트는 하루 동안 기능이 계속 붙은 앱이다. 로그인, 매장과 초대 코드, 출퇴근 기록으로 시작해 수정 요청·휴가·대타, 매장 로고, 근무 시간표가 차례로 들어왔다. 기능이 들어올 때마다 DB 스키마도 바뀌었고 마이그레이션 다섯 개가 전부 운영 DB 까지 올라갔다. 뒤의 셋은 테스트 계정이 아닌 실제 사용자가 이미 있는 DB 에 적용됐다.

실제 사용자 데이터가 있으면 스키마를 바꿀 때마다 DB 를 비우고 다시 만들 수는 없다. 이 글은 스키마가 다섯 번 바뀌는 동안 운영 DB 를 한 번도 초기화하지 않고 따라간 방법을 다룬다. Prisma 7 설정부터 마이그레이션 원칙, 운영 적용 순서, 막힌 곳까지 차례로 본다. 마지막에 DB 위치가 성능에 준 영향도 짚는다.

1. Prisma 와 Neon 이 맡은 일

Prisma 는 TypeScript 에서 DB 를 다루는 도구다. schema.prisma 파일에 테이블 구조를 적으면 두 가지를 만들어 준다. 하나는 그 구조대로 타입이 붙은 쿼리 클라이언트고 다른 하나는 DB 를 그 구조로 바꾸는 SQL 파일(마이그레이션)이다. Neon 은 서버를 직접 띄우지 않고 쓰는 호스팅 Postgres 다.

구성값누가 정했나
ORMprisma 7.10.0, @prisma/client 7.10.0첫 커밋(워크스페이스 뼈대)부터 있었다. 고른 이유는 기록에 없다
드라이버@prisma/adapter-pg 7.10.0 + pg 8.23.0코드에 있는 구성이다
로컬 DBDocker postgres:18.6-alpine, 포트 5434server/compose.yaml
운영 DBNeon 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 이다.

모델테이블무엇을 담나눈여겨볼 제약
Userusers이메일·닉네임·비밀번호 해시email 유니크. 소문자로 바꿔 저장한다
Membershipmemberships사용자와 매장의 소속, 역할(master·member), 시급, 근무 요일·시각기본 키가 user_id 다. 한 사용자는 매장 하나에만 속한다
Shiftshifts출근·퇴근 시각end 가 null 이면 아직 퇴근하지 않은 열린 기록. 사용자당 하나만 허용 (5절)
Leaveleaves휴가 기간·사유·상태·유급 여부유급 여부는 사장님이 승인할 때 정한다
Substitutionsubstitutions대타를 부탁한 사람·맡은 사람·날짜상태가 6가지(requested → accepted → approved 등)
ScheduleExceptionschedule_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_fkinvites.used_by 에 외래 키 추가 (2줄)초대 코드를 쓴 사용자 연결
20260925051339_requests_leave_substitution테이블 3개 추가, 대기 요청 부분 유니크 인덱스수정 요청·휴가·대타
20260925062253_store_logostores 에 컬럼 3개 추가 (logo, logo_type, logo_updated_at)매장 로고
20260925075132_work_schedulememberships 에 컬럼 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:33requests_leave_substitutionAPI → 웹 연달아 배포, 운영 e2e 8/8
15:29store_logoAPI 배포, 운영 e2e LG-1~LG-7 통과
16:57 이후work_scheduleAPI 원격 빌드 → 웹 배포, 운영 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 기록과 맞지 않는다.

  1. 관계를 빼서 이전 스키마로 되돌리고 prisma migrate diff --from-empty --to-schema 로 SQL 을 만든다
  2. 생성기가 앞에 붙인 CREATE SCHEMA IF NOT EXISTS "public"; 줄을 뗀다
  3. 끝 개행을 바꿔 가며 SHA-256 을 DB 의 체크섬(1c4ad119…)과 비교해 같은 것을 찾는다
  4. 그 위에 외래 키 추가를 두 번째 마이그레이션(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서울 ↔ 미국 동부 왕복과 함수 기본 시간
멤버 기록 API226~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 를 지우지 않고 스키마를 다섯 번 바꿀 수 있었나

바꿀 수 있었다. 장치는 세 가지다.

트레이드오프

선택얻은 것대가
추가만 하는 마이그레이션기존 행과 옛 API 가 깨지지 않는다. DB 를 먼저 바꿔도 된다쓰지 않게 된 컬럼이 남는다. Leave.paid 는 승인 전 값이 의미 없는데도 false 로 채워진다
로고를 bytea 로 DB 에새 외부 저장소 없이 끝난다전역 omit 을 잊고 새 클라이언트를 만들면 모든 매장 조회에 바이트가 딸려 온다
부분 유니크 인덱스를 SQL 로동시 출근에도 열린 기록이 하나로 지켜진다Prisma 스키마에 보이지 않는다. 주석으로만 알린다
Neon us-east-1Vercel 함수(iad1)와 가까워 쿼리가 빠르다서울 사용자는 요청마다 약 210ms 의 왕복을 치른다

표 6. DB 쪽 선택과 대가

확인하지 못한 것

이전 편: [타임시트] zod 계약 하나로 검증·문서·FE 타입 (03)

다음 편: [타임시트] 로그인 인증 — JWT, argon2, 쿠키 세션, CSRF (05)