본문 바로가기

FrontEnd

[TypeORM] 릴레이션 기본과 조인

반응형

TypeORM - Amazing ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms.

 

TypeORM - Amazing ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server,

 

typeorm.io

TypeORM 공식 문서를 스터디한 게시물이다.

 

애플리케이션에서 DB사용시 가장 중요한 요소는 INDEX다.

정규화된 테이블이 index를 통해 연결되면 조인 시 성능 저하 없이 쿼리를 통해 데이터를 잘 가져올 수 있다.

n+1같은 문제와(1개의 객체 안에 n개의 필드가 있을때, 각 필드의 요청을 처리하기 위해 n번의 추가요청) 인덱스 부재와 연결고리 이상 시 성능 저하 등을 피하기 위해 relation을 잘 알아둘 필요가 있다.

 

# 릴레이션이란?

데이터 모델에는 릴레이션십이라는 것이 있다. DBMS를 이야기하는 것이 아니다.

DBMS의 스키마에서 릴레이션십은 FK로 표현된다.

릴레이션형 DB(RDB)에서 릴레이션은 곧 테이블이다.

TypeORM에서 릴레이션은 해당 테이블 간의 관계 자체를 의한다.

아래와 같은 관계 타입이 있다.

(각각의 자세한 내용은 데이터 모델링을 학습하자. TypeORM같은 데이터 접근 기술보다 데이터 모델링이 훨씬 중요한 기술이다.)

 

 

# Relation 옵션

        • eager: boolean - true면 관계가 있는 엔터티를 find* 사용 시 옵션 없어도 자동으로 가져온다.
          • QueryBuilder는 비활성화됨.
          • 필요할 때마다 querybuilder와 트리 그래프로 가져오자
        • cascade: boolean | ("insert" | "update")[] - true 면 객체 참조 관계를 따라서 자동으로 관계있는 엔터티를 삽입 or 수정한다. boolean 외의 옵션은 아래의 예제에서 확인하자.
          • 실무에서는 ["insert" ,"update" ,"remove" ]
        • onDelete: "RESTRICT"|"CASCADE"|"SET NULL" - FK 참조된 row 삭제 시 참조하는 외래키의 동작 방식을 설정한다.
          • CASCADE : 만약 해당 키가 없어지면 외래키로 참조하는 데이터도 지워버린다.
          • RESTRICT : 걍 냅둔다
        • primary: boolean - 관계의 속성이 pk인지 여부를 나타냄
        • nullable: boolean - 관계의 열이 nullable인지 여부를 나타냄. 기본적으로 nullable.
        • orphanedRowAction: "nullify" | "delete" - 부모와 참조 관계가 제거되면 그대로 냅둘지 / DB에서 삭제할지 여부 : 부모의 리스트에서 slicing하면 DB에서 삭제됨...
          • 실무에서는 "delete"하는게 좋다고 함

# Cascades

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[];

}

// 보통 부모로 여겨지는 쪽(관계주인, 행위주체)에 @JoinTable

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[];

}


// category1 및 category2에 대해 save를 호출하지 않았습니다.
// cascade를 true로 설정했기 때문에 자동으로 삽입됩니다.
// 편한것 같지만 비명시적이고 버그의 주범이 될 수 있습니다.
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 connection.manager.save(question);

 

# Cascade Options

캐스케이드 옵션은 boolean |  ("insert" | "update" | "remove" | "soft-remove" | "recover")[] 타입이 가능하다.

기본값은 false로 캐스케이드가 없음을 의미합니다. cascade: true로 설정하면 전체 캐스케이드가 활성화됩니다.

배열 옵션을 지정할 수도 있습니다.

@Entity(Post)
export class Post {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    title: string;

    @Column()
    text: string;

    // Full cascades on categories.
    @ManyToMany(type => PostCategory, {
        cascade: true
    })
    @JoinTable()
    categories: PostCategory[];

    // 여기의 Cascade insert는 이전에 PostDetails 테이블에 없는 row가 PostDetails[]에 있으면
    // Post save(upsert) 시 자동으로 삽입한다는 뜻입니다.
    @ManyToMany(type => PostDetails, details => details.posts, {
        cascade: ["insert"]
    })
    @JoinTable()
    details: PostDetails[];

    // 여기의 Cascade update는 Post save(upsert) 시 
    // PostImage[]의 변경사항을 PostImage 테이블에 반영한다는 뜻입니다.
    @ManyToMany(type => PostImage, image => image.posts, {
        cascade: ["update"]
    })
    @JoinTable()
    images: PostImage[];

