junyeokk
Blog
MikroORM·2025. 11. 27

M:N 피벗 엔티티 패턴

데이터베이스에서 다대다(M:N) 관계는 피벗 테이블(중간 테이블, junction table)을 통해 구현한다. 학생과 강의, 주문과 상품, 사용자와 역할 같은 관계가 대표적이다. ORM에서는 보통 @ManyToMany 데코레이터 하나로 피벗 테이블을 자동 생성해주는데, 이 자동 생성 테이블에는 양쪽 FK 두 개만 들어간다. 그런데 실무에서는 "관계 자체에 속하는 데이터"가 필요한 경우가 훨씬 많다.

주문-상품 관계에서 "수량"이나 "단가"는 주문에도 상품에도 속하지 않는다. 주문과 상품 사이의 관계 자체에 속하는 데이터다. 이런 경우 자동 생성 피벗 테이블로는 해결할 수 없고, 피벗 테이블을 명시적인 엔티티로 정의해야 한다.


자동 생성 피벗 테이블의 한계

MikroORM의 기본 @ManyToMany는 이렇게 작동한다:

typescript
@Entity()
export class Book {
  @PrimaryKey()
  id!: number;

  @ManyToMany(() => BookTag, tag => tag.books, { owner: true })
  tags = new Collection<BookTag>(this);
}

@Entity()
export class BookTag {
  @PrimaryKey()
  id!: number;

  @ManyToMany(() => Book, book => book.tags)
  books = new Collection<Book>(this);
}

이 코드는 book_tags라는 피벗 테이블을 자동으로 만든다. 테이블에는 book_idbook_tag_id 두 컬럼만 존재한다. 만약 여기에 "태그가 붙은 날짜"나 "태그를 붙인 사람" 같은 정보를 저장하고 싶다면? 자동 생성 테이블에는 추가 컬럼을 넣을 방법이 없다.

이 시점에서 선택지는 두 가지다:

  1. M:N 관계를 포기하고 1:N + N:1로 분해한다
  2. 피벗 테이블을 명시적 엔티티로 정의한다 (커스텀 피벗 엔티티)

MikroORM은 v5.1부터 pivotEntity 옵션을 지원하면서 두 번째 방법을 깔끔하게 구현할 수 있게 됐다.


커스텀 피벗 엔티티 정의

주문(Order)과 상품(Product) 관계에 수량(amount)을 저장해야 하는 전형적인 예시를 보자.

피벗 엔티티

typescript
@Entity()
export class OrderItem {
  @ManyToOne({ primary: true })
  order!: Order;

  @ManyToOne({ primary: true })
  product!: Product;

  @Property({ default: 1 })
  amount!: number;

  @Property()
  unitPrice!: number;

  @Property()
  createdAt: Date = new Date();
}

핵심은 두 개의 @ManyToOne 프로퍼티를 primary: true로 설정하는 것이다. 이렇게 하면 복합 PK(composite primary key)가 되어 (order_id, product_id) 조합이 유일하게 된다. 별도의 @PrimaryKey() id는 필요 없다.

추가 컬럼(amount, unitPrice, createdAt)은 일반 @Property()로 정의하면 된다. 이것이 자동 생성 피벗 테이블에서는 불가능했던 부분이다.

중요한 제약: 피벗 엔티티의 @ManyToOne 프로퍼티는 정확히 두 개여야 하고, 첫 번째는 owning 엔티티를, 두 번째는 target 엔티티를 가리켜야 한다. 순서가 바뀌면 JOIN 쿼리가 깨진다.

owning 엔티티

typescript
@Entity()
export class Order {
  @PrimaryKey()
  id!: number;

  @ManyToMany({ entity: () => Product, pivotEntity: () => OrderItem })
  products = new Collection<Product>(this);

  // 피벗 엔티티에 직접 접근하기 위한 1:M 관계
  @OneToMany(() => OrderItem, item => item.order)
  items = new Collection<OrderItem>(this);
}

pivotEntity: () => OrderItem으로 커스텀 피벗 엔티티를 지정한다. 이 설정이 핵심이다.

