MySQL
MySQL 트랜잭션

트랜잭션과 동시성제어에 대해

트랜잭션과 동시성제어를 통해 격리성을 보장하는 방법

#MySQL

#DataBase

#Transaction

2021년 12월 02일

프로젝트에서 좋아요, 조회수 기능을 구현하기 위해
트랜잭션을 공부하고 구현했던 것을 기록했습니다

트랜잭션이란


트랜잭션(Transaction)은 DBMS에서 하나의 단위로 수행되길 바라는 작업의 단위입니다.

어떤 문제를 해결하는가?


DBMS에서 지원하는 트랜잭션은 데이터의 무결성(데이터의 정확성, 일관성, 유효성)을 지키기 위해
아래와 같은 특성(원자성, 일관성, 지속성, 고립성)을 가지고 있고
DBMS는 이 성질을 유지할 수 있도록 지원하고 있습니다.

트랜잭션의 특성 ACID


트랜잭션에서 보장되어야 하는 특성입니다
DBMS에서는 다양한 기능으로 해당 특성이 지켜질 수 있도록 지원합니다

원자성 Atomicity

트랜잭션에 포함된 작업은 전부 수행되거나 전부 수행되지 않아야 합니다
DBMS에서는 START TRANSACTION, COMMIT, ROLLBACK, SAVE와 같은 형태로 지원하고 있습니다

일관성 Consistency

트랜잭션은 데이터베이스의 일관성을 유지해야 합니다
일관성은 테이블을 생성할 때 DBMS에서 제공하는 CREATE, ALTER 문의 무결성 제약 조건을 통해 명시됩니다
하지만 트랜잭션 도중에는 일시적으로 일관성을 유지하지 못하는 상태가 될 수 있으나
트랜잭션이 종료된 후에는 반드시 일관성이 지켜져야 합니다

지속성 Durability

트랜잭션이 완료 혹은 부분 완료한 데이터는 데이터베이스에 기록되어야 합니다
시스템이 멈추어도 트랜잭션 수행으로 변경된 내용은 디스크에 기록되고
DBMS 복구 시스템이 작업한 내용을 반영하거나, 취소합니다

고립성 Isolation

데이터베이스에서는 많은 커넥션에서 여러 트랜잭션이 동시에 수행됩니다
이때 각 트랜잭션 간 상호 간섭이나 데이터 충돌이 일어나지 않도록 해야 합니다
DBMS에서는 동시성 제어를 통해 고립성을 제어할 수 있도록 지원합니다
많은 요청 데이터를 고립성을 지키면서 업데이트하기 위해
DBMS가 고립성을 제어할 수 있도록 지원하는 기능에 대해서 조금 더 자세히 알아보겠습니다.

동시성 제어란?


각각의 트랜잭션은 서로 같은 데이터를 공유하고 있다는 사실을 인지하지 못하기 때문에
데이터의 일관성, 고립성이 훼손될 수 있으므로 DBMS에서는 동시성 제어라는 기능을 통해
데이터의 일관성, 고립성이 훼손되지 않도록 제어합니다
동시성 제어는 트랜잭션 고립 수준, 두 가지 기능으로 나뉘고
동시에 두 개 이상의 트랜잭션이 하나의 데이터에 접근할 때의 상황과
동시성 제어를 하지 않으면 발생하는 문제는 다음과 같습니다

상황 1 → 트랜잭션1[단순 읽기] / 트랜잭션2[단순 읽기]

  • 단순 읽기에는 문제가 발생하지 않습니다

상황 2 → 트랜잭션1[단순 읽기] / 트랜잭션2[쓰기]

  • 오손읽기(Dirty Read)
    • 다른 트랜잭션이 특정 데이터를 변경했고 커밋하지 않은 상태이지만
      해당 데이터를 다른 트랜잭션에서 읽을 수 있는 문제
  • 반복불가능 읽기 (Non-Repeatable Read)
    • 트랜잭션에서 데이터를 읽어와서 작업을 처리하고 있을 때
      다른 트랜잭션에서 값을 수정 또는 삭제해서 데이터의 일관성이 깨지는 문제
  • 유령데이터 읽기 (Phantom Read)
    • 트랜잭션에서 일정 범위의 레코드를 2번 차례대로 조회할 때
      다른 트랜잭션에서 수정, 삽입, 삭제 하면 첫 번째 조회에는 없었던 레코드가 보이거나, 없어지는 문제

상황 3 → 트랜잭션1[쓰기] / 트랜잭션2[쓰기]

  • 갱신손실
    • 이전 트랜잭션의 변경 사항이 나중에 접근한 트랜잭션의 변경 사항으로 덮어써지는 문제

