본문 바로가기

TypeORM을 사용한 데이터베이스 연동

ORM의 역할을 이해하고 TypeORM 엔티티·리포지토리·데이터소스를 Nest 모듈에 연결해 관계형 데이터를 다룹니다.

2장에서는 NestJS의 핵심 개념을 깊이 살펴봤습니다. 3장에서는 애플리케이션의 또 다른 중요한 축인 데이터베이스 연동을 다룹니다. 대부분의 웹 애플리케이션은 사용자, 게시물, 상품 같은 데이터를 오래 저장하고 관리해야 하므로 데이터베이스 연동이 꼭 필요합니다.

NestJS는 여러 데이터베이스 도구를 지원하며, 그 가운데 TypeORM은 관계형 데이터베이스(RDBMS)와 연동할 때 널리 쓰이는 도구입니다. TypeORM은 TypeScript 기반의 ORM(Object-Relational Mapping) 라이브러리입니다. ORM은 객체 지향 언어의 객체와 관계형 데이터베이스의 테이블을 대응시켜 주는 기술로, 복잡한 SQL을 직접 쓰는 대신 익숙한 객체 지향 코드로 데이터베이스를 다룰 수 있게 해 줍니다. 이 교재는 2026년 5월에 1.0이 나온 TypeORM 1.x(현재 1.1)와 @nestjs/typeorm 12를 기준으로 합니다.

DataSource가 메타데이터를 조립하고 서비스가 저장소 계약을 실행한다

설정/마이그레이션·DataSource/entity 메타데이터 준비 뒤 runtime service의 Repository/SQL 실행으로 넘어간다.

Nest TypeORM · 시작과 쿼리

forRoot는 연결을, forFeature는 피처 모듈의 Repository 토큰을 준비합니다

forRoot의 엔티티 목록만으로 모든 피처 모듈에 저장소가 공개되지는 않습니다. 서비스가 주입된 Repository로 CRUD를 실행하며, 명시적 SQL과 트랜잭션 manager는 확장할 때의 별도 경계입니다.

DataSource가 메타데이터를 조립하고 서비스가 저장소 계약을 실행한다 부트스트랩에서 설정과 마이그레이션이 DataSource와 엔티티 메타데이터를 준비하고, 런타임에서 서비스가 Repository 또는 매개변수 SQL을 통해 데이터베이스에 접근하는 두 단계 흐름이다. BOOTSTRAP PHASE CONFIG · SCHEMA 검토된 스키마 synchronize: false TYPEORM DataSource forRoot 설정 ASSEMBLE Entity · feature forFeature([User]) REQUEST PHASE USE CASE Nest service DEFAULT DATA PATH Repository<Entity> OPTIONAL QUERY dataSource.sql 값은 자동 매개변수화 DATABASE 제약 · 인덱스 DB 실행 계획 검증 트랜잭션 안의 저장소는 전달된 manager에서 얻습니다
요청 실행과 저장소 주입을 구분하는 TypeORM 흐름 실행 경로는 Controller, UsersService, Repository, DataSource, MySQL이다. forFeature 저장소 토큰 등록은 서비스 실행 전의 주입 전제다. create는 메모리 객체를 만들고 save는 DB 쓰기를 수행한다. 점선은 forFeature의 등록·주입 의존성을 가리키며 HTTP 실행 경로와 다르다. forFeature([User])시작 시 저장소 등록 HTTP Controller 요청 값 → 서비스 UsersService Repository 토큰 주입 Repository<User> create: 객체만 생성 save: DB 쓰기 DataSource 연결 설정 · 드라이버 MySQL DB 제약 검사 · 결과 반환
  1. 스키마 준비

    운영은 synchronize: false와 검토된 마이그레이션으로 스키마를 변경합니다.

  2. DataSource 초기화

    forRoot로 연결과 엔티티 metadata를 준비하고, 소비 피처 module의 forFeature([User])로 Repository 토큰을 등록합니다.

  3. Repository 실행

    서비스는 주입된 Repository<Entity>로 일반 CRUD를 수행합니다.

  4. 명시적 SQL

    확장 시 dataSource.sql 같은 지원 API로 값을 매개변수화할 수 있습니다. 원문의 문자열 조합 SQL은 이 경로가 아닙니다.

  5. 트랜잭션 경계

    콜백 안의 모든 작업은 전달된 manager에서 저장소를 다시 얻어 실행합니다.