여기서 productsitems 두 개의 컬렉션을 동시에 정의한 점에 주목하자. products는 M:N 관계로 Product를 바로 접근할 때 사용하고, items는 피벗 엔티티의 추가 필드(amount, unitPrice 등)를 조작할 때 사용한다. 읽기는 products로, 쓰기는 items로 하는 패턴이 실무에서 가장 자주 쓰인다.

target 엔티티

typescript
@Entity()
export class Product {
  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @Property()
  price!: number;

  @ManyToMany({ entity: () => Order, mappedBy: o => o.products })
  orders = new Collection<Order>(this);
}

양방향 관계에서 inverse 쪽에는 mappedBy만 지정하면 된다. pivotEntity는 owning 쪽에서 이미 정의했으므로 여기서는 필요 없다.


데이터 조작

피벗 엔티티로 직접 생성

추가 필드에 값을 지정해야 하므로, 피벗 엔티티를 직접 생성하는 방식이 가장 명확하다:

typescript
const order = await em.findOneOrFail(Order, 1);
const product = await em.findOneOrFail(Product, 42);

const item = em.create(OrderItem, {
  order,
  product,
  amount: 3,
  unitPrice: product.price,
});

await em.flush();

em.create()에 관계 엔티티의 참조와 추가 필드 값을 함께 전달한다. MikroORM은 자동으로 FK를 설정하고 INSERT 쿼리를 생성한다.

M:N 컬렉션으로 추가

Collection.add()를 사용할 수도 있지만, 추가 필드에 DB 레벨 default 값이 설정되어 있어야 한다:

typescript
// amount에 default: 1이 설정되어 있으므로 가능
order.products.add(product);
await em.flush();
// → amount = 1 (default), unitPrice = ?? (default 없으면 에러)

이 방식은 추가 필드가 모두 default를 가지고 있을 때만 사용할 수 있다. default가 없는 필드가 있다면 피벗 엔티티를 직접 생성해야 한다. 사실상 추가 필드가 있는 피벗 엔티티에서는 직접 생성 방식을 권장한다.

수정

피벗 엔티티를 먼저 찾고, 프로퍼티를 수정한 뒤 flush한다:

typescript
const item = await em.findOneOrFail(OrderItem, {
  order: 1,
  product: 42,
});

item.amount = 5;
await em.flush();

복합 PK 조건으로 찾기 때문에 { order: 1, product: 42 }처럼 FK 값을 객체로 전달한다.

삭제

피벗 엔티티를 삭제하면 관계가 끊어진다:

typescript
// 방법 1: em.remove
const item = await em.findOneOrFail(OrderItem, { order: 1, product: 42 });
em.remove(item);
await em.flush();

// 방법 2: nativeDelete (엔티티 로드 없이 직접 DELETE)
await em.nativeDelete(OrderItem, { order: 1, product: 42 });

nativeDelete는 엔티티를 메모리에 로드하지 않고 바로 DELETE 쿼리를 실행하므로 대량 삭제에 유리하다. 단, Unit of Work를 거치지 않기 때문에 이미 로드된 엔티티의 상태와 불일치가 생길 수 있다.


읽기와 쓰기를 분리하는 패턴

앞서 Order 엔티티에서 products(M:N)와 items(1:M) 두 컬렉션을 동시에 정의했다. 이 패턴을 좀 더 자세히 살펴보자.

typescript
@Entity()
export class Order {
  // 읽기용: Product에 바로 접근
  @ManyToMany({ entity: () => Product, pivotEntity: () => OrderItem })
  products = new Collection<Product>(this);

  // 쓰기용: 피벗 엔티티의 추가 필드 접근
  @OneToMany(() => OrderItem, item => item.order)
  items = new Collection<OrderItem>(this);
}

왜 이렇게 하는가?

M:N 컬렉션(products)은 간편하다. order.products를 populate하면 Product 배열을 바로 얻을 수 있고, 필터링이나 정렬도 Product의 속성으로 할 수 있다. 하지만 피벗 엔티티의 추가 필드(amount, unitPrice)에 접근할 수 없다.

1:M 컬렉션(items)은 피벗 엔티티 자체의 배열이므로, 추가 필드를 읽고 쓸 수 있다. 대신 Product 정보를 보려면 item.product을 추가로 populate해야 한다.

실전 사용:

