NestJS와 TypeORM 2026: 마이그레이션, 관계, 면접 질문

NestJS TypeORM 통합을 마스터합니다. TypeORM 1.1 마이그레이션, 엔티티 관계, 리포지토리 패턴, 백엔드 개발자를 위한 면접 질문을 다룹니다.

NestJS와 TypeORM 마이그레이션 및 관계 아키텍처 다이어그램

NestJS TypeORM은 엔터프라이즈 Node.js 백엔드에서 가장 검증된 ORM 통합 중 하나입니다. TypeORM이 2026년 5월에 버전 1.0에 도달하고, 이어진 1.1.x 패치 시리즈에서 연결 처리와 관계 영속화가 개선되었으며, NestJS 12가 ESM 마이그레이션을 완료하면서, 이 스택은 프로덕션 애플리케이션을 위한 안정적인 기반으로 성숙했습니다. 이 가이드에서는 백엔드 개발자가 반드시 알아야 할 핵심 패턴을 다룹니다. 마이그레이션, 엔티티 관계, 리포지토리 패턴, 그리고 면접에서 나오는 질문들입니다.

NestJS 12와 TypeORM 1.1

NestJS 12는 코어 패키지를 ESM으로 제공하며, Node.js 20.19 이상 또는 22.12 이상이 필요합니다. @nestjs/typeorm v12 패키지도 마찬가지입니다. TypeORM 1.1.2에서는 DataSource.initialize()의 연결 누수가 수정되었고, 관계가 로드되지 않은 경우의 ManyToMany 중간 테이블 행 영속화가 개선되었습니다.

NestJS 12에서 TypeORM 마이그레이션 설정

프로덕션 데이터베이스의 첫 번째 원칙은 synchronize: true를 사용하지 않는 것입니다. 자동 동기화는 엔티티 메타데이터를 라이브 스키마와 비교하여 변경 사항을 직접 적용하므로, 컬럼 삭제, 타입 변경, 필드 이름 변경 시 데이터 손실 위험이 있습니다. 마이그레이션은 버전 관리되고, 검토 가능하며, 되돌릴 수 있는 대안을 제공합니다.

TypeORM 1.x는 CLI를 위해 NestJS 모듈 설정과 별도의 전용 DataSource 설정 파일이 필요합니다. CLI는 NestJS 의존성 주입을 해석할 수 없으므로, 독립적인 진입점이 필요합니다.

src/config/typeorm.config.tstypescript
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

config(); // Load .env variables

