Study/Node.JS

[Node.js] 03-prisma-deep-dive : Prisma 심화 학습

the.Dev.Cat 2026. 3. 22. 07:34

ORM 원리

SQL 인젝션이란?

// 위험한 코드 — SQL 직접 조합
const userId = req.params.id; // 사용자 입력: "1 OR 1=1"
const query = `SELECT * FROM users WHERE id = ${userId}`;
// 실제 실행: SELECT * FROM users WHERE id = 1 OR 1=1
// → 모든 유저 데이터 반환!

SQL 인젝션: 사용자 입력이 SQL 쿼리의 일부로 해석되어 의도치 않은 쿼리가 실행되는 취약점.

Prisma가 자동으로 방어하는 원리 (Parameterized Query)

// Prisma — 안전
const user = await prisma.user.findUnique({
  where: { id: Number(userId) },
});

// Prisma가 내부적으로 실행하는 SQL:
// SELECT * FROM users WHERE id = $1  (with parameter: [1])
// 사용자 입력은 반드시 파라미터로 전달 → SQL 구조에 영향 못 줌

파라미터화된 쿼리: SQL 구조를 먼저 컴파일하고, 사용자 값을 별도 파라미터로 전달한다. $1, $2가 SQL 구조의 일부가 아니라 “여기에 값이 들어갈 자리”로 처리된다.

N+1 문제란?

// N+1 문제 — 쿼리가 N+1번 발생
const users = await prisma.user.findMany(); // 쿼리 1번 (N명의 유저 조회)

for (const user of users) {
  // 유저마다 Todo를 별도 쿼리로 조회 → N번 추가 쿼리
  const todos = await prisma.todo.findMany({
    where: { userId: user.id },
  });
}
// 유저가 100명이면 총 101번 쿼리 = 심각한 성능 문제

Prisma의 include가 해결하는 방법

// include — 쿼리 2번으로 해결
const users = await prisma.user.findMany({
  include: { todos: true },
});
// 실제 실행:
// 1. SELECT * FROM users
// 2. SELECT * FROM todos WHERE user_id IN (1, 2, 3, ...)  ← IN 절로 한 번에
// 총 2번 쿼리로 모든 데이터 가져옴

include는 IN 절을 사용해서 관련 데이터를 한 번에 가져온다.


Prisma 스키마 문법 한 줄씩 해부

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma/client"
}