각 상황을 해결하기 위해서는 트랜잭션 고립 수준과 락을 적절하게 사용해야 합니다.

동시성 제어를 위한 락


락은 트랜잭션이 데이터를 읽거나 수정할 때 데이터에 표시하는 일종의 자물쇠입니다
여러 트랜잭션에서 동일한 데이터를 편집하기 위해 접근할 때
요청 순서대로 하나의 트랜잭션만 접근하도록 합니다

락을 사용하면 앞서 언급한 모든 동시성 문제를 해결할 수 있습니다
MySQL에서 락은 두 가지로 나뉩니다

  • 공유 락 ( LOCK IN SHARE MODE )
    • 트랜잭션이 끝날 때 까지 다른 트랜잭션이 조회만 가능한 잠금상태입니다
    • 사용
      • 특정 레코드(A)를 읽어 다른 테이블(B)에 데이터를 추가해야 하는 경우 레코드(A)가 변조 또는 삭제되는 것을 막고자 사용
  • 배타 락 ( FOR UPDATE )
    • 트랜잭션이 끝날 때까지 다른 트랜잭션의 조회 수정 삭제 등의 모든 접근을 차단합니다
      • 사용
        • 특정 레코드(A)를 읽어서 해당 레코드(A)의 데이터를 수정해야 할 경우

동시성 제어를 위한 트랜잭션 고립 수준


락을 사용하면 모든 문제를 해결할 수 있지만 락이 풀릴 때까지 다른 트랜잭션이 계속 대기해야 하기 때문에
처리 속도가 상당히 제한됩니다.
트랜잭션을 동시에 실행시키면서 락보다 조금 완화된 방법으로 문제를 해결하는 방법을
트랜잭션 고립 수준이라고 하며 트랜잭션 내 작업 내용을 어디까지 공유하고 차단할 것인지 결정할 수 있습니다

  • Read Uncommitted

    • SELECT→락 걸지 않음
    • UPDATE→배타락
    • 다른 트랜잭션이 변경 중인 내용에 접근할 수 있습니다.
    • 오손 읽기, 반복불가능 읽기, 유령데이터 읽기 문제가 모두 발생하는 레벨입니다
  • Read Committed

    • SELECT→공유락, 수행후 바로 해제
    • UPDATE→배타락
    • 오라클 DBMS에서 주로 사용됩니다
    • 트랜잭션이 커밋된 상황에서 다른 트랜잭션이 변경한 내용에 접근할 수 있습니다.
    • 반복불가능 읽기, 유령데이터 읽기 문제가 발생합니다
  • Repeatable Read

    • SELECT→공유락 트랜잭션 끝까지 유지
    • UPDATE→배타락
    • MySQL InnoDB에서 기본적으로 사용됩니다
    • 현재 시점의 스냅샷을 조회해 동일 트랜잭션에서 일관성을 보장합니다
    • 상위 트랜잭션 스냅샷 데이터에는 접근할 수 없습니다
    • 유령데이터 읽기 문제가 발생합니다 (INSERT에 대해서)
  • Serializable

    • 가장 엄격한 격리 수준입니다
    • 한 트랜잭션에서 읽고 쓰는 레코드에 다른 트랜잭션이 접근할 수 없습니다

적용하기


앞선 개념을 바탕으로 NestJS + TypeORM에서 실제로 적용한 코드입니다
특정 게시글에 좋아요를 설정할 때를 예시로 들어보겠습니다

