티스토리 뷰

728x90
SMALL

개요

보통 서버에서 db를 연결해서 쿼리문을 직접적으로 사용하지 않고 orm을 사용한다. 사실 나는 지금도 쿼리문이 더 편한것같다. 하지만 orm을 많이 사용하는 추세다. 일단 orm을 사용하지 않으면 쿼리문이 굉장히 지저분하게 보인다. 효율적이고 빠른 쿼리문을 짜는 것도 중요하지만 가독성도 그만큼 중요하다. 그래서 한방쿼리보다 그냥 여러번 쿼리를 만드는 방법을 사용하기도 한다. 어쨋든... sequelize는 책을보고 사용해봤고 우아한테크캠프에서도 사용을 했다. 나름대로 사용법을 익혔다고 생각하고 다른 orm을 사용해보고 싶었다. 우아한테크캠프의 마지막 프로젝트를 보니 우리팀을 제외한 모든팀이 typeorm을 사용했다. 그리고 개발바닥의 향로님도 typeorm을 사용하는것을보고 한번 공부해봐야겠다는 생각이 들었다.

npm 버전

  "dependencies": {
    "dotenv": "^10.0.0",
    "express": "^4.17.1",
    "mysql2": "^2.3.0",
    "reflect-metadata": "^0.1.13",
    "typeorm": "^0.2.37",
    "typeorm-naming-strategies": "^2.0.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.13",
    "@types/node": "^16.9.1",
    "ts-node": "^10.2.1",
    "tsc-watch": "^4.5.0",
    "tsconfig-paths": "^3.11.0",
    "typescript": "^4.4.3"
  }

연결하기

먼저 기본설정을 해줘야 한다.

db-loader.ts

import 'reflect-metadata';
import { ConnectionOptions, createConnection } from 'typeorm';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';

const dbLoader = async (): Promise<void> => {
  try {
    const connectionOption: ConnectionOptions = {
      type: 'mysql',
      host: process.env.DB_HOST,
      port: Number(process.env.DB_PORT) || 3306,
      username: process.env.DB_USERNAME,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_DATABASE,
      synchronize: true,
      logging: false,
      entities: ['src/entity/**/*.ts'],
      migrations: ['src/migration/**/*.ts'],
      subscribers: ['src/subscriber/**/*.ts'],
      cli: {
        entitiesDir: 'src/entity',
        migrationsDir: 'src/migration',
        subscribersDir: 'src/subscriber',
      },
      namingStrategy: new SnakeNamingStrategy(),
    };
    await createConnection(connectionOption);
  } catch (err) {
    console.log('db connection error \n', err);
  }
};

export default dbLoader;

migration과 subscribers는 아직 적용하지 않았다. 추후에 기회가 된다면 업데이트하겠다.
synchronize가 true면 db테이블을 만들어준다. logging이 true면 쿼리문을 로그로 보여준다.
db 컬럼명은 보통 snake case를 사용하는데 js에서는 camel case를 사용한다. 진짜 귀찮은 작업이다. 그래서 저번에 프로젝트 할 때는 팀원과 협의하에 그냥 카멜케이스를 사용했다. 그런데 이걸 자동으로 해주는 라이브러리가 있다. 기본 typeorm에는 존재하지 않고 typeorm-naming-strategies를 설치하고 위와같이 적용해주면 된다.

entity

이제 entity를 생성하자. 사실 sequelize든 typeorm이든 entity의 함수는 그냥 공식문서를 살펴보면 된다. 조금 신경쓸부분은 아무래도 외래키부분이다.

공식문서의 entity 예시

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

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

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column()
    isActive: boolean;
}

대충 위와같이 사용하면 끝이다. 그런데 우리는 이렇게 무지성으로 사용하려고 코딩하는것이 아니다. 조금더 괜찮은 방법을 찾아보자.
구글링햅니 추상클래스를 만들어서 상속하는 방법을 많이 사용한다. 내가 사용한 예시를 적겠다.

상속 클래스

base-time-entity.ts

import { CreateDateColumn, UpdateDateColumn } from 'typeorm';

export abstract class BaseTimeEntity {
  @CreateDateColumn({ type: 'datetime' })
  createdAt!: Date;

  @UpdateDateColumn({ type: 'datetime' })
  updatedAt!: Date;
}

auto-id-entity.ts

import { PrimaryGeneratedColumn } from 'typeorm';
import { BaseTimeEntity } from './base-time-entity';

export abstract class AutoIdEntity extends BaseTimeEntity {
  @PrimaryGeneratedColumn()
  id!: number;
}
import { Generated, PrimaryColumn } from 'typeorm';
import { BaseTimeEntity } from './base-time-entity';

export abstract class UUIdEntity extends BaseTimeEntity {
  @PrimaryColumn({ type: 'char', length: 36 })
  @Generated('uuid')
  id!: string;
}

entity 예시

