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.ts의 entities에 등록해준다.
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.postsRepository를 repository로 변경한다.
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가 추가로 생성되지 않는 것을 볼 수 있다. 이로써 QueryRunner의 rollbackTransaction가 작동된 것을 알 수 있다.
'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 |