Nest.js + TypeORM + PostgreSQL
Nest JS에 DB를 연동해 보자
들어가며
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입니다.
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;
}
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;
}