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 — create의 data.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의 id를 user_id로 사용해서 todos 테이블에 Todo 2개 삽입 3. 모두 하나의 트랜잭션으로 처리 (하나라도 실패하면 전부 롤백)
이것이 ORM의 강점 중 하나다. 관계 데이터를 한 번의 호출로 생성한다.
'Study > Node.JS' 카테고리의 다른 글
| [Node.js / Express 5] 실전 SQL과 페이지네이션 (0) | 2026.03.25 |
|---|---|
| [Node.js / Express 5] SQL 기초 문법과 정규화 (0) | 2026.03.25 |
| [Node.js / Express 5] Database 설계 기초 (0) | 2026.03.25 |
| [Node.js / Express 5] 서버와 네트워크 기초 (0) | 2026.03.25 |
| [Node.js] Express 5 — 이벤트 루프부터 프로젝트 세팅까지 (0) | 2026.03.12 |