    // 여기의 Cascade update는 Post save(upsert)시 PostInformation 테이블에
    // PostInformation[]의 변경사항을 upsert 한다는 뜻입니다.
    @ManyToMany(type => PostInformation, information => information.posts, {
        cascade: ["insert", "update"]
    })
    @JoinTable()
    informations: PostInformation[];

"remove" | "soft-remove" | "recover"는 반대로 삭제 시 사용되는 옵션이다.

remove는 부모가 삭제되면 자식을 자동으로 삭제한다

soft-remove와 recover는 @DeleteDateColumn 데코레이터와 같이 동작한다. 부모 객체 삭제 시, 해당 데코레이터가 표시된 필드에 삭제 시간이 표시되며 실제로 row는 삭제되지 않는다. recover는 부모의 deletedAt 필드가 null처리 되면 같이 부활하는 옵션이다. (다음 포스팅에 보게됨.)

 

# @JoinColumn 옵션

@JoinColumn을 통해 어떤 관계쪽이 FK를 갖고 있는지 나타내며

조인 컬럼명과 참조되는 컬럼명을 커스터마이징 할 수 있다.

@JoinColumn을 설정하면 데이터베이스에 propertyName + referencedColumnName이라는 열이 자동으로 생성됨.

// name에 FK속성명, referencedColumnName에 참조 엔터티의 참조 속성명
// name없으면 propertyName + referencedColumnName가 디폴트임
// referencedColumnName 없으면 id가 디폴트임
// 둘다 없으면 따라서 FK필드는 FK속성명 + Id가되어 아래의 경우 categoryId가됨
// 두개 있으면 복합키가 됨.
@ManyToOne(type => Category)
@JoinColumn([
    { name: "category_id", referencedColumnName: "id" },
    { name: "locale_id", referencedColumnName: "locale_id" }
])
category: Category;

// 이 결과는 해당 엔터티에 FK2개가 category_id,locale_id로 생김. 참조 필드는 위 참조

 

# @JoinTable

joinTable은 다대다 관계를 나타낼 때 중간에 관계를 나타내는 junction 테이블을 만들 때 사용한다.

(따로 엔터티로 분리해서 선언해도 된다. 이게 더 명시적인듯.)

굳이 안쓰는걸 추천하는 타입.

@JoinColumn을 사용하여 접합 테이블 내부의 열 이름과 참조 열을 변경할 수 있습니다. 생성된 "접합" 테이블의 이름도 변경할 수 있습니다.

composite primary key가 있는 경우 속성 배열을 @JoinTable에 작성한다.

@ManyToMany(type => Category)
@JoinTable({
    name: "question_categories", // table name for the junction table of this relation
    joinColumn: {
        name: "question",
        referencedColumnName: "id"
    },
    inverseJoinColumn: {
        name: "category",
        referencedColumnName: "id"
    }
})
categories: Category[];

 

# Eager relations

Eager relation은 데이터베이스에서 엔터티를 로드할 때 자동으로 로드됩니다.

find* 메서드를 사용할 때만 작동합니다. 
QueryBuilder를 사용하면 eager relation이 비활성화되고 leftJoinAndSelect를 사용하여 relation을 로드해야 합니다.

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, {
        eager: true
    })
    @JoinTable()
    categories: Category[];

}

const questionRepository = connection.getRepository(Question);

// 해당 질문의 카테고리 배열이 자동으로 로드됨.
const questions = await questionRepository.find();

 

사놓고 안본 김영한님 JPA 강의 팁

  • 실무에서는 다 LAZY로 쓰자. 즉시 로딩 사용하지 말자.
    • TypeORM은 find시 relation을 선언하는 기능이 있다.
    • const users = await userRepository.find({ relations: ["profile"] });
  • JPQL fetch join(쿼리빌더)이나, 엔티티 그래프(트리 엔터티가 유사함) 기능으로 해결하자.

출처: https://ict-nroo.tistory.com/132 [개발자의 기록습관]

 

# Lazy relations

자바와 다르게 비동기임.

지연 관계의 엔터티는 액세스하면 로드됩니다.

이러한 관계는 프로미스 타입을 가져야 합니다.

프로미스로 값을 저장하고 프로미스로 값을 리턴한다.

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: Promise<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)
    @JoinTable()
    categories: Promise<Category[]>; // 프로미스로 타입 선언 - 값 저장

} 


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

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

const question = new Question();
// categories를 프로미스로 만들어서 저장해야 한다.
question.categories = Promise.resolve([category1, category2]);
await connection.manager.save(question);

Lazy로 값 가져오기 - 쿼리 한번 더날림.

