관계(Relations)

장현욱(Artlogy)·2022년 11월 28일
0

TypeORM

목록 보기
5/5
post-thumbnail

관계


관계를 사용하면 관련 엔티티에서 쉽게 사용 가능하며 다음과 같은 유형의 관계 설정이 가능하다.

  • 1:1 : @OneToOne(type,inverseSide,option)
  • N:1 : @ManyToOne(type,inverseSide,option)
  • 1:N : @OneToMany(type,inverseSide,option)
  • N:N : @ManyToMany(type,inverseSide,option)

Option


  • createForeignKeyConstraints : boolean false면 외래키 제약조건을 없앨수 있다.
  • lazy : boolean true로 설정하면 관계를 프록시 객체로 바인딩한다.
  • eager : boolean true로 설정하면 find*매서드를 사용하거나 QueryBuilder에서 관계가 항상 기본 앤티티와 함께 로드 된다.
  • cascade : boolean | ("insert" | "update" | "remove" | "soft-remove" | "recover")[] true로 설정하면 케스케이드 옵션이 적용된다.
  • onDelete : "RESTRICT"|"CASCADE"|"SET NULL" 참조된 객체가 삭제될 때 외래키의 동작 방식을 정의함. (냅둠, 같이 삭제, NULL로만듬)
  • nullable : boolean 이 관계에서 null이 허용되는지 여부
  • orphanedRowAction : "nullfy"|"delete"|"soft-delete"|"disable" 관계 요소 없이 생성된 열이 있을 때 실제 일어날 일을 정의함 delete는 제거하며, soft-delete는 soft-deleted처리하며, nullify는 관계키를 제거하고 disable은 관계를 계속 유지함

Cascade


케스케이드는 관계를 주도하는 데이터가 변경이 있을때 관계 데이터들도 같이 변화를 겪게 만드는 것이다. (삭제하면 같이 삭제 되는게 가장 일반적인 예)

import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from "typeorm"
import { Question } from "./Question"

@Entity()
export class Category {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    name: string

    @ManyToMany((type) => Question, (question) => question.categories)
    questions: Question[]
}
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    ManyToMany,
    JoinTable,
} from "typeorm"
import { Category } from "./Category"

@Entity()
export class Question {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    title: string

    @Column()
    text: string

    @ManyToMany((type) => Category, (category) => category.questions, {
        cascade: true,
    })
    @JoinTable()
    categories: Category[]
}
const category1 = new Category()
category1.name = "ORMs"

const category2 = new Category()
category2.name = "Programming"

const question = new Question()
question.title = "How to ask questions?"
question.text = "Where can I ask TypeORM-related questions?"
question.categories = [category1, category2]
await dataSource.manager.save(question)

위 코드를 보면 Category1,Category2에선 따로 Question을 save하지 않았지만 cascade를 설정한 덕분에 둘다 잘 저장된다.

Option

케스케이드는 boolean으로 사용여부를 설정하거나 문자열로 세세하게 설정해줄 수 있다.

  • false : 케스케이드 사용안함 (default)
  • true : 케이케이드를 사용하며 모든 옵션 포함
  • "insert" : 데이터 삽입 때 케스케이드
  • "update: : 데이터 변경시 케스케이드
  • "remove" : 데이터 삭제시 케스케이드
  • "soft-remove" : 데이터 삭제시 케스케이드(soft-delete전용)

일대일 관계 (One-To-One)


일대일 관계는 A가 B를 하나만 포함하고 B가 A를 하나만 포함하는 관계를 뜻한다.
User엔티티와 Profile엔티티로 예를 들겠다.
지금부터 작성할 코드에서 두 엔티티의 관계는 다음과 같다.
User는 하나의 프로필을 가지고 Profile 또한 하나의 유저만 소유한다.

profile.entity.ts

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"

@Entity()
export class Profile {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    gender: string

    @Column()
    photo: string
}

user.entity.ts

import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    OneToOne,
    JoinColumn,
} from "typeorm"
import { Profile } from "./Profile"

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

    @Column()
    name: string

    @OneToOne(() => Profile)
    @JoinColumn()
    profile: Profile
}

