03. 관계 정의 (Relations)

2026년 4월 30일 수정됨

📋 개요

1:1, 1:N, N:M 등 실무에서 마주칠 수 있는 모든 관계 패턴을 정리한 레퍼런스입니다.

📋 목차

실무에서 마주칠 수 있는 모든 관계 패턴을 정리했습니다. "복사해서 쓰기 좋은" 형태의 레퍼런스입니다.


📐 1. 관계의 두 축: References vs Relations

구분References (.references())Relations (relations())
역할DB 제약조건 (Foreign Key)ORM 조회 편의성 (Navigation)
위치pgTable 안의 컬럼 정의relations 함수 내부
효과무결성 보장 (없는 ID 입력 차단)with: { posts: true } 사용 가능
필수?필수 (DB 설계의 기본)선택 (조회 편하게 하려면 필수)

⭐️ Rule: relations를 정의하려면, 먼저 테이블에 Foreign Key가 있어야 합니다.


🧩 2. 패턴별 구현 (Copy & Paste)

1️⃣ 1:N (일대다) - 가장 기본

상황: 한 유저(User)가 여러 글(Post)을 작성.

// schema/users.ts
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name'),
});
 
// schema/posts.ts
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title'),
  // 1. Foreign Key 설정 (DB)
  authorId: integer('author_id').references(() => users.id),
});
 
// relations.ts (혹은 각 파일 하단)
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts), // 유저는 글을 '여러 개' 가짐
}));
 
export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId], // 내 테이블 컬럼
    references: [users.id],   // 참조 테이블 컬럼
  }),
}));

💡 Q. 왜 한쪽만 fields, references를 길게 쓰나요?

A. 외래키(Foregin Key)를 '실제로 가진 쪽'이 관계의 주인입니다.

  • Post (자식/FK 보유): authorId라는 실제 컬럼이 있으므로, "내 authorId 컬럼이 User 테이블의 id 컬럼을 가리킵니다" 라고 명확히 적어줘야 합니다. (fields, references 필수)
  • User (부모/참조 당함): DB에 실제 컬럼이 없습니다. 그냥 "난 Post들을 여러 개 가지고 있어"(many(posts))라고 선언만 하면, Drizzle이 알아서 Post 쪽의 정의를 보고 연결해줍니다.

2️⃣ 1:1 (일대일) - 프로필, 설정

상황: 유저(User)는 하나의 프로필(Profile)만 가짐.

// schema/profile.ts
export const profiles = pgTable('profiles', {
  id: serial('id').primaryKey(),
  bio: text('bio'),
  // Foreign Key에 unique()를 붙이면 1:1이 됨! ⭐️
  userId: integer('user_id').references(() => users.id).unique(),
});
 
// relations
export const usersRelations = relations(users, ({ one }) => ({
  profile: one(profiles), // 유저는 프로필을 '하나' 가짐
}));
 
export const profilesRelations = relations(profiles, ({ one }) => ({
  user: one(users, {
    fields: [profiles.userId],
    references: [users.id],
  }),
}));

💡 Pro Tip: 어디에 references를 넣어야 할까요?

1:1 관계에서는 "종속되는 쪽(Dependent)" 이 Foreign Key를 가집니다.

  • User vs Profile: 프로필은 유저 없이는 존재할 수 없으므로, Profile 테이블에 user_id 컬럼을 만듭니다.
  • User vs Config: 설정도 유저에 종속되므로, Config 테이블에 user_id를 만듭니다.

Drizzle의 relations 정의 시:

  • FK가 있는 쪽(Profile): fields: [profile.userId], references: [users.id]를 명시합니다.
  • FK가 없는 쪽(User): 그냥 profile: one(profiles)라고만 적어도 Drizzle이 알아서 연결해줍니다.

3️⃣ N:M (다대다) - 연결 테이블 필수 ⭐️

상황: 글(Post)에 태그(Category)가 여러 개, 태그 하나에도 글이 여러 개.