원문 forRoot는 로컬 학습용 synchronize: true다. 운영은 마이그레이션을 사용한다. raw SQL 문자열 삽입 예시는 매개변수화 구현이 아니므로 외부 입력을 그대로 결합하지 않는다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 부트스트랩에서 설정과 마이그레이션이 DataSource와 엔티티 메타데이터를 준비하고, 런타임에서 서비스가 Repository 또는 매개변수 SQL을 통해 데이터베이스에 접근하는 두 단계 흐름이다.
  • 실행 경로는 Controller, UsersService, Repository, DataSource, MySQL이다. forFeature 저장소 토큰 등록은 서비스 실행 전의 주입 전제다. create는 메모리 객체를 만들고 save는 DB 쓰기를 수행한다. 점선은 forFeature의 등록·주입 의존성을 가리키며 HTTP 실행 경로와 다르다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
DataSource 준비

설정·migration이 DataSource와 entity metadata를 준비합니다.

시점: 부트스트랩 · 자료: entity metadataruntime 조회 기반이 조립됩니다.
서비스 저장소 사용

runtime service가 Repository 또는 매개변수 SQL을 씁니다.

소유: service · 접근: Repository·SQL업무 입력이 저장소 계약을 넘습니다.
DB 결과 반환

DB 실행 결과를 서비스에 돌려줍니다.

자료: DB 결과 · migration: 요청 단계 아님조립과 요청 실행의 결과를 분리합니다.

DataSource가 메타데이터를 조립하고 서비스가 저장소 계약을 실행한다

설정/마이그레이션·DataSource/entity 메타데이터 준비 뒤 runtime service의 Repository/SQL 실행으로 넘어간다.

Nest TypeORM · 시작과 쿼리

forRoot는 연결을, forFeature는 피처 모듈의 Repository 토큰을 준비합니다

forRoot의 엔티티 목록만으로 모든 피처 모듈에 저장소가 공개되지는 않습니다. 서비스가 주입된 Repository로 CRUD를 실행하며, 명시적 SQL과 트랜잭션 manager는 확장할 때의 별도 경계입니다.

DataSource가 메타데이터를 조립하고 서비스가 저장소 계약을 실행한다 부트스트랩에서 설정과 마이그레이션이 DataSource와 엔티티 메타데이터를 준비하고, 런타임에서 서비스가 Repository 또는 매개변수 SQL을 통해 데이터베이스에 접근하는 두 단계 흐름이다. BOOTSTRAP PHASE CONFIG · SCHEMA 검토된 스키마 synchronize: false TYPEORM DataSource forRoot 설정 ASSEMBLE Entity · feature forFeature([User]) REQUEST PHASE USE CASE Nest service DEFAULT DATA PATH Repository<Entity> OPTIONAL QUERY dataSource.sql 값은 자동 매개변수화 DATABASE 제약 · 인덱스 DB 실행 계획 검증 트랜잭션 안의 저장소는 전달된 manager에서 얻습니다
요청 실행과 저장소 주입을 구분하는 TypeORM 흐름 실행 경로는 Controller, UsersService, Repository, DataSource, MySQL이다. forFeature 저장소 토큰 등록은 서비스 실행 전의 주입 전제다. create는 메모리 객체를 만들고 save는 DB 쓰기를 수행한다. 점선은 forFeature의 등록·주입 의존성을 가리키며 HTTP 실행 경로와 다르다. forFeature([User])시작 시 저장소 등록 HTTP Controller 요청 값 → 서비스 UsersService Repository 토큰 주입 Repository<User> create: 객체만 생성 save: DB 쓰기 DataSource 연결 설정 · 드라이버 MySQL DB 제약 검사 · 결과 반환
  1. 스키마 준비

    운영은 synchronize: false와 검토된 마이그레이션으로 스키마를 변경합니다.

  2. DataSource 초기화

    forRoot로 연결과 엔티티 metadata를 준비하고, 소비 피처 module의 forFeature([User])로 Repository 토큰을 등록합니다.

  3. Repository 실행

    서비스는 주입된 Repository<Entity>로 일반 CRUD를 수행합니다.

  4. 명시적 SQL

    확장 시 dataSource.sql 같은 지원 API로 값을 매개변수화할 수 있습니다. 원문의 문자열 조합 SQL은 이 경로가 아닙니다.

  5. 트랜잭션 경계

    콜백 안의 모든 작업은 전달된 manager에서 저장소를 다시 얻어 실행합니다.

