트랜잭션이란 데이터베이스의 여러 작업을 원자적으로 수행하여 데이터의 일관성을 유지하는 방법입니다.
하지만 트랜잭션을 사용할 때 몇 가지 문제점이 발생할 수 있습니다
Lost Reads 또는 Lost Update는 트랜잭션이 데이터베이스에서 데이터를 읽은 후 다른 트랜잭션이 그 데이터를 수정하여 원래 트랜잭션의 작업 결과가 손실되는 상황을 말합니다.
트랜잭션 A와 B가 같은 데이터를 읽고 업데이트한다고 가정해 보겠습니다.
// 트랜잭션 A
await connection.transaction(async (manager) => {
const user = await manager.findOne(User, { where: { id: 1 } });
user.balance += 100;
await manager.save(user);
});
// 트랜잭션 B
await connection.transaction(async (manager) => {
const user = await manager.findOne(User, { where: { id: 1 } });
user.balance -= 50;
await manager.save(user);
});
트랜잭션 A와 B가 동시에 실행되면, 트랜잭션 A가 완료된 후 트랜잭션 B가 원래의 값을 덮어쓰게 되어 업데이트가 손실될 수 있습니다.
Dirty Reads는 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 데이터를 읽는 상황을 말합니다.
이 경우 다른 트랜잭션이 롤백되면 읽은 데이터가 무효가 될 수 있습니다.
트랜잭션 A가 데이터를 수정하고 커밋하지 않았다고 가정합니다.
// 트랜잭션 A
await connection.transaction(async (manager) => {
const user = await manager.findOne(User, { where: { id: 1 } });
user.balance += 100;
// 아직 커밋하지 않음
});
// 트랜잭션 B
await connection.transaction(async (manager) => {
const user = await manager.findOne(User, { where: { id: 1 } });
console.log(user.balance); // 트랜잭션 A의 변경 사항을 읽음
});
트랜잭션 A가 롤백되면 트랜잭션 B에서 읽은 값이 잘못된 데이터가 됩니다.
Non-repeatable Reads는 같은 트랜잭션 내에서 동일한 데이터를 여러 번 읽을 때 데이터가 변경되는 상황을 말합니다.
// 트랜잭션 A
await connection.transaction(async (manager) => {
const user1 = await manager.findOne(User, { where: { id: 1 } });
// 사용자 정보를 읽음
const user2 = await manager.findOne(User, { where: { id: 1 } });
// 같은 트랜잭션에서 다시 사용자 정보를 읽음
// user1과 user2가 다를 수 있음
});
트랜잭션 A가 두 번 읽은 값이 서로 다를 수 있습니다.
Phantom Reads는 같은 트랜잭션 내에서 쿼리를 실행할 때 결과에 새로운 행이 추가되거나 삭제되는 상황을 말합니다.
// 트랜잭션 A
await connection.transaction(async (manager) => {
const users1 = await manager.find(User, { where: { balance: MoreThan(100) } });
// 다른 트랜잭션에서 새로운 사용자 추가
await manager.save(new User({ balance: 150 }));
const users2 = await manager.find(User, { where: { balance: MoreThan(100) } });
// users1과 users2의 결과가 다를 수 있음
});
트랜잭션 A에서 쿼리 결과가 새로운 데이터로 인해 변경될 수 있습니다.
트랜잭션의 격리 수준은 트랜잭션이 동시에 실행될 때 데이터의 일관성을 보장하는 방법을 정의합니다.
일반적으로 4가지 격리 수준이 있습니다:
Read Uncommitted: 트랜잭션이 커밋되지 않은 데이터를 읽을 수 있습니다.
가장 낮은 격리 수준으로, Dirty Reads를 허용합니다.
Read Committed: 커밋된 데이터만 읽을 수 있습니다.
Dirty Reads를 방지하지만 Non-repeatable Reads는 여전히 발생할 수 있습니다.
Repeatable Read: 트랜잭션이 시작된 시점의 데이터는 반복적으로 읽을 수 있습니다.
Non-repeatable Reads를 방지하지만 Phantom Reads는 발생할 수 있습니다.
Serializable: 트랜잭션이 순차적으로 실행되는 것처럼 동작합니다.
가장 높은 격리 수준으로, 모든 트랜잭션의 불일치 문제를 방지합니다.
TypeORM을 사용하여 트랜잭션을 구현할 때,
update와 create 작업을 다음과 같이 처리할 수 있습니다.
예제:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, EntityManager } from 'typeorm';
import { Movie } from './movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';
import { UpdateMovieDto } from './dto/update-movie.dto';
@Injectable()
export class MovieService {
constructor(
@InjectRepository(Movie)
private readonly movieRepository: Repository<Movie>,
) {}
async create(createMovieDto: CreateMovieDto) {
return await this.movieRepository.transaction(async (manager: EntityManager) => {
const movie = manager.create(Movie, createMovieDto);
return await manager.save(movie);
});
}
async update(id: number, updateMovieDto: UpdateMovieDto) {
return await this.movieRepository.transaction(async (manager: EntityManager) => {
await manager.update(Movie, id, updateMovieDto);
return await manager.findOne(Movie, id);
});
}
}
Serializable 격리 수준은 트랜잭션이 완벽하게 순차적으로 실행되는 것처럼 동작하게 합니다.
가장 높은 데이터 일관성을 제공하지만 성능이 떨어질 수 있습니다.
모든 트랜잭션이 순차적으로 실행되기 때문에, 동시에 많은 트랜잭션을 처리할 때 성능 문제가 발생할 수 있습니다.
성능 저하: 트랜잭션을 직렬화하기 때문에 성능이 저하될 수 있습니다.
데드락: 동시에 많은 트랜잭션이 직렬화되면 데드락이 발생할 가능성이 있습니다.
스케일링: 대규모 애플리케이션에서는 성능과 확장성 문제로 인해 적절히 설정해야 합니다.
트랜잭션을 직렬화하려면 데이터베이스의 설정에서 격리 수준을 Serializable로 설정해야 합니다.
Nest.js 코드에서는 직접 설정할 수 없지만, 데이터베이스 설정 파일에서 설정할 수 있습니다.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
데이터베이스의 트랜잭션 격리 수준을 설정하고 트랜잭션을 관리할 수 있습니다.