실무에선 명시적 연결 테이블(Explicit Junction Table) 을 만드는 게 좋습니다. 그래야 연결 테이블에 추가 정보(예: assignedAt)를 넣을 수 있거든요.

// 1. Post 테이블
export const posts = pgTable('posts', { id: serial('id').primaryKey() });
 
// 2. Category 테이블
export const categories = pgTable('categories', { id: serial('id').primaryKey() });
 
// 3. 연결 테이블 (Junction Table)
export const postsToCategories = pgTable('posts_to_categories', {
  postId: integer('post_id').references(() => posts.id).notNull(),
  categoryId: integer('category_id').references(() => categories.id).notNull(),
}, (t) => ({
  pk: primaryKey({ columns: [t.postId, t.categoryId] }), // 복합키 추천
}));
 
// 4. Relations 정의
export const postsRelations = relations(posts, ({ many }) => ({
  postsToCategories: many(postsToCategories), // 일단 연결 테이블이랑 1:N
}));
 
export const categoriesRelations = relations(categories, ({ many }) => ({
  postsToCategories: many(postsToCategories),
}));
 
export const postsToCategoriesRelations = relations(postsToCategories, ({ one }) => ({
  post: one(posts, { fields: [postsToCategories.postId], references: [posts.id] }),
  category: one(categories, { fields: [postsToCategories.categoryId], references: [categories.id] }),
}));

💡 Q. N:M인데 왜 연결 테이블에선 one을 쓰나요?

A. 연결 테이블의 '한 행(Row)' 기준이기 때문입니다.

  • posts_to_categories 테이블에 들어가는 데이터 한 줄 은, "이 글(1개)"과 "이 태그(1개)"를 잇는 단일 연결고리 입니다.
  • 연결고리 하나는, 양쪽 끝에 각각 하나의 Post와 하나의 Category를 잡고 있습니다. 그래서 one입니다.
  • 반대로 Post 입장에서는 이런 연결고리가 여러 개 주렁주렁 매달릴 수 있으니 many가 됩니다.

💡 Q. 아하, '몇 개를 쥐고 있냐'가 기준인가요?

A. 맞습니다! "현재 정의하고 있는 테이블의 데이터 1줄"이 주인공입니다.

  • 연결 테이블의 1줄: postId 하나, categoryId 하나를 쥐고 있죠? -> 그래서 양쪽 다 one
  • Post 테이블의 1줄: 내 글에 달린 연결고리(PostsToCategories)는 여러 개일 수 있죠? -> 그래서 many

요약: "나(현재 테이블의 Row)를 기준으로 쟤(대상)가 몇 개야?"라고 물어보세요.

조회할 때:

const result = await db.query.posts.findMany({
  with: {
    postsToCategories: {
      with: {
        category: true, // 한 단계 거쳐서 가져옴
      }
    }
  }
});
// result[0].postsToCategories[0].category.name 으로 접근

4️⃣ Self-Referencing (자기 참조) - 댓글, 팔로우

상황 1: 대댓글 (계층형 구조)
댓글(Comment)이 부모 댓글(parent_id)을 가짐.

export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  content: text('content'),
  parentId: integer('parent_id'), // 자기 자신의 id를 참조할 예정
});
 
export const commentsRelations = relations(comments, ({ one, many }) => ({
  // 부모 댓글 (1개)
  parent: one(comments, {
    fields: [comments.parentId],
    references: [comments.id],
    relationName: 'comment_parent', // ⭐️ 이름 필수 (구분용)
  }),
  // 대댓글들 (여러 개)
  replies: many(comments, {
    relationName: 'comment_parent', // 같은 이름으로 매칭
  }),
}));

상황 2: 유저 팔로우 (User -> User)
N:M 자기 참조입니다. 별도의 팔로우 테이블이 필요합니다.

export const follows = pgTable('follows', {
  followerId: integer('follower_id').references(() => users.id),
  followingId: integer('following_id').references(() => users.id),
}, (t) => ({
  pk: primaryKey({ columns: [t.followerId, t.followingId] }),
}));
 
