ORM & TypeORM

ksk7584·2022년 5월 18일
0

Typescript

목록 보기
2/5

ORM

Object Relational Mapping

객체지향 프로그래밍 언어와 데이터베이스 사이의 호환되지 않는 데이터를 변환해주는 시스템.

장점

  • SQL Query 대신 Class Method 로 데이터를 조작할 수 있어서
    객체 모델로 프로그래밍하는데 집중할 수 있다.
    - 선언문, 할당, 종료 같은 부수적인 코드가 없거나 급격히 줄어든다.
    - SQL의 절차적으로 순차적인 접근이 아닌 객체 지향적인 접근이 가능하다.
  • 재사용 및 유지보수의 편리성이 증가한다.
    • Model에서 가공된 데이터를 Controller에 의해 View와 합쳐지는 형태로
      디자인 패턴을 견고하게 만들 수 있다. ( MVC 패턴 )
  • DBMS에 대한 종속성이 줄어든다.

단점

  • ORM 만으로는 서비스를 구현하기 힘들다.
    • 프로젝트의 복잡성이 커질 경우 난이도 또한 올라간다.
    • 잘 못 구현된 경우, 속도 저하 및 일관성 유지가 안되는 문제점이 생긴다.
    • 대형 쿼리는 속도를 위해 별도의 튜닝이 필요하다.
  • Prosedure가 많은 시스템에선 ORM의 장점을 활용하기 어렵다.

TypeORM

Typescript에서 사용하는 ORM

설치

npm install typeorm @types/node mysql reflect-metadata
typeorm init --name <project_name> --database <database>

설정

import 'reflect-metadata';
import { createConnection } from "typeorm";
import { Photo } from "./entity/Photo";

const Connection = async () => {
	const connect = await createConnection({
    type: "mysql",
    host: "localhost",
    port: 3306,
    username: "root",
    password: "admin",
    database: "test",
    entities: [
      Photo,
    ],
    synchronize: true,
    logging: false
  });
  return connect;
}

Active Record

  • Model에 Query Method를 정의하고 Model Method를 사용하여 객체를 저장, 제거, 불러오는 방식
  • 규모가 작은 Application에서 적합하고 간단하게 사용할 수 있다.
/* User Model */
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();
  }
}
/* Main */

// save
const user = new User();
user.firstName = "Timber";
user.lastName = "Saw";
user.isActive = true;
await user.save();

// remove
await user.remove();

// load
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

  • 분리된 Class에 Query Method를 정의
  • Repository를 이용하여 객체를 저장, 제거, 불러온다.
  • Model에 접근하는 것이 아닌, Repository에서 데이터에 접근한다.
/* User Model */
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

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

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column()
  isActive: boolean;
}
/* User Repository */
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();
  }
}
/* Main */
import { User } from "../entity/User";
import { UserRepository } from "../repository/UserRepository";

// getRepository
const userRepository = connection.getRepository( User );

// save
const user = new User();
user.firstName = "Timber";
user.lastName = "Saw";
user.isActive = true;
await userRepository.save( user );

// remove
await userRepository.remove( user );

// load
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"
});
const timber = await userRepository.findByName('Timber', 'Saw');

Entity Decorator

@Entity(options)
export class User {}
  • 데이터베이스 테이블을 정의하기 전에 실행해야하는 데코레이터.
  • 테이블명을 따로 지정하지 않으면 클래스명으로 매핑하지만,
    문자열을 넣어주는 경우, 그 문자열의 내용으로 테이블이 지정된다.
@Entity({
  name: 'users',
  engine: 'MyISAM',
  database: 'example_dev',
  schema: 'schema_with_best_tables',
  synchronize: false,
  orderBy: {
    name: 'ASC',
    id: 'DESC',
  },
})
export class User {}
nameTable Name
database선택된 DB서버의 데이터베이스 이름
schemaSchema Name
engine테이블 생성 중에 설정할 수 있는 DB엔진 이름
synchronizefalse로 설정할 시 스키마 싱크를 건너뜁니다.
orderByQueryBuilder와 find를 실행할 때 엔티티의 기본 순서를 지정합니다.

Entity 상속 1 - Concrete Table Inheritance

// Abstract Class
abstract class Content {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  description: string;
}

