kakasoo

[TypeORM] 2. ManyToMany, 다대다 관계의 표현 본문

프로그래밍/TypeORM

[TypeORM] 2. ManyToMany, 다대다 관계의 표현

카카수(kakasoo) 2023. 1. 4. 23:48
반응형

다대 다 관계를 표현하기 위해서는 연결 테이블이 필요하다.

spreadsheet나 excel 등 바둑판 형태의 테이블들을 이용해서 관계도를 표현한다고 해보자.

서로 연결된 대상끼리는 한 쪽이 다른 한 쪽의 행 id를 가짐으로써 관계를 표현할 수 있다.

그런데 두 대상이 서로가 서로를 다수 가질 수 있다면 테이블에서는 이걸 어떻게 표현해야 할까?

정답은 두 시트 사이에 새로운 시트를 하나 두고, 그 시트에게 두 시트의 id만을 가지게 하는 것이다.

두 시트를 가지고는 이 관계를 논리적으로 표현할 수가 없기 때문에 가운데에 이 표현을 도울 테이블을 하나 둔다.

이러한 테이블을 연결 테이블 ( Junction Table ) 이라고 부르는데, 이를 TypeORM로는 이렇게 표현한다.

 

import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { UserEntity } from './user.entity';
import { ProductEntity } from './product.entity';

@Entity({ name: 'product_buyer_bridge_entity' })
export class ProductBuyerBridgeEntity {
    @PrimaryColumn()
    productId: number;

    @PrimaryColumn()
    buyerId: number;

    /**
     * below are relations
     */

    @ManyToOne(() => ProductEntity, (product) => product.bridges)
    @JoinColumn({ name: 'productId', referencedColumnName: 'id' })
    product: productEntity;

    @ManyToOne(() => UserEntity, (buyer) => buyer.bridges)
    @JoinColumn({ name: 'buyerId', referencedColumnName: 'id' })
    buyer: UserEntity;
}

 

연결 테이블은 Primary Key를 2개 가진다.

이 두 개 가진 키를 통해서 Compoiste Key, 즉 복합키를 표현한다.

복합키란, 2개 이상의 칼럼을 하나의 고유한 값처럼 사용하여 해당 row를 식별할 수 있음을 의미한다.

당연한 소리지만 Relations는 최소 2개를 가지게 된다.

 

import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { ProductBuyerBridgeEntity } from './product-buyer-birdge.entity';
import { ProductEntity } from './product.entity';

@Entity()
export class UserEntity {
    @PrimaryColumn()
    id: number;

    @Column()
    name: string;

    /**
     * below are relations
     */

    // 아래 설명에 이어지는 C 테이블, 연결 테이블을 의미한다!
    @OneToMany(() => ProductBuyerBridgeEntity, pbb => pbb.user)
    bridges: ProductBuyerBridgeEntity[]; 
}

 

뜬금없이 하나의 시트를 뒀기 때문에 왜 이러한 모양이 되는지는 당황스러울 수 있으니 아래의 논리를 따라가보자.

두 테이블을 각각 A와 B라고 부르고, 가운데 연결 테이블을 C 라고 표현해보자.

  1. A는 1:M으로, 다수의 연결들을 가지게 된다.
  2. B 역시 1:N으로 다수의 연결들을 가지게 된다.
  3. 고로 A에서 C는 1:M, C에서 B는 N:1의 관계이기 때문에 이 연결선에서 C를 지우면 M:N이 남게 된다.

두 개의 시트만 가지고는 표현할 수 없었던 ManyToMany를 한 장의 시트를 추가함으로써 표현이 가능해졌다.

 

ManyToMany Decorator의 사용

import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { ProductBuyerBridgeEntity } from './product-buyer-birdge.entity';
import { ProductEntity } from './product.entity';

@Entity()
export class UserEntity {
    @PrimaryColumn()
    id: number;

    @Column()
    name: string;

    /**
     * below are relations
     */

    @ManyToMany(() => ProductEntity, p => p.users)
    @JoinTable({
        name: 'product_buyer_bridge_entity',
        joinCloumn: { name: 'userId', referencedColumnName: 'id' },
        inverseJoinColumn: { name: 'productId', referencedColumnName: 'id' },
    })
    products: ProductEntity[];

    @OneToMany(() => ProductBuyerBridgeEntity, pbb => pbb.user)
    bridges: ProductBuyerBridgeEntity[];
}

 

TypeORM에서는 이런 관계를 표현하기 위해 ManyToMany decorator를 제공한다.

다른 ORM과 마찬가지로, 원래대로면 2번의 조인을 통해서 반대측 다수를 찾아갈 수 있었던 쿼리를,

마치 한 번의 조인처럼 ( 내부적으로는 두 번을 조인하게 되겠지만 ) 사용할 수 있도록 코드를 줄이는 역할을 한다.

 

