데이터베이스 작업이 중간에 실패하면? NestJS 트랜잭션이 구원한다

발행: (2026년 6월 10일 AM 03:18 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

간단한 송금 시나리오를 상상해 보세요. John이 친구 Sarah에게 돈을 보냅니다. 시스템은 John의 계좌에서 돈을 성공적으로 차감하지만, Sarah의 계좌에 입금하기 전에 애플리케이션이 크래시됩니다. 적절한 보호 장치가 없으면 John의 돈은 시스템에서 사라져 버려, 일관성 없고 신뢰할 수 없는 재무 기록이 만들어집니다.
이러한 문제를 방지하기 위해 데이터베이스 트랜잭션을 사용합니다. 트랜잭션은 관련된 여러 데이터베이스 작업이 모두 성공적으로 완료되거나 모두 실패하도록 보장합니다. 과정 중 어느 부분에서 오류가 발생하면 모든 변경 사항이 되돌려져 데이터베이스가 일관된 상태를 유지합니다.

트랜잭션은 데이터베이스 작업을 원자성 있게 만듭니다. 원자성은 트랜잭션 안의 모든 작업이 하나의 작업 단위로 취급된다는 의미입니다. 모든 작업이 성공해 커밋되든, 아니면 모든 작업이 실패해 롤백되든, 부분적인 업데이트가 영구적으로 저장되는 일은 없습니다.

데이터베이스 트랜잭션은 하나 이상의 데이터베이스 작업을 하나의 단위로 실행하는 것입니다. 모든 작업이 함께 성공하거나 함께 실패합니다. 이는 애플리케이션이 크래시되거나, 네트워크 장애가 발생하거나, 실행 중 예기치 않은 오류가 발생하더라도 데이터베이스 일관성을 보장합니다.

트랜잭션은 단순히 하나의 데이터베이스 쿼리와는 다릅니다. save, update, delete 같은 개별 쿼리는 데이터베이스와 직접 상호작용하지만, 트랜잭션은 여러 쿼리를 전부 혹은 전무(all-or-nothing) 경계 안에 감싸서 부분 업데이트를 방지하고 전체 과정에서 데이터 무결성을 보장합니다.

시작하기 전에 준비할 것

  • NestJS 기본 지식
  • TypeORM 기본 이해
  • PostgreSQL 기본 지식
  • TypeORM에서 기본 데이터베이스 작업(save, update, delete) 이해

이 글에서는 여러 데이터베이스 쓰기 또는 업데이트 작업을 함께 실행해야 할 때 트랜잭션 쿼리의 중요성을 보여주기 위해 간단한 NestJS 애플리케이션을 만들겠습니다.

nest new nestjs-transaction

프로젝트 루트에 config 폴더를 만들고 typeorm.config.ts 파일을 생성합니다.

필요한 패키지를 설치합니다. 여기에는 TypeORM, PostgreSQL 드라이버, 그리고 설정 패키지가 포함됩니다.

npm i @nestjs/typeorm @nestjs/config typeorm pg

config/typeorm.config.ts에 다음 코드를 붙여넣으세요:

import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { DataSource, DataSourceOptions } from 'typeorm';

ConfigModule.forRoot({ isGlobal: true });

const configService = new ConfigService();
const DB_PORT = configService.getOrThrow('DATABASE_PORT');

export const typeOrmConfig: TypeOrmModuleOptions = {
  type: 'postgres',
  host: configService.getOrThrow('HOST_NAME'),
  port: DB_PORT,
  username: configService.getOrThrow('DATABASE_USERNAME'),
  password: configService.getOrThrow('DATABASE_PASSWORD'),
  database: configService.getOrThrow('DATABASE_NAME'),
  entities: [__dirname + '/../**/*.entity.{js,ts}'],
  migrations: [__dirname + '/../src/migrations/*.{js,ts}'],
  synchronize: true,
};

export const dataSource = new DataSource(
  typeOrmConfig as DataSourceOptions,
);

프로젝트 루트에 .env 파일을 만들고 아래 값을 추가합니다. 자신의 데이터베이스 인증 정보로 교체하세요. nestjs_transaction이라는 PostgreSQL 데이터베이스를 미리 생성해 두어야 합니다.

HOST_NAME=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=your_username
DATABASE_PASSWORD=your_password
DATABASE_NAME=nestjs_transaction

src/user 폴더 안에 user.entity.ts 파일을 생성합니다:

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('user')
export class User {
 @PrimaryGeneratedColumn()
 id: number;

 @Column()
 name: string;

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

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

애플리케이션 모듈에 데이터베이스 설정을 등록합니다:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { typeOrmConfig } from '../config/typeorm.config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';

@Module({
 imports: [
   ConfigModule.forRoot({ isGlobal: true }),
   TypeOrmModule.forRoot(typeOrmConfig),
   UserModule,
 ],
 controllers: [AppController],
 providers: [AppService],
})
export class AppModule {}

User 모듈을 구성하여 엔티티, 레포지토리, 서비스, 컨트롤러를 등록합니다:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
import { UserService } from './user.service';
import { UserController } from './user.controller';

@Module({
 imports: [TypeOrmModule.forFeature([User])],
 providers: [UserRepository, UserService],
 controllers: [UserController],
})
export class UserModule {}

공식 NestJS 문서에서는 트랜잭션을 다룰 때 QueryRunner 클래스를 사용할 것을 권장합니다. QueryRunner는 트랜잭션 관리에 대한 완전한 제어권을 제공하기 때문입니다. TypeORM은 여러 트랜잭션 처리 방식을 지원하지만, QueryRunner가 가장 유연하고 트랜잭션 라이프사이클을 명확히 볼 수 있습니다.

전체 소스 코드는 이 글 말미에 링크된 GitHub 저장소에서 확인할 수 있습니다. 구현에는 부가적인 지원 코드가 포함되어 있기 때문에 여기서는 핵심 트랜잭션 로직만 다룹니다.

// Send 500 from John to Sarah — with QueryRunner transaction
// Validation (existence + balance) happens here in the service
async sendMoney(): Promise {
  const fromEmail = 'john@example.com';
  const toEmail = 'sarah@example.com';
  const amount = 500;

  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    const sender = await this.userRepository.findByEmail(
      fromEmail,
      queryRunner,
    );
    if (!sender) throw new NotFoundException(`User ${fromEmail} not found`);

    const receiver = await this.userRepository.findByEmail(
      toEmail,
      queryRunner,
    );
    if (!receiver) throw new NotFoundException(`User ${toEmail} not found`);

    if (Number(sender.balance) < amount) {
      throw new BadRequestException(
        `Insufficient balance. John has ${sender.balance}, needs ${amount}`,
      );
    }

    await this.userRepository.updateBalance(
      sender.id,
      Number(sender.balance) - amount,
      queryRunner,
    );
    await this.userRepository.updateBalance(
      receiver.id,
      Number(receiver.balance) + amount,
      queryRunner,
    );

    await queryRunner.commitTransaction();
    return {
      message: `Successfully transferred ${amount} from ${fromEmail} to ${toEmail}`,
    };
  } catch (err) {
    await queryRunner.rollbackTransaction();
    throw err;
  } finally {
    await queryRunner.release();
  }
}

await queryRunner.startTransaction(); 구문은 트랜잭션의 시작을 알립니다. 이 시점부터 모든 데이터베이스 작업은 트랜잭션 범위 안에서 실행됩니다. 실행 중에 변경이 이루어지지만, 트랜잭션이 커밋될 때까지는 데이터베이스에 영구적으로 저장되지 않습니다.

await queryRunner.commitTransaction(); 구문은 트랜잭션을 커밋합니다. 이 라인이 성공적으로 실행되면 트랜잭션 내에서 수행된 모든 변경이 영구적으로 적용되어 데이터베이스에 보이게 됩니다.

await queryRunner.rollbackTransaction(); 구문은 트랜잭션 내에서 이루어진 모든 변경을 되돌립니다. 이 구문은 catch 블록 안에 위치해 있기 때문에, 트랜잭션이 완료되기 전에 예외가 발생하면 언제든 실행됩니다.

0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...