Skip to content

TypeORM

ypd01018 edited this page Dec 21, 2020 · 1 revision

TypeORM을 도입하게 된 이유

ORM(Object Relational Mapping)은 객체와 관계형 데이터베이스의 데이터를 자동으로 매핑해주는 것으로, 객체 지향적인 코드로 인해 직관적이고 비즈니스 로직에 집중할 수 있습니다.

Node에서 지원하는 다양한 ORM 모듈 중에서 어떤 ORM을 사용하는 것이 좋을지 고민하다가 저희는 그중 TypeORM을 도입하기로 했습니다.

프로젝트의 기본 언어로 TypeScript를 사용하는데, TypeORM은 TypeScript를 지원하며, 모델 정의를 제대로 하면 타입을 정하는 장점을 최대한으로 얻을 수 있습니다. 또한, 복잡한 모델 간의 관계를 형성할 수 있고, Validation이 간편해서 생산성과 신뢰성이 높아진다는 장점도 있습니다.

Active Record vs Data Mapper

TypeORM을 이용하면 Active Record 패턴과 Data Mapper 패턴으로 개발할 수 있습니다.

| Active Record 패턴

Active Record 접근 방식을 사용한다면 모델 내에 모든 쿼리 메서드를 정의하고, 모델 메서드를 사용하여 객체를 save, remove 및 load 합니다. 간단히 말해서 Active Record 패턴은 모델 내에서 데이터베이스에 엑세스하는 접근 방식입니다.

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

| Data Mapper 패턴

Data Mapper 접근 방식은 repository라는 별도의 클래스에 모든 쿼리 메서드를 정의하고 repository를 사용하여 객체를 save, remove 및 load 합니다. 간단히 말해 Data Mapper는 모델이 아닌 repository 내에서 데이터베이스에 접근하는 방식입니다.

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

@Entity()
export class User {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column()
    isActive: boolean;

}

first name과 last name을 이용해서 사용자를 반환하는 함수를 만들고 싶다면 이러한 기능을 custom 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();
    }
}

| Active Record vs Data Mapper

Active Record 접근 방식은 간단하게 사용할 수 있으며, 규모가 작은 애플리케이션에 적합합니다. 그리고 Data Mapper 접근 방식은 규모가 큰 애플리케이션에 적합하고 유지보수하는 데 효과적입니다.

Slack Clone Project인 black을 구현하는 데 있어서 규모가 큰 프로젝트이기 때문에 모든 쿼리 메서드가 모델 내에 정의된다면 모델의 크기가 너무 커질 수도 있겠다는 생각이 들었습니다. 또한, 쿼리 메서드를 별도의 repository에서 관리한다면 추후 기능을 추가하거나 유지 보수하기 좋다고 판단해서 Data Mapper 패턴을 사용하기로 했습니다.

따라서 Server 디렉터리 구조를 model - repository - service - controller - router 로 정했으며, model 디렉터리에는 TypeORM의 Model을 정의하고, repository 디렉터리에는 service 에서 사용될 쿼리 메서드를 정의하도록 했습니다.

server
└── src
    ├── common
    │   ├── config
    │   ├── constants
    │   ├── error
    │   ├── middleware
    │   └── utils
    ├── controller
    ├── model
    ├── repository
    ├── router
    ├── seeds
    ├── service
    └── socket
        ├── event
        ├── handler
        └── middleware

Relation

TypeORM에서 Relation을 사용해서 관련된 Entity와의 작업을 쉽게 할 수 있습니다.
● one-to-one   ⇒  @OneToOne
● many-to-one  ⇒  @ManyToOne
● one-to-many  ⇒  @OneToMany
● many-to-many ⇒  @ManyToMany

| ex) User와 Message 사이의 관계 정의

User는 여러 Message를 가질 수 있습니다.

/* server\src\model\user.ts */

@Entity({ name: 'user' })
export default class User {
// 생략

  @OneToMany(() => Message, (message) => message.user)
  messages: Message[];

// 생략
}
User Model에서는 Message Model과의 관계를 one-to-many로 정의하고, messages의 타입을 Message Model의 배열로 정했습니다.
/* server\src\model\message.ts */

@Entity({ name: 'message' })
export default class Message {
// 생략

  @ManyToOne(() => User, (user) => user.userId)
  @JoinColumn({ name: 'userId' })
  user: User;

// 생략
}

Message Model에서는 User Model과의 관계를 many-to-one으로 정의하고, user의 타입을 User Model로 정했습니다.

Transaction

Service Logic을 수행하다가 중간에 실패를 했을 경우 Rollback을 하기 위해서는 Service Logic을 하나의 트랜잭션으로 가져가야 한다고 생각했습니다. 따라서 typeorm-transactional-cls-hooked 모듈을 사용해서 트랜잭션을 적용했습니다.

typeorm-transactional-cls-hooked 는 서로 다른 Repository 및 Service Method 간의 트랜잭션을 처리하고 전파하는 TypeORM용 트랜잭션 메서드 데코레이터입니다.

