TypeORM 연동 — NestJS에서 데이터베이스 다루기
NestJS에서 DB를 다루려면 TypeORM, Prisma, MikroORM 중 하나를 고르게 됩니다. 그중 TypeORM은 Spring JPA와 가장 비슷한 패턴을 가지고 있습니다. 데코레이터로 엔티티를 정의하고, Repository로 쿼리하는 구조가 거의 동일하니까요.
설정
npm install @nestjs/typeorm typeorm pg # PostgreSQL 기준
// app.module.ts
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'app',
password: 'password',
database: 'mydb',
entities: [User, Post], // 엔티티 클래스 등록
synchronize: true, // ⚠️ 개발용만! 운영에서 절대 금지
}),
UserModule,
],
})
export class AppModule {}
synchronize: true는 앱이 시작될 때 엔티티 정의와 DB 스키마를 자동 동기화합니다. 편하지만 운영 환경에서는 데이터가 날아갈 수 있습니다. 운영에서는 반드시 마이그레이션을 사용해야 합니다.
엔티티 정의
Spring JPA의 @Entity, @Column, @Id와 거의 동일한 데코레이터를 사용합니다.
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50 })
name: string;
@Column({ unique: true })
email: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
}
| TypeORM | Spring JPA | 역할 |
|---|---|---|
@Entity() | @Entity | 테이블 매핑 |
@PrimaryGeneratedColumn() | @Id + @GeneratedValue | 자동 증가 PK |
@Column() | @Column | 컬럼 매핑 |
@CreateDateColumn() | @CreatedDate | 생성 시간 자동 기록 |
@ManyToOne() | @ManyToOne | 다대일 관계 |
연관관계 설정
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(() => User, user => user.posts)
author: User;
}
@Entity()
export class User {
// ...기존 필드
@OneToMany(() => Post, post => post.author)
posts: Post[];
}
JPA와 마찬가지로 @ManyToOne 쪽이 외래키를 가집니다.
Repository 패턴 — @InjectRepository
NestJS에서 TypeORM의 Repository를 사용하려면 모듈에 등록하고 주입받습니다.
// user.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])], // Repository 등록
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
findAll(): Promise<User[]> {
return this.userRepository.find();
}
findOne(id: number): Promise<User | null> {
return this.userRepository.findOneBy({ id });
}
async create(dto: CreateUserDto): Promise<User> {
const user = this.userRepository.create(dto); // 엔티티 인스턴스 생성
return this.userRepository.save(user); // DB에 저장
}
async remove(id: number): Promise<void> {
await this.userRepository.delete(id);
}
}
Spring JPA의 JpaRepository를 상속받아 쓰는 것과 비슷합니다. 다만 TypeORM은 인터페이스를 상속하는 대신 ** 제네릭 Repository를 주입 **받는 방식입니다.
QueryBuilder — 복잡한 쿼리
단순한 CRUD는 Repository 메서드로 충분하지만, 조건이 복잡해지면 QueryBuilder를 사용합니다.
async findActiveUsersByName(name: string): Promise<User[]> {
return this.userRepository
.createQueryBuilder('user')
.where('user.isActive = :isActive', { isActive: true })
.andWhere('user.name LIKE :name', { name: `%${name}%` })
.orderBy('user.createdAt', 'DESC')
.take(10)
.getMany();
}
Spring의 Querydsl이나 JPQL과 비슷한 역할입니다. 파라미터 바인딩(:isActive)을 사용해서 SQL 인젝션을 방지합니다.
트랜잭션
여러 쿼리를 하나의 트랜잭션으로 묶어야 할 때 두 가지 방법이 있습니다.
방법 1: QueryRunner (수동)
async transferPoints(fromId: number, toId: number, amount: number) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.decrement(User, { id: fromId }, 'points', amount);
await queryRunner.manager.increment(User, { id: toId }, 'points', amount);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
방법 2: transaction 메서드
async transferPoints(fromId: number, toId: number, amount: number) {
await this.dataSource.transaction(async manager => {
await manager.decrement(User, { id: fromId }, 'points', amount);
await manager.increment(User, { id: toId }, 'points', amount);
});
}
Spring의 @Transactional처럼 데코레이터 하나로 트랜잭션을 관리하는 방식은 TypeORM에 기본 제공되지 않습니다. typeorm-transactional 같은 서드파티 라이브러리를 쓰거나 직접 구현해야 합니다.
마이그레이션
운영 환경에서는 synchronize: true 대신 ** 마이그레이션 **으로 스키마를 관리합니다.
# 마이그레이션 생성
npx typeorm migration:generate -d src/data-source.ts src/migrations/AddUserPhone
# 마이그레이션 실행
npx typeorm migration:run -d src/data-source.ts
# 마이그레이션 롤백
npx typeorm migration:revert -d src/data-source.ts
생성된 마이그레이션 파일 예시입니다.
export class AddUserPhone1680000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "phone" varchar(20)`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" DROP COLUMN "phone"`
);
}
}
주의할 점
synchronize: true를 운영에서 쓰면 안 되는 이유
엔티티에서 컬럼을 삭제하면 TypeORM이 해당 ** 테이블 컬럼을 DROP**합니다. 데이터가 통째로 사라집니다. 개발 환경에서는 편하지만, 운영에서는 절대 사용하면 안 됩니다.
N+1 문제
TypeORM도 JPA와 마찬가지로 ** 지연 로딩(Lazy Loading)** 시 N+1 문제가 발생합니다.
// N+1 발생 — 유저 N명 조회 후 각각의 posts를 추가 쿼리
const users = await this.userRepository.find();
for (const user of users) {
console.log(user.posts); // 각 유저마다 SELECT 쿼리 발생
}
// 해결 — relations 옵션으로 즉시 로딩
const users = await this.userRepository.find({
relations: ['posts'], // JOIN으로 한 번에 가져옴
});
정리
| 포인트 | 내용 |
|---|---|
| 설정 | TypeOrmModule.forRoot()로 DB 연결, forFeature()로 엔티티 등록 |
| Repository | @InjectRepository()로 주입, CRUD 메서드 내장 |
| 트랜잭션 | QueryRunner 또는 transaction() 메서드로 수동 관리 |
| 마이그레이션 | 운영 환경 필수 — synchronize: true는 개발용만 |
| 핵심 함정 | synchronize로 컬럼 삭제 시 데이터 유실, N+1 문제 |