본문 바로가기

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

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

2장에서는 NestJS의 핵심 개념들을 깊이 있게 살펴보았습니다.

이제 3장에서는 NestJS 애플리케이션의 또 다른 중요한 축인 데이터베이스 통합에 대해 다룹니다.

대부분의 웹 애플리케이션은 사용자 데이터, 게시물, 상품 정보 등 다양한 데이터를 영구적으로 저장하고 관리해야 하므로 데이터베이스 연동은 필수입니다.

NestJS는 다양한 데이터베이스 연동을 지원하지만, 그중에서도 TypeORM은 관계형 데이터베이스(RDBMS) 연동에서 가장 널리 쓰이는 도구 중 하나입니다.

TypeORM은 이름 그대로 TypeScript 기반 ORM(Object-Relational Mapping) 라이브러리입니다.

ORM은 객체 지향 언어의 객체와 관계형 데이터베이스 테이블을 자동으로 매핑하는 기술을 의미합니다.

덕분에 복잡한 SQL을 직접 작성하는 대신, 익숙한 객체 지향 코드로 데이터베이스를 다룰 수 있습니다.

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: 요청 단계 아님조립과 요청 실행의 결과를 분리합니다.

ORM이란 무엇이며 왜 사용할까요?

TypeORM 데이터베이스 연동을 다룰 때는 entity 정의, repository 주입, relation 처리, transaction 경계를 함께 살펴봅니다.

ORM을 이해하기 위해 간단한 예를 들어보겠습니다.

여러분이 User라는 객체를 가지고 있고, 이 객체를 데이터베이스의 users 테이블에 저장하고 싶다고 가정해 봅시다.

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

위 코드는 객체 저장과 SQL 작성의 차이를 보여 주는 비교용 발췌입니다. 외부 입력을 문자열에 직접 끼워 넣어 실행하면 작은따옴표가 포함된 이름에서 문법이 깨지거나 SQL 삽입 취약점이 생길 수 있습니다. 이미 연결한 mysql2/promise의 connection으로 실행한다면 SQL 구조와 값을 분리합니다.

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

?는 문자열 연결을 나중에 하라는 뜻이 아니라 드라이버에 별도 인자로 전달할 값의 자리입니다. ORM을 선택해도 직접 작성한 SQL이나 입력 검증의 책임까지 사라지지는 않습니다.

ORM을 사용하는 경우 (TypeORM)
// User 엔티티 객체 생성
const newUser = new User();
newUser.username = 'Alice';
newUser.email = '[email protected]';

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

보시다시피, ORM을 사용하면 SQL 쿼리를 직접 작성할 필요 없이, 마치 JavaScript 객체를 다루듯이 데이터베이스 작업을 수행할 수 있습니다.

ORM 사용의 주요 장점
  • 생산성 향상: SQL 쿼리를 직접 작성하고 관리하는 수고를 덜어 개발 속도를 높일 수 있습니다.
  • 유지보수 용이성: 객체 지향 패러다임으로 데이터베이스를 다루기 때문에 코드의 가독성과 유지보수성이 향상됩니다.
  • 데이터베이스 독립성: 대부분의 ORM은 다양한 데이터베이스(MySQL, PostgreSQL, SQLite, MS SQL Server 등)를 지원합니다. 데이터베이스를 변경하더라도 코드 수정이 최소화됩니다.
  • 타입 안전성 (TypeScript와 함께): TypeORM은 TypeScript의 타입 시스템을 활용하여, 컴파일 시점에 데이터베이스 관련 오류를 미리 감지할 수 있도록 돕습니다.
  • 객체-관계 불일치 해소: 객체 모델과 관계형 모델 간의 패러다임 불일치(Impedance Mismatch)를 줄여줍니다.

물론, ORM이 모든 상황에 완벽한 해결책은 아닙니다.

매우 복잡하고 최적화가 중요한 쿼리의 경우, 직접 SQL을 작성하는 것이 더 효율적일 수도 있습니다.

하지만 대부분의 애플리케이션에서는 ORM의 장점이 훨씬 크게 다가옵니다.


핵심 개념: 엔티티, 리포지토리, 데이터소스

TypeORM을 이해하고 사용하기 위해서는 몇 가지 핵심 개념을 알아야 합니다.

엔티티 (Entity): 엔티티는 데이터베이스 테이블과 매핑되는 TypeScript 클래스입니다.

각 엔티티 인스턴스는 테이블의 한 행(Row)에 해당하며, 엔티티 클래스의 속성(Property)은 테이블의 컬럼(Column)에 매핑됩니다.

@Entity(), @PrimaryColumn(), @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(), findOne(), delete() 등과 같은 메서드를 제공하여 엔티티 데이터를 생성, 조회, 수정, 삭제(CRUD)할 수 있도록 돕습니다.

SQL 쿼리 없이 객체 지향적인 방식으로 데이터베이스를 조작할 수 있게 해주는 핵심 인터페이스입니다.

NestJS에서는 TypeORM 모듈을 통해 EntityManager나 Repository를 의존성 주입으로 편리하게 사용할 수 있습니다.

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 오류 정책을 따로 구현해야 합니다.

데이터소스 (DataSource) / 연결 (Connection): 데이터소스는 TypeORM이 데이터베이스와 통신하기 위한 연결(Connection) 설정을 담당하는 객체입니다.

어떤 데이터베이스를 사용할지(MySQL, PostgreSQL 등), 연결 정보(호스트, 포트, 사용자명, 비밀번호), 사용할 엔티티 목록 등을 정의합니다.

NestJS 애플리케이션이 시작될 때 이 설정을 기반으로 데이터베이스 연결을 수립합니다.

NestJS에서는 일반적으로 TypeOrmModule.forRoot()를 사용하여 애플리케이션의 루트 모듈(AppModule)에서 이 데이터베이스 연결을 설정합니다.

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 옵션은 개발 환경에서 매우 유용합니다.

엔티티 클래스의 변경 사항에 따라 데이터베이스 스키마를 자동으로 동기화해 주기 때문입니다.

하지만 운영 환경에서는 절대 true로 설정하면 안 됩니다!

데이터 손실의 위험이 있기 때문에, 운영 환경에서는 마이그레이션 도구(Migration Tool)를 사용하여 스키마 변경을 관리해야 합니다.


NestJS와 TypeORM 설정 요약

NestJS에서 TypeORM을 통합하는 기본적인 단계는 다음과 같습니다.

필요한 패키지 설치: TypeORM과 사용할 데이터베이스 드라이버를 설치합니다.

예를 들어 MySQL을 사용한다면:

npm install @nestjs/typeorm typeorm mysql2
npm install --save-dev @types/node # Node.js 타입 정의 (TypeScript용)

또는 PostgreSQL을 사용한다면:

npm install @nestjs/typeorm typeorm pg

엔티티 정의: @Entity(), @Column() 등의 데코레이터를 사용하여 데이터베이스 테이블과 매핑되는 TypeScript 클래스를 만듭니다.

TypeOrmModule 설정: 루트 모듈(AppModule)의 imports 배열에 TypeOrmModule.forRoot()를 사용하여 데이터베이스 연결 정보를 설정하고, 정의한 엔티티들을 등록합니다.

리포지토리 주입 및 사용: 피처 모듈에서 TypeOrmModule.forFeature([User])로 저장소 토큰을 등록한 뒤, @InjectRepository()로 서비스에 Repository를 주입합니다. forRoot의 엔티티 목록만으로 모든 피처 모듈에 저장소가 자동 공개되지는 않습니다.

위 AppModule에서 import한 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 {}

저장 결과를 따라 확인하기

MySQL 서버와 your_database_name 데이터베이스가 먼저 존재하고, 예시의 계정·비밀번호·포트를 실제 접속 정보로 바꿔야 합니다. npm 패키지 설치나 forRoot()는 DB 서버와 계정을 생성하지 않습니다. 엔티티는 테이블의 매핑을 선언하며, 개발용 synchronize 또는 migration이 연결된 DB의 구조에 반영합니다.

UsersService를 주입받은 코드에서 createUser('Alice', '[email protected]')를 호출하고 반환된 사용자의 id로 findUserById(id)를 호출해 보세요. 새 저장에서 생성된 ID는 DB 상태에 따라 달라지므로 1이라고 가정하지 않습니다. 조회 결과의 이름·이메일·isActive가 저장한 값과 같은지 확인하고, 삭제 후 같은 ID를 조회하면 null인지 확인합니다. 중복 이름으로 다시 생성하면 unique 제약이 있는 DB에서 저장 오류가 발생할 수 있지만, 그 오류가 자동으로 HTTP 409로 변환되는 것은 아닙니다.

스스로 원인 구분하기

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

연결·주입·저장 경계로 풀이하기

DB 연결과 피처 모듈의 저장소 등록은 다른 단계입니다. 서비스를 선언한 모듈의 imports에 TypeOrmModule.forFeature([User])가 있는지 확인합니다. 두 번째 경우에는 create()가 객체만 만든다는 점을 확인하고, 실제 save()를 호출하고 기다렸는지 살펴봅니다. 두 상황 모두 DB 서버를 다시 설치하는 것으로 해결할 문제는 아닙니다.

특히 synchronize: true는 로컬 학습에는 편하지만 운영 환경에서는 마이그레이션과 분리해서 다루어야 합니다.

TypeORM은 일대일·일대다·다대다 관계, 트랜잭션, 사용자 정의 쿼리 빌더를 제공합니다.

이번 절에서는 기본 개념과 연동 방식에 집중했고, 이후 절에서는 관계 매핑, 트랜잭션 경계, 쿼리 빌더 사용 기준을 이어서 다룹니다.


이제는 NestJS 애플리케이션에 데이터베이스를 통합하는 첫걸음을 내디뎠습니다.