🗄️ TypeORM2. Relations (1:1 · 1:N · N:M)03 — ManyToMany (다대다 관계)

03 — ManyToMany (다대다 관계)

질문: PostTag처럼 양쪽이 다 Many인 관계는 어떻게 표현하는가? 그 조인 테이블에 addedAt 같은 추가 컬럼을 넣고 싶어지면? 한 줄 답: @ManyToMany + @JoinTable한 쪽에만 붙이면 TypeORM이 제3의 조인 테이블을 자동 생성한다. 하지만 조인 테이블에 추가 컬럼이 필요해지는 순간 — 그건 더 이상 ManyToMany가 아니라, 중간 엔티티 패턴(1:N + N:1 두 쌍)으로 분해해야 한다.


한 장 그림


Why — 왜 제3의 테이블이 필요한가

시도결과
Post에 tagIds INT[]정규화 깨짐 + FK 무결성 X
Tag에 postIds INT[]같음
둘 다 단일 FK1: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 시 cascadetags: [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는 임시다”

실전에서 @ManyToManyMVP의 출발점이고, 거의 모든 관계가 결국 중간 엔티티로 승격된다. 흔한 트리거:

  • “이 태그를 언제 추가했는지 알고 싶어요” → 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로 가는 가장 빠른 길.