원문 forRoot는 로컬 학습용 synchronize: true다. 운영은 마이그레이션을 사용한다. raw SQL 문자열 삽입 예시는 매개변수화 구현이 아니므로 외부 입력을 그대로 결합하지 않는다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 부트스트랩에서 설정과 마이그레이션이 DataSource와 엔티티 메타데이터를 준비하고, 런타임에서 서비스가 Repository 또는 매개변수 SQL을 통해 데이터베이스에 접근하는 두 단계 흐름이다.
  • 실행 경로는 Controller, UsersService, Repository, DataSource, MySQL이다. forFeature 저장소 토큰 등록은 서비스 실행 전의 주입 전제다. create는 메모리 객체를 만들고 save는 DB 쓰기를 수행한다. 점선은 forFeature의 등록·주입 의존성을 가리키며 HTTP 실행 경로와 다르다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
DataSource 준비

설정·migration이 DataSource와 entity metadata를 준비합니다.

시점: 부트스트랩 · 자료: entity metadataruntime 조회 기반이 조립됩니다.
서비스 저장소 사용

runtime service가 Repository 또는 매개변수 SQL을 씁니다.

소유: service · 접근: Repository·SQL업무 입력이 저장소 계약을 넘습니다.
DB 결과 반환

DB 실행 결과를 서비스에 돌려줍니다.

자료: DB 결과 · migration: 요청 단계 아님조립과 요청 실행의 결과를 분리합니다.

그림의 위쪽은 시작 단계에서 TypeOrmModule.forRoot()가 연결과 엔티티 메타데이터를 준비하고, 피처 모듈의 forFeature([User])가 Repository 토큰을 등록하는 흐름입니다. 아래쪽은 요청 단계에서 컨트롤러가 받은 값을 UsersService에 넘기고, 서비스가 주입받은 Repository<User>로 데이터베이스에 접근하는 흐름이며, create()는 메모리에서 객체만 만들고 save()가 실제로 DB에 씁니다. 점선은 HTTP 실행 경로가 아니라 forFeature의 등록과 주입 관계를 가리킵니다. 맨 아래 줄은 예제의 synchronize: true가 로컬 학습용이고, 운영에서는 마이그레이션을 쓴다는 점을 짚습니다.


ORM을 쓰는 이유

User라는 객체를 데이터베이스의 users 테이블에 저장하고 싶다고 해 봅시다. ORM 없이 SQL을 직접 쓰면 다음과 같습니다.

// 사용자 데이터를 데이터베이스에 삽입하는 SQL 쿼리
const username = 'Alice';
const email = '[email protected]';
const sql = `INSERT INTO users (username, email) VALUES ('${username}', '${email}')`;
// 데이터베이스 드라이버를 통해 SQL 실행...

이 코드는 객체 저장과 SQL 작성의 차이를 보여 주는 비교용 발췌입니다. 외부 입력을 문자열에 그대로 끼워 넣어 실행하면, 작은따옴표가 들어간 이름에서 문법이 깨지거나 SQL 삽입(SQL injection) 취약점이 생길 수 있습니다. 이미 연결한 mysql2/promise의 connection으로 실행한다면 SQL의 구조와 값을 나눠서 넘깁니다.

await connection.execute(
  'INSERT INTO users (username, email) VALUES (?, ?)',
  [username, email],
);

?는 나중에 문자열을 이어 붙이라는 뜻이 아니라, 드라이버에 별도의 인자로 넘길 값의 자리입니다. ORM을 고른다고 해서 직접 쓰는 SQL이나 입력 검증에 대한 주의가 사라지지는 않습니다. TypeORM으로는 같은 저장을 다음처럼 씁니다.

// User 엔티티 객체 생성
const newUser = new User();
newUser.username = 'Alice';
newUser.email = '[email protected]';

// TypeORM의 리포지토리(Repository)를 통해 객체 저장
await userRepository.save(newUser);

ORM을 쓰면 SQL을 직접 쓰지 않고 자바스크립트 객체를 다루듯 데이터베이스 작업을 할 수 있습니다. SQL을 직접 쓰고 관리하는 수고가 줄어 개발이 빨라지고, 객체 지향 방식으로 다루므로 코드가 읽기 쉬워집니다. 대부분의 ORM은 MySQL, PostgreSQL, SQLite, SQL Server 같은 여러 데이터베이스를 지원하므로 데이터베이스를 바꿀 때 고칠 코드가 줄고, TypeORM은 TypeScript의 타입으로 일부 오류를 컴파일 단계에서 잡아 줍니다. 객체 모델과 관계형 모델 사이의 차이(impedance mismatch)도 줄여 줍니다.

물론 ORM이 모든 상황의 해결책은 아닙니다. 아주 복잡하고 최적화가 중요한 쿼리라면 SQL을 직접 쓰는 편이 나을 수도 있습니다. 다만 대부분의 애플리케이션에서는 ORM의 장점이 더 큽니다.


엔티티, 리포지토리, 데이터소스

엔티티

엔티티(entity)는 데이터베이스 테이블에 대응하는 TypeScript 클래스입니다. 엔티티 인스턴스 하나가 테이블의 한 행이고, 클래스의 속성이 테이블의 열에 대응합니다. @Entity(), @PrimaryGeneratedColumn(), @Column() 같은 데코레이터로 스키마를 정의합니다.

src/users/entities/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('users') // 'users' 테이블과 매핑됩니다.
export class User {
  @PrimaryGeneratedColumn() // 기본 키(Primary Key)이자 자동 증가하는 숫자 컬럼
  id: number;

  @Column({ type: 'varchar', length: 100, unique: true }) // 문자열 타입, 최대 길이 100, 유니크 제약 조건
  username: string;

  @Column() // 타입이 명시되지 않으면 자동으로 추론됩니다 (여기서는 string).
  email: string;

  @Column({ default: true }) // 기본값 설정
  isActive: boolean;
}

이 User 엔티티는 users 테이블에 대응하며, id, username, email, isActive 열을 가집니다.

리포지토리

리포지토리(repository)는 특정 엔티티의 데이터베이스 작업을 맡는 객체입니다. save(), find(), findOneBy(), delete() 같은 메서드로 엔티티를 생성, 조회, 수정, 삭제(CRUD)할 수 있게 해 주며, NestJS에서는 TypeORM 모듈을 통해 Repository나 EntityManager를 의존성 주입으로 받습니다.

src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) // User 엔티티에 대한 Repository를 주입받습니다.
    private usersRepository: Repository<User>,
  ) {}

  async createUser(username: string, email: string): Promise<User> {
    const newUser = this.usersRepository.create({ username, email, isActive: true });
    return this.usersRepository.save(newUser); // 데이터베이스에 저장
  }

  async findAllUsers(): Promise<User[]> {
    return this.usersRepository.find(); // 모든 사용자 조회
  }

  async findUserById(id: number): Promise<User | null> {
    return this.usersRepository.findOneBy({ id }); // ID로 사용자 한 명 조회
  }

  async deleteUser(id: number): Promise<void> {
    await this.usersRepository.delete(id); // ID로 사용자 삭제
  }
}

repository.create()는 메모리에서 엔티티 객체를 만들 뿐이고, save()가 실제로 DB에 씁니다. findOneBy()는 일치하는 행이 없으면 null을 돌려줍니다. 위 deleteUser()는 삭제 결과를 돌려주지 않으므로 호출하는 쪽은 실제로 몇 행이 지워졌는지 알 수 없으며, 없는 사용자에게 404를 응답하려면 삭제 결과와 HTTP 오류 정책을 따로 구현해야 합니다.

TypeORM 1.0부터는 where 조건에 null이나 undefined가 들어가면 기본으로 TypeORMError를 던집니다. 0.3에서는 이런 값이 조용히 무시되어 findOneBy({ id: undefined })가 조건 없는 조회처럼 동작했지만, 0.3에서 1.0으로 올리는 안내는 이런 버그를 막으려고 기본값을 바꿨다고 설명합니다. null인 값을 찾으려면 IsNull() 연산자를 씁니다. 이 밖에 findByIds()는 사라져 findBy({ id: In([...]) })로, relations와 select의 문자열 배열 문법은 { profile: true } 같은 객체 문법으로 바뀌었습니다.

데이터소스

데이터소스(DataSource)는 TypeORM이 데이터베이스와 통신하기 위한 연결 설정을 담는 객체입니다. 어떤 데이터베이스를 쓸지, 호스트·포트·사용자명·비밀번호 같은 연결 정보, 사용할 엔티티 목록을 정하며, NestJS 애플리케이션이 시작할 때 이 설정으로 연결을 맺습니다. NestJS에서는 보통 루트 모듈에서 TypeOrmModule.forRoot()로 이 연결을 설정합니다.

src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; // TypeORM 모듈 임포트
import { User } from './users/entities/user.entity'; // 엔티티 임포트
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql', // 사용할 데이터베이스 타입 (예: 'mysql', 'postgres', 'sqlite')
      host: 'localhost',
      port: 3306,
      username: 'your_username',
      password: 'your_password',
      database: 'your_database_name',
      entities: [User], // 애플리케이션에서 사용할 엔티티 목록을 여기에 추가합니다.
      synchronize: true, // 개발 환경에서만 true로 설정하여 엔티티에 따라 테이블을 자동으로 생성/업데이트
      logging: true, // SQL 쿼리 로깅 활성화 (개발 시 유용)
    }),
    UsersModule, // 사용자 모듈도 임포트
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

synchronize: true는 엔티티 클래스가 바뀌면 데이터베이스 스키마를 자동으로 맞춰 주므로 개발할 때 편리합니다. 하지만 운영 환경에서는 데이터가 사라질 위험이 있어 절대 켜면 안 되며, 마이그레이션 도구로 스키마 변경을 관리해야 합니다.


NestJS와 TypeORM 설정 순서

먼저 TypeORM과 사용할 데이터베이스의 드라이버를 설치합니다. MySQL이라면 다음과 같으며, TypeORM 1.0부터 MySQL·MariaDB 드라이버는 mysql2만 지원합니다. 새 Nest 프로젝트에는 @types/node가 이미 개발 의존성으로 들어 있습니다.

npm install @nestjs/typeorm typeorm mysql2

두 명령은 마지막 드라이버 패키지만 다르므로, 한 프로젝트에서는 실제로 연결할 데이터베이스의 명령 하나만 실행합니다. PostgreSQL이라면 드라이버로 pg를 설치합니다. 이때는 앞의 forRoot() 설정에서도 type을 'postgres'로, 포트를 PostgreSQL의 기본값인 5432로 함께 바꿔야 합니다.

npm install @nestjs/typeorm typeorm pg

다음으로 @Entity()와 @Column() 같은 데코레이터로 테이블에 대응하는 엔티티를 만들고, 루트 모듈의 imports에 TypeOrmModule.forRoot()로 연결 정보와 엔티티를 등록합니다. 마지막으로 피처 모듈에서 TypeOrmModule.forFeature([User])로 리포지토리 토큰을 등록한 뒤, @InjectRepository()로 서비스에 Repository를 주입합니다. forRoot()의 엔티티 목록만으로는 모든 피처 모듈에 리포지토리가 저절로 공개되지 않습니다.

앞의 AppModule이 가져오는 UsersModule은 다음처럼 연결합니다. 이 발췌는 서비스의 리포지토리 주입을 완성할 뿐, HTTP 경로는 아직 만들지 않습니다.

src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

exports에 UsersService를 넣었으므로, UsersModule을 가져가는 다른 모듈도 이 서비스를 주입받을 수 있습니다. 반면 forFeature()가 등록한 User 리포지토리는 내보내지 않았으므로, 이 모듈 바깥에서는 @InjectRepository(User)로 주입받을 수 없습니다. 다른 모듈은 리포지토리를 직접 쓰지 않고 UsersService를 거쳐 사용자 데이터에 접근합니다.


저장 결과 따라가기

MySQL 서버와 your_database_name 데이터베이스가 먼저 있어야 하고, 예시의 계정·비밀번호·포트를 실제 접속 정보로 바꿔야 합니다. npm 패키지 설치나 forRoot()가 DB 서버나 계정을 만들어 주지는 않습니다. 엔티티는 테이블의 대응 관계를 선언하고, 개발용 synchronize나 마이그레이션이 연결된 DB의 구조에 그것을 반영합니다.

UsersService를 주입받은 코드에서 createUser('Alice', '[email protected]')를 호출하고, 반환된 사용자의 id로 findUserById(id)를 호출해 봅니다. 새로 저장할 때 만들어지는 ID는 DB의 상태에 따라 달라지므로 1이라고 가정하지 않습니다. 조회 결과의 이름, 이메일, isActive가 저장한 값과 같은지 확인하고, 삭제한 뒤 같은 ID를 조회하면 null이 나오는지 확인합니다. 같은 이름으로 다시 생성하면 unique 제약이 있는 DB에서 저장 오류가 나지만, 그 오류가 저절로 HTTP 409로 바뀌지는 않습니다.

TypeORM은 일대일·일대다·다대다 관계, 트랜잭션, 쿼리 빌더도 제공합니다. 이 글은 기본 개념과 연동 방법에 집중했고, 트랜잭션 관리와 데이터 정합성에서 트랜잭션의 범위를 이어서 다룹니다.

더 깊이: save()는 저장하기 전에 무엇을 조회할까

save()는 메서드 하나로 삽입과 수정을 함께 맡습니다. 엔티티가 이미 DB에 있으면 수정하고 없으면 삽입하는데, 이를 판단하려고 TypeORM의 영속화 코드는 기본 키 값이 있는 엔티티의 원래 행을 먼저 DB에서 읽어 옵니다. 같은 종류의 엔티티를 여러 개 저장하면 이 조회를 WHERE IN 하나로 묶습니다. 반대로 id가 비어 있어 DB가 키를 만들어 줄 새 엔티티는 읽어 올 행이 없으므로 이 단계를 건너뜁니다. 그래서 조회한 엔티티를 고쳐 다시 save()하면, UPDATE 앞에 원래 행을 읽는 SELECT가 함께 실행됩니다.

저장 과정에서 생기는 쿼리는 기본적으로 하나의 트랜잭션으로 묶이며, save(users, { transaction: false })처럼 끌 수 있습니다. 또 값이 undefined인 속성은 건너뛰므로, 일부 필드만 담은 객체로 부분 수정을 할 수도 있습니다. 반면 insert()는 쿼리 빌더로 INSERT 문을 바로 만들어 실행할 뿐 원래 행을 읽어 비교하지 않습니다. 새 행만 대량으로 넣는다면 insert()가 더 단순한 경로이고, 아주 많은 엔티티를 save()해야 한다면 chunk 옵션으로 여러 묶음으로 나눠 드라이버의 매개변수 수 제한을 피할 수 있습니다.


정리

  • 엔티티는 테이블에 대응하는 클래스이고, 리포지토리는 그 엔티티의 CRUD를 맡으며, 데이터소스는 연결 설정을 담습니다.
  • forRoot()는 연결과 엔티티를 준비하고, 피처 모듈의 forFeature()가 리포지토리 토큰을 등록해야 서비스에 주입할 수 있습니다.
  • create()는 객체만 만들고 save()가 DB에 쓰며, synchronize: true는 개발용이고 운영에서는 마이그레이션을 씁니다.
  • TypeORM 1.0부터 where의 null·undefined는 기본으로 오류이고, 관계와 선택은 객체 문법으로 씁니다.

연습 문제

1. 연결은 되는데 주입이 안 될 때

DB 연결은 성공했지만 UsersService의 Repository<User>를 찾을 수 없다는 오류가 납니다. forRoot()에 User를 넣었는데도 왜 문제가 남나요? 또 repository.create()까지 성공한 객체가 DB 조회에 나타나지 않는 이유는 무엇인가요?

해설 보기

DB 연결과 피처 모듈의 리포지토리 등록은 다른 단계입니다. 서비스를 선언한 모듈의 imports에 TypeOrmModule.forFeature([User])가 있는지 확인합니다.

두 번째 경우에는 create()가 객체만 만든다는 점을 확인하고, 실제로 save()를 호출하고 그 결과를 기다렸는지 살펴봅니다. 두 상황 모두 DB 서버를 다시 설치해서 해결할 문제가 아닙니다.

2. 중복 이름의 저장 오류

unique: true인 username에 이미 'Alice'가 있는 상태에서 createUser('Alice', '[email protected]')를 호출합니다. 서비스는 어떤 결과를 받고, 이 오류를 컨트롤러가 그대로 던지면 HTTP 응답은 어떻게 되나요? 409로 응답하려면 무엇이 더 필요한가요?

해설 보기

save()가 DB의 고유 제약 조건 위반으로 실패하므로, 서비스는 TypeORM이 감싼 쿼리 오류를 받습니다. 이것은 HttpException이 아니므로, 그대로 던지면 기본 예외 처리가 500으로 응답합니다.

409로 응답하려면 이 쿼리 오류를 잡아 DB의 오류 코드를 확인한 뒤 ConflictException 같은 HTTP 예외로 바꾸거나, 그런 변환을 맡는 예외 필터를 따로 둬야 합니다. 저장 전에 같은 이름이 있는지 조회하는 방법만으로는 동시에 들어온 두 요청을 막지 못하므로, 제약 조건 위반을 처리하는 경로는 여전히 필요합니다.

3. undefined인 ID로 조회하면

경로 매개변수를 잘못 읽어 id가 undefined인 채로 findUserById(id)가 호출됐습니다. TypeORM 0.3과 1.0에서 findOneBy({ id })의 결과는 각각 어떻게 되나요? 어느 쪽이 버그를 더 빨리 드러내나요?

해설 보기

0.3에서는 undefined인 조건이 조용히 무시되어, 조건 없는 조회처럼 테이블의 첫 행을 돌려줄 수 있었습니다. 엉뚱한 사용자가 조회되는데도 오류가 나지 않습니다.

1.0에서는 invalidWhereValuesBehavior의 기본값이 throw이므로 TypeORMError가 납니다. 잘못된 입력이 오류로 바로 드러나므로 버그를 더 빨리 찾을 수 있습니다. 그래도 근본적인 해결은 파이프로 ID를 검증해, 서비스에 undefined가 넘어가지 않게 하는 것입니다.

다음 글 Mongoose를 활용한 MongoDB 연동에서는 관계형 데이터베이스 대신 문서 기반 데이터베이스인 MongoDB를 Mongoose로 연결하는 방법을 다룹니다.