NestJS에서 DB를 다루려면 TypeORM, Prisma, MikroORM 중 하나를 고르게 됩니다. 그중 TypeORM은 Spring JPA와 가장 비슷한 패턴을 가지고 있습니다. 데코레이터로 엔티티를 정의하고, Repository로 쿼리하는 구조가 거의 동일하니까요.


설정

BASH
npm install @nestjs/typeorm typeorm pg  # PostgreSQL 기준
TYPESCRIPT
// 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와 거의 동일한 데코레이터를 사용합니다.

TYPESCRIPT
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;
}
TypeORMSpring JPA역할
@Entity()@Entity테이블 매핑
@PrimaryGeneratedColumn()@Id + @GeneratedValue자동 증가 PK
@Column()@Column컬럼 매핑
@CreateDateColumn()@CreatedDate생성 시간 자동 기록
@ManyToOne()@ManyToOne다대일 관계

연관관계 설정

TYPESCRIPT
@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를 사용하려면 모듈에 등록하고 주입받습니다.

TYPESCRIPT
// 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를 사용합니다.

TYPESCRIPT
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 (수동)

TYPESCRIPT
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 메서드

TYPESCRIPT
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 대신 ** 마이그레이션 **으로 스키마를 관리합니다.

BASH
# 마이그레이션 생성
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

생성된 마이그레이션 파일 예시입니다.

TYPESCRIPT
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 문제가 발생합니다.

TYPESCRIPT
// 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 문제
댓글 로딩 중...