Transaction, Isolation

Transaction, Isolation

카테고리
MySQL
날짜
2024년 06월 25일
작성자
JeongjungsikJeongjungsik
태그

Transaction

💡
typeorm에서 제공해주는 기능이다. 모든 작업이 성공적으로 완료되어야 비로소 DB에 save해주는 기능이다.
 
코드의 주석을 따라가며 이해해보자,
아래의 코드는 생략이 많이되어있긴하지만,
db의 find, save, update를 트랜잭션을 적용시킨 코드이다.
 
import { DataSource, Repository } from 'typeorm'; @Injectable() export class PointsTransactionsService { constructor( ... // 자세한 코드는 생략 private readonly dataSource: DataSource, // 트랜잭션 기능을 위해 의존성 주입 ) {} try{ const queryRunner = this.dataSource.createQueryRunner(); //트랜잭션 생성 await queryRunner.connect(); // 트랜잭션 연결 await queryRunner.startTransaction(); // 트랜잭션 시작 // 유저의 point를 생성하는 코드 const pointTransaction = this.pointsTransactionsRepository.create({ impUid, amount, user: _user, status: POINT_TRANSACTION_STATUS_ENUM.PAYMENT, }); // 트랜잭션에 넣어준다. await queryRunner.manager.save(pointTransaction); ... // 유저의 point업데이트 await queryRunner.manager.save(updatedUser); } catch(error){ await queryRunner.rollbackTransaction(); // 에러발생시, 지금까지 담아온 것들 다 초기화 } finally { await queryRunner.release(); // 연결 끊기 } }
 

Isolation

💡
Isolation이란, 트랜잭션들이 DB의 데이터를 읽거나 쓰는 방법을 제어하는 것을 말한다. 가령, 사람1이 어떤 특정한 데이터를 조회하고 거기에 값을 더하여 저장하려는 사이에, 다른 사람2가 그 값을 조회하고 업데이트해버리면 데이터가 꼬이는 현상이 발생한다. 이런것을 방지해 줄 수 있다. 보통 이런걸 락을 건다. 라고 표현한다.
 
MySQL에서 지원하는 트랜잭션 격리 수준은 다음과 같다.
요약하자면, 번호가 높아질 수록 높은 단계이고,오류가 적어지지만 그만큼 성능이 느려진다.
 
  1. READ UNCOMMITTED: 가장 낮은 격리 수준임. 트랜잭션이 커밋되지 않은 데이터를 다른 트랜잭션이 읽을 수 있다. "더러운 읽기(dirty read)"가 발생할 수 있다.
  1. READ COMMITTED: 트랜잭션이 커밋된 데이터만 다른 트랜잭션에서 읽을 수 있다. "더러운 읽기"는 방지하지만, "반복 불가능한 읽기(non-repeatable read)"가 발생할 수 있다.
  1. REPEATABLE READ: 트랜잭션 동안 같은 데이터를 여러 번 읽을 때 항상 동일한 결과를 보장한다. "반복 불가능한 읽기"와 "팬텀 읽기(phantom read)"를 방지한다. MySQL의 기본 격리 수준이다.
  1. SERIALIZABLE: 가장 높은 격리 수준이다. 트랜잭션이 순차적으로 실행되는 것처럼 보이게 하여 "더러운 읽기", "반복 불가능한 읽기", "팬텀 읽기" 모두를 방지한다. 그러나 성능이 저하될 수 있다.
 
TypeORM에서 트랜잭션을 사용하고 격리 수준을 설정하는 방법은 다음과 같다.
 
먼저 전체 예제코드고,
import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { User } from '../users/entities/user.entity'; import { POINT_TRANSACTION_STATUS_ENUM, PointTransaction, } from './entities/pointTransaction.entity'; import { IPointsTransactionsServiceCreate } from './interfaces/points-transactions-service.interface'; @Injectable() export class PointsTransactionsService { constructor( @InjectRepository(PointTransaction) private readonly pointsTransactionsRepository: Repository<PointTransaction>, @InjectRepository(User) private readonly usersRepository: Repository<User>, private readonly dataSource: DataSource, ) {} async create({ impUid, amount, user: _user, }: IPointsTransactionsServiceCreate): Promise<PointTransaction> { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction('SERIALIZABLE'); try { const pointTransaction = this.pointsTransactionsRepository.create({ impUid, amount, user: _user, status: POINT_TRANSACTION_STATUS_ENUM.PAYMENT, }); await queryRunner.manager.save(pointTransaction); // Isolation 락 적용 const user = await queryRunner.manager.findOne(User, { where: { id: _user.id }, // row-lock 적용 lock: { mode: 'pessimistic_write' }, }); const updatedUser = this.usersRepository.create({ ...user, point: user.point + amount, }); await queryRunner.manager.save(updatedUser); await queryRunner.commitTransaction(); return pointTransaction; // } catch (error) { await queryRunner.rollbackTransaction(); } finally { await queryRunner.release(); } } }
 
중심적으로 살펴봐야할 부분의 코드이다.
await queryRunner.startTransaction('SERIALIZABLE'); const user = await queryRunner.manager.findOne(User, { where: { id: _user.id }, // row-lock 적용 lock: { mode: 'pessimistic_write' }, });
이렇게 where 부분에 id를 준다면 row-lock 이라고해서, 전체 테이블에 락을 걸어 그 테이블에 접근을 막는것이 아닌, 그 테이블 row부분의 접근만을 막을 수 있게 한다.
 
또한, lock:mode를 사용해서 옵션을 설정하여 락의 잠금방식을 지정해줄 수 있는데,
TypeORM에서 사용할 수 있는 잠금 모드는 다음과 같다.
💡
  1. pessimistic_read: 다른 트랜잭션이 동일한 행을 수정하지 못하도록 잠굼. 읽기 작업이 완료될 때까지 다른 트랜잭션이 이 행을 쓰지 못하게 함.
  1. pessimistic_write: 다른 트랜잭션이 동일한 행을 읽거나 수정하지 못하도록 잠굼. 쓰기 작업이 완료될 때까지 다른 트랜잭션이 이 행을 읽거나 쓰지 못하게 한다.
  1. optimistic: 잠금을 사용하지 않고 낙관적 잠금 방식을 적용. 트랜잭션이 커밋될 때 버전 정보를 사용하여 데이터의 변경 여부를 확인함. 데이터가 변경되었을 경우 예외를 발생시킨다.
위에 예제코드에서 사용한 pessimistic_write 잠금 모드는 특히 데이터의 일관성을 보장해야 하는 중요한 작업에서 많이 사용된다.
 
끝으로, Isolation은 무조건 사용하는것이 아닌 데이터의 중요성에따라서 사용이 결정된다.
 

코드 최적화

💡
기존의 코드를..
const user = await queryRunner.manager.findOne(User, { where: { id: _user.id }, // row-lock 적용 lock: { mode: 'pessimistic_write' }, });
 
💡
이렇게 변경해줄 수 있다. 단! 숫자만 가능하다
const id = _user.id; await queryRunner.manager.increment(User, { id }, 'point', amount);
즉, 이 코드는 ID가 주어진 특정 사용자의 point 값을 amount만큼 증가시키는 SQL 명령을 생성하고 실행하는 코드다.

댓글

guest