@Entity()
export class Photo extends Content {
  @Column()
  size: string;
}

@Entity()
export class Question extends Content {
  @Column()
  answersCount: number;
}

@Entity()
export class Post extends Content {
  @Column()
  viewCount: number;
}

Entity 상속 2 - Single Table Inheritance

  • 데이터베이스에 Content 테이블이 생성된다.
@Entity()
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
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

class Name {
  @Column()
  first: string;

  @Column()
  last: string;
}

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

  @Column((type) => Name)
  name: Name;

  @Column()
  isActive: boolean;
}

ViewEntity

  • 여러 모델을 미리 연결하여 Entity에 불러놓은 것
@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;
};

Column

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

  @Column({ type: 'varchar', length: 200, unique: true })
  firstName: string;

  @Column({ nullable: true })
  lastName: string;

  @Column({ default: false })
  isActive: boolean;
}
type( ColumnType )js 원시타입들을 세분화해서 사용할 수 있다.
length( string | number )최대 길이 설정
onUpdate( string )cascading을 하기 위한 옵션.
ON UPDATE 트리거.
nullable( boolean )false: NULL
true : NOT NULL
default( string )DEFAULT 값
unique( boolean )false: NOT UNIQUE
true : UNIQUE
enum( string[] | AnyEnum )
enumName( string )
transformer({
from(value: DatabaseType): EntityType,
to(value: EntityType): DatabaseType
})

IdColumn

  • PrimaryColumn
    • Primary Key
  • PrimaryGeneratedColumn
    • 자동생성되는 PK ID
    • increment: AUTO_INCREMENT ( 기본 옵션 )
    • uuid: 유니크한 uuid를 사용할 수 있다.
  • Generated
    • PK로 쓰는 ID 외에 추가로 uuid를 기록하기 위해서 사용할 수 있다.

DateColumn

  • CreateDateColumn
    • 해당 열이 추가된 시각을 자동으로 기록한다.
    • 옵션을 적지 않으면 datetime 으로 기록된다.
  • UpdateDateColumn
    • 해당 열이 수정된 시각을 자동으로 기록한다.
    • 옵션을 적지 않으면 datetime 으로 기록된다.
  • DeleteDateColumn
    • 해당 열이 삭제된 시각을 자동으로 기록한다.
    • 옵션을 적지 않으면 datetime 으로 기록된다.
    • deleteAt에 시각이 기록되지 않은 열들만 쿼리하기 위해 softdelete 기능을 활용할 수 있다.

Relation

Table간의 관계

OneToOne

  • JoinColumn()을 사용한 필드는 FK(외래키)로 타겟 테이블에 등록된다.
    • @JoinColumn()은 반드시 한쪽 테이블에서만 사용해야 한다.
  • @OneToOne()을 사용해 관계를 나타낼 수 있다.
@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();

Self Join

1개의 테이블에서 부모-자식 관계를 나타낼 수 있는 패턴

  • 상품 카테고리 ( 소, 중, 대 분류 )
  • 사원 ( 사원, 관리자, 상위관리자 )
  • 지역 ( 읍/면/동, 구/군, 시/도 )

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;
}

Materilaized path

  • 구체화된 경로 혹은 경로 열거.
  • 간단하고 효율적이다.
  • Nested set과 사용 방법은 동일하다.
  • @Tree() 인자로 materialized-path가 들어간다.
@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이 들어간다.
@Entity()
@Tree('closure-table')
export class Category {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @TreeChildren()
  children: Category[];

  @TreeParent()
  parent: Category;
}

JoinColumn & JoinTable

공통

  • Eager 옵션으로 N + 1 문제를 제어할 수 있음.
  • cascade, onDelete 옵션으로 연결된 객체를 추가 / 수정 / 삭제 되도록 할 수 있다.
    • 버그를 유발할 수 있으니 주의해서 사용해야 한다.

N + 1 문제란?

  • 하위 Entity 들을 첫 쿼리 실행시 한 번에 가져오지 않고, Laze Loading 방식으로 가져올때 발생하는 문제.

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 관계에서 사용되며, 연결 테이블을 설정할 수 있다.
  • 옵션을 사용해 연결 테이블의 칼럼명과 참조 칼럼명을 설정할 수 있다.
