프로젝트를 진행하면서 한 번의 Post request가 들어왔을 때 다수의 엔티티 인스턴스를 생성 혹은 수정해야 하는 일이 생길 수 있다. 이럴 때에는 트랜잭션 단위로 묶어서 처리해 주어야 하는데 typeorm에서는 어떻게 트랜잭션 처리를 할 수 있도록 제공하는지 알아보자.
우선 트랜잭션
이란 무엇일까?
트랜잭션
이란 데이터베이스의 상태를 변경시키기 위해 수행하는 하나의 작업 단위이다.
예를 들어 게시물에 태그를 달 수 있는 상황을 생각해 보자.
사용자는 게시글 하나를 작성하고 그에 맞는 태그를 입력할 것이다. 그런 뒤 사용자가 게시글 등록 버튼을 누르면 게시글과 태그 정보 각각을 저장하는 동작이 이루어져야 한다. 이때 게시글만 생성되어서도 안되고 태그만 생성되어서도 안되며, 게시글과 태그가 일련의 작업으로 함께 생성되어야 한다. 이러한 작업 단위 하나를 트랜잭션
이라고 이야기한다.
앞서 이야기한 것처럼 이때에는 게시글만 생성되어서도 안되고 태그만 생성되어서도 안되며, 게시글과 태그가 일련의 작업으로 함께 생성되어야 한다.
이렇게 전부 완료되거나, 하나도 완료되지 않아야 한다는 것이 트랜잭션의 중요한 특성이며 이것을 all or nothing
이라 한다.
TypeORM에서는 트랜잭션을 DataSource
혹은 EntityManager
를 통해 만들 수 있으며, 콜백 함수를 실행하여 원하는 동작을 처리할 수 있도록 제공하고 있다.
await myDataSource.manager.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.save(users)
await transactionalEntityManager.save(photos)
// ...
})
queryRunner는 single database connection을 제공하기 때문에 트랜잭션 제어가 가능하다.
좀더 세부적으로 직접 트랜잭션을 제어하고 싶을 때에는 QueryRunner를 사용하면 된다.
// queryRunner 생성!!
const queryRunner = dataSource.createQueryRunner()
// 새로운 queryRunner를 연결한다.
await queryRunner.connect()
// 생성한 쿼리러너를 통해 쿼리문을 날리는 것도 가능하다.
await queryRunner.query("SELECT * FROM users")
// 새로운 트랜잭션을 시작한다는 의미의 코드이다.
await queryRunner.startTransaction()
try {
// 원하는 트랜잭션 동작을 정의하면 된다!
await queryRunner.manager.save(user1)
await queryRunner.manager.save(user2)
// 모든 동작이 정상적으로 수행되었을 경우 커밋을 수행한다.
await queryRunner.commitTransaction()
} catch (err) {
// 동작 중간에 에러가 발생할 경우엔 롤백을 수행한다.
await queryRunner.rollbackTransaction()
} finally {
// queryRunner는 생성한 뒤 반드시 release 해줘야한다.
await queryRunner.release()
}
Transaction
데코레이터도 사용이 가능하지만 NestJS에서 권장하는 방법은 아니라고 한다.
지금부터는 게시글을 생성하고 게시글에 붙은 태그를 함께 저장하는 예시를 살펴보자.트랜잭션 처리는 queryRunner를 사용한다.
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 50 })
title: string;
@Column({ type: 'varchar' })
content: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => Tag, (tag) => tag.post)
tags: Tag[];
}
@Entity()
export class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 30 })
TagName: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Post, (post) => post.tags)
post: Post;
}
Post와 Tag 엔티티가 다음과 같이 존재하며 한번의 post 요청으로 두가지 엔티티의 인스턴스가 함께 생성되는 동작이 이루어져야 한다.
여기서 주의할 점은 post 인스턴스를 생성하는 중간에 오류가 발생하면 tag에 대한 생성도 이루어지면 안된다. 앞서 이야기한 것 처럼 트랜잭션에서 이것을 all or noting
의 개념으로 나타낸다.
export class CreatePostDto {
@IsString()
readonly title: string;
@IsString()
readonly content: string;
@IsArray()
@IsString({ each: true })
readonly tag: string[];
}
CreatePostDto는 다음과 같다. 게시물의 title과 content를 받고, 함께 딸린 태그 정보도 배열로 받는다. (태그가 여러 개일 경우)
(게시물 생성 시 태그 정보는 필수라고 가정한다!)
async createPost(createPostDto: CreatePostDto) {
const { title, content, tag } = createPostDto;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const post = await this.postRepository.save({
title: title,
content: content,
});
const tag = tag.map(async (tag) => {
await this.tagRepository.save({ tagName: tag });
});
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
transaction을 처리하는 코드는 다음과 같다. 앞서 본 queryRunner를 사용하여 트랜잭션 제어한다.
마지막에 finally 코드를 추가하여 생성한 queryRunner를 release 해주는 것을 잊지말자! 🚨
참고자료
https://cherrypick.co.kr/typeorm-basic-transaction/
https://orkhan.gitbook.io/typeorm/docs/transactions