본문 바로가기
NestJS

[NestJS] Transaction

by Programmer.Junny 2025. 3. 10.

Transaction 이란?

여러 데이터베이스 연산을 하나의 작업 단위로 묶어서 모두 성공하거나 모두 실패하도록 처리하는 메커니즘이다.

예를 들어, 은행 계좌 간의 송금 시 출금과 입금 작업이 모두 성공해야만 거래가 완료되도록 하는 상황에서 사용된다.

ACID 원칙

트랜잭션은 데이터의 신뢰성과 일관성을 보장하기 위해 ACID 원칙을 따른다.

  • Atomicity (원자성):
    • 트랜잭션 내의 모든 작업은 하나의 단위로 실행되며, 하나라도 실패하면 전체 작업이 취소된다.
  • Consistency (일관성):
    • 트랜잭션 수행 전후에 데이터베이스가 정의된 규칙(제약조건, 트리거 등)을 항상 만족해야 한다.
  • Isolation (격리성):
    • 각 트랜잭션은 독립적으로 실행되어 다른 트랜잭션의 영향을 받지 않는다. (단, 격리 수준에 따라 다르게 적용될 수 있다.)
  • Durability (지속성):
    • 트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 저장되어 시스템 오류 등에도 영향을 받지 않는다.

ImageModel 만들기

posts.entity.ts 의 image 필드 제거

@Column({
    nullable: true,
  })
@Transform(({ value }: { value: unknown }): string | undefined => {
if (typeof value === 'string') {
  // value가 string이면 경로 문자열 반환, 그렇지 않으면 undefined 반환
  return `/${join(POST_PUBLIC_IMAGE_PATH, value)}`;
}
return undefined;
})
image: string;

여러 이미지를 등록하고 불러올 수 있도록 하기 위하여 기존 image 필드를 제거하도록 한다.

common/entity 경로에 image.entity.ts 파일 생성

/* eslint-disable @typescript-eslint/no-unsafe-return */
import { Column, Entity, ManyToOne } from "typeorm";
import { BaseModel } from "./base.entity";
import { IsEnum, IsInt, IsOptional, IsString } from "class-validator";
import { Transform } from "class-transformer";
import { join } from "path";
import { POST_IMAGE_PATH } from "../const/path.const";
import { PostsModel } from "src/posts/entities/posts.entity";

export enum ImageModelType {
    POST_IMAGE,
}

@Entity()
export class ImageModel extends BaseModel {
    @Column({
        default: 0,
    })
    @IsInt()
    @IsOptional()
    order: number;

    /**
     * UsersModel -> 사용자 프로필 이미지
     * PostsModel -> 포스트 이미지
     */
    @Column({
        enum: ImageModelType,
    })
    @IsEnum(ImageModelType)
    type: ImageModelType;

    @Column()
    @IsString()
    @Transform(({value, obj}) => {
        if(obj.type === ImageModelType.POST_IMAGE) {
            return join(
                POST_IMAGE_PATH,
                value,
            );
        } else {
            return value;
        }
    })
    path: string;

    @ManyToOne(() => PostsModel, (post) => post.images)
    post?: PostsModel;
}

Image는 Post와 다대일 관계이다. (글 하나당 여러개의 이미지를 첨부할 수 있으니 이미지가 N, 포스트가 1)

PostsModel 에 images 필드 추가

@OneToMany(() => ImageModel, (image) => image.post)
images: ImageModel[];

반대로 포스트는 OneToMany로 설정한다.

app.module.ts의 entities에 ImageModel 등록

entities: [
        PostsModel,
        UsersModel,
        ImageModel
      ],

ImageModel 작성이 완료되었으면 모델을 등록하기 위해 app.module.tsentities에 등록해준다.

ImageModel 생성하는 로직 작성하기

CreatePostDto 필드 수정

import { PickType } from "@nestjs/mapped-types";
import { PostsModel } from "../entities/posts.entity";
import { IsOptional, IsString } from "class-validator";

export class CreatePostDto extends PickType(PostsModel, ['title', 'content']) {
    @IsString({
        each: true,
    })
    @IsOptional()
    images: string[] = [];
}

Post를 생성할 때 클라이언트에서 데이터로 값을 받아오는 것을 DTO라고 한다. 이전 CreatePostDto에서 이미지들을 받을 수 있도록 프로퍼티를 작성해준다. 참고로 배열을 검증할 때는 each를 true로 해줘야한다.

CreatePostImageDto 생성하기

import { PickType } from "@nestjs/mapped-types";
import { ImageModel } from "src/common/entity/image.entity";