@OneToOne@JoinColumn은 무조건 같이 있어야한다. 관계가 설정되는 하나의 엔티티에서만 선언해주면 되며 @JoinColumn을 설정한 테이블에는 외래키 컬럼이 포함된다.

Insert

관계와 함께 데이터를 삽입하는 방법은 다음과 같다.

const profile = new Profile()
profile.gender = "male"
profile.photo = "me.jpg"
await dataSource.manager.save(profile)

const user = new User()
user.name = "Joe Smith"
user.profile = profile
await dataSource.manager.save(user)

CaseCade를 활용하면 save호출을 한번만 해도 관계를 저장 할 수 있다.

Select

관계와 함께 데이터를 로드하는 방법은 다음과 같다.

const users = await dataSource.getRepository(User).find({
    relations: {
        profile: true,
    },
})

FindOptions에서 같이 join되어질 관계를 선택해주면 된다.
eager옵션을 사용하면 로드시 모든 관계에 대한 데이터를 자동으로 가져 올 수 있다.

쿼리빌더를 통해 다음처럼 관계를 로드 할 수 있다.

const users = await dataSource
    .getRepository(User)
    .createQueryBuilder("user")
    .leftJoinAndSelect("user.profile", "profile")
    .getMany()

📌 예제는 단방향 상태에 대한 것이다. 양방향 설정을 하면 프로필을 기준으로 조인도 가능하다.

일대다/다대일 관계 (One-To-Many, Many-To-One)


다대일/일대다 관계는 A가 B를 여러개 가지지만 B는 A를 하나만 가지는 관계를 뜻 한다.
보편적으로 양방향 관계로 많이 작성하기 때문에 예제도 양방향으로 작성하겠다.
photo.entity.ts

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm"
import { User } from "./User"

@Entity()
export class Photo {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    url: string

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

user.entity.ts

import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm"
import { Photo } from "./Photo"

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

    @Column()
    name: string

    @OneToMany(() => Photo, (photo) => photo.user)
    photos: Photo[]
}

여기서 OneToMany를 선언 한 곳에 외래키가 생기며 때문에 JoinColumn을 생략해도 된다.

Insert

저장을 할땐 다음과 같은 예를 들수 있다.

const photo1 = new Photo()
photo1.url = "me.jpg"
await dataSource.manager.save(photo1)

const photo2 = new Photo()
photo2.url = "me-and-bears.jpg"
await dataSource.manager.save(photo2)

const user = new User()
user.name = "John"
user.photos = [photo1, photo2]
await dataSource.manager.save(user)

또는 먼저 저장하고 여러개의 관계를 저장해도 된다.

const user = new User()
user.name = "Leo"
await dataSource.manager.save(user)

const photo1 = new Photo()
photo1.url = "me.jpg"
photo1.user = user
await dataSource.manager.save(photo1)

const photo2 = new Photo()
photo2.url = "me-and-bears.jpg"
photo2.user = user
await dataSource.manager.save(photo2)

CaseCade를 활용하면 save호출을 한번만 해도 관계를 저장 할 수 있다.

Select

관계와 함께 데이터를 로드하는 방법은 다음과 같다.

const userRepository = dataSource.getRepository(User)
const users = await userRepository.find({
    relations: {
        photos: true,
    },
})

// 역방향 조인일 경우 (양방향 관계에서만 가능 ^^)

const photoRepository = dataSource.getRepository(Photo)
const photos = await photoRepository.find({
    relations: {
        user: true,
    },
})

쿼리빌더를 사용 할 경우 다음과 같이 로드 할 수 있다.

const users = await dataSource
    .getRepository(User)
    .createQueryBuilder("user")
    .leftJoinAndSelect("user.photos", "photo")
    .getMany()

// 역방향 조인일 경우 (양방향 관계에서만 가능 ^^)

const photos = await dataSource
    .getRepository(Photo)
    .createQueryBuilder("photo")
    .leftJoinAndSelect("photo.user", "user")
    .getMany()

다대다 관계 (Many-To-Many)