export const usersRelations = relations(users, ({ many }) => ({
  // 내가 팔로우하는 사람들
  following: many(follows, { relationName: 'user_following' }),
  // 나를 팔로우하는 사람들
  followers: many(follows, { relationName: 'user_followers' }),
}));
 
export const followsRelations = relations(follows, ({ one }) => ({
  follower: one(users, {
    fields: [follows.followerId],
    references: [users.id],
    relationName: 'user_following',
  }),
  following: one(users, {
    fields: [follows.followingId],
    references: [users.id],
    relationName: 'user_followers',
  }),
}));

🚦 3. 자주 겪는 에러 (Troubleshooting)

Q. "Relation name collision"?

같은 테이블 간 관계가 2개 이상이면(예: 작성자 author, 수정자 editor 둘 다 User 참조), Drizzle이 헷갈려 합니다.
👉 relationName 옵션을 줘서 이름을 붙여주세요.

author: one(users, { ..., relationName: 'author' }),
editor: one(users, { ..., relationName: 'editor' }),

Q. 순환 참조 (Circular Dependency)

users.tsposts.ts를 import하고, posts.tsusers.ts를 import하면 에러 뜹니다.
👉 해결책:

  1. 관계 정의(relations)만 따로 모아서 schema/relations.ts 파일을 만듭니다.
  2. 혹은 relations 정의를 각 파일의 맨 아래로 내리고, 함수 내부에서 변수를 참조하게 합니다(Lazy evaluation).

📝 마무리 퀴즈

Q1. Drizzle에서 .references()relations()의 역할 차이로 가장 정확한 것은 무엇인가요?

정답: .references()는 DB 무결성 제약조건이고, relations()는 ORM 조회 편의성과 타입 추론을 위한 선언입니다.

💡 상세 해설: .references()가 없으면 DB는 없는 유저 ID를 가진 게시글을 막지 못합니다. relations()가 있으면 with: { posts: true }처럼 관계를 따라 조회하기 쉬워집니다.

Q2. 1:1 관계에서 프로필 테이블의 userId.unique()를 붙이는 이유는 무엇인가요?

정답: 한 유저에게 프로필이 여러 개 생기는 것을 DB 수준에서 막기 위해서입니다.

💡 상세 해설: 외래키만 있으면 "존재하는 유저를 참조한다"는 것만 보장합니다. 1:1을 만들려면 외래키 컬럼이 동시에 유일해야 합니다.

Q3. 영철이의 테스트 타임: 댓글의 부모 댓글과 대댓글을 모두 comments 테이블로 표현하다가 relation 충돌이 났습니다. 무엇을 추가해야 하나요?

정답: 서로 다른 자기 참조 관계를 구분할 relationName을 명시해야 합니다.

💡 상세 해설: 같은 테이블을 두 방향으로 참조하면 Drizzle은 어떤 관계끼리 짝인지 추론하기 어렵습니다. parentreplies가 같은 관계라는 것을 relationName으로 맞춰줘야 합니다.

🐣 영철이의 퇴근 일기

오늘은 관계 정의가 단순히 one, many를 고르는 문제가 아니라는 걸 배웠다. DB가 지켜야 할 무결성과 코드가 편하게 탐색할 관계는 서로 다른 층이었다.

영수네 커뮤니티 댓글을 생각하니 자기 참조 관계가 바로 현실 문제가 됐다. 부모 댓글, 대댓글, 팔로우처럼 같은 테이블이 여러 역할로 등장하면 이름을 명확히 붙이지 않으면 코드도 사람도 헷갈린다.

💡 "관계 설계는 테이블끼리 선을 긋는 일이 아니라, 한 행이 누구를 소유하고 누구에게 속하는지 운영 규칙을 적는 일이다."

다음에 관계 PR을 보면 FK가 어느 쪽에 있는지, 1:1이면 unique가 있는지, 자기 참조면 relationName이 정확한지 체크해야겠다.

🔗 레퍼런스

다음 장: 04. 마이그레이션 (Migrations)