Transaction / ACID / Lock

이재홍·2022년 6월 4일

서비스에서 가장 치명적인 문제는 데이터의 오염이다.
중요한 데이터를 오염시키지 않기 위해
트랜잭션을 만들어 성공했을때는 모두 성공을 실패했을 때는 롤백 시켜주어야 한다.

TypeORM을 이용한 트랜잭션 처리

typeorm에서는 트랜잭션 기능을 제공해준다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { User } from '../user/entities/user.entity';
import {
  PointTransaction,
  POINT_TRANSACTION_STATUS_ENUM,
} from './entities/pointTransaction.entity';

@Injectable()
export class PointTransactionService {
  constructor(
    @InjectRepository(PointTransaction)
    private readonly pointTransactionRepository: Repository<PointTransaction>,

    @InjectRepository(User)
    private readonly userRepository: Repository<User>,

    private readonly connection: Connection, // 커넥션 주입받기
  ) {}

  async create({ impUid, amount, currentUser }) {
    const queryRunner = await this.connection.createQueryRunner(); // 쿼리러너 실행
    await queryRunner.connect(); // 디비에 연결 (연결 제한 개수가 정해져있으므로 디비에서 늘려줘야한다.)
    await queryRunner.startTransaction(); // 트랜잭션 시작
    try {
      // 1. pointTransaction 테이블에 거래기록 생성
      const pointTansaction = await this.pointTransactionRepository.create({
        impUid,
        amount,
        user: currentUser,
        status: POINT_TRANSACTION_STATUS_ENUM.PAYMENT,
      });
      // await this.pointTransactionRepository.save(pointTansaction);
      await queryRunner.manager.save(pointTansaction); // 임시저장

      // throw new Error(); // 임의의 에러발생

      // 2. 유저정보를 조회
      const user = await this.userRepository.findOne({ id: currentUser.id });

      // 3. 유저의 돈 업데이트
      // await this.userRepository.update(
      //   { id: user.id },
      //   { point: user.point + amount },
      // );
      // 유저 객체 생성
      const updatedUser = this.userRepository.create({
        ...user,
        point: user.point + amount,
      });
      await queryRunner.manager.save(updatedUser); // 임시저장
      await queryRunner.commitTransaction(); // 에러없을시 커밋

      // 4. 최종결과 돌려주기
      return pointTansaction;
    } catch (error) {
      await queryRunner.rollbackTransaction(); // 임시저장 하나라도 실패시 트랜잭션 전체 롤백.
    } finally {
      await queryRunner.release(); // 접속(커넥트) 종료 // 종료시켜주지않으면 허용개수 초과시 에러발생.
    }
  }
}
show databases; -- 데이터베이스 목록 조회

use myproject; -- myproject 데이터베이스 사용

show tables; -- 테이블 목록 조회 

show variables; -- 데이터베이스에서 관리하는 변수 목록 조회 (max_connections로 커넥션 최대값 확인)

show status; -- 현재 데이터베이스 상태 조회 (Threads_connected로 현재 커넥션 개수 확인)

set GLOBAL max_connections = 1000; -- 커넥션 최대갓 조정

show processlist; -- 현재 프로세스 목록 확인 (현재 연결된 커넥션 목록 확인)

kill 20; -- 프로세스 아이디로 강제종료 시키기 (실수로 종료 안시킨 프로세스 종료시 사용)

트랜잭션의 속성들 - ACID

  1. Atomicity(원자성) : 모두 성공 or 모두 실패
  2. Consistency(일관성) : 조회할 때 일관된 결과
  3. Isolation(격리성) : 트랜잭션이 격리되어 기다렸다 하나씩 실행
  4. Durability(지속성) : 성공 후에는 장애발생시에도 유지되어야함

Isolation-Level

Isolation-LevelDirty-ReadNon-Repeatable-ReadPhantom-Read
Read-UncommittedOOO
Read-CommittedXOO
Repeatable-ReadXXO
SerializableXXX