다대다 관계는 A와 B가 서로 여러 인스턴스를 포험하는 관계를 말한다.
Question과 Category가 적절한 예가 될 수 있을것이다.
하나의 질문엔 여러개의 카테고리가 존재 할 수 있고, 하나의 카테고리에 여러 질문이 엮여 있을 수 있다.

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"

@Entity()
export class Category {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    name: string
}
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    ManyToMany,
    JoinTable,
} from "typeorm"
import { Category } from "./Category"

@Entity()
export class Question {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    title: string

    @Column()
    text: string

    @ManyToMany(() => Category)
    @JoinTable()
    categories: Category[]
}

@ManyToMany관계에선 꼭 관계 한쪽에 @JoinTable선언이 필요하다.

Insert

CaseCade를 활용하면 save호출을 한번만 해도 관계를 저장 할 수 있다.

const category1 = new Category()
category1.name = "animals"
await dataSource.manager.save(category1)

const category2 = new Category()
category2.name = "zoo"
await dataSource.manager.save(category2)

const question = new Question()
question.title = "dogs"
question.text = "who let the dogs out?"
question.categories = [category1, category2]
await dataSource.manager.save(question)

Select

const questionRepository = dataSource.getRepository(Question)
const questions = await questionRepository.find({
    relations: {
        categories: true,
    },
})

쿼리 빌더를 사용한 예

const questions = await dataSource
    .getRepository(Question)
    .createQueryBuilder("question")
    .leftJoinAndSelect("question.categories", "category")
    .getMany()

JoinColumn


관계설정시 @JoinColumn을 사용하면 관계의 어느쪽이 조인 열을 포함하는지 정의하고 조인된 열의 이름과 참조 열 이름을 커스텀 할 수 있다.

📌 JoinColumn은 기본적으로 Optional로 적용하지만 OneToOne관계에선 필수적이다.

@ManyToOne(type => Category)
@JoinColumn({ name: "cat_name", referencedColumnName: "name" })
category: Category;
  • name : 참조열의 이름을 지정합니다.
  • referencedColumnName : 참조 할 열의 이름을 정의합니다.

위 처럼 정의하면 id대신 name의 이름을 가진 기본키를 참조하여 cat_name참조열이 만들어지고 관계가 만들어진다.

JoinTable


@ManyToMany관계에선 JoinTable을 사용한다. 참조하는 테이블 내부의 열 이름과 참조 열은 @JoinColumn처럼 커스텀 할 수 있다.

@ManyToMany(type => Category)
@JoinTable({
    name: "question_categories", // 참조 테이블은 다음과 같이 변경 가능하다.
    joinColumn: {
        name: "question",
        referencedColumnName: "id"
    },
    inverseJoinColumn: {
        name: "category",
        referencedColumnName: "id"
    }
})
categories: Category[];

N + 1


연관 관계에서 발생되는 이슈로 연관 관계가 설정된 모델을 조회 할 때
모델(1)과 모델과 연관된 데이터(N)만큼 추가 조회 쿼리가 발생되는 문제를 뜻한다.

ORM을 사용하는 프레임워크(JPA, TypeORM등)에서 주로 문제가 되며,
당연히 1:1관계에선 괜찮지만 N:N, 1:N, N:1 관계에서 발생한다.

해결방법

eager, lazy등의 자동 조회 옵션을 사용하지말고 join해주면 된다.
eager, lazy를 사용하는 초보 개발자는 당연히 join을 해서 조회할거라고 생각하지만
내부적으론 N번 select 쿼리를 발생시킨다. (당연히 최적화 측면에서 최악이다.)

QueryBuilder (LeftJoin)

const questions = await dataSource
    .getRepository(Question)
    .createQueryBuilder("question")
    .leftJoinAndSelect("question.categories", "category")
    .getMany()

Manager (Join)

const questionRepository = dataSource.getRepository(Question)
const questions = await questionRepository.find({
    relations: {
        categories: true,
    },
})

나는 개인적으로 쿼리빌더를 사용해서 문제를 해결하는 편이다.

0개의 댓글