/* server\src\application.ts */

import { initializeTransactionalContext, patchTypeORMRepositoryWithBaseRepository } from 'typeorm-transactional-cls-hooked';

// 생략

export default class Application {

  async initDatabase() {
    initializeTransactionalContext();
    patchTypeORMRepositoryWithBaseRepository();
    await createConnection();
  }

// 생략
`typeorm-transactional-cls-hooked` 모듈을 install하고, application.ts에 트랜잭션 관련 설정을 추가해 트랜잭션을 적용할 수 있도록 했습니다.
/* server\src\service\chatroom-service.ts */

@Transactional()
  async createChannel({ userId, title, description, isPrivate }) {
    const user = await this.userRepository.findOne(userId);
    const chatroom = await this.chatroomRepository.findByTitle(title);

    if (chatroom || !user) {
      throw new BadRequestError();
    }

    const newChatroom = await this.saveChatroom({ title, description, isPrivate, chatType: ChatType.Channel });
    await this.saveUserChatroom({ sectionName: DefaultSectionName.Channels, user, chatroom: newChatroom });
    return newChatroom.chatroomId;
  }

Service Logic에서는 함수에 @Transactional() 데코레이터를 붙여서 트랜잭션 설정을 했습니다.

Validation

사용자가 발생시킬 수 있는 오류를 방지하고, 올바른 접근 방법을 알려주기 위해 사용자가 입력한 데이터에 대해서 검증하는 과정은 중요하다고 생각합니다.

TypeORM에서는 Model을 정의할 때 class-validator 모듈을 활용하여 각 Column이 가져야할 데이터의 형태나 길이, 조건들을 데코레이터 형식으로 추가하면 validate 함수를 사용해서 데이터에 대한 검증을 할 수 있습니다.

/* server\src\model\reaction.ts */

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany, DeleteDateColumn } from 'typeorm';
import MessageReaction from '@model/message-reaction';
import ReplyReaction from '@model/reply-reaction';
import { IsString } from 'class-validator';

@Entity({ name: 'reaction' })
export default class Reaction {
  @PrimaryGeneratedColumn()
  reactionId: number;

  @Column({ length: 30, unique: true })
  @IsString()
  title: string;

  @Column({ length: 100 })
  @IsString()
  emoji: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @OneToMany(() => MessageReaction, (messageReaction) => messageReaction.reaction)
  messageReactions: MessageReaction[];

  @OneToMany(() => ReplyReaction, (replyReaction) => replyReaction.reaction)
  replyReactions: ReplyReaction[];
}

Reaction Model의 경우 title과 emoji 속성이 string 형식이어야 하기 때문에 @IsString() 데코레이터를 붙였습니다.

/* server\src\common\utils\validator.ts */

import { validate } from 'class-validator';
import BadRequestError from '@error/bad-request-error';

const validator = async (reqType: object) => {
  const errors = await validate(reqType);
  if (errors.length > 0) {
    throw new BadRequestError();
  }
};

export default validator;

또한, 공통으로 사용되는 validator 함수를 구현해서 설정한 데이터 조건에 맞는지 확인하고, 일치하지 않는다고 하면 오류를 반환해 Error Handler 쪽에서 해당 오류를 처리하도록 구현했습니다.

느낀 점

TypeORM을 활용하면서 가장 먼저 느꼈던 점은 TypeScript와 궁합이 잘 맞는다는 것입니다. 모델을 정의할 때 타입을 정함으로써 해당 속성이 어떤 타입을 가졌는지 쉽게 확인할 수 있는 등 TypeScript를 활용하는 장점을 최대한 가져갈 수 있었습니다. 또한 데코레이터를 이용해 모델 코드 작성하기 때문에 가독성도 좋다고 생각했습니다.

Validation 하는 부분에서도 데코레이터를 이용해 간단하게 설정할 수 있어 신뢰성이 높아지고 생산성이 좋아졌으며, 프로젝트 중간에 DB 설계가 변경된 부분이 있었는데, 모델 설정을 통해 변경 정보를 DB에 쉽게 반영할 수 있다는 ORM의 장점도 느낄 수 있었습니다.

마치며

프로젝트에 TypeORM을 도입해 DB 설계를 바탕으로 모델을 정의하고 각 모델 사이의 관계를 설정하면서 ORM과 조금 더 친해질 수 있었던 좋은 경험이었습니다.

하지만 쿼리 메서드를 작성하는 데 있어서 수많은 join을 할 필요가 있었고, TypeORM 이용이 익숙하지 않아 쿼리를 한 번이 아니라 여러 번에 나눠서 처리하는 경우도 발생해서 이에 관한 공부가 더 필요하다고 느꼈습니다.

이번에 TypeORM을 처음 사용해봤는데, TypeORM에 더 익숙해지면 다른 ORM도 사용해보면서 장단점을 직접 비교해보면 좋겠다고 생각했습니다.

Clone this wiki locally