typescript
// 읽기: "이 주문에 어떤 상품이 들어있지?"
const order = await em.findOneOrFail(Order, 1, {
  populate: ['products'],
});
console.log(order.products.getItems()); // [Product, Product, ...]

// 읽기: "각 상품의 수량과 단가는?"
const orderWithItems = await em.findOneOrFail(Order, 1, {
  populate: ['items', 'items.product'],
});
for (const item of orderWithItems.items) {
  console.log(`${item.product.name}: ${item.amount}개 × ${item.unitPrice}원`);
}

// 쓰기: 상품 추가
const newItem = em.create(OrderItem, {
  order,
  product: someProduct,
  amount: 2,
  unitPrice: someProduct.price,
});
await em.flush();

이렇게 하면 단순 조회(products)와 상세 조작(items)을 목적에 맞게 선택할 수 있다.


@ManyToMany vs 1:N + N:1 수동 분해

피벗 엔티티 패턴의 대안으로, M:N 관계를 아예 사용하지 않고 1:N + N:1 두 개의 관계로 분해하는 방법도 있다.

수동 분해 방식

typescript
@Entity()
export class Order {
  // M:N 없이 1:M만 정의
  @OneToMany(() => OrderItem, item => item.order)
  items = new Collection<OrderItem>(this);
}

@Entity()
export class Product {
  @OneToMany(() => OrderItem, item => item.product)
  orderItems = new Collection<OrderItem>(this);
}

@Entity()
export class OrderItem {
  @PrimaryKey()
  id!: number;

  @ManyToOne(() => Order)
  order!: Order;

  @ManyToOne(() => Product)
  product!: Product;

  @Property()
  amount!: number;
}

비교

기준pivotEntity 패턴수동 분해
M:N 직접 접근order.products로 Product 바로 접근 가능항상 중간 엔티티를 거쳐야 함
추가 필드 접근1:M 컬렉션(items)으로 접근1:M 컬렉션으로 접근
QueryBuilder 조인M:N 조인 가능2단계 조인 필요
PK 설계복합 PK (order_id + product_id)보통 별도 PK (id)
코드량관계 정의가 약간 더 많음관계 정의가 적음
의미적 명확성"Order와 Product는 M:N이다"가 명시적관계의 본질이 코드에 드러나지 않음

어떤 걸 선택해야 하는가?

  • 중간 테이블에 추가 필드가 없거나 매우 적으면 → 기본 @ManyToMany (자동 피벗)
  • 중간 테이블에 추가 필드가 있고, Product를 직접 접근하는 경우가 많으면pivotEntity 패턴
  • 중간 엔티티가 독립적인 도메인 개념이면 (예: "수강 신청", "주문 항목") → 수동 분해

세 번째 케이스를 좀 더 설명하자면, OrderItem이 단순한 "관계"가 아니라 자체적인 비즈니스 로직이 있는 경우(할인 계산, 상태 관리 등)에는 완전히 독립된 엔티티로 취급하는 게 맞다. 이때는 @ManyToMany를 쓰지 않고 OrderItem을 중심으로 설계한다.


복합 PK와 별도 PK

피벗 엔티티의 PK 설계에는 두 가지 선택지가 있다.

복합 PK (두 FK를 PK로)

typescript
@Entity()
export class OrderItem {
  @ManyToOne({ primary: true })
  order!: Order;

  @ManyToOne({ primary: true })
  product!: Product;
}
  • 같은 주문에 같은 상품이 두 번 들어갈 수 없다 (유일성 보장)
  • 별도 PK 컬럼이 없으므로 저장 공간 절약
  • 참조할 때 항상 두 값을 알아야 한다

별도 PK (auto-increment 또는 UUID)

typescript
@Entity()
export class OrderItem {
  @PrimaryKey()
  id!: number;

  @ManyToOne(() => Order)
  order!: Order;

  @ManyToOne(() => Product)
  product!: Product;

  // 유일성이 필요하면 유니크 인덱스로 별도 설정
  // @Unique({ properties: ['order', 'product'] })
}
  • 단일 값으로 참조 가능 (API URL에 유리: /order-items/42)
  • 같은 조합이 여러 번 들어갈 수 있다 (필요하면 유니크 인덱스 추가)
  • MikroORM의 pivotEntity 옵션을 사용하려면 복합 PK가 필요하므로, 이 방식은 수동 분해와 함께 사용