이제 위에서 만든 auto-id-entity 또는 uuid-entity를 상속해서 사용하면 된다.
게시판을 예시로 설명하겠다.

user.ts

import { USER_ENTITY } from 'constants/entity';
import { Column, Entity, PrimaryColumn, Generated, OneToMany } from 'typeorm';
import { UUIdEntity } from './abstract-class/uuid-entity';
import Chatting from './chatting';
import Comment from './comment';
import Post from './post';

@Entity({ name: 'user' })
class User extends UUIdEntity {
  @Column({ type: 'varchar', unique: true, length: USER_ENTITY.emailMaxLength })
  email!: string;

  @Column({ type: 'varchar', unique: true, length: USER_ENTITY.nicknameMaxLength })
  nickname!: string;

  @Column({ type: 'char', nullable: true, length: USER_ENTITY.hashPasswordLength })
  password!: string;

  @OneToMany(() => Post, post => post.user)
  posts!: Post[];

  @OneToMany(() => Comment, comment => comment.user)
  comments!: Comment[];

  @OneToMany(() => Chatting, chatting => chatting.user)
  chattings!: Chatting[];
}

export default User;

user와 post, comment는 1대 n 관게다. user가 한명일때 글과 댓글이 여러개이니 말이다. 외래키의 기본과 관계에 대해서는 생략하겠다. 위와 같이 OneToMany데코레이터를 사용하면 된다. 아 그냥 string으로 사용해도 괜찮지만 아니 괜찮지 않다. default값이 들어가게 되는데 원하는 값을 저렇게 넣어주자.

post.ts

import { POST_ENTITY } from 'constants/entity';
import { Column, Entity, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { AutoIdEntity } from './abstract-class/auto-id-entity';
import Comment from './comment';
import User from './user';

@Entity({ name: 'post' })
class Post extends AutoIdEntity {
  @Column({ type: 'varchar', length: POST_ENTITY.titleMaxLength })
  title!: string;

  @Column({ type: 'text', nullable: true })
  content!: string;

  @ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
  @JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
  user!: User;

  @Column({ type: 'char', length: 36 })
  userId!: string;

  @OneToMany(() => Comment, comment => comment.post, { cascade: ['insert', 'update'] })
  comments!: Post[];
}

export default Post;

post는 user와 n대 1 관계이다. 그래서 ManyToOne을 사용한다. joinColumn 파라미터를 보자. name은 만들 컬럼명이고 referencedColumnName은 말그대로 참조하는 컬럼명이다.
그리고 밑에서 userId를 컬럼으로 또 설정해줘야 한다.
(이부분이 좀 껄끄럽긴하다. 좋은 방법을 더 찾아보겠다.)

쿼리빌더

typeorm은 쿼리문을 사용하는 방법이 굉장히 많다. entity manager, repository를 이용하는 방법도 있지만 나는 custom repository를 이용했다. 이 방법이 규모가 있는 프로젝트를 할 때 좋다고 한다. 쿼리를 미리 만들어 놓으니 당연한 말이다.

user-repository.ts

import { EntityRepository, Repository } from 'typeorm';
import User from 'entity/user';

@EntityRepository(User)
class UserRepository extends Repository<User> {
  async checkEmail(email: string): Promise<boolean> {
    const user = await this.createQueryBuilder('user').where('user.email = :email', { email }).getOne();
    return !!user;
  }

  async checkNickname(nickname: string): Promise<boolean> {
    const user = await this.createQueryBuilder('user').where('user.nickname = :nickname', { nickname }).getOne();
    return !!user;
  }

  async createUser(email: string, nickname: string, password: string): Promise<string> {
    const user = await this.createQueryBuilder().insert().into(User).values({ email, nickname, password }).execute();
    return user.identifiers[0].id;
  }

  async getUserByEmail(email: string): Promise<User | undefined> {
    const user = await this.createQueryBuilder('user')
      .select(['user.id', 'user.nickname', 'user.password'])
      .where('user.email = :email', { email })
      .getOne();
    return user;
  }
}

export default UserRepository;

createQueryBuilder('user')이 코드는 from user라고 생각하면 된다. 나머지는 실제 쿼리문과 거의 유사해서 설명할 필요가 없을 것 같다. getOne은 1개선택, getMany()는 여러개 선택, execute는 저장이다.

post-repository.ts

import { EntityRepository, Repository } from 'typeorm';
import Post from 'entity/post';

const LIMIT = 20;

@EntityRepository(Post)
class PostRepository extends Repository<Post> {
  async createPost(title: string, content: string, userId: string): Promise<number> {
    const post = await this.createQueryBuilder().insert().into(Post).values({ title, content, userId }).execute();
    return post.identifiers[0].id;
  }

  async readPost(id: number): Promise<Post | undefined> {
    const post = await this.createQueryBuilder('post')
      .select(['post.id', 'post.title', 'post.content', 'post.createdAt', 'post.updatedAt', 'user.nickname'])
      .innerJoin('post.user', 'user')
      .where('post.id = :id', { id })
      .getOne();
    return post;
  }

  async readPostList(): Promise<Post[] | undefined> {
    const post = await this.createQueryBuilder('post')
      .select(['post.id', 'post.title', 'post.content', 'post.createdAt', 'user.nickname'])
      .limit(LIMIT)
      .innerJoin('post.user', 'user')
      .orderBy('post.id', 'DESC')
      .getMany();
    return post;
  }

  async readPostListByLastId(lastId: number): Promise<Post[] | undefined> {
    const post = await this.createQueryBuilder('post')
      .select(['post.id', 'post.title', 'post.content', 'post.createdAt', 'user.nickname'])
      .limit(LIMIT)
      .innerJoin('post.user', 'user')
      .where('post.id < :id', { id: lastId })
      .orderBy('post.id', 'DESC')
      .getMany();
    return post;
  }

  async getPostForUserId(id: number): Promise<Post | undefined> {
    const post = await this.createQueryBuilder('post')
      .select(['post.id', 'post.userId'])
      .where('post.id = :id', { id })
      .getOne();
    return post;
  }

  async updatePost(id: number, title: string, content: string): Promise<void> {
    await this.createQueryBuilder().update(Post).set({ title, content }).where('post.id = :id', { id }).execute();
  }

  async deletePost(id: number): Promise<void> {
    await this.createQueryBuilder('post').delete().where('post.id = :id', { id }).execute();
  }

  async checkPost(id: number): Promise<boolean> {
    const post = await this.createQueryBuilder('post').where('post.id = :id', { id }).getOne();
    return !!post;
  }
}

export default PostRepository;

참고로 take를 이용하니 where in문이 적용되고 limit를 이용하니 그냥 limit가 적용된다.

서비스 계층

auth-service.ts

import { getCustomRepository } from 'typeorm';
import UserRepository from 'repositories/user-repository';
import errorGenerator from 'error/error-generator';
import { comparePassword, hashPassword } from 'utils/crypto';
import { AUTH_ERROR_MESSAGE } from 'constants/error-message';
import { createToken } from 'utils/jwt';

const FROM = 'auth';

class AuthService {
  async signup(email: string, nickname: string, password: string) {
    const existEmail = await getCustomRepository(UserRepository).checkEmail(email);
    if (existEmail) {
      throw errorGenerator({
        status: 400,
        message: AUTH_ERROR_MESSAGE.duplicateEmail,
        from: FROM,
      });
    }

    const existNickname = await getCustomRepository(UserRepository).checkNickname(nickname);
    if (existNickname) {
      throw errorGenerator({
        status: 400,
        message: AUTH_ERROR_MESSAGE.duplicateNickname,
        from: FROM,
      });
    }

    const hash: string = await hashPassword(password);
    const id: string = await getCustomRepository(UserRepository).createUser(email, nickname, hash);

    const accessToken = createToken('access', { id, nickname });
    const refreshToken = createToken('refresh', { id, nickname });

    return { accessToken, refreshToken };
  }

  async login(email: string, password: string) {
    const user = await getCustomRepository(UserRepository).getUserByEmail(email);
    if (!user) {
      throw errorGenerator({
        status: 409,
        message: AUTH_ERROR_MESSAGE.notFoundEmail,
        from: FROM,
      });
    }
    const { id, nickname, password: dbPassword } = user;

    const compare = await comparePassword(password, dbPassword);
    if (!compare) {
      throw errorGenerator({
        status: 409,
        message: AUTH_ERROR_MESSAGE.notFoundPassword,
        from: FROM,
      });
    }

    const accessToken = createToken('access', { id, nickname });
    const refreshToken = createToken('refresh', { id, nickname });
    return { accessToken, refreshToken };
  }
}

export default AuthService;

위와 같이 getCustomRepository(Repository).으로 쿼리문을 이용하면 끝!!
계층에 대한 얘기는 typeorm과 관련이 없으니 하지 않겠다. 다른 글에서 다루겠다.

추가

위에서도 적었지만 migration은 작성하지 않았다. 그리고 트랜잭션쪽도 건드리지 않았다. 나중에 공부해보자. 둘다 꼭 필요한 내용이기 때문에 내가 프론트를 한다고해도 이정도는 알아야한다.
의존성 주입이라는 말을 들어본적은 있다. 그런데 사실 잘 모른다. typedi를 이용해 의존성 주입을 하는 코드를 몇번 보긴했는데 아직 잘모르겠다. 아직 그 단계가 아니라고 생각했다. 처음부터 시니어 개발자가 하는 방식대로 코딩할수는 없다. 나름대로 고민하고 공식문서도 찾아보면서 차근차근 발전해가야 된다고 생각한다. 지금은 때가 아니지만 한번쯤 찾아보고 1년안에 블로그에 글을 올리겠다. 최근에 oop를 공부해야겠다는 생각이 많이 든다. 너무 부족한게 많다...

728x90
LIST
댓글
공지사항