await this.dataSource.manager.getRpository(UserEntity).find({
    relations: {
        products: true,
    },
})

 

위 코드는 UserRepository에서 위에 표현된 것과 같이 products를 찾는 쿼리를 제공한다.

 

Junction Table Entity가 필요한 이유

ManyToMany 데코레이터로 name을 명시하고, JoinColumn, inverseJoinColumn을 명시해보자.

그러면 더 이상 가운데 있는 연결 테이블을 구현할 이유가 없다.

TypeORM 기능 중 하나인 synchronize를 사용하면 해당하는 name 값으로 테이블을 생성해주기 때문에,

사실 상 이 테이블의 존재를 무시한 채로도 서버 작업이 가능해진다.

그런데 사실 실무에서는 이 테이블에 대응되는 Entity를 생성하지 않는 것은 거의 불가능에 가까운 듯 하다.

그 이유는,

 

  1. 다른 Column들을 추가로 정의하고 싶지만 Relation에서는 연결 관계만 묘사 가능하다.
    • 엄밀히 말하면 불가능은 아니지만 특정 RDBMS 에서만 동작하기 때문에 불가능으로 취급하겠다.
  2. 다른 Column들을 기준으로 한 추가 로직이 개발되어야 할 때, 예를 들어 연결된 날짜 기준 정렬.
  3. 연결 테이블과 연결된 두 참조 테이블이, 연결 테이블에 대한 cascade 설정이 필요해진다.

 

가장 기본적으로, 연결 테이블이 아닌 다른 모든 테이블이 createdAt, updatedAt, deletedAt 가진다 해보자.

그러면 연결 테이블에는 최소한의 Column으로 createdAt으로, 생성된 날짜를 가지게 된다.

이게 있어야 적어도 정렬을 하고, 추가되거나 연결된 시간으로 관계를 표현할 수 있기 때문이다.

 

3개 이상의 FK가 있는 Junction Table

// info : USerEntity 기준으로 작성된 JoinTable Example

@ManyToMany(() => ProductEntity, p => p.users)
@JoinTable({
    name: 'product_buyer_bridge_entity',
    joinCloumn: { name: 'userId', referencedColumnName: 'id' },
    inverseJoinColumns: [
        { name: 'productId', referencedColumnName: 'id' },
        { name: 'sellerId', referencedColumnName: 'id' },
    ]
})
products: ProductEntity[];

 

joinColumn과 inverseJoinColumn 말고도 각 프로퍼티 명에 s를 붙여 배열 타입의 프로퍼티들이 있다.

이 프로퍼티드들은 JoinColumn, InverseJoinColumn 다수를 받는다.

위와 같이, 한 상품이 여러 판매자들에 의해 팔릴 수 있고, 한 판매자가 여러 상품을 판매할 수 있다고 해보자.

그러면 테이블에는 판매자까지도 묶어 3개의 키를 하나의 키로 Composite Key를 구성하게 된다.

 

import { JoinTableMultipleColumnsOptions } from "../options/JoinTableMultipleColumnsOptions";
import { JoinTableOptions } from "../options/JoinTableOptions";
/**
 * JoinTable decorator is used in many-to-many relationship to specify owner side of relationship.
 * Its also used to set a custom junction table's name, column names and referenced columns.
 */
export declare function JoinTable(): PropertyDecorator;
/**
 * JoinTable decorator is used in many-to-many relationship to specify owner side of relationship.
 * Its also used to set a custom junction table's name, column names and referenced columns.
 */
export declare function JoinTable(options: JoinTableOptions): PropertyDecorator;
/**
 * JoinTable decorator is used in many-to-many relationship to specify owner side of relationship.
 * Its also used to set a custom junction table's name, column names and referenced columns.
 */
export declare function JoinTable(options: JoinTableMultipleColumnsOptions): PropertyDecorator;
import { JoinColumnOptions } from "./JoinColumnOptions";
/**
 * Describes all join table with multiple column options.
 */
export interface JoinTableMultipleColumnsOptions {
    /**
     * Name of the table that will be created to store values of the both tables (join table).
     * By default is auto generated.
     */
    name?: string;
    /**
     * First column of the join table.
     */
    joinColumns?: JoinColumnOptions[];
    /**
     * Second (inverse) column of the join table.
     */
    inverseJoinColumns?: JoinColumnOptions[];
    /**
     * Database where join table will be created.
     * Works only in some databases (like mysql and mssql).
     */
    database?: string;
    /**
     * Schema where join table will be created.
     * Works only in some databases (like postgres and mssql).
     */
    schema?: string;
}

 

위는 TypeORM의 내부 코드로, 여러 개의 FK 표현이 어떻게 가능한지에 대한 설명을 담고 있다.

반응형