READ UNCOMMITTED

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { Payment } from './entities/payment.entity';

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly paymentRepository: Repository<Payment>,

    private readonly connection: Connection,
  ) {}

  async create({ amount }) {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ UNCOMMITTED'); // READ UNCOMMITTED => 가장 낮은 단계(Dirty-Read 등 발생): 롤백되기전까진 잠깐의 저장된 상태가 조회가된다.
    try {
      const payment = await this.paymentRepository.create({ amount });
      await queryRunner.manager.save(payment);

      // 5초뒤에 특정 이유로 실패
      setTimeout(async () => {
        await queryRunner.rollbackTransaction();
      }, 5000);
      // await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }

  async findAll() {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ UNCOMMITTED');
    try {
      // 만약 5초 이내에 조회하면, 위에서 등록한 금액(커밋되지 않은 금액)이 조회됨
      const payment = await queryRunner.manager.find(Payment);
      await queryRunner.commitTransaction();
      return payment;
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }
}

READ COMMITTED

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { Payment } from './entities/payment.entity';

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly paymentRepository: Repository<Payment>,

    private readonly connection: Connection,
  ) {}

  async findAll() {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ COMMITTED'); // 커밋되지않은 데이터가 조회되는 Dirty-Read는 해결됐지만 Non-Repeatable-Read => 한 트랜잭션에서 반복된 조회시 커밋된 데이터들의 조회값이 다르게 나옴.
    try {
      // 하나의 트랜잭션 내에서 500원이 조회됐으면,
      // 해당트랜잭션이 끝나기 전까지는(커밋 전까지는) 다시 조회하더라도 항상 500원이 조회(Repeatable-Read) 되어야 함.
      // 1초간 반복해서 조회하는 중에, 누군가 등록하면(create), Repeatable-Read가 보장되지 않음 => Non-Repeatable-Read 문제!!!
      setInterval(async () => {
        const payment = await queryRunner.manager.find(Payment);
        console.log(payment);
      }, 1000);

      // await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }

  async create({ amount }) {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ COMMITTED');
    try {
      // 중간에 돈 추가해보기
      const payment = await this.paymentRepository.create({ amount });
      await queryRunner.manager.save(payment);
      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }
}

SERIALIZABLE

팬텀리드는 Non-Repeatable-Read는 트랜잭션에 ID를 부여하여 트랜잭션이 끝나기전까지는 데이터가 커밋되어도 조회 결과가 바뀌지 않도록 해결하였지만 데이터의 개수를 조회하거나 업데이트를 해주거나 한 경우에는 한 조회 트랜잭션내에서 조회값이 바뀌는 경우이다. (데이터 저장 커밋 같은 경우에는 이전값을 undo에 임시 백업해두어 트랜잭션ID로 비교후 값을 가져오기때문에 해결가능했다)
SERIALIZABLE은 이 문제를 차단해준다.
(mysql에서는 phantom-read를 자동차단해준다. - 트랜잭션 설정없을시 Repeatable-Read가 기본값 + 팬텀리드차단 (보통 DB들은 Read-Committed가 기본값 Repeatable-Read시에도 팬텀리드 자동차단X))

또한 SERIALIZABLE 레벨에서는 lock을 걸 수 있다.

lock

  • 공유락(Shared Lock) - pessimistic_read(읽기전용 쓰기잠금)
  • 베타락(Exclusive Lock) - pesimistic_write(읽기쓰기 모두잠금)

락을 검으로써 해당 데이터를 여러사용자가 동시에 조작(읽고 쓰기)하는것을 방지할 수 있다.
대기상태가 되었다가 실행되므로 성능은 느려지기에 오염이 발생할 수 있는 중요 데이터에만 락을 건다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { Payment } from './entities/payment.entity';

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly paymentRepository: Repository<Payment>,

    private readonly connection: Connection,
  ) {}

  async findAll() {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('SERIALIZABLE');
    try {
      // 조회시 락을 걸고 조회함으로써, 다른 쿼리에서 조회 못하게 막음(대기시킴) => Select ~ For Update
      const payment = await queryRunner.manager.find(Payment, {
        lock: { mode: 'pessimistic_write' },
      });
      console.log(payment);

      // 처리에 5초간의 시간이 걸림을 가정!!
      setTimeout(async () => {
        await queryRunner.commitTransaction();
      }, 5000);
      return payment;
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }

  async create({ amount }) {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('SERIALIZABLE');
    try {
      // 조회를 했을 때, 바로 조회되지 않고 락이 풀릴 때까지 대기
      const payment = await queryRunner.manager.find(Payment); // 동시 조회 불가능
      console.log('========== 철수가 시도 =============');
      console.log(payment);
      console.log('=================================');
      await queryRunner.commitTransaction();
      return payment;
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }
}

0개의 댓글