async setPostToLike(requestData: SetPostToLikeDto): Promise<SetPostToLikeResponseDto> {
    // 쿼리러너 객체 생성
    const queryRunner = this.connection.createQueryRunner();

    // 데이터 베이스 연결
    await queryRunner.connect();

    // 트랜잭션 시작
    await queryRunner.startTransaction();

    try {
      // PostLike 테이블에 기록이 있는지 확인합니다.
      const getPostLikeQuery = queryRunner.manager
        .createQueryBuilder()
        .select('*')
        .from(PostLike, 'postLike')
        .where('postLike.usersId = :userID', { userID: requestData.usersId })
        .andWhere('postLike.postsId = :postID', { postID: requestData.postsId })
        .maxExecutionTime(1000);

      /**
       * @returns undefined | TextRow{}
       */
      const getPostLikeResult = await getPostLikeQuery.getRawOne();

      // 유저가 해당 포스트에 '좋아요'를 설정한 기록이 있을경우 에러를 리턴합니다
      if (!!getPostLikeResult) {
        throw new Error('ALREADY_SET_LIKE');
      }
      // Post 테이블의 현재 Like 카운트를 구하고 배타락을 설정합니다
      const getPostQuery = queryRunner.manager
        .createQueryBuilder()
        .select('post.likes')
        .from(Posts, 'post')
        .where('post.id = :postID', { postID: requestData.postsId })
        // 비밀 게시글인 경우 좋아요 기능을 비활성화 합니다.
        .andWhere('post.private = 0')
        // 삭제된 게시글인 경우 좋아요를 등록할 수 없습니다
        .andWhere('post.deletedAt IS NULL')
        .setLock('pessimistic_write')
        .maxExecutionTime(1000);

      /**
       * @returns TextRow { post_likes: 0 }
       */
      const getPostQueryResult = await getPostQuery.getRawOne();

      // 만족하는 포스트를 찾을 수 없을 경우 에러를 리턴합니다
      if (!getPostQueryResult) {
        throw new Error('POST_NOT_FOUND');
      }

      // 갱신할 카운트를 설정합니다.
      const LIKE_COUNT = getPostQueryResult.post_likes + 1;

      // Post 테이블의 Like 카운트를 업데이트 합니다.
      const setPostQuery = queryRunner.manager
        .createQueryBuilder()
        .update(Posts)
        .set({ likes: LIKE_COUNT })
        .where('id = :postID', { postID: requestData.postsId })
        .updateEntity(false);

      /**
       * @Returns UpdateResult { generatedMaps: [], raw: [], affected: 1 }
       */
      const PostUpdateResult = await setPostQuery.execute();
      console.log(PostUpdateResult);
      // 테이블 업데이트가 반영되었는지 확인합니다
      if (PostUpdateResult.affected === 0) {
        throw new Error('PostUpdateResult_AFFECTED_IS_0');
      }

      // PostLike 테이블에 '좋아요' 기록을 저장합니다.
      const setPostLikeQuery = queryRunner.manager
        .createQueryBuilder()
        .insert()
        .into(PostLike)
        .values({ usersId: requestData.usersId, postsId: requestData.postsId, likedAt: Time.nowDate() });

      const setPostLikeQueryResult = await setPostLikeQuery.execute();
      // 테이블 업데이트가 반영되었는지 확인합니다
      if (setPostLikeQueryResult.raw.affectedRows === 0) {
        throw new Error('setPostLikeQuery_AFFECTED_IS_0');
      }

      // DTO Mapping
      let response = new SetPostToLikeResponseDto();
      response.likes = LIKE_COUNT;

      // 트랜잭션 커밋
      await queryRunner.commitTransaction();

      // 응답 리턴
      return response;
    } catch (error) {
      // 롤백, 오류 리턴
      await queryRunner.rollbackTransaction();
      throw new SetPostToLikeFail(`posts.service.setPostToLike ${!!error.message ? error.message : 'Unknown_Error'}`);
    } finally {
      // 커넥션 해제
      await queryRunner.release();
    }
  }

로직은 다음과 같이 진행됩니다

  • 트랜잭션을 시작합니다
  • SELECT → 좋아요 기록이 있는지 확인합니다
    • MySQL 기본고립 수준 설정으로 공유락이 설정됩니다
  • SELECT [배타락] → 포스트 테이블 좋아요 카운트를 가져옵니다
    • 갱신손실을 막기위해 락을 설정합니다
  • UPDATE → 카운트를 업데이트합니다
  • UPDATE → 좋아요 기록을 저장합니다
  • 트랜잭션을 커밋합니다
    • 모든 락을 해제합니다

트랜잭션, 동시성 제어를 통해서 레이스 컨디션으로 인한 좋아요 카운트 갱신 손실을 방지할 수 있게 되었습니다

+ 단순 조회문에 트랜잭션이 필요한 이유


MySQL은 Repeatable Read 가 기본 격리 수준으로 SELECT를 할 때 트랜잭션이 끝날 때까지
공유락을 유지합니다. 만약 조회 시간이 길거나 조회 범위가 클경우 락쿼리가 발생할 수 있습니다
단순 조회 쿼리일 경우엔 격리 수준을 낮춰서 질의해야 합니다.

Reference


MySQL Lock 메뉴얼
http://www.mysqlkorea.com/ex/mysql_reference.html?mcode=develop&scode=01&m_no=21883&cat1=14&cat2=422&cat3=444&lang=

MySQL 공유락 배타락 무엇이 다른가?
https://stackoverflow.com/questions/32827650/mysql-innodb-difference-between-for-update-and-lock-in-share-mode

MySQL 트랜잭션 고립 수준과 발생가능한 문제에 대해
https://devlog-wjdrbs96.tistory.com/334

TypeORM 트랜잭션 가이드 문서
https://orkhan.gitbook.io/typeorm/docs/transactions?q=pessimistic_write