본문 바로가기

Project/TypeORM

너랑 나랑 무슨 관계(relation)야?

제목이 심상치 않습니다. 이런 연락을 받아보신 적 있으신가요? 저는 없습니다. 어쨌든 별건 아니고 객체 간의 관계를 의미하는 것입니다. ORM에서는 여러 가지 관계가 존재하는데요? 그 관계들을 정리해 보도록 하겠습니다.

여기서 설명 드릴 관계는 총 세 가지입니다.

첫 번째, 일대일(@OneToOne())
두 번째, 일대다(@OneToMany(), @ManyToOne())
세 번째, 다대다(@ManyToMany()) 가 되겠습니다.

천천히 작성을 해보도록 하겠습니다. 왜 천천히 작성이냐면, 제가 아는 지식에서 더 늘어나거나 잘못된 지식인 것을 발견하거나 하면 차근차근 추가 또는 삭제해 나갈 것이기 때문입니다. 그래서 천천히 작성해 보겠다고 글을 먼저 작성해 봤습니다. 아래부터는 순서대로 작성해 보겠습니다.

나도 하나, 너도 하나. 무조건 하나.

1 : 1 (일대일, @OneToOne)입니다.

어떠한 상황인지 가정을 해보겠습니다. 

"사용자는 하나의 제품을 가질 수 있고, 제품은 하나의 사용자에 연결되어 있습니다." 위에 작성되어 있는 나도 하나, 너도 하나와 일치하게 되네요? ORM에서는 이런 관계를 @OneToOne() 데코레이터로 표현합니다.

글로서는 이해가 되지 않을 수 있습니다. 저 또한 항상 그러합니다.
그래서 예시를 작성해 보았습니다. User와 Product 객체부터 먼저 살펴보겠습니다.

User Entity

@Entity()
export class User {
   @PrimaryGeneratedColumn()
   id: number;
   
   @Column()
   name: string;
   
   @OneToOne(()=>Product)
   @JoinColumn()
   product: Product;
}

User 엔티티는 @OneToOne( () => Product) 옵션으로 Product 엔티티 테이블과 연관 관계를 맺습니다. @JoinColumn() 데코레이터로 연관 관계의 주인임을 나타냅니다. 이는 외래키(FK)가 있어야 하는 테이블에서 사용합니다.

더보기

@JoinColumn() 데코레이터는 User 테이블에 product_id 칼럼을 생성하며, 이 칼럼은 Product 테이블의 id와 외래키 관계를 형성합니다.

Product Entity

@Entity()
export class Product{
   @PrimaryGeneratedColumn()
   id: number;
}
더보기
더보기

여기서 잠깐만요. 

연관관계의 주인이란 무엇인가요? 연관관계의 주인은 외래키를 관리하는 객체를 뜻합니다.
그렇다면, 왜 연관관계의 주인을 설정해야 할까요?

첫 번째, 데이터베이스와 ORM 간의 일관성 유지 때문입니다.

ORM에서 두 엔티티 간의 관계를 관리할 때, 외래 키는 한 테이블에만 존재합니다. 테이블에서도 마찬가지로 외래 키는 한 테이블에만 존재하기 때문에 똑같은 일관성으로 유지해야 합니다.

두 번째, 중복 데이터 방지 때문입니다.

연관관계의 주인을 지정하지 않으면, 양쪽 엔티티에서 각각 서로의 관계를 설정할 수 있게 됩니다. 이 경우에 어느 쪽이든 외래키를 변경할 수 있게 되어 데이터베이스에 일관적이지 못한 상태가 발생할 수 있습니다. 주인을 지정해 한쪽에서만 외래키를 관리 해야합니다.

세 번째, ORM의 효율적인 쿼리 관리 때문입니다.

연관관계의 주인을 지정하면, ORM이 쿼리를 생성할 때 어느 엔티티를 기준으로 외래 키를 조작해야 하는지 명확하게 알 수 있습니다. 이를 통해 ORM은 불필요한 쿼리를 방지하고, 효율적인 데이터베이스 접근을 할 수 있습니다.

이런 식으로 객체를 작성했을 때, 데이터베이스에서 Table은 어떻게 나오게 될까?
product: Product라는 조인 칼럼을 걸었으니까 product의 PrimaryGeneratedColumn과 연관을 짓게 되는 걸까요?

CREATE TABLE "Product" (
    "id" SERIAL PRIMARY KEY
);

CREATE TABLE "User" (
    "id" SERIAL PRIMARY KEY,
    "name" VARCHAR(255) NOT NULL,
    "product_id" INTEGER,
    FOREIGN KEY ("product_id") REFERENCES "product"("id")
);

예상한 대로 나와주었습니다. User 테이블에서 연관관계의 주인을 지정해 준 곳에서 외래키 product_id를 product 테이블의 id 칼럼을 참조해 만들었습니다.

하지만, 우리는 이전 글에서 객체는 방향이 정해져 있다고 했습니다. 그래서 정해진 방향대로 움직여야 하지요. 하지만 데이터베이스에서는 양방향으로 조회가 가능합니다. 이러한 패러다임 차이를 극복하기 위해서 @OneToOne 데코레이터를 Product에도 붙여주면서 양방향인 객체를 만들어 줍니다.

@Entity()
export class User{
   @PrimaryGeneratedColumn()
   id: number;
   
   @Column()
   name: string;
   
   @OneToOne(()=>Product, (product)=>product.user)
   @JoinColumn()
   product: Product;
}