datasource db {
  provider = "postgresql"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  password  String
  todos     Todo[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

model Todo {
  id          Int      @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean  @default(false)
  userId      Int      @map("user_id")
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")

  @@map("todos")
}

provider = "prisma-client" — Prisma 7에서 이름이 바뀐 이유

Prisma 6 이하: provider = "prisma-client-js"
Prisma 7:      provider = "prisma-client"

Prisma 7부터 JavaScript 전용이 아니라 다른 언어도 지원할 가능성을 열어두며 -js 접미사를 제거했다.

@id @default(autoincrement()) — DB에서 어떻게 변환되는가

id Int @id @default(autoincrement())

이 한 줄이 생성하는 SQL:

"id" SERIAL NOT NULL

SERIAL은 PostgreSQL의 자동 증가 정수 타입이다. 내부적으로 SEQUENCE를 생성하고 기본값을 nextval('sequence_name')으로 설정한다.

INSERT 시:
  Prisma: prisma.user.create({ data: { email: '...' } })
  SQL:    INSERT INTO users (email) VALUES ('...')
  PostgreSQL: id는 자동으로 1, 2, 3... 할당

@unique — DB의 UNIQUE INDEX

email String @unique

생성되는 SQL:

CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

단순 컬럼 선언이 아니라 인덱스가 생성된다. UNIQUE INDEX가 있으면: - 같은 email로 INSERT 시도 → DB가 unique constraint violation 에러 반환 - Prisma가 이 에러를 P2002 코드로 감싸서 전달 - 우리 errorHandler에서 P2002를 잡아 409 응답

String?? — nullable 필드

description String?
"description" TEXT  -- NOT NULL 없음 → NULL 허용

?가 없으면 NOT NULL 제약이 붙는다.

TypeScript에서 Prisma 타입을 보면:

// description?: string | null
// description이 선택적이 아니라 null 가능
const todo = await prisma.todo.findUnique({ where: { id: 1 } });
todo?.description; // string | null

@default(false) vs @default(now())

completed Boolean  @default(false)    // 스칼라 기본값
createdAt DateTime @default(now())    // DB 함수 호출
"completed" BOOLEAN NOT NULL DEFAULT false
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP

false는 리터럴 값이다. now()는 PostgreSQL의 함수다. Prisma는 now()CURRENT_TIMESTAMP로 변환한다.

INSERT 시 createdAt을 명시하지 않으면 DB가 현재 시각을 자동으로 넣는다.

@updatedAt — 자동 갱신 원리

updatedAt DateTime @updatedAt @map("updated_at")

@updatedAt은 DB 트리거가 아니라 Prisma Client가 처리한다.

await prisma.user.update({
  where: { id: 1 },
  data: { name: '새 이름' },
});
// Prisma가 실제로 실행하는 SQL:
// UPDATE users SET name = '새 이름', updated_at = NOW() WHERE id = 1
//                              ↑ Prisma가 자동으로 추가

만약 Prisma를 우회해서 DB에 직접 SQL을 실행하면 @updatedAt은 갱신되지 않는다.

@map("snake_case") / @@map("table_name") — 매핑 규칙

model User {
  createdAt DateTime @map("created_at")  // 필드 레벨: JS ↔ DB 컬럼 매핑
  @@map("users")                          // 모델 레벨: JS 모델명 ↔ DB 테이블명 매핑
}
TypeScript 코드:  user.createdAt   (camelCase)
DB 컬럼:          created_at       (snake_case)
Prisma가 자동 변환

TypeScript 코드:  prisma.user      (PascalCase 모델명)
DB 테이블:        users            (소문자 복수형)
Prisma가 자동 변환

@map 없이 createdAt으로 정의하면? DB 컬럼명도 createdAt이 된다. SQL 컨벤션과 어긋난다.


@relation 한 줄 완벽 해석

model Todo {
  userId Int  @map("user_id")
  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

fields: [userId]

“이 모델(Todo)의 어떤 필드가 외래키인가?” userId 필드가 외래키. DB에서는 user_id 컬럼.

references: [id]

“상대 모델(User)의 어떤 필드를 참조하는가?” User의 id 필드를 참조. DB에서는 users.id.

-- 위의 @relation이 생성하는 SQL
ALTER TABLE "todos" ADD CONSTRAINT "todos_user_id_fkey"
    FOREIGN KEY ("user_id") REFERENCES "users"("id")
    ON DELETE CASCADE ON UPDATE CASCADE;

onDelete: Cascade

onDelete: Cascade  → User 삭제 시 해당 User의 Todo도 모두 삭제
onDelete: Restrict → User 삭제 불가 (Todo가 존재하는 한)
onDelete: SetNull  → Todo의 userId를 NULL로 설정 (nullable 필드여야 함)
onDelete: NoAction → DB에 위임 (기본값, Restrict와 유사)

우리 설계: User가 탈퇴하면 그 User의 Todo도 모두 삭제해야 한다. Cascade가 적합.

외래키가 “N” 쪽에 있는 이유

User(1) : Todo(N) 관계에서 외래키(user_id)는 Todo 테이블에 있다.

왜 User 쪽에 외래키가 없는가?

User에 외래키를 두면:
User { id: 1, todo_id: ??? }
→ User가 Todo를 여러 개 가질 때 어떻게?
→ todo_ids: [1, 2, 3]?  → 관계형 DB는 배열 컬럼 지원 안 함 (PostgreSQL의 ARRAY 제외)
→ User를 복수개 만들어야?  → 데이터 중복

Todo에 외래키를 두면:
Todo { id: 1, user_id: 1 }  ← 각 Todo가 자신의 주인(User)을 기억
Todo { id: 2, user_id: 1 }
→ 자연스럽게 1:N 표현 가능

“N 쪽이 1 쪽을 기억한다”가 1:N 관계의 핵심이다.


Prisma 7 Driver Adapter 원리

Prisma 6 이전 방식

Prisma Client (Node.js)
  ↓ protobuf 직렬화 (IPC)
Prisma 엔진 (Rust로 작성된 바이너리)
  ↓
PostgreSQL 드라이버 (Rust)
  ↓
PostgreSQL DB

문제점: - Prisma 엔진 바이너리 크기가 큼 (~50MB) - 서버리스 환경(Vercel, Cloudflare Workers)에서 바이너리 실행 불가 - Cold start 시간 증가

Prisma 7 Driver Adapter 방식

Prisma Client (Node.js)
  ↓
Driver Adapter (Node.js 레이어)
  ↓
pg (Node.js PostgreSQL 드라이버)
  ↓
PostgreSQL DB
// src/lib/prisma.ts
const pool = new pg.Pool({ connectionString: env.DATABASE_URL });
const adapter = new PrismaPg(pool); // pg 드라이버를 어댑터로 감쌈
const prisma = new PrismaClient({ adapter }); // Prisma에 어댑터 주입

장점: - Rust 엔진 바이너리 불필요 → 번들 크기 대폭 감소 - Node.js 생태계의 드라이버를 그대로 활용 - 서버리스 환경 완전 호환 - 커넥션 풀 설정을 pg.Pool에서 직접 관리


싱글톤 패턴 코드 해부

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

globalThis가 뭔가?

브라우저: window (전역 객체)
Node.js:  global (전역 객체)
Web Worker: self (전역 객체)

globalThis: 어느 환경에서든 전역 객체를 가리키는 표준 방법 (ES2020)

모듈 시스템에서 각 파일은 자체 스코프를 가진다. 하지만 globalThis는 모든 모듈이 공유하는 단 하나의 전역 객체다.

as unknown as { prisma: PrismaClient | undefined } — 2단계 타입 단언

// 직접 캐스팅하면 에러:
globalThis as { prisma: PrismaClient | undefined }
// → TypeScript: globalThis는 typeof globalThis인데
//   { prisma: PrismaClient | undefined }와 겹치는 부분이 없어 타입 에러

// 해결: unknown을 경유
globalThis as unknown as { prisma: PrismaClient | undefined }
// unknown: 어떤 타입과도 교환 가능한 중간 다리
// as unknown으로 타입 정보를 지움 → 다시 원하는 타입으로 캐스팅

?? (nullish coalescing) 연산자

export const prisma = globalForPrisma.prisma ?? createPrismaClient();
// 의미: globalForPrisma.prisma가 null이거나 undefined이면 createPrismaClient() 호출
//       아니면 globalForPrisma.prisma를 그대로 사용 (재사용!)

||와 차이:

0 || 'default'   // 'default' — 0은 falsy
0 ?? 'default'   // 0 — 0은 nullish가 아님

undefined ?? 'default' // 'default'
null ?? 'default'      // 'default'

개발 환경에서만 globalThis에 저장하는 이유

if (env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}
프로덕션:
  서버가 시작되면 Node.js 모듈 캐시에 의해
  lib/prisma.ts는 단 한 번만 로드됨
  → 자동으로 싱글톤 보장
  → globalThis에 저장할 필요 없음

개발 환경 (tsx watch):
  파일 변경 시 해당 모듈의 캐시를 무효화하고 재로드
  → lib/prisma.ts가 다시 실행됨
  → 매번 새 PrismaClient 생성 → 커넥션 풀 누적
  → globalThis에 저장해두면 재로드 후에도 같은 인스턴스 재사용

CRUD 쿼리 메서드 비교

findUnique vs findFirst

// findUnique: @unique 또는 @id 필드만 where 조건으로 사용 가능
const user = await prisma.user.findUnique({
  where: { id: 1 },       // id는 @id → OK
  // where: { name: 'Kim' }, // name은 @unique 아님 → 컴파일 에러
});
// 없으면 null 반환

// findFirst: 아무 필드나 조건으로 사용 가능
const user = await prisma.user.findFirst({
  where: { name: 'Kim' }, // OK
  orderBy: { createdAt: 'desc' },
});
// 없으면 null 반환

성능 차이: findUnique는 인덱스를 통해 직접 조회 (더 빠름). findFirst는 조건에 맞는 첫 번째 행 탐색.

findMany — 항상 배열 반환

const todos = await prisma.todo.findMany({
  where: { userId: 1 },
});
// 없어도 [] (빈 배열) 반환, null 반환하지 않음

select vs include — 동시 사용 불가 이유

// select: 원하는 필드만 지정 (나머지 제외)
const user = await prisma.user.findUnique({
  where: { id: 1 },
  select: { id: true, email: true, name: true },
  // password 빠짐 → 응답에 비밀번호 안 나감
});

// include: 기본 필드 + 관계 데이터 추가
const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: { todos: true },
  // 모든 User 필드 + todos 배열 포함
});

// 동시 사용 → 타입 에러
// select이 "이것만 가져와"고 include가 "이것도 추가해"는 의미상 충돌

select 안에서 관계를 포함하는 방법:

const user = await prisma.user.findUnique({
  where: { id: 1 },
  select: {
    id: true,
    email: true,
    todos: {
      select: { id: true, title: true },
    },
    // password는 빠짐
  },
});

Nested Write — createdata.todos.create

const user = await prisma.user.create({
  data: {
    email: 'lee@example.com',
    name: 'Lee',
    password: 'hash',
    todos: {
      create: [
        { title: '할 일 1' },
        { title: '할 일 2' },
      ],
    },
  },
  include: { todos: true },
});

실행 과정: 1. users 테이블에 User 삽입 2. 생성된 User의 iduser_id로 사용해서 todos 테이블에 Todo 2개 삽입 3. 모두 하나의 트랜잭션으로 처리 (하나라도 실패하면 전부 롤백)

이것이 ORM의 강점 중 하나다. 관계 데이터를 한 번의 호출로 생성한다.