-
Node와 RDB의 ORM 서비스는 sequelize, typeorm, prisma 등 많은 라이브러리들이 있습니다.
-
가장 먼저 사용한 ORM 서비스는 sequelize 였지만 typeorm이 typescript 지원이나 시장의 흐름에 따라 조금 더 많이 사용된다고 생각되어 공부하게 되었습니다.
- prisma도 고려해 보았으나 아직은 시기상조인 것 같았습니다.
typeorm에 대해 알아보기 전에 ORM이 무엇인지 알아보겠습니다.
- ORM(Object-relational mapping)은 객체지향 프로그래밍(Object-Oriented-Programming)과 관계형 데이터베이스(Relational-Database)사이의 호환되지 않는
데이터를 변환하는 시스템입니다.- 객체와 테이블 시스템(RDBMS)을 변형 및 연결해주는 작업입니다.
- ORM을 이용한 개발은 객체와 데이터베이스의 변형에 유연하게 대처할 수 있습니다.
- ORM을 객체 지향 프로그래밍 관점에서 생각해보면, 관계형 데이터베이스에 제약을 최대한 받지 않으면서, 객체를 클래스로 표현하는 것과 같이 관계형 데이터베이스를 객체처럼 쉽게 표현합니다.
- 객체지향 프로그래밍은 Class를 사용하고 관계형 데이터베이스는 Table을 사용합니다.
바로 본론으로 들어가서 typeorm을 프로젝트에서 어떻게 도입할지 알아보겠습니다.
- 의존성 설치 및 init
npm install typeorm reflect-metadata @types/node mysql
typeorm init --name [프로젝트이름] --database [데이터베이스]- global place 의 app.ts에 import 추가합니다.
- reflect-metadata 패키지를 사용하면 유형에 대한 런타임 반영을 수행 할 수 있습니다. TypeORM은 대부분 데코레이터 (@Entity 또는 @Column)와 함께 작동하므로이 패키지는 이러한 데코레이터를 구문 분석하고 SQL 쿼리를 작성하는 데 사용합니다.
import 'reflect-metadata';노드 모듈(typeorm-model-generator) 이용해 모델 자동 생성하는 방법도 있습니다.
- 각 옵션을 수동으로 입력
typeorm-model-generator- 커맨드 한 번에 생성
typeorm-model-generator -h localhost -u $userName -x $password -e mysql -o ./entities --ssltypeorm 작성 패턴에 대해 알아보겠습니다.
- Active Record 패턴과 Data Mapper 패턴이 있습니다.
Active Record 패턴에 대해 먼저 알아보겠습니다.
- Active Record 패턴은
모델 그 자체에 쿼리 메소드를 정의하고,모델의 메소드를 사용하여 객체를 저장, 제거, 불러오는 방식입니다. - BaseEntity라는 클래스를 사용하여 새로운 클래스에 상속하게 한 후 사용할 수 있습니다. 이를 통해 BaseEntity가 갖고 있는 메소드와 static으로 만들어 내는 커스텀 메소드를 이용할 수 있습니다.
- 규모가 작은 애플리케이션에서 적합하고 간단히 사용할 수 있습니다.
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isActive: boolean;
static findByName(firstName: string, lastName: string) {
return this.createQueryBuilder('user')
.where('user.firstName = :firstName', { firstName })
.andWhere('user.lastName = :lastName', { lastName })
.getMany();
}
}// example how to save AR entity
const user = new User();
user.firstName = 'Timber';
user.lastName = 'Saw';
user.isActive = true;
await user.save();
// example how to remove AR entity
await user.remove();
// example how to load AR entities
const users = await User.find({ skip: 2, take: 5 });
const newUsers = await User.find({ isActive: true });
const timber = await User.findOne({ firstName: 'Timber', lastName: 'Saw' });
const timber = await User.findByName('Timber', 'Saw');Data Mapper 패턴에 대해서도 알아보겠습니다.
- Data Mapper 패턴은
분리된 클래스에 쿼리 메소드를 정의하는 방식이며,Repository를 이용하여 객체를 저장, 제거, 불러옵니다. - Active Record 패턴과의 차이점은 모델에 접근하는 방식이 아닌
Repository에서 데이터에 접근한다는 것입니다. - 규모가 큰 애플리케이션에 적합하고 유지보수하는데 효과적이다.
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isActive: boolean;
}import { EntityRepository, Repository } from 'typeorm';
import { User } from '../entity/User';
@EntityRepository()
export class UserRepository extends Repository<User> {
findByName(firstName: string, lastName: string) {
return this.createQueryBuilder('user')
.where('user.firstName = :firstName', { firstName })
.andWhere('user.lastName = :lastName', { lastName })
.getMany();
}
}const userRepository = connection.getRepository(User);
// example how to save DM entity
const user = new User();
user.firstName = 'Timber';
user.lastName = 'Saw';
user.isActive = true;
await userRepository.save(user);
// example how to remove DM entity
await userRepository.remove(user);
// example how to load DM entities
const users = await userRepository.find({ skip: 2, take: 5 });
const newUsers = await userRepository.find({ isActive: true });
const timber = await userRepository.findOne({
firstName: 'Timber',
lastName: 'Saw',
});entity들을 만들었다면 데이터베이스에 대한 연결 생성하는 법을 알아 보겠습니다.
import 'reflect-metadata';
import { createConnection } from 'typeorm';
import { Photo } from './entity/Photo';
createConnection({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'admin',
database: 'test',
entities: [
Photo,
// __dirname + '/entity/*.js'
],
synchronize: true,
logging: false,
})
.then((connection) => {
// here you can start to work with your entities
})
.catch((error) => console.log(error));Entity를 만드는 방법에 대해 조금 더 상세히 알아보겠습니다.
Entity 데코레이터
- 데이터베이스 테이블을 정의하기 전에 실행해야하는 데코레이터입니다.
- 테이블명을 따로 지정하지 않아도 클래스명으로 매핑하지만, 옵션으로 테이블명을 지정할 수 있습니다.
@Entity('users')
export class User {}- name:
테이블 이름입니다. 지정하지 않으면 테이블 이름은 엔티티클래스명으로 생성됩니다. - database: 선택된 DB서버의 데이터베이스 이름입니다.
- schema: 스키마 이름입니다.
- MySQL에서는 schema와 database가 따로 분리되어있지 않습니다. OracleDB에서는 schema를 따로 분리해서 database에 할당된 사용자로 사용합니다.
- engine: 테이블 생성 중에 설정할 수 있는 DB엔진 이름입니다.
- synchronize: false로 설정할 시 스키마 싱크를 건너뜁니다.
- orderBy: QueryBuilder과 find를 실행할 때 엔티티의 기본순서를 지정합니다.
@Entity({
name: 'users',
engine: 'MyISAM',
database: 'example_dev',
schema: 'schema_with_best_tables',
synchronize: false,
orderBy: {
name: 'ASC',
id: 'DESC',
},
})
export class User {}중복되는 Entity를 상속를 사용하여 해결할 수 있습니다.
- Concrete table inheritance와 Single table inheritance가 있습니다.
Concrete table inheritance
- 중복된 칼럼을 베이스가 되는
추상 클래스를 선언한 다음 확장할 수 있습니다. - 참고로 active record 패턴을 사용할 예정이라면,
BaseEntity라는 이름은 피하는게 좋습니다.- typeorm에서 제공하는 클래스인 BaseEntity는 기본 쿼리 메서드 hasId, save, remove 등의 메서드를 담은 클래스입니다.
// 추상 클래스
export abstract class Content {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
description: string;
}
// 1
@Entity()
export class Photo extends Content {
@Column()
size: string;
}
// 2
@Entity()
export class Question extends Content {
@Column()
answersCount: number;
}
// 3
@Entity()
export class Post extends Content {
@Column()
viewCount: number;
}Single table inheritance
- @TableInheritance(), @ChildEntity()를 사용하는 방법입니다.
- 이 방법은 데이터베이스에 Content 테이블이 생성됩니다.
Content 위에 @Entity()를 선언해줘야 아래와 같은 패턴을 사용할 수 있습니다.
@Entity()
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
export class Content {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
description: string;
}
@ChildEntity()
export class Photo extends Content {
@Column()
size: string;
}
@ChildEntity()
export class Question extends Content {
@Column()
answersCount: number;
}
@ChildEntity()
export class Post extends Content {
@Column()
viewCount: number;
}Embedded entities
- 이름이 비슷하고 타입이 같은 칼럼들을 묶는 패턴입니다.
- User.name은 User.nameFirst, User.nameLast로 분기합니다. Name은 데코레이터 @Entity()가 붙어있지 않기때문에 위의 패턴처럼
실제 테이블이 생겨나지는 않습니다.
export class Name {
@Column()
first: string;
@Column()
last: string;
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: string;
@Column((type) => Name)
name: Name;
@Column()
isActive: boolean;
}ViewEntity
SQL view란 ?
- view는 하나의
가상 테이블입니다. - 실제 데이터가 저장되는 것은 아니지만, view를 통해 데이터를 가상 테이블로 관리가 가능합니다.
- 1개의 view로 여러 테이블의 데이터를 조회할 수 있습니다.
- 복잡한 쿼리를 통해 얻을 수 있는 결과를 간단한 쿼리로 얻을 수 있게 도와줍니다.
- 특정 기준에 따른 사용자 별로 다른 데이터를 액세스할 수 있도록 도와줄 수도 있습니다.
- 조회 대상을 줄이고 싶을 때 사용할 수 있습니다.
ViewEntity 데코레이터 인자
- 데코레이터에 들어가는 인자가 아래와 같이 @Entity()와는 약간 다릅니다.
- name: 테이블 이름입니다. 지정하지 않으면 테이블 이름은 엔티티 클래스명으로 생성됩니다.
- database: 선택된 DB서버의 데이터베이스 이름입니다.
- schema: 스키머 이름입니다.
- expression: view를 정의합니다. 꼭 있어야하는 파라미터로 SQL쿼리문이나 queryBuilder 체이닝 메서드가 들어갈 수 있습니다.
- expression은 SQL 쿼리문이나 QueryBuilder에 체이닝할 수 있는 메서드가 들어갈 수 있습니다. 특이점으로는 필드명 위에 들어가는 데코레이터를 id까지 전부 @ViewColumn()을 사용해야 한다는 점이 있습니다. 만약 사용을 고려한다면, JOIN을 쳐서 테이블끼리 연결을 시키냐, 아니면 view를 통해 나중에 자주 사용할 가상 테이블을 미리 만들어두냐의 차이로 생각할 수 있습니다.
@ViewEntity({
expression: `
SELECT "post"."id" AS "id", "post"."name" AS "name", "category"."name" AS "categoryName"
FROM "post" "post"
LEFT JOIN "category" "category" ON "post"."categoryId" = "category"."id"
`,
})
export class PostCategory {
@ViewColumn()
id: number;
@ViewColumn()
name: string;
@ViewColumn()
categoryName: string;
}- 구조가 복잡하고, 여러군데서 호출하는 데이터의 경우 미리 ViewEntity로 view 테이블을 만들어두면 유용합니다.
Column
- entity의 속성을 테이블 칼럼으로 표시합니다.
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ tpye: 'varchar', length: 200, unique: true })
firstName: string;
@Column({ nullable: true })
lastName: string;
@Column({ default: false })
isActive: boolean;
}-
@Column()에 들어갈 수 있는 옵션들 중 중요하다고 판단한 것들은 아래와 같습니다.
- type(ColumnType) :
javascript의 원시타입들을 세분화해서 사용할 수 있습니다. 타입을 정의하는 방법은 다음과 같습니다. - length(string | number) : javascript의 원시타입들을 세분화해서 사용하기 위해 type 옵션과 같이 사용할 수 있습니다.
- onUpdate(string) : cascading을 하기 위한 옵션으로 ON UPDATE 트리거입니다.
- nullable(boolean) : 칼럼을 NULL이나 NOT NULL로 만드는 옵션입니다. 기본값은 false입니다.
- default(string) : 칼럼에 DEFAULT 값을 설정합니다.
- unique(boolean) : 유니크 칼럼이라고 표시할 수 있습니다. 유니크 constraint를 만듭니다. 기본값은 false 입니다.
- enum(string[] | AnyEnum) : 칼럼의 값으로 enum을 사용할 수 있습니다. enum은 db단에서 처리할 수도, orm단에서 처리할 수도 있습니다.
- enumName(string) : 다른 테이블에서 같은 enum을 사용하는 경우 필요합니다.
- transformer({ from(value: DatabaseType): EntityType, to(value: EntityType): DatabaseType }) : 아래와 같은 코드를 만들어내서 json을 문자열로 만들고 파싱하는 역할을 합니다. 또는 boolean을 integer로 바꿔주는 일도 할 수 있습니다.
import { ValueTransformer } from 'typeorm'; class SomeTransformer implements ValueTransformer { to(value: Map<string, number>): string { return JSON.stringify([...value]); } from(value: string): Map<string, number> { return new Map(JSON.parse(value)); } } - type(ColumnType) :
IdColumn
- PrimaryColumn과 PrimaryGeneratedColum이 있습니다.
PrimaryColumn
- @Column()의 옵션인 primary를 대체할 수 있습니다.
PK를 만드는 역할을 합니다.
PrimaryGeneratedColumn
자동생성되는 ID값을 표현하는 방식을 아래와 같이 2가지 옵션을 사용할 수 있도록 도와줍니다.- increment: AUTO_INCREMENT를 사용해서 1씩 증가하는 ID를 부여합니다. 기본 옵션입니다.
- uuid: 유니크한 uuid를 사용할 수 있습니다.
// using increment
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
}
// using uuid
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
}Generated
- PK로 쓰는 ID 외에 추가로 uuid를 기록하기 위해서 사용할 수 있습니다.
@Entity()
export class User {
@Column()
@Generated('uuid')
uuid: string;
}DateColumn
- CreateDateColumn과 UpdateDateColumn, DeleteDateColumn이 있습니다.
CreateDateColumn
- 해당 열이 추가된 시각을 자동으로 기록합니다.
- 옵션을 적지 않을시 datetime 타입으로 기록됩니다.
@Entity()
export class User {
@CreateDateColumn()
createdAt: Date;
}UpdateDateColumn
- 해당 열이 수정된 시각을 자동으로 기록합니다.
- 옵션을 적지 않을시 datetime 타입으로 기록됩니다.
@Entity()
export class User {
@UpdateDateColumn()
updatedAt: Date;
}DeleteDateColumn
- 해당 열이 삭제된 시각을 자동으로 기록합니다.
- 옵션을 적지 않을시 datetime 타입으로 기록됩니다.
- deletedAt에 시각이 기록되지 않은 열들만 쿼리하기 위해 TypeORM의 soft delete 기능을 활용할 수 있습니다.
@Entity()
export class User {
@DeleteDateColumn()
deletedAt: Date;
}soft delete이란 ?
- 데이터 열을 실제로 삭제하지 않고, 삭제여부를 나타내는 칼럼인 deletedAt을 사용하는 방식입니다.
- 일반적인 삭제 대신 삭제된 열을 갱신하는 UPDATE문을 사용하는 방식입니다.
- 시각이 기록되지 않은 열들만 필터해서 쿼리하도록 도와주는 역할을 합니다.
- 다른 테이블과 JOIN시 항상 삭제된 열을 검사해서 성능이 떨어집니다.
- 복구하거나 예전 기록을 확인하고자 할 때 간편합니다.
Relation. 이제 하나의 테이블이 아닌 테이블간의 관계에 대해 알아보겠습니다.
- 테이블간의 관계는 1:1, 1:N, M:N 관계가 있습니다.
OneToOne
- @JoinColumn()을 사용한 필드는 FK(외래키)로 타겟 테이블에 등록됩니다.
@JoinColumn()은 반드시 한쪽 테이블에서만 사용해야 합니다. - 관계는 단방향과 양방향 모두 작성이 가능합니다.
- uni-directional은 @OneToOne()을 한쪽에만 써주는 것
- bi-directional은 양쪽에 모두 써주는 것
@Entity()
export class Profile {
@PrimaryGeneratedColumn()
id: number;
@Column()
gender: string;
@Column()
photo: string;
@OneToOne(() => User, (user) => user.profile)
user: User;
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToOne((type) => Profile, (profile) => profile.user)
@JoinColumn()
profile: Profile;
}
// using find* method
const userRepo = connection.getRepository(User);
const users = await userRepo.find({ relations: ['profile'] });
// using query builder
const users = await connection
.getRepository(User)
.createQueryBuilder('user')
.leftJoinAndSelect('user.profile', 'profile')
.getMany();ManyToOne/OneToMany
- @OneToMany()/@ManyToOne()에서는
@JoinColumn()을 생략할 수 있습니다. @OneToMany()는 @ManyToOne()이 없으면 안됩니다.- 하지만 반대로 @ManyToOne()은 @OneToMany()이 없어도 정의할 수 있습니다.
- @ManyToOne()을 설정한 테이블에는
relation id가 외래키를 가지고 있게 됩니다.
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany((type) => Photo, (photo) => photo.user)
photos: Photo[];
}
@Entity()
export class Photo {
@PrimaryGeneratedColumn()
id: number;
@Column()
url: string;
@ManyToOne((type) => User, (user) => user.photos)
user: User;
}
// using find* method
const userRepository = connection.getRepository(User);
const users = await userRepository.find({ relations: ['photos'] });
// or from inverse side
const photoRepository = connection.getRepository(Photo);
const photos = await photoRepository.find({ relations: ['user'] });
// using query builder
const users = await connection
.getRepository(User)
.createQueryBuilder('user')
.leftJoinAndSelect('user.photos', 'photo')
.getMany();
// or from inverse side
const photos = await connection
.getRepository(Photo)
.createQueryBuilder('photo')
.leftJoinAndSelect('photo.user', 'user')
.getMany();ManyToMany
- @ManyToMany() 관계에서는
@JoinTable()이 반드시 필요합니다. 한쪽 테이블에만 @JoinTable()을 넣어주면 됩니다. - 단, @ManyToMany()에서 옵션 cascade가 true인 경우 soft delete를 할 수 있습니다. 필요에 따라 사용할 수 있습니다.
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
@Entity()
export class Question {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
text: string;
@ManyToMany(() => Category)
@JoinTable()
categories: Category[];
}
// using find* method
const questionRepository = connection.getRepository(Question);
const questions = await questionRepository.find({ relations: ['categories'] });
// using query builder
const questions = await connection
.getRepository(Question)
.createQueryBuilder('question')
.leftJoinAndSelect('question.categories', 'category')
.getMany();Tree entity
- TypeORM은 트리 구조를 저장하기 위해 인접 목록 및 클로저 테이블 패턴을 지원합니다.
먼저 셀프조인에 예시에 대해 알아보겠습니다.
- 1개의 테이블에서 부모-자식 관계를 나타낼 수 있는 패턴
- 상품 카테고리(소,중,대분류)
- 사원(사원,관리자,상위관리자)
- 지역(읍/면/동,구/군,시/도)
TypeORM은 셀프조인을 아래와 같은 4가지 패턴으로 지원합니다.
Adjacency list
- 자기참조를 @ManyToOne(), @OneToMany() 데코레이터로 표현할 수 있습니다.
- 이 방식은 간단한 것이 가장 큰 장점이지만, JOIN하는데 제약이 있어 큰 트리를 로드하는데 문제가 있습니다.
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
description: string;
@ManyToOne((type) => Category, (category) => category.children)
parent: Category;
@OneToMany((type) => Category, (category) => category.parent)
children: Category[];
}Nested set
- @Tree(), @TreeChildren(), @TreeParent()를 사용한 또 다른 패턴입니다.
- 읽기 작업에는 효과적이지만 쓰기 작업에는 효과적이지 않습니다.
- 여러 개의 루트를 가질 수 없다는 점도 문제입니다.
- @Tree()의 인자로
nested-set이 들어갑니다.
@Entity()
@Tree('nested-set')
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@TreeChildren()
children: Category[];
@TreeParent()
parent: Category;
}Materialized path
- 구체화된 경로 혹은 경로 열거라고 부릅니다.
- 간단하고 효율적입니다.
- nested set과 사용방법은 같습니다.
- @Tree()의 인자로
materialized-path이 들어갑니다.
import {
Entity,
Tree,
Column,
PrimaryGeneratedColumn,
TreeChildren,
TreeParent,
TreeLevelColumn,
} from 'typeorm';
@Entity()
@Tree('materialized-path')
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@TreeChildren()
children: Category[];
@TreeParent()
parent: Category;
}Closure table
- 부모와 자식 사이의 관계를 분리된 테이블에 특별한 방법으로 저장합니다.
- 읽기와 쓰기 모두 효율적으로 할 수 있습니다.
- nested set과 사용방법은 같습니다.
- @Tree()의 인자로 closure-table이 들어갑니다.
import {
Entity,
Tree,
Column,
PrimaryGeneratedColumn,
TreeChildren,
TreeParent,
TreeLevelColumn,
} from 'typeorm';
@Entity()
@Tree('closure-table')
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@TreeChildren()
children: Category[];
@TreeParent()
parent: Category;
}- 선택적 매개 변수 옵션을 @Tree ( “closure-table”, options)로 설정하여 클로저 테이블 이름 또는 클로저 테이블 열 이름을 지정할 수 있습니다.
- ancestorColumnName 및 descandantColumnName은 기본 열의 메타 데이터를 수신하고 열의 이름을 반환하는 콜백 함수입니다.
@Tree("closure-table", {
closureTableName: "category_closure",
ancestorColumnName: (column) => "ancestor_" + column.propertyName,
descendantColumnName: (column) => "descendant_" + column.propertyName,
})JoinColumn/JoinTable에 대해 조금 더 알아보겠습니다.
- 아래는 아래 2개의 데코레이터에 공통으로 사용할 수 있는 옵션입니다.
- eager 옵션이 있어서 N+1 문제를 제어할 수 있음
- cascade, onDelete 옵션이 있어 관계가 연결된 객체를 추가/수정/삭제되도록 할 수 있습니다. 버그를 유발할 수 있으니 주의해서 사용해야 합니다.
N+1 문제란?
- 하위 엔티티들을 첫 쿼리 실행시 한번에 가져오지 않고, Lazy Loading으로 필요한 곳에서 사용되어 쿼리가 실행될때 발생하는 문제가 N+1 쿼리 문제입니다.
JoinColumn
- @JoinColumn()을 사용하면 테이블에 자동으로 칼럼명과 참조 칼럼명을 합친 이름의 칼럼을 만들어냅니다.
- 외래키를 가진 칼럼명과 참조칼럼명을 설정할 수 있는 옵션을 가지고 있습니다.
- 설정하지 않으면 테이블명을 가지고 자동으로 매핑합니다.
- 주의할 점으로는 @ManyToOne()에서는 꼭 적지 않아도 칼럼을 자동으로 만들어주지만,
@OneToOne()에서는 반드시 적어줘야 합니다.
@Entity()
export class Post {
@ManyToOne((type) => Category)
@JoinColumn({
name: 'category_id',
referencedColumnName: 'name',
})
category: Category;
}
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}JoinTable
M:N 관계에서 사용하며 연결 테이블을 설정할 수 있습니다.- @JoinTable()의 옵션을 사용해 연결 테이블의 칼럼명과 참조 칼럼명을 설정할 수 있습니다.
@Entity()
export class Question {
@ManyToMany((type) => Category)
@JoinTable({
name: 'question_categories',
joinColumn: {
name: 'question',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'category',
referencedColumnName: 'id',
},
})
categories: Category[];
}
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}RelationId
- 1:N/M:N 관계에서 entity에 명시적으로 관계가 있는 테이블의 칼럼 id를 적고싶은 경우, @RelationId()를 사용하면 됩니다.
- @RelationId()가 꼭 필요하지는 않지만 entity를 보면서 칼럼을 한 눈에 볼 수 있다는 장점이 있습니다.
- @RelationId()로 테이블을 조회하면 새로운 칼럼명도 결과에 같이 들고올 수 있습니다.
- 하지만 relationId 칼럼에 삽입하여 사용할 수 없습니다.
- inset or update 시 payload에 relationId가 아닌 relation으로 사용해야 합니다.
- 관련 이슈 : https://github.com/typeorm/typeorm/issues/3867
- @RelationId를 사용하는 대신 @Column으로 relationId를 장식 할 수 있습니다. JoinColumn의 이름이 숫자열 이름과 같을 때 TypeORM이 둘 다 일치하고 userId를 설정하거나 사용자를 설정하면 TypeORM이 처리합니다.
// using many to one
@Entity()
export class Post {
@ManyToOne((type) => Category)
category: Category;
@RelationId((post: Post) => post.category)
categoryId: number;
}
// using many to many
@Entity()
export class Post {
@ManyToMany((type) => Category)
categories: Category[];
@RelationId((post: Post) => post.categories)
categoryIds: number[];
}Subscriber
- 데이터베이스에 특화된 리스너로 CRUD 이벤트 발생을 리슨합니다.
- 다음과 같은 데코레이터들을 가지고 있습니다.
- @AfterLoad, @AfterInsert, @BeforeInsert, @AfterUpdate, @BeforeUpdate, @AfterRemove, @BeforeRemove
- logging 옵션이 있긴 하지만 쿼리만을 보여주기 때문에 한 줄씩 분석하기 위해 로그를 남기는 경우에는 지양하는 것이 좋습니다.
import {
Connection,
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm/index';
import { User } from './User';
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
constructor(connection: Connection) {
connection.subscribers.push(this);
}
listenTo() {
return User;
}
beforeInsert(event: InsertEvent<User>): Promise<any> | void {
console.log('User 테이블에 입력 전 : ', event.entity);
}
afterInsert(event: InsertEvent<User>): Promise<any> | void {
console.log('User 테이블에 입력 후 : ', event.entity);
}
}Index
- 테이블
쿼리 속도를 올려주는 자료구조입니다. - 테이블 내 1개 혹은 그 이상의 칼럼을 이용해 생성할 수 있습니다.
- 인덱스는 보통 키-필드만 갖고있고, 테이블의 다른 세부항목을 갖지 않기때문에 보통 테이블을 저장하는 공간보다 더
적은 공간을 차지합니다. - 특정 칼럼 값을 가지고 있는 열이나 값을
빠르게 찾기위해 사용합니다.- 인덱싱하지 않은 경우는 첫번째 열부터 전체 테이블을 걸쳐 연관된 열을 검색하기때문에 테이블이 클수록 쿼리비용이 커집니다.
- 인덱싱을 한 경우는 모든 데이터를 조회하지 않고 데이터 파일의 중간에서 검색위치를 빠르게 잡을 수 있습니다.
WHERE절과 일치하는 열을 빨리 찾기 위해서 사용합니다.JOIN을 실행할 때 다른 테이블에서 열을 추출하기 위해서 사용합니다.- 데이터 양이 많고 변경보다
검색이 빈번한 경우인덱싱을 하면 좋습니다.
쉽게 말해 책에서 transaction이란 주제가 어딨는지 목차 없이 찾으려면 눈물날지도 모릅니다. 책의 주요내용을 가나다순으로 정리한 목록이 있으면 찾기 쉬울텐데 인덱스가 바로 그 역할을 합니다. 특정 칼럼에 인덱스를 걸 수 있습니다. 옵션으로 고유키를 부여할 수도 있습니다. 단일 칼럼에 인덱스를 걸고 싶으면 칼럼마다 추가할 수도 있지만, 테이블 전체에 인덱스를 걸고싶은 경우 @Entity()아래 @Index()를 추가할 수도 있습니다.
// using with single column
@Entity()
export class User {
@Index()
@Column()
firstName: string;
@Index({ unique: true })
@Column()
lastName: string;
}
// using with entity
@Entity()
@Index(['firstName', 'lastName'], { unique: true })
export class User {
@Column()
firstName: string;
@Column()
lastName: string;
}Unique
- 특정 칼럼에 고유키 제약조건을 생성할 수 있습니다.
- @Unique()는 테이블 자체에만 적용하는 것이 가능합니다.
@Entity()
@Unique(['firstName', 'lastName'])
export class User {
@Column()
firstName: string;
@Column()
lastName: string;
}Check
- 테이블에서 데이터 추가 쿼리가 날아오면 값을 체크하는 역할을 합니다.
@Entity()
@Check('"age" > 18')
export class User {
@Column()
firstName: string;
@Column()
firstName: string;
@Column()
age: number;
}Transaction
- 데이터베이스 내에서 하나의
그룹으로 처리해야하는 명령문을 모아서 처리하는 작업의 단위를 말합니다.- 여러 단계의 처리를
하나의 처리처럼다루는 기능입니다. - 여러 개의 명령어의 집합이 정상적으로 처리되면 정상종료됩니다.
- 하나의 명령어라도
잘못되면 전체 취소됩니다.
- 여러 단계의 처리를
- 트랜잭션을 쓰는 이유는
데이터의 일관성을 유지하면서안정적으로 데이터를 복구하기 위함입니다. - 격리성 수준 설정을 통해 트랜잭션이 열려있는 동안 외부에서 해당 데이터에 접근하지 못하도록 락을 걸 수 있습니다.
격리성 수준
- READ UNCOMMITTED
- READ COMMITTED
- REPEATABLE READ
- SERIALIZABLE
global connection을 열어서 트랜젝션을 사용하는 경우는 아래와 같이 사용합니다.
await getManager().transaction(
'SERIALIZABLE',
(transactionalEntityManager) => {},
);하지만 global connection은 사이드이펙트가 많은 방법이기때문에 데코레이터나 queryRunner를 사용한 방법을 추천합니다.
- 아래는 데코레이터 @Transaction(), @TransactionManager(), @TransactionRepository()를 사용한 패턴입니다.
// using transaction manager
@Transaction({ isolation: 'SERIALIZABLE' })
save(@TransactionManager() manager: EntityManager, user: User) {
return manager.save(user)
}
// using transaction repository
@Transaction({ isolation: 'SERIALIZABLE' })
save(user: User, @TransactionRepository(User) userRepository: Repository<User>) {
return userRepository.save(user)
}아래는 queryRunner를 사용한 방법입니다. 다만 이 방법에서는 격리성 수준 설정이 불가능합니다.
- startTransaction은 트랜잭션을 시작하는 메서드
- commitTransaction는 모든 변경사항을 커밋하는 메서드
- rollbackTransaction는 모든 변경사항을 되돌리는 메서드
await queryRunner.startTransaction();
try {
await queryRunner.manager.save(user);
await queryRunner.manager.save(photos);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}Eager and Lazy Relations
eager
- relationship을 설정하지 않아도 eager를 설정하면 자동으로 relationship을 불러옵니다.
- find* (즉, find, findAll, findOne…)에서 자동으로 relationship을 불러옵니다.
@ManyToMany(type => Category, category => category.questions, {
eager: true
})lazy
- Promise로 반환하면, 자동으로 lazy relationship이 됩니다.
- lazy를 불러올 때는 Promise.resolve를 하던가, await로 불러오면 됩니다.
@ManyToMany(type => Question, question => question.categories)
questions: Promise<Question[]>;const question = await connection.getRepository(Question).findOne(1);
const categories = await question.categories;- 참고 : 다른 언어 (자바, PHP 등)에서 왔고 모든 곳에서 게으른 관계를 사용하는 데 익숙하다면 조심하세요. 이러한 언어는 비동기식이 아니며 지연로드는 다른 방식으로 이루어집니다. 그렇기 때문에 거기에서 promise를 사용하지 않습니다. 자바 스크립트와 Node.JS에서 지연로드 된 관계를 원하면 promise를 사용해야합니다. 이것은 비표준 기술이며 TypeORM에서 실험적인 것으로 간주됩니다.
typeorm의 bigint
- typeorm bigint는 number로 바꿔주지 않습니다. string입니다.