export class CreatePostImageDto extends PickType(ImageModel, [
    'path',
    'post',
    'order',
    'type',
]) {}

 

CreatePostImageDto는 CreatePostDto의 프로퍼티와 images 프로퍼티로부터 데이터를 받아온다. CreatePostImageDto를 가지고 ImageModel을 DB에 저장할 수 있게 된다.

posts.service.ts - createPostImage 수정하기

  constructor(
    @InjectRepository(PostsModel)
    private readonly postsRepository: Repository<PostsModel>,
    @InjectRepository(ImageModel)
    private readonly imageRepository: Repository<ImageModel>,
    private readonly commonServices: CommonService,
    private readonly configService: ConfigService,
  ) {}

PostsService 의 생성자에 ImageModel Repository를 쓸 수 있도록 수정한다.

 

async createPostImage(dto: CreatePostImageDto) {
    // dto의 이미지 이름을 기반으로 파일의 경로를 생성한다.
    const tempFilePath = join(
      TEMP_FOLDER_PATH,
      dto.path,
    );

    try {
      // 파일이 존재하는지 확인. 파일이 없는 경우 에러발생.
      await promises.access(tempFilePath);
    } catch (error) {
      throw new BadRequestException(`${error}: 존재하지 않는 파일입니다.`);
    }

    // 파일 이름만 가져오기
    const fileName = basename(tempFilePath);

    // 새로 이동할 포스트 폴더 이름 + 이미지 이름
    const newPath = join(
      POST_IMAGE_PATH,
      fileName,
    );

    // save -> 파일 옮기기전에 하는 이유는 rollback 시 파일 옮기기가 실행되지 않기 때문
    const result = this.imageRepository.save({
      ...dto,
    });

    // 파일 옮기기
    await promises.rename(tempFilePath, newPath);

    return result;
  }

createPostImage 메서드는 Post를 생성하는 postPosts 라우터에서 실행된다.

서버에 저장된 파일 이름을 가져와 데이터베이스에 저장하고, 파일의 위치를 temp -> post로 옮긴다.

posts.controller.ts - postPosts 수정

@Post()
  @UseGuards(AccessTokenGuard)
  async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    // @Body('title') title: string,
    // @Body('content') content: string,
  ) {
    const post = await this.postsService.createPost(userId, body);

    for(let i = 0; i < body.images.length; i++) {
      await this.postsService.createPostImage({
        post,
        order: i,
        path: body.images[i],
        type: ImageModelType.POST_IMAGE,
      });
    }

    return this.postsService.getPostById(post.id);
  }

postPosts에선 여러 개의 이미지를 받을 수 있으므로, for loop을 통해 createPostImage를 실행하도록 한다.

Images가 보여지도록 PostsModel의 Images 옵션에 egar true로 활성화

@OneToMany(() => ImageModel, (image) => image.post, {
    eager: true,
  })
images: ImageModel[];

images의 내용이 보이도록 하려면, eager 옵션을 true로 활성화한다.

Transaction 시작하기

@Post()
  @UseGuards(AccessTokenGuard)
  async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    // @Body('title') title: string,
    // @Body('content') content: string,
  ) {
    const post = await this.postsService.createPost(userId, body);

    throw new InternalServerErrorException('에러가 생겼습니다.');

    for(let i = 0; i < body.images.length; i++) {
      await this.postsService.createPostImage({
        post,
        order: i,
        path: body.images[i],
        type: ImageModelType.POST_IMAGE,
      });
    }

    return this.postsService.getPostById(post.id);
  }

postPosts에서 위와 같이 중간에 에러가 발생한다고 가정한다면, 에러가 발생하였으나 포스트가 생성되어버리는 상황이 발생한다. 심지어 중간에 에러가 발생한 것이기 때문에 Image는 생성되지 않게 된다.

DataSource 받아오기

export class PostsController {
  constructor(private readonly postsService: PostsService,
    private readonly dataSource: DataSource,
  ) {}

PostsController의 생성자에 DataSource를 받아오도록 인젝션한다.

QueryRunner 구현하기 (PostsController)

@Post()
  @UseGuards(AccessTokenGuard)
  async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    // @Body('title') title: string,
    // @Body('content') content: string,
  ) {
    /**
     * 트랜잭션과 관련된 모든 쿼리를 담당할
     * 쿼리 러너를 생성한다.
     */
    const qr = this.dataSource.createQueryRunner();

    // 쿼리 러너에 연결한다.
    await qr.connect();

    /**
     * 쿼리 러너에서 트랜잭션을 시작한다.
     * 이 시점부터 같은 쿼리 러너를 사용하면, 트랜잭션 안에서 데이터베이스 액션을 실행할 수 있다.
     */
    await qr.startTransaction();

    try {
      const post = await this.postsService.createPost(userId, body, qr);

      for(let i = 0; i < body.images.length; i++) {
        await this.postsImagesService.createPostImage({
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        }, qr);
      }

      await qr.commitTransaction();
      await qr.release();

      return this.postsService.getPostById(post.id);
    } catch (error) {
      /**
       * 어떤 에러든 에러가 발생하면,
       * 트랜잭션을 종료하고 원래 상태로 되돌린다.
       */
      await qr.rollbackTransaction();
      await qr.release();
      throw new InternalServerErrorException(error);
    }
  }

createPost 수정하기 (posts.service.ts)

getRepository(qr?: QueryRunner) {
    return qr ? qr.manager.getRepository<PostsModel>(PostsModel) : this.postsRepository;
  }

qr의 존재여부를 체크해 위와 같이 반환해준다.

async createPost(authorId: number, postDTO: CreatePostDto, qr?: QueryRunner) {
    // 1) create -> 저장할 객체를 생성한다.
    // 2) save -> 객체를 저장한다 (create 메서드로 생성한 객체로)
    const repository = this.getRepository(qr);

    const post = repository.create({
      author: {
        id: authorId,
      },
      ...postDTO,
      images: [],
      likeCount: 0,
      commentCount: 0,
    });

    const newPost = await repository.save(post);

    return newPost;
  }

기존 this.postsRepositoryrepository로 변경한다.

createPostImage 수정하기 (images.service.ts)

import { BadRequestException, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { ImageModel } from "src/common/entity/image.entity";
import { QueryRunner, Repository } from "typeorm";
import { POST_IMAGE_PATH, TEMP_FOLDER_PATH } from 'src/common/const/path.const';
import { basename, join } from 'path';
import { promises } from 'fs';
import { CreatePostImageDto } from "./dto/create-image.dto";

@Injectable()
export class PostsImagesService {
    constructor(
        @InjectRepository(ImageModel)
        private readonly imageRepository: Repository<ImageModel>
    ) {}

    getRepository(qr?: QueryRunner) {
        return qr ? qr.manager.getRepository<ImageModel>(ImageModel) : this.imageRepository;
    }

    async createPostImage(dto: CreatePostImageDto, qr?: QueryRunner) {
        const repository = this.getRepository(qr);

        // dto의 이미지 이름을 기반으로 파일의 경로를 생성한다.
        const tempFilePath = join(
            TEMP_FOLDER_PATH,
            dto.path,
        );

        try {
            // 파일이 존재하는지 확인. 파일이 없는 경우 에러발생.
            await promises.access(tempFilePath);
        } catch (error) {
            throw new BadRequestException(`${error}: 존재하지 않는 파일입니다.`);
        }

        // 파일 이름만 가져오기
        const fileName = basename(tempFilePath);

        // 새로 이동할 포스트 폴더 이름 + 이미지 이름
        const newPath = join(
            POST_IMAGE_PATH,
            fileName,
        );

        // save -> 파일 옮기기전에 하는 이유는 rollback 시 파일 옮기기가 실행되지 않기 때문
        const result = repository.save({
            ...dto,
        });

        // 파일 옮기기
        await promises.rename(tempFilePath, newPath);

        return result;
    }
}

createPostImage 를 따로 service 파일을 만들어 관리하도록 구현한다.

테스트 결과

기존 마지막 post 아이디는 108번

postPosts 에 에러 추가

 const post = await this.postsService.createPost(userId, body, qr);

      throw new InternalServerErrorException('에러 발생');

      for(let i = 0; i < body.images.length; i++) {
        await this.postsImagesService.createPostImage({
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        }, qr);
      }

중간에 throw new InternalServerErrorException('에러 발생'); 를 넣어 트랜잭션이 잘 되는지 테스트해보자.

postPosts 실행

결과

에러가 발생하여도 post가 추가로 생성되지 않는 것을 볼 수 있다. 이로써 QueryRunnerrollbackTransaction가 작동된 것을 알 수 있다.

'NestJS' 카테고리의 다른 글

[NestJS] Exception Filter  (0) 2025.03.10
[NestJS] Interceptor  (0) 2025.03.10
[NestJS] 파일 업로드 (선 업로드 방식)  (0) 2025.03.07
[NestJS] 파일 업로드 (클래식 방식)  (0) 2025.03.07
[NestJS] Config 모듈 사용하기  (0) 2025.03.05

최근댓글

최근글

skin by © 2024 ttuttak