const question = await connection.getRepository(Question).findOne(1);
const categories = await question.categories; // 쿼리를 날린다.

 

 

# 자기 참조 릴레이션

자신을 자신을 중첩하고, 중첩된 자신은 또 다른 자신을 중첩하는 관계

보통 이런 식으로 표현됨

import {Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany} from "typeorm";

@Entity()
export class Category {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    title: string;

    @Column()
    text: string;

    @ManyToOne(type => Category, category => category.childCategories)
    parentCategory: Category; // Category는 여러개지만 부모는 하나

    @OneToMany(type => Category, category => category.parentCategory)
    childCategories: Category[]; // 내가 부모일때 자식이 여러개

}

 

# 조인 안하고 릴레이션 id 사용하기

FK필드를 조인 없이 사용할 수 없을까?

릴레이션 필드는 조인 안하면 아예 생성되지 않음.

조인하면 해당 필드에는 오브젝트 타입이 담겨 있음.

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

@Entity()
export class User {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;
	// 관계에 의해 생성된 열과같은 이름의 @Column을 사용하여 엔터티에 다른 속성을 추가하면 됨.
    // 해당 필드의 경우 이 엔터티의 id를 이용해 해당 속성을 채움
    @Column({ nullable: true })
    profileId: number;

    @OneToOne(type => Profile)
    @JoinColumn()
    profile: Profile;

}

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

@Entity()
export class Profile {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    gender: string;

    @Column()
    photo: string;

}


// 결과
User {
  id: 1,
  name: "Umed",
  profileId: 1
}

# 한 번에 많은 릴레이션 가져오기

// 레포지토리 메소드
const users = await connection.getRepository(User).find({ relations: ["profile", "photos", "videos"] });

// 쿼리빌더 사용 : 더 유연함
const user = await connection
    .getRepository(User)
    .createQueryBuilder("user")
    .leftJoinAndSelect("user.profile", "profile")
    .leftJoinAndSelect("user.photos", "photo")
    .leftJoinAndSelect("user.videos", "video")
    .getMany();


// 복잡한 쿼리 예시
await getManager()
        .createQueryBuilder(table1, 't1')
        .select('t1.id', 't1_id')
        .addSelect('t2.id_2', 't2_id_2')
        .addSelect('t3.event', 't3_event')
        .addSelect('t4.column1', 't4_column1') // up to this point: SELECT t1.id,t2.id_2,t3.event,t3.column1,t4.column1 FROM table1 t1
        .innerJoin(table2, 't2', 't1.id = t2.id') //INNER JOIN table2 t2 ON t1.id = t2.id
        .innerJoin(table3, 't3', 't2.event = t3.event') // INNER JOIN table3 t3 ON t2.event = t3.event
        .innerJoin(table4, 't4', 't4.id = t2.id_2') // INNER JOIN table4 t4 ON t4.id = t2.id_2 
        .where('t3.event = 2019') // WHERE t3.event = 2019
        .getRawMany() // depend on what you need really

 

# 관계 속성 초기화 하지 말기

때떄로 속성 초기화가 유용함

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)
    @JoinTable()
    categories: Category[] = []; // see = [] initialization here

}

초기화가 있을 때, 조인 없이 Question만 불러온다고 가정하자.

// 초기화가 없다면...
Question {
    id: 1,
    title: "Question about ..."
}

// 초기화가 있으면...
Question {
    id: 1,
    title: "Question about ...",
    categories: []
}

초기화의 문제가 뭘까? 만약 초기화된 객체를 save하면 해당 row의 이전에 set된 카테고리 FK 정보를 전부 지워버리게 된다. (categoryId) (원래 1,2,3이 있는데.... []로 초기화된걸 저장하면... 다날려버림)

즉, 관계 필드를 초기화하지 말자.

생성자에서도 초기화하지 말자.

 

# 외래키 제약 조건을 만들지 말자.

때때로 성능상의 이유로 엔터티 간에 관계를 외래 키 제약 조건 없이 맺는 경우가 있습니다.

createForeignKeyConstraints 옵션(기본값: true)으로 외래 키 제약 조건을 생성해야 하는지 정의할 수 있습니다.

(없으면 자동으로 만들어버림)

import {Entity, PrimaryColumn, Column, ManyToOne} from "typeorm";
import {Person} from "./Person";

@Entity()
export class ActionLog {

    @PrimaryColumn()
    id: number;

    @Column()
    date: Date;

    @Column()
    action: string;

    @ManyToOne(type => Person, {
        createForeignKeyConstraints: false // FK 안만들기
    })
    person: Person;

}

 

반응형