Drizzle ORM Relations 심화

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의 철학입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux