Nest.js + TypeORM + PostgreSQL

Nest JS에 DB를 연동해 보자

Nest.js + TypeORM + PostgreSQL

들어가며

💡
NestJS 서버 생성에 대한 이야기는 다루지 않습니다. 해당 내용은 공식 문서를 참고해 주세요.

NestJS 백엔드 서버에 TypeORM + PostgreSQL을 연동하는 방법을 다룹니다. 본 문서를 바탕으로 기존 내용의 잘못된 부분이나 부족한 부분을 수정하여 재게시하였습니다.

PostgreSQL 설치

서버 설치

Linux

$ sudo apt install postgresql-14

MacOS

$ brew install postgresql@14

Docker가 설치된 환경이라면 Docker로 간단하게 설치하고 사용할 수 있습니다. Docker Hub의 공식 이미지 페이지를 참고해 주세요.

PostgreSQL CLI 연결

기본 User인 postgres로 접속합니다.
Linux

sudoer$ sudo -u postgres psql
or
$ psql -u postgres -p

MacOS

$ psql postgres

SSL 활성화

PostgreSQL은 SSL 연결을 지원합니다. 로컬에서 활용하는 경우에는 꼭 설정할 필요는 없지만, NestJS 서버와 DB 서버가 별도 인스턴스에서 실행되는 구조라면 보안을 위해 SSL 설정이 필요합니다. 패키지 매니저를 통해 PostgreSQL을 설치했을 경우 SSL이 비활성화 상태이므로, 설정해 줍시다.

우선 PostgreSQL 데이터 디렉터리 위치를 확인해야 합니다.

# SHOW data_directory;

그러면 다음과 같이 위치를 출력합니다.

postgres=# show data_directory;
         data_directory
---------------------------------
 /path/to/postgresql
(1 row)

\q를 입력해서 psql CLI를 종료하고 해당 위치로 이동합니다.

$ cd /path/to/postgresql

SSL 사용을 위해 인증서를 생성합니다. OpenSSL 외 다른 방법으로 생성한 인증서가 있다면 그걸 활용해 주세요.

$ openssl req \
    -nodes -new -x509 \
    -keyout server.key \
    -out server.crt \
    -subj '/C=국가코드 2글자/L=지역/O=조직명/CN=이름'

인증서의 올바른 권한인 400을 적용합니다. 인증서는 소유자 외에는 권한이 없어야 하며, 소유자도 읽기 권한만을 가져야 합니다. 다른 SSL 접속을 설정할 때도 동일한 사항입니다.

$ sudo chmod 400 server.{crt, key}

다시 psql CLI를 실행한 뒤 다음을 입력합니다.

# ALTER system ssl=on();
# ALTER system pg_reload_conf();

이제 정상적으로 SSL을 통한 PostgreSQL 접속이 가능합니다. 참고로, PostgreSQL 기본 포트는 5432입니다.

Enabling and Enforcing SSL/TLS for PostgreSQL Connections
Enabling SSL in PostgreSQL is very straightforward and here go through the steps and check/validate the connections are indeed using the safer SSL protocol.

User 생성

postgres User는 DB의 모든 권한을 가집니다. 로컬에서 테스트할 때라면 몰라도, 실제 Production에서는 권한을 세분화하여 여러 사용자에게 적절히 분배해야 합니다. PostgreSQL CLI에 연결하고 아래 명령어를 실행합시다.

# CREATE USER 유저이름 WITH PASSWORD `비밀번호`;

사용자를 만들고 해당 사용자에 맞는 DB를 생성합니다. PostgreSQL은 각 유저 이름에 해당하는 기본 DB를 가지고 실행 시 해당 DB를 사용합니다.

# ALTER USER 유저이름 CREATEDB;

TypeORM on NestJS

ts-node, TypeORM과 pg를 설치합니다.

$ npm install --save-dev ts-node
$ npm install --save nestjs/typeorm
$ npm install --save typeorm
$ npm install --save pg

Connect RDB

TypeORM에서는 두 가지 방법으로 DB를 활용할 수 있습니다.
DataSource, 즉 DB 자체에 연결하여 QueryBuilder나 QueryRunner등을 사용하는 방법이 있고, 특정 테이블에 대한 여러 기능을 제공하는 Repository를 생성하여 사용하는 방식이 있습니다. Spring 사용자 분들이시라면 Repository 패턴이 익숙할 것 같습니다. 어떤 걸 주로 활용하든 DataSource는 반드시 설정해 줘야 합니다.

개인적으로는 View 테이블 생성이나 Transaction 활용, 복잡한 쿼리 등은 DataSource로, 각 테이블에 대한 검색이나 삽입, 삭제 등은 Repository를 활용했던 기억이 있습니다.

RDB Module

우선 DataSource 제공을 위한 모듈을 분리합니다. 분리하지 않아도 사용에는 문제가 없지만, 한 번에 설정하여 여러 사용처에서 import하는 편이 훨씬 간단합니다. rdbProviders에 대해서는 바로 다음 단계에 소개하겠습니다.

rdb
ㄴ rdb.module.ts
ㄴ rdb.providers.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { rdbProviders } from './rdb.providers';

@Module({
  imports: [ConfigModule],
  providers: [...rdbProviders],
  exports: [...rdbProviders],
})
export class RdbModule {}

DataSource Provider

RDB 모듈 디렉토리에 DataSource Provider rdb.providers.ts를 생성합니다.

import { getDataSourceToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';

export const rdbProviders = [
  {
    inject: [ConfigService], // 환경변수 사용을 위해 외부 서비스 inject
    provide: 'DATA_SOURCE',
    // 상수가 아닌 값을 Config로 활용하려면 비동기적으로 생성해야 합니다.
    useFactory: async (envService: EnvService) => {
      const dataSource = new DataSource({
        type: 'postgres',
        host: configService.get<string>('DB HOST 환경변수명'),
        port: configService.get<number>('DB PORT 환경변수명'),
        database: configService.get<string>('DB 환경변수명'),
        username: configService.get<string>('DB User 이름 환경변수명'),
        password: configService.get<string>('DB User 비밀번호 환경변수명'),
        extra: {
          // ssl 활용 옵션의 경우 필요에 따라 변경해 주세요.
          // 본 예시는 ssl을 활용하지 않을 때의 설정입니다.
          ssl: {
            rejectUnauthorized: false,
          },
        },
        synchronize: true, // 실행 시 자동으로 DB에 동기화
        entities: [`${__dirname}/../**/*.{entity,view}.{ts,js}`],
        migrations: [`${__dirname}/../migrations/**/*.ts`],
        subscribers: [`${__dirname}/../subscribers/**/*.ts`],
        // 위와 같이 파일명 패턴을 활용해도 되고, Entity 클래스를 직접 가져오는 것도 가능
      });

      return dataSource.initialize();
    },
  },
  {
    // 외부에서 DataSource Token을 활용할 수 있도록 합니다.
    // 설정하면 일일이 'DATA_SOURCE' 문자열을 입력하지 않고도 불러올 수 있습니다.
    provide: getDataSourceToken(),
    useFactory: (dataSource: DataSource) => dataSource,
    inject: ['DATA_SOURCE'],
  },
];

Repository Provider

Repository Provider를 생성합니다. 이제 외부에서 RDB 모듈만 가져오면 각 테이블에 맞​Repository를 사용할 수 있습니다.

import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { User } from './user.entity';

const DATA_SOURCE = 'DATA_SOURCE' as const;

export const userProviders = [
  {
    provide: getRepositoryToken(User),
    useFactory: (dataSource: DataSource) => dataSource.getRepository(User),
    inject: [DATA_SOURCE],
  },
];

설정한 후에는 다음과 같이 확장하여 Custom Repository를 만들 수도 있습니다.

export const UserRepository = dataSource.getRepository(User).extend({
  findByName(firstName: string, lastName: string) {
    return this.createQueryBuilder("user")
      .where("user.firstName = :firstName", { firstName })
      .andWhere("user.lastName = :lastName", { lastName })
      .getMany()
  },
})

생성한 Repository는 다음과 같이 사용하시면 됩니다. 다음은 User 모듈에서 사용하는 예제입니다.

// user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { userProviders } from './user.providers';
import { UserService } from './user.service';
import { RdbModule } from '@/rdb/rdb.module';

@Module({
  imports: [RdbModule],
  controllers: [UserController],
  providers: [...userProviders, UserService],
  exports: [...userProviders, UserService],
})
export class UserModule {}
// user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity'; // entity

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}
  
  async getUserById(userId: number): Promise<User> {
    const user = await this.userRepository.findOneBy({
      id: userId,
    }); // User entity의 id로 row 한 개를 검색, 해당하는 row가 없으면 null 반환

    if (user === null) {
      throw new NoSuchUserException();
    }

    return user;
  }
}

Global Module

만약 위와 같은 과정 없이 어디서든 TypeORM을 활용할 수 있도록 하려면 app.module.ts을 다음과 같이 작성해 줍니다. forRootAsync를 활용할 경우 이를 불러오기 위해 필요한 고유한 name을 꼭 설정해 주세요.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      name: 'connectionName',
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get<string>('DB HOST 환경변수명'),
        port: configService.get<number>('DB PORT 환경변수명'),
        database: configService.get<string>('DB 이름 환경변수명'),
        username: configService.get<string>('DB User 이름 환경변수명'),
        password: configService.get<string>('DB User 비밀번호 환경변수명'),
        synchronize: true,
        entities: [`${__dirname}/../**/*.{entity,view}.{ts,js}`],
        migrations: [`${__dirname}/../migrations/**/*.ts`],
        subscribers: [`${__dirname}/../subscribers/**/*.ts`],
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

이 경우에는 다음과 같이 사용하시면 됩니다.

// user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { userProviders } from './user.providers';
import { UserService } from './user.service';
import { RdbModule } from '@/rdb/rdb.module';

@Module({
  imports: [TypeOrmModule.forFeature(User)],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}
// user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository, InjectDataSource } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity'; // entity

@Injectable()
export class UserService {
  constructor(
    @InjectDataSource('connectionName')
    private readonly dataSource: DataSource,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}
  
  async getUserById(userId: number): Promise<User> {
    const user = await this.userRepository.findOneBy({
      id: userId,
    }); // User entity의 id로 row 한 개를 검색, 해당하는 row가 없으면 null 반환

    if (user === null) {
      throw new NoSuchUserException();
    }

    return user;
  }
}

Entity

데이터베이스 Code First 접근을 위해서는 Entity를 작성할 필요가 있습니다. 아래는 작성 예시입니다.

import {
  Column,
  CreateDateColumn,
  Entity,
  OneToMany,
  OneToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class MyEntity {
    @PrimaryGeneratedColumn() // auto increment PK
    id: number;
    
    @CreateDateColumn()
    createdAt: Date;
    
    @DeleteDateColumn({ nullable: true })
    deletedAt: Date; // soft delete date
    
    @UpdateDateColumn()
    updatedAt: Date;
    
    @OneToOne((type) => AnotherEntity, (anotherEntity) => anotherEntity.myEntity)
    /*
        타 Entity와 일대일 연결 관계를 생성합니다.
        타 Entity에는 본 Entity를 위한 Column을 하나 생성해야 합니다.
        이 경우에는 @OneToOne annotation이 붙은 myEntity Column이 필요합니다.
    */
    anotherEntity: AnotherEntity;
    
    @OneToMany((type) => AnotherEntity, (anotherEntity) => anotherEntity.myEntity)
    /*
        타 Entity와 일대다 연결 관계를 생성합니다.
        타 Entity에는 본 Entity를 위한 Column을 하나 생성해야 합니다.
        이 경우에는 @ManyToOne annotation이 붙은 myEntity Column이 필요합니다.
    */
    anotherEntities!: AnotherEntity[];
    
    @ManyToOne((type) => OtherEntity, (otherEntity) => otherEntity.myEntities)
    /*
        타 Entity와 다대일 연결 관계를 생성합니다.
        타 Entity에는 본 Entity를 위한 Column 배열을 하나 생성해야 합니다.
        이 경우에는 @OneToMany annotation이 붙은 myEntities Column이 필요합니다.
    */
    otherEntity!: OtherEntity[];
    
    @Column({type: 'text', array: true}) // {array: true}는 postgres만 지원합니다.
    someArray?: string[];
}

더 자세한 예시는 공식 문서를 참고해 주세요.

View Entity

편리한 조회를 위해 View Table을 생성할 수도 있습니다. 쿼리의 결과를 저장해 두는 방식으로 활용하여 반복적인 쿼리 작성을 피할 수 있습니다. 다음은 제가 작성했던 코드입니다.

import { DataSource, ViewColumn, ViewEntity } from 'typeorm
import { Post } from './post.entity';
import { User } from '@/user/user.entity';

@ViewEntity({
  expression: (dataSource: DataSource) =>
    dataSource
      .createQueryBuilder()
      .select('post.id', 'postId') // 조회할 column, 부여할 이름
      // select, groupBy 추가는 add***
      .addSelect('user.id', 'userId')
      .addSelect("CONCAT(user.lastName, ' ', user.firstName)", 'userName')
      .addSelect('post.type', 'postType')
      .addSelect('post.title', 'title')
      .addSelect('post.content', 'content')
      .addSelect('post.createdAt', 'createdAt')
      .from(Post, 'post')
      .leftJoin(User, 'user', 'user.id = post.userId')
      .leftJoin(
        PostInteraction,
        'post_interaction',
        'post_interaction.postId = post.id',
      ),
})
export class PostView {
  @ViewColumn()
  postId: number;

  @ViewColumn()
  userId: number;

  @ViewColumn()
  userName: string;

  @ViewColumn()
  title: string;

  @ViewColumn()
  content: string;

  @ViewColumn()
  createdAt: Date;
}
View Entities | typeorm

Transaction

하나의 작업을 위해 여러 번의 쿼리가 필요할 수 있습니다. 이때만약 여러 개의 쿼리 중 하나 이상이 실패하여 데이터의 무결성이 훼손될 수도 있습니다.

그래서 대부분의 데이터베이스는 트랜잭션을 지원합니다. 일정 시점의 상태를 저장하고, 원할 경우 해당 시점 이후 일어난 변경 사항을 저장하거나 취소할 수 있도록 하는 기능입니다. 다음은 게시글과 첨부 파일 경로 정보들을 게시하는 트랜잭션 예제입니다.

async addPost(
    postInfo: AddPostDto,
    userId: number,
    filePaths: string[],
  ) {

    const post = new Post();
    post.title = postInfo.title;
    post.content = postInfo.content;
    post.user = await this.userService.getUserById(userId);

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    
    // 트랜잭션 시작
    await queryRunner.startTransaction();

    try {
      // 게시글 업로드
      const uploadedPost = await queryRunner.manager.save(post);

      // 파일 정보 업로드
      await Promise.all(
        filePaths.map((filePath) => {
          const postFile = new PostFile();
          postFile.path = filePath;
          postFile.post = post;

          return queryRunner.manager.save(postFile);
        }),
      );

      // 트랜잭션 종료, 변경 사항 저장
      await queryRunner.commitTransaction();
    } 
    // 트랜잭션 진행 중에 오류가 발생하면 이전 상태로 롤백합니다.
    catch (err) {  
      await queryRunner.rollbackTransaction();
      /* error handling here */
    } 
    // 트랜잭션 성공 여부와 상관없이 작업 후 연결을 해제합니다.
    finally {
      await queryRunner.release();
    }

    return post.id;
  }
Documentation | NestJS - A progressive Node.js framework
Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming).

TypeORM Transaction 공식 문서