@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() 를 필수적으로 기술해야하는 것은 아니지만,
    기술하게 되면 Entity를 보면서 칼럼을 한 눈에 볼 수 있는 장점이 있다.
  • 테이블을 조회할때 새로운 칼럼명도 결과에 같이 들고올 수 있다.
// 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

  • DB에 특화된 리스너로, CRUD 이벤트를 Listen한다.
  • 여러 데코레이터들을 가지고 있다.
    • AfterLoad
    • AfterInsert, BeforeInsert
    • AfterUpdate, BeforeUpdate
    • AfterRemove, BeforeRemove
  • logging 옵션이 있긴 하지만,
    쿼리만을 보여주기 때문에 한 줄씩 분석하기 위해 사용하는 것은 지양하는 것이 좋다.
@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개 혹은 그 이상의 칼럼을 이용해 생성할 수 있다.
  • 보통 Key-Field만 갖고 있으며, 다른 세부항목을 갖지 않기 때문에
    보통 테이블을 저장하는 공간보다 더 적은 공간을 차지한다.
  • 특정 칼럽 값을 가지고 있는 열이나 값을 빠르게 찾기 위해 사용된다.
    • 인덱싱하지 않은 경우, 첫 번째 열부터 전체 테이블을 걸쳐 연관된 열을 검색하기 때문에
      테이블이 클수록 쿼리비용이 커진다.
    • 인덱싱을 한 경우, 모든 데이터를 조회하지 않고
      데이터 파일의 중간에서 검색위치를 빠르게 잡을 수 있다.
  • WHERE 절과 일치하는 열을 빨리 찾기 위해서 사용된다.
  • JOIN을 실행할때 다른 테이블에서 열을 추출하기 위해 사용된다.
  • 데이터 양이 많고 변경보다 검색이 빈번한 경우 사용하면 좋다.
// 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

DB 내에서 하나의 그룹으로 처리해야하는 명령문을 모아 처리하는 작업의 단위.

  • Transaction의 의미
    • 여러 단계의 처리를 하나의 처리처럼 다루는 기능이다.
    • 여러 개의 명령어의 집합이 정상적으로 처리되면 정상종료된다.
    • 하나의 명령어라도 잘 못 되었다면 전체 취소 된다.
  • 트랜잭션을 쓰는 이유는 데이터의 일관성을 유지하고, 안정적으로 데이터를 복구하기 위함이다.
  • 격리성 수준 설정을 통해 트랜잭션이 열려있는 동안
    외부에서 해당 데이터에 접근하지 못하도록 락을 걸 수 있다.
  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE
격리성 수준설명
Read Uncommitted트랜잭션에서 처리 중인 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 것을 허용
Read Committed트랜잭션이 커밋되어 확정된 데이터만 다른 트랜잭션이 읽도록 허용
Repeatable Read트랜잭션에서 삭제, 변경에 대해서 Undo 로그에 넣어두고, 앞서 발생한 트랜잭션에 대해서는 실제 데이터가 아닌 Undo 로그에 있는 백업데이터를 읽음.
Serializable Read트랜잭션에서 쿼리를 두 번 이상 수행할 때, 첫 번째 쿼리에 있던 레코드가 사라지거나 값이 바뀌지 않음은 물론 새로운 레코드가 나타나지도 않도록 하는 설정.
/* Decorator */
// 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:    트랜잭션 시작 Method
 *  - commitTransaction:   모든 변경사항을 커밋하는 Method
 *  - rollbackTransaction: 모든 변경사항을 되돌리는 Method
 */
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 Relations

  • releationship을 설정하지 않아도 eager를 설정하면 자동으로 relationship을 불러온다.
    • find, findAll, findOne... 에서 자동으로 relationship을 불러온다.
@ManyToMany(
  type => Category, 
  category => category.questions, 
  { eager: true }
)

Lazy Relations

  • Promise로 반환하면, 자동으로 laze relationship이 된다.
    • 데이터를 불러올때는 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;

URL

https://gmlwjd9405.github.io/2019/02/01/orm.html

https://yuni-q.github.io/backend/typeorm-톺아보기/

https://sabarada.tistory.com/117

profile
백엔드 개발자 지망생

0개의 댓글