export default new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/migrations/*.ts'],
  synchronize: false,
});

NestJS 모듈 설정은 이러한 설정을 반영하지만, 의존성 주입을 활용한 TypeOrmModule.forRootAsync 패턴을 사용합니다.

src/app.module.tstypescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get('DB_HOST'),
        port: config.getOrThrow<number>('DB_PORT'),
        username: config.get('DB_USER'),
        password: config.get('DB_PASSWORD'),
        database: config.get('DB_NAME'),
        autoLoadEntities: true, // Auto-register entities from feature modules
        synchronize: false,     // Always false in production
      }),
    }),
  ],
})
export class AppModule {}

autoLoadEntities: true는 수동 entities 배열을 대체하여, 피처 모듈에서 TypeOrmModule.forFeature()를 통해 가져온 모든 엔티티를 자동으로 등록합니다.

마이그레이션 워크플로우: 생성, 검토, 실행

TypeORM 1.x는 권장 CLI 러너로 typeorm-ts-node-commonjs를 제공하며, 기존의 ts-node ./node_modules/typeorm/cli 방식을 대체합니다. 다음 스크립트를 package.json에 추가합니다.

package.json (scripts section)json
{
  "migration:generate": "typeorm-ts-node-commonjs migration:generate src/migrations/$npm_config_name -d src/config/typeorm.config.ts",
  "migration:run": "typeorm-ts-node-commonjs migration:run -d src/config/typeorm.config.ts",
  "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/config/typeorm.config.ts"
}

generate 명령은 엔티티 메타데이터를 라이브 데이터베이스와 비교하여 SQL 문을 생성합니다. 커밋 전에 반드시 출력을 확인해야 합니다. TypeORM은 컬럼 추가, 삭제, 타입 변경, 인덱스 수정, 외래 키 변경을 감지하지만, 데이터 백필, 테이블 이름 변경(삭제 후 생성으로 표시됨), 데이터베이스 확장 또는 트리거 관련 작업은 감지하지 않습니다.

생성된 마이그레이션을 반드시 검토

컬럼 이름 변경은 ALTER RENAME 대신 DROP + ADD로 생성됩니다. 이는 기존 데이터를 파괴합니다. up() 메서드의 SQL을 반드시 확인하고, 필요 시 수동으로 조정해야 합니다.

엔티티 관계: OneToMany, ManyToOne, ManyToMany

TypeORM 엔티티 관계는 데코레이터를 사용하여 SQL 외래 키에 직접 매핑됩니다. 세 가지 핵심 관계 타입과 그 옵션을 이해하는 것은 NestJS 데이터베이스 모듈의 기본입니다.

여러 Order 레코드를 가진 User는 가장 일반적인 패턴을 보여줍니다.

src/entities/user.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from 'typeorm';
import { Order } from './order.entity';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;

  @OneToMany(() => Order, (order) => order.user)
  orders: Order[];  // No DB column created here; relation lives on the Order side

  @CreateDateColumn()
  createdAt: Date;
}
src/entities/order.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';

@Entity('orders')
export class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('decimal', { precision: 10, scale: 2 })
  total: number;

  @Column({ default: 'pending' })
  status: string;

  @ManyToOne(() => User, (user) => user.orders, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })  // Explicit FK column name
  user: User;
}

@ManyToOne 쪽이 외래 키를 소유합니다. @JoinColumn은 @ManyToOne에서 선택 사항이지만(TypeORM이 추론함), 명시적인 이름 지정을 통해 원시 쿼리나 마이그레이션 diff에서 컬럼 이름이 중요한 경우 예상치 못한 동작을 방지할 수 있습니다.

ManyToMany 관계에서 TypeORM은 자동으로 중간 테이블을 생성합니다.

src/entities/product.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Category } from './category.entity';

@Entity('products')
export class Product {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column('decimal', { precision: 10, scale: 2 })
  price: number;

  @ManyToMany(() => Category, (category) => category.products)
  @JoinTable({ name: 'product_categories' })  // Owns the junction table
  categories: Category[];
}

@JoinTable은 관계의 한쪽에만 배치해야 합니다. 이는 어떤 엔티티가 중간 테이블을 소유하는지 정의하고 테이블 이름을 제어합니다.

Node.js / NestJS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

리포지토리 패턴과 커스텀 리포지토리

NestJS 모듈과 DI 시스템은 TypeOrmModule.forFeature()를 통해 TypeORM 리포지토리를 통합합니다. 기본 Repository<T>를 주입하면 표준 CRUD 작업을 처리할 수 있습니다. 쿼리가 복잡해지면, 커스텀 리포지토리가 해당 로직을 캡슐화합니다.

src/orders/orders.module.tstypescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from '../entities/order.entity';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  providers: [OrdersService],
  controllers: [OrdersController],
})
export class OrdersModule {}
src/orders/orders.service.tstypescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from '../entities/order.entity';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepo: Repository<Order>,
  ) {}

  async findByUser(userId: string): Promise<Order[]> {
    return this.orderRepo.find({
      where: { user: { id: userId } },
      relations: ['user'],          // Eager-load the user relation
      order: { createdAt: 'DESC' }, // Most recent first
    });
  }

  async findWithFilters(status: string, minTotal: number): Promise<Order[]> {
    // QueryBuilder for complex queries
    return this.orderRepo
      .createQueryBuilder('order')
      .leftJoinAndSelect('order.user', 'user')
      .where('order.status = :status', { status })
      .andWhere('order.total >= :minTotal', { minTotal })
      .orderBy('order.total', 'DESC')
      .getMany();
  }
}

find() API는 간단한 쿼리를 처리합니다. QueryBuilder는 조인, 서브쿼리, 집계, 세밀한 SQL 제어가 필요한 경우를 처리합니다. 같은 서비스 내에서 두 가지를 혼합하는 것은 전혀 문제없습니다. 단순 조회에는 find()를 사용하고, 옵션 API로 표현할 수 없는 쿼리에는 QueryBuilder를 사용합니다.

데이터 무결성을 위한 트랜잭션 관리

여러 테이블을 수정하는 작업에는 트랜잭션이 필요합니다. TypeORM은 두 가지 접근 방식을 제공합니다. DataSource.transaction() 메서드와 QueryRunner API입니다.

src/orders/orders.service.ts (additional method)typescript
async createOrderWithItems(
  userId: string,
  items: { productId: string; quantity: number }[],
  dataSource: DataSource,
): Promise<Order> {
  return dataSource.transaction(async (manager) => {
    // All operations use the transactional manager
    const order = manager.create(Order, {
      user: { id: userId },
      total: 0,
      status: 'pending',
    });
    const savedOrder = await manager.save(order);

    let total = 0;
    for (const item of items) {
      const product = await manager.findOneByOrFail(Product, { id: item.productId });
      total += product.price * item.quantity;

      await manager.save(OrderItem, {
        order: savedOrder,
        product,
        quantity: item.quantity,
        unitPrice: product.price,
      });
    }

    savedOrder.total = total;
    return manager.save(savedOrder);
  });
}

콜백 기반 transaction() 메서드는 성공 시 자동으로 커밋하고, 에러가 발생하면 롤백합니다. 콜백 내의 모든 데이터베이스 작업은 주입된 리포지토리가 아닌 제공된 manager를 사용해야 합니다. 그렇지 않으면 해당 작업은 트랜잭션 외부에서 실행됩니다.

QueryRunner와 transaction()의 차이

단순한 다단계 작업에는 DataSource.transaction()을 사용합니다. 커밋/롤백 지점이나 세이브포인트에 대한 수동 제어가 필요한 경우(예: 배치 처리에서의 부분 롤백)에는 QueryRunner로 전환합니다.

NestJS TypeORM 일반 면접 질문

이러한 질문은 백엔드 개발자 면접에서 자주 출제됩니다. 각 답변은 시니어 레벨 기술 토론에서 기대되는 깊이를 목표로 합니다.

프로덕션에서 synchronize: true를 사용하면 안 되는 이유는?

자동 동기화는 확인 없이 파괴적인 스키마 변경을 적용합니다. 컬럼 삭제, 타입 변경, 인덱스 제거가 라이브 데이터에 즉시 실행됩니다. 마이그레이션은 버전 관리, 코드 리뷰, 롤백 기능을 제공합니다. synchronize의 유일하게 안전한 사용처는 폐기 가능한 데이터를 사용하는 로컬 개발입니다.

eager 관계와 lazy 관계의 차이는?

Eager 관계는 부모 엔티티에 대한 모든 쿼리에서 자동으로 로드됩니다. Lazy 관계는 Promise를 반환하고 접근 시에만 별도의 쿼리를 실행합니다. 관련 데이터가 필요하지 않을 때 Eager 로딩은 N+1 스타일의 오버헤드를 유발합니다. Lazy 로딩은 비용을 지연시키지만, 콜 스택 깊은 곳에서 예상치 못한 쿼리를 트리거할 수 있습니다. find()의 명시적 relations 옵션이나 QueryBuilder의 leftJoinAndSelect를 통해 쿼리별로 무엇을 로드할지 완전히 제어할 수 있습니다.

TypeORM 관계에서 cascade는 어떻게 작동하는가?

관계에 cascade: true를 설정하면, 부모 엔티티를 저장할 때 저장되지 않은 자식 엔티티도 함께 영속화됩니다. cascade: ['insert', 'update']는 동작을 특정 작업으로 제한합니다. 캐스케이드는 ORM 레벨에서 작동하며, 데이터베이스 레벨이 아닙니다. 데이터베이스 레벨 캐스케이드 삭제를 위해서는 @ManyToOne 데코레이터에서 onDelete: 'CASCADE'를 사용하여 마이그레이션에 ON DELETE CASCADE 제약 조건을 생성합니다.

기본 Repository<T> 대신 커스텀 리포지토리를 사용해야 하는 경우는?

커스텀 리포지토리는 서비스에 속하지 않는 복잡한 쿼리 로직을 캡슐화합니다. 커스텀 리포지토리를 추출해야 하는 징후: 동일한 QueryBuilder 체인이 여러 서비스에 나타남, 쿼리에 여러 조인이나 서브쿼리가 포함됨, 쿼리에 원시 SQL이 필요함. 단순한 find() 호출은 커스텀 리포지토리를 정당화하지 않습니다.

NestJS 아키텍처에 대한 더 많은 질문은 NestJS 가드와 인터셉터 완벽 가이드를 참조하세요.

Node.js / NestJS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

소스

NestJS TypeORM 프로덕션 앱의 핵심 포인트

  • TypeORM CLI에는 NestJS 모듈 설정과 독립적인 별도의 DataSource 설정 파일을 사용한다
  • 프로덕션 환경에서는 synchronize를 활성화하지 않는다. 모든 스키마 변경에는 생성 및 검토된 마이그레이션을 사용한다
  • 외래 키를 소유하는 엔티티에 @ManyToOne을 배치하고, 원시 쿼리와 마이그레이션 출력의 명확성을 위해 명시적인 @JoinColumn 이름을 사용한다
  • 단순 쿼리에는 find() 옵션 API를, 복잡한 조인, 집계, 조건부 로직에는 QueryBuilder를 선택한다
  • 다중 테이블 변경은 DataSource.transaction()을 사용하여 트랜잭션으로 래핑하고, 콜백 내의 모든 작업이 트랜잭셔널 manager를 사용하도록 한다
  • TypeORM 1.1.x가 API 표면을 안정화하고 NestJS 12가 ESM 마이그레이션을 완료하면서, 이 스택은 구조화되고 타입 안전한 데이터베이스 액세스가 필요한 엔터프라이즈 백엔드에 강력한 선택지로 남아 있다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

오늘의 챌린지

Node.js / NestJS 코드의 버그를 찾을 수 있나요

실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

Anthony Fillion-Maillet

작성자

Anthony Fillion-Maillet

SharpSkill 창업자

10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.

2026년 10월 21일 업데이트

태그

#nestjs
#typeorm
#migrations
#database
#typescript
#backend

공유

관련 기사