03 — ManyToMany (다대다 관계)
질문:
Post와Tag처럼 양쪽이 다 Many인 관계는 어떻게 표현하는가? 그 조인 테이블에addedAt같은 추가 컬럼을 넣고 싶어지면? 한 줄 답:@ManyToMany+@JoinTable을 한 쪽에만 붙이면 TypeORM이 제3의 조인 테이블을 자동 생성한다. 하지만 조인 테이블에 추가 컬럼이 필요해지는 순간 — 그건 더 이상 ManyToMany가 아니라, 중간 엔티티 패턴(1:N + N:1 두 쌍)으로 분해해야 한다.
한 장 그림
Why — 왜 제3의 테이블이 필요한가
| 시도 | 결과 |
|---|---|
Post에 tagIds INT[] | 정규화 깨짐 + FK 무결성 X |
Tag에 postIds INT[] | 같음 |
| 둘 다 단일 FK | 1:1로 변질 |
별도 테이블 post_tag(postId, tagId) ★ | 각 조합이 한 row → 정규형 충족 |
결정적 사실: M:N은 반드시 제3의 관계 테이블로 분해된다 — RDB가 이걸 직접 표현하는 방법은 없다. TypeORM의
@JoinTable은 그 제3의 테이블을 자동 생성하는 데코레이터다.
How — 코드의 네 가지 시나리오
1) 단방향 — 가장 간단
// post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn() id: number;
@Column() title: string;
@ManyToMany(() => Tag)
@JoinTable() // ★ 한 쪽만
tags: Tag[];
}
// tag.entity.ts
@Entity()
export class Tag {
@PrimaryGeneratedColumn() id: number;
@Column() name: string;
}생성되는 스키마:
CREATE TABLE post_tags_tag ( -- 자동 생성 (이름은 옵션으로 변경 가능)
postId INT,
tagId INT,
PRIMARY KEY (postId, tagId),
FOREIGN KEY (postId) REFERENCES post(id),
FOREIGN KEY (tagId) REFERENCES tag(id)
);2) 양방향 — 양쪽 탐색
// post.entity.ts
@Entity()
export class Post {
@ManyToMany(() => Tag, (tag) => tag.posts)
@JoinTable() // ★ 한 쪽만
tags: Tag[];
}
// tag.entity.ts
@Entity()
export class Tag {
@ManyToMany(() => Post, (post) => post.tags) // ★ JoinTable 없음
posts: Post[];
}스키마 동일. 차이는 tag.posts 탐색 가능 여부뿐.
3) @JoinTable 커스터마이즈 — 테이블/컬럼명, 추가 옵션
@ManyToMany(() => Tag)
@JoinTable({
name: 'post_tags', // ★ 조인 테이블 이름
joinColumn: { name: 'post_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' },
})
tags: Tag[];레거시 DB·snake_case 규약을 맞출 때 필수.
4) ★ 추가 컬럼이 필요한 순간 — 중간 엔티티 패턴
// ❌ ManyToMany로는 불가능
// 'addedAt'을 어디에 둘 것인가?
// ✅ 중간 엔티티로 분해
@Entity()
export class PostTag {
@PrimaryGeneratedColumn() id: number;
@ManyToOne(() => Post, (post) => post.postTags)
post: Post;
@ManyToOne(() => Tag, (tag) => tag.postTags)
tag: Tag;
@Column() addedAt: Date; // ★ 추가 컬럼
@Column({ nullable: true }) addedBy: string;
@Column({ default: 0 }) order: number;
}
@Entity()
export class Post {
@OneToMany(() => PostTag, (pt) => pt.post)
postTags: PostTag[];
}
@Entity()
export class Tag {
@OneToMany(() => PostTag, (pt) => pt.tag)
postTags: PostTag[];
}핵심: 조인 테이블에 컬럼이 필요해지는 순간, M:N은 끝났다. 그 테이블은 자기 자신이 엔티티가 되어야 한다.
What — ManyToMany vs 중간 엔티티 비교 테이블
| 항목 | @ManyToMany | 중간 엔티티 (@OneToMany + @ManyToOne) |
|---|---|---|
| 조인 테이블 | TypeORM이 자동 생성 | 사용자가 직접 엔티티로 정의 |
| 조인 테이블 컬럼 | postId · tagId 뿐 | postId · tagId + 자유롭게 추가 |
| 조인 테이블 PK | 복합 PK (postId, tagId) | 자동 증가 id (관례) |
| 추가 컬럼 (addedAt 등) | 불가 | 가능 |
| 조회 시점에 조인 데이터 필요? | 못 얻음 | relations: { postTags: { tag: true } } |
| save 시 cascade | tags: [tag1, tag2] 한 줄로 OK | 중간 엔티티 직접 save |
| 마이그레이션 시 컬럼 추가 | 어려움 (관계 자체 변경) | 자연스럽게 가능 |
결정 규칙: 조인 테이블이 자기 ID/타임스탬프/추가 메타를 가질 가능성이 1%라도 있으면 처음부터 중간 엔티티로 시작하라. 나중에 마이그레이션은 비싸다.
What-if — 자주 만나는 함정
1) 양쪽에 @JoinTable 붙임
// Post
@ManyToMany(() => Tag)
@JoinTable() // ★
tags: Tag[];
// Tag
@ManyToMany(() => Post)
@JoinTable() // ★ ← 두 번째!
posts: Post[];결과: TypeORM이 조인 테이블 두 개를 만들려고 시도 → 마이그레이션 에러. @JoinTable은 반드시 한 쪽.
2) 양방향인데 inverseSide 콜백을 빠뜨림
// Post
@ManyToMany(() => Tag) // ★ 콜백 없음
@JoinTable()
tags: Tag[];
// Tag
@ManyToMany(() => Post) // ★ 콜백 없음
posts: Post[];결과: 별개 관계 두 개로 인식 → 조인 테이블 두 개. 양방향이면 콜백 필수.
3) save로 조인 테이블 갱신 — 의도와 다른 동작
const post = await postRepo.findOne({
where: { id: 1 },
relations: { tags: true },
});
post.tags = [newTag]; // ★ 기존 태그 *전체 교체*
await postRepo.save(post);결과: posts_tags에서 이 post의 모든 row 삭제 + 새 row 1개 추가. 추가가 아니라 교체다. 추가만 하려면:
post.tags.push(newTag); // 기존 유지 + 추가
await postRepo.save(post);주의:
tags를 완전한 배열로 덮어쓰면 교체, push로 추가하면 추가. 직관과 어긋나는 동작이라 매뉴얼 확인이 필요.
4) 큰 M:N에서 eager: true
@ManyToMany(() => Tag, { eager: true }) // ★ 모든 find가 join
tags: Tag[];결과: postRepo.find() → 모든 post + 그 모든 tag를 항상 join. post가 만 개면 조인 테이블 + tag 테이블도 만 번 결과 — N+1보다 더 무거운 카르테시안 폭발. 다음 챕터 04 — Eager vs Lazy에서 자세히.
5) 중간 엔티티의 PK를 복합 PK로 만듦
@Entity()
export class PostTag {
@PrimaryColumn() postId: number; // ★
@PrimaryColumn() tagId: number; // ★
@Column() addedAt: Date;
}결과: 같은 post-tag 조합이 두 번 들어갈 수 없음은 좋지만, 시간 순서로 같은 조합이 여러 번 일어나야 한다면 (예: tag를 뗐다 붙였다) 불가능. 그래서 자동 증가 id + (postId, tagId) UNIQUE 인덱스가 더 유연.
Insight — 제3의 테이블은 제3의 엔티티가 될 운명
”ManyToMany는 임시다”
실전에서 @ManyToMany는 MVP의 출발점이고, 거의 모든 관계가 결국 중간 엔티티로 승격된다. 흔한 트리거:
- “이 태그를 언제 추가했는지 알고 싶어요” →
addedAt - “누가 추가했는지” →
addedBy - “태그가 주요 태그/부 태그냐” →
isPrimary - “태그를 순서대로 표시” →
order - “이 태그를 임시로 숨기기” →
hidden
처음부터 중간 엔티티로 시작하는 게 마이그레이션 비용 0. @ManyToMany는 진짜로 부가 정보가 없을 때만 쓴다.
”JPA의 @ManyToMany는 안티패턴?”
자바 진영에서는 @ManyToMany를 처음부터 쓰지 말라는 의견이 강하다 (Vlad Mihalcea의 글). 이유: 조인 테이블의 진화 가능성 + delete 시 모호한 동작.
TypeORM도 같은 함정을 공유한다 — @ManyToMany는 진짜 단순 M:N에만 쓴다고 명문화하면 좋다.
”Prisma는 explicit/implicit 두 모드를 명시적으로 구분한다”
// implicit M:N — TypeORM의 @ManyToMany와 동등
model Post { id Int @id; tags Tag[] }
model Tag { id Int @id; posts Post[] }
// explicit M:N — 중간 엔티티 명시
model PostTag {
postId Int
tagId Int
addedAt DateTime
post Post @relation(fields: [postId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@id([postId, tagId])
}Prisma는 처음부터 두 모드가 다르다는 걸 표면화한다 — TypeORM은 같은 데코레이터가 나중에 깨지는 식. 도메인 설계 시 어느 모드인지를 강제로 선택하는 게 더 안전한 설계.
”조인 테이블의 PK 선택이 동시성을 결정한다”
(postId, tagId) 복합 PK면 완전히 같은 조합의 동시 insert가 DB 레벨에서 차단된다. 자동 id PK면 동시 insert가 둘 다 성공하고 중복 row가 생길 수 있다 — UNIQUE 인덱스로 보호하지 않는 한.
결정 규칙: 추가 컬럼 없음 → 복합 PK, 추가 컬럼 있음 → 자동 id + UNIQUE 인덱스.
요약
@JoinTable은 한 쪽에만: 양쪽 붙이면 조인 테이블 두 개. 양방향이면 콜백 필수: 안 그러면 별개 관계 둘로 분열. 추가 컬럼이 보이면 중간 엔티티로:@ManyToMany는 진짜 단순 M:N에만. eager: true는 카르테시안 폭발: M:N에서는 절대 금지에 가까움. save 시 tags 덮어쓰면 교체, push하면 추가: 직관과 어긋남 — 주의.
다음: 04 — Eager vs Lazy 로딩 — 이 관계들을 언제 로드할 것인가. 잘못 쓰면 N+1로 가는 가장 빠른 길.