[Nest.js] 사이드프로젝트하면서 트랜잭션 문제점 발생한 격리 수준 학습 정리하기

궁금하면 500원·2024년 8월 14일
0

1. 트랜잭션의 문제점

트랜잭션이란 데이터베이스의 여러 작업을 원자적으로 수행하여 데이터의 일관성을 유지하는 방법입니다.

하지만 트랜잭션을 사용할 때 몇 가지 문제점이 발생할 수 있습니다

1-1. Lost Reads (Lost Update)

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가 원래의 값을 덮어쓰게 되어 업데이트가 손실될 수 있습니다.

1-2. Dirty Reads

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에서 읽은 값이 잘못된 데이터가 됩니다.

1-3. Non-repeatable Reads

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가 두 번 읽은 값이 서로 다를 수 있습니다.

1-4. Phantom Reads

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에서 쿼리 결과가 새로운 데이터로 인해 변경될 수 있습니다.

2. 트랜잭션 레벨 & 트랜잭션 불일치 (Transaction Levels & Anomalies)

트랜잭션의 격리 수준은 트랜잭션이 동시에 실행될 때 데이터의 일관성을 보장하는 방법을 정의합니다.
일반적으로 4가지 격리 수준이 있습니다:

Read Uncommitted: 트랜잭션이 커밋되지 않은 데이터를 읽을 수 있습니다.

가장 낮은 격리 수준으로, Dirty Reads를 허용합니다.

Read Committed: 커밋된 데이터만 읽을 수 있습니다.
Dirty Reads를 방지하지만 Non-repeatable Reads는 여전히 발생할 수 있습니다.

Repeatable Read: 트랜잭션이 시작된 시점의 데이터는 반복적으로 읽을 수 있습니다.

Non-repeatable Reads를 방지하지만 Phantom Reads는 발생할 수 있습니다.

Serializable: 트랜잭션이 순차적으로 실행되는 것처럼 동작합니다.

가장 높은 격리 수준으로, 모든 트랜잭션의 불일치 문제를 방지합니다.

3. Update와 Create에서 트랜잭션 사용할 때

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);
    });
  }
}

4. Serializable에 대한 주의점

Serializable 격리 수준은 트랜잭션이 완벽하게 순차적으로 실행되는 것처럼 동작하게 합니다.

가장 높은 데이터 일관성을 제공하지만 성능이 떨어질 수 있습니다.

모든 트랜잭션이 순차적으로 실행되기 때문에, 동시에 많은 트랜잭션을 처리할 때 성능 문제가 발생할 수 있습니다.

주의점:

성능 저하: 트랜잭션을 직렬화하기 때문에 성능이 저하될 수 있습니다.

데드락: 동시에 많은 트랜잭션이 직렬화되면 데드락이 발생할 가능성이 있습니다.

스케일링: 대규모 애플리케이션에서는 성능과 확장성 문제로 인해 적절히 설정해야 합니다.

예제:

트랜잭션을 직렬화하려면 데이터베이스의 설정에서 격리 수준을 Serializable로 설정해야 합니다.

Nest.js 코드에서는 직접 설정할 수 없지만, 데이터베이스 설정 파일에서 설정할 수 있습니다.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

데이터베이스의 트랜잭션 격리 수준을 설정하고 트랜잭션을 관리할 수 있습니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글