@Entity()
export class Product{
   @PrimaryGeneratedColumn()
   id: number;
   
   @OneToOne(()=>User, (user)=>user.product)
   user: User;
}

이런 식으로 말이죠. Product 테이블에 @OneToOne() 데코레이터를 추가해 주면서, 이제는 양방향으로 접근이 가능하게 되었습니다. 아래의 코드와 같이 접근이 가능합니다.

//원래는 첫번째 코드밖에 안 됐었는데, 두 번째 코드도 가능합니다.
const user = await userRepository.findOne({ where: { id: 1 }, relations: ['product'] });
const product = await productRepository.findOne({ where: { id: 1 }, relations: ['user'] });
나는 하나 하지만 갖고 싶은 것은 여러 개.

1:N (일대다, OneToMany(), ManyToOne())

@OneToMany()는 2번째 파라미터가 필수입니다. 왜냐하면, 관계를 맺는 엔티티(Many) 쪽을 명시해주어야 하기 때문이죠. 이는 ORM에서 양방향 관계를 설정할 때 상대적인 엔티티를 지정하여 관계를 매핑하는 방식입니다. 따라서 단일로 사용할 수 없고, @ManyToOne과 같이 사용해야 합니다.

User Entity

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @OneToMany(() => Order, (order) => order.user)
    orders: Order[];
}

Order Entity

@Entity()
export class Order {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    total: number;

    @ManyToOne(() => User, (user) => user.orders)
    user: User;
}

뭔가 자연스럽게 내려왔죠? 여기에는 @OneToOne에서 작성한 @JoinColumn 데코레이션이 존재하지 않습니다. 왜일까요? @ManyToOne() 데코레이터가 있는 Order Entity가 실제로 외래키를 관리하고, 데이터 베이스에 user_id라는 외래키 열을 생성합니다. 아까 말씀드린 대로 두 번째 매개변수가 필요한 이유가 여기 있습니다. 그리고 암묵적으로 @JoinColumn()이 적용되어 있는 점도 알게 되었습니다. 위 코드의 결과로 아래와 같은 테이블 생성 쿼리를 생성해 냅니다.

CREATE TABLE "user" (
    "id" SERIAL PRIMARY KEY,
    "name" VARCHAR(255) NOT NULL
);

CREATE TABLE "order" (
    "id" SERIAL PRIMARY KEY,
    "total" INTEGER NOT NULL,
    "user_id" INTEGER
);

user_id가 생성되었네요. 암묵적으로 @ManyToOne에서 @JoinColumn()이 실행되는 모습입니다.

 

나도 여러 개, 너도 여러 개.

N:M (다대다, ManyToMany)

ManyToMany는 두 엔티티 간에 서로 여러 개의 관계를 가질 때 사용됩니다. 예를 들어, 유저가 여러 개의 제품을 가질 수 있고, 제품도 여러 유저와 연결될 수 있는 경우입니다.

이 관계를 정의하기 위해 @ManyToMany 데코레이터와 @JoinTable 데코레이터를 사용합니다. @JoinTable은 중간 테이블을 생성해 두 엔티티 간의 관계를 관리합니다.

더보기
더보기

여기서 잠깐만요.

중간 테이블을 생성하는 이유는 무엇일까요?

ManyToMany 관계에서 직접적인 관계를 설정하면 중간 테이블을 설정하는데요. 이 중간 테이블은 두 엔티티의 식별자를 외래 키로 포함하여 관계를 유지합니다. 왜냐하면 나중에 추가 속성을 쉽게 확장할 수 있기 때문입니다. 예를 들어서 테이블에 추가 속성을 추가할 수 있는 상황이 발생할 수 있습니다. 구매 날짜 또는 수량처럼 말이죠? 이때, 중간 테이블을 위한 별도의 엔티티를 생성하는 것이 더 명확하고 관리하기 쉽기 때문에 중간 테이블을 생성합니다. 

User Entity

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @ManyToMany(() => Product, (product) => product.users)
    @JoinTable()
    products: Product[];
}

Product Entity

@Entity()
export class Product {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @ManyToMany(() => User, (user) => user.products)
    users: User[];
}

Table

CREATE TABLE "user" (
    "id" SERIAL PRIMARY KEY,
    "name" VARCHAR(255) NOT NULL
);

CREATE TABLE "product" (
    "id" SERIAL PRIMARY KEY,
    "name" VARCHAR(255) NOT NULL
);

CREATE TABLE "user_products_product" (
    "userId" INTEGER NOT NULL,
    "productId" INTEGER NOT NULL,
    PRIMARY KEY ("userId", "productId"),
    FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE,
    FOREIGN KEY ("productId") REFERENCES "product"("id") ON DELETE CASCADE
);

실제로 중간 테이블은 user_products_product를 만들어서 외래키를 전부 넣어준 모습입니다.

여기까지 @OneToOne , @OneToMany, @ManyToOne , @ManyToMany에 대해서 작성해 보았습니다. 테이블을 작성할 때 많은 고민이 있어야 합니다. 실제로 프로젝트에 투입되었을 때 칼럼을 추가할 일이 비일비재하고 정해진대로 흘러가는 것은 없었습니다. 그래서 이번 프로젝트 때에서 Column을 추가했다 뺐다를 많이 했었습니다. 이런 일을 겪으면서 성장해 나가는 것이겠죠? 여기까지 관계였습니다. 그래서 우리 관계를 무엇일까?