Drizzle Relations란?
Drizzle ORM의 Relations API는 테이블 간 관계를 TypeScript 레벨에서 선언하여 타입 안전한 JOIN 쿼리와 중첩 데이터 로딩을 가능하게 합니다. SQL 스키마의 외래 키와는 별개로, Drizzle의 relations() 함수는 쿼리 빌더의 with 절에서 관계 데이터를 자동으로 로딩하는 데 사용됩니다.
Prisma의 include나 TypeORM의 relations 옵션과 유사하지만, Drizzle은 SQL에 가까운 명시적 제어를 유지하면서도 편리한 관계 쿼리를 제공합니다. 이 글에서는 1:1, 1:N, N:M 관계 설정부터 중첩 쿼리, 성능 최적화까지 실전 중심으로 다루겠습니다.
스키마와 Relations 분리 구조
// Drizzle은 스키마(테이블)와 관계(relations)를 분리 선언합니다
// schema/users.ts
import { pgTable, serial, varchar, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { posts } from './posts';
import { profiles } from './profiles';
// 1. 테이블 정의 (SQL 스키마)
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// 2. 관계 정의 (쿼리 빌더용, DB에 영향 없음)
export const usersRelations = relations(users, ({ one, many }) => ({
profile: one(profiles, {
fields: [users.id],
references: [profiles.userId],
}),
posts: many(posts),
}));
1:1 관계 (One-to-One)
// schema/profiles.ts
export const profiles = pgTable('profiles', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().unique()
.references(() => users.id, { onDelete: 'cascade' }),
bio: text('bio'),
avatarUrl: varchar('avatar_url', { length: 500 }),
});
export const profilesRelations = relations(profiles, ({ one }) => ({
user: one(users, {
fields: [profiles.userId],
references: [users.id],
}),
}));
// 쿼리: 유저 + 프로필 함께 로딩
const userWithProfile = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
profile: true, // 1:1 관계 자동 JOIN
},
});
// 결과 타입이 자동 추론됨:
// {
// id: number;
// name: string;
// email: string;
// profile: { id: number; bio: string | null; avatarUrl: string | null } | null
// }
1:N 관계 (One-to-Many)
// schema/posts.ts
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
content: text('content'),
authorId: integer('author_id').notNull()
.references(() => users.id),
categoryId: integer('category_id')
.references(() => categories.id),
status: varchar('status', { length: 20 }).default('draft'),
publishedAt: timestamp('published_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
category: one(categories, {
fields: [posts.categoryId],
references: [categories.id],
}),
comments: many(comments),
tags: many(postTags),
}));
// 쿼리: 유저의 게시글 + 댓글 중첩 로딩
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: {
where: eq(posts.status, 'published'),
orderBy: [desc(posts.publishedAt)],
limit: 10,
with: {
comments: {
limit: 5,
orderBy: [desc(comments.createdAt)],
},
category: true,
},
},
},
});
N:M 관계 (Many-to-Many)
// N:M은 중간 테이블(junction table)을 명시적으로 정의합니다
// schema/tags.ts
export const tags = pgTable('tags', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 50 }).notNull().unique(),
});
// 중간 테이블
export const postTags = pgTable('post_tags', {
postId: integer('post_id').notNull()
.references(() => posts.id, { onDelete: 'cascade' }),
tagId: integer('tag_id').notNull()
.references(() => tags.id, { onDelete: 'cascade' }),
}, (t) => ({
pk: primaryKey({ columns: [t.postId, t.tagId] }),
}));
// 관계 정의 (양방향)
export const tagsRelations = relations(tags, ({ many }) => ({
posts: many(postTags),
}));
export const postTagsRelations = relations(postTags, ({ one }) => ({
post: one(posts, {
fields: [postTags.postId],
references: [posts.id],
}),
tag: one(tags, {
fields: [postTags.tagId],
references: [tags.id],
}),
}));
// 쿼리: 게시글 + 태그 로딩
const postWithTags = await db.query.posts.findFirst({
where: eq(posts.id, 1),
with: {
tags: {
with: {
tag: true, // 중간 테이블 → 태그 데이터
},
},
},
});
// 결과에서 태그 이름 추출
const tagNames = postWithTags?.tags.map(pt => pt.tag.name);
// ['typescript', 'orm', 'database']
Self-Referencing 관계
// 카테고리 트리, 댓글 대댓글 등 자기 참조 관계
export const categories = pgTable('categories', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
parentId: integer('parent_id')
.references((): AnyPgColumn => categories.id),
depth: integer('depth').default(0),
});
export const categoriesRelations = relations(categories, ({ one, many }) => ({
parent: one(categories, {
fields: [categories.parentId],
references: [categories.id],
relationName: 'categoryTree',
}),
children: many(categories, {
relationName: 'categoryTree', // 같은 relationName으로 양방향 연결
}),
posts: many(posts),
}));
// 2단계 트리 로딩
const tree = await db.query.categories.findMany({
where: isNull(categories.parentId), // 루트 카테고리만
with: {
children: {
with: {
children: true, // 2단계 중첩
},
},
},
});
Query API vs Select API
Drizzle은 두 가지 쿼리 방식을 제공합니다. Relations는 Query API에서만 사용 가능합니다.
| 항목 | Query API (db.query) | Select API (db.select) |
|---|---|---|
| Relations 지원 | ✅ with 절 사용 | ❌ 수동 JOIN 필요 |
| SQL 제어 | 추상화됨 | 완전 제어 |
| 집계/GROUP BY | 제한적 | 완전 지원 |
| 서브쿼리 | 미지원 | 완전 지원 |
| 사용 시점 | CRUD + 관계 로딩 | 복잡한 분석 쿼리 |
// Query API: 관계 로딩에 최적
const result = await db.query.posts.findMany({
with: { author: true, tags: { with: { tag: true } } },
});
// Select API: 복잡한 JOIN + 집계
const stats = await db.select({
authorName: users.name,
postCount: count(posts.id),
avgComments: avg(comments.id),
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.leftJoin(comments, eq(posts.id, comments.postId))
.groupBy(users.id)
.orderBy(desc(count(posts.id)));
성능 최적화 팁
// 1. columns로 필요한 필드만 선택 (SELECT * 방지)
const users = await db.query.users.findMany({
columns: {
id: true,
name: true,
// email, createdAt 등은 제외
},
with: {
posts: {
columns: { id: true, title: true },
limit: 5,
},
},
});
// 2. 깊은 중첩 대신 별도 쿼리로 분리
// ❌ 3단계 이상 중첩 → 쿼리 복잡도 급증
const deep = await db.query.users.findMany({
with: { posts: { with: { comments: { with: { author: true } } } } },
});
// ✅ 필요한 데이터만 단계별 로딩
const userPosts = await db.query.posts.findMany({
where: eq(posts.authorId, userId),
columns: { id: true, title: true },
});
const postComments = await db.query.comments.findMany({
where: inArray(comments.postId, userPosts.map(p => p.id)),
with: { author: { columns: { id: true, name: true } } },
});
// 3. 인덱스 필수: 외래 키 컬럼에 반드시 인덱스
export const posts = pgTable('posts', {
// ...
authorId: integer('author_id').notNull(),
}, (t) => ({
authorIdx: index('posts_author_idx').on(t.authorId),
categoryIdx: index('posts_category_idx').on(t.categoryId),
}));
마무리
Drizzle ORM의 Relations API는 마이그레이션이나 타입 안전 쿼리와 함께 Drizzle의 핵심 기능입니다. SQL 스키마와 관계 정의를 분리하는 명시적 설계 덕분에, ORM 마법에 의존하지 않으면서도 편리한 관계 쿼리를 작성할 수 있습니다. N:M은 중간 테이블을 직접 정의해야 하는 번거로움이 있지만, 그만큼 SQL에 대한 완전한 제어를 유지할 수 있다는 점이 Drizzle의 철학입니다.