복합 PK는 "관계 그 자체"를 표현할 때 자연스럽고, 별도 PK는 피벗 엔티티가 독립적인 도메인 개념일 때 적합하다.


피벗 엔티티 쿼리

피벗 엔티티도 일반 엔티티와 동일하게 쿼리할 수 있다.

조건 필터링

typescript
// 특정 주문의 아이템 중 수량이 3 이상인 것
const items = await em.find(OrderItem, {
  order: 1,
  amount: { $gte: 3 },
}, {
  populate: ['product'],
  orderBy: { amount: 'DESC' },
});

집계

typescript
// 주문별 총 금액 계산
const result = await em.getConnection().execute(`
  SELECT order_id, SUM(amount * unit_price) as total
  FROM order_item
  GROUP BY order_id
`);

피벗 엔티티에 추가 필드가 있으므로, 그 필드를 기준으로 필터링하거나 집계할 수 있다. 자동 생성 피벗 테이블에서는 불가능한 작업이다.

M:N 관계를 통한 필터링

pivotEntity를 설정해두면, QueryBuilder에서 M:N 관계를 통한 필터링도 가능하다:

typescript
// "특정 상품을 포함하는 주문" 검색
const orders = await em.find(Order, {
  products: { name: { $like: '%키보드%' } },
});

MikroORM이 내부적으로 피벗 테이블 JOIN을 생성하므로, M:N 관계의 편리함을 그대로 누릴 수 있다.


실전 예시: 사용자-역할-권한

좀 더 복잡한 실전 예시를 보자. 사용자에게 역할을 부여하되, 역할이 부여된 날짜와 부여한 관리자를 기록해야 하는 경우:

typescript
@Entity()
export class User {
  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @ManyToMany({ entity: () => Role, pivotEntity: () => UserRole })
  roles = new Collection<Role>(this);

  @OneToMany(() => UserRole, ur => ur.user)
  userRoles = new Collection<UserRole>(this);
}

@Entity()
export class Role {
  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @ManyToMany({ entity: () => User, mappedBy: u => u.roles })
  users = new Collection<User>(this);
}

@Entity()
export class UserRole {
  @ManyToOne({ primary: true })
  user!: User;

  @ManyToOne({ primary: true })
  role!: Role;

  @Property()
  assignedAt: Date = new Date();

  @ManyToOne(() => User, { nullable: true })
  assignedBy?: User;

  @Property({ nullable: true })
  expiresAt?: Date;
}

사용:

typescript
// 역할 부여
const userRole = em.create(UserRole, {
  user: targetUser,
  role: adminRole,
  assignedBy: currentAdmin,
  expiresAt: new Date('2026-12-31'),
});
await em.flush();

// "admin 역할을 가진 사용자" 간편 조회
const admins = await em.find(User, {
  roles: { name: 'admin' },
});

// 만료된 역할 정리
const expired = await em.find(UserRole, {
  expiresAt: { $lt: new Date() },
});
for (const ur of expired) {
  em.remove(ur);
}
await em.flush();

user.roles로는 "이 사용자가 어떤 역할을 가지고 있는지" 간편하게 조회하고, user.userRoles로는 "언제, 누가 부여했는지" 상세 정보에 접근한다. 이것이 피벗 엔티티 패턴의 핵심적인 활용 방식이다.


정리

피벗 엔티티 패턴은 "관계에 데이터가 필요할 때" 사용한다. MikroORM의 pivotEntity 옵션을 사용하면 M:N 컬렉션의 편리함(직접 접근, 자동 JOIN)과 추가 필드의 유연함을 동시에 얻을 수 있다.

핵심 규칙:

  1. 피벗 엔티티에는 정확히 두 개의 @ManyToOne({ primary: true })가 필요하다
  2. 첫 번째는 owning 엔티티, 두 번째는 target 엔티티를 가리켜야 한다
  3. Collection.add()로 추가하려면 모든 추가 필드에 DB default가 필요하다
  4. 추가 필드 조작이 필요하면 피벗 엔티티를 직접 생성하라
  5. 읽기는 M:N 컬렉션, 쓰기는 1:M 컬렉션으로 분리하면 편하다

관련 문서