Transaction

hyejin·2024년 4월 4일
0

study-2024

목록 보기
1/16
post-thumbnail

⭐️트랜잭션의 개념 소개

트랜잭션(Transaction)이란?

  • 사전적인 의미
    - 거래
  • 컴퓨터 과학 분야에서의 의미
    - 더이상 분할이 불가능한 업무 처리의 단위
    - 데이터 베이스의 상태를 변화시키기 위해서 수행하는 작업의 단위
    - 데이터베이스에서 여러 작업을 하나의 논리적 단위로 묶어서 처리하는 메커니즘
    - 하나의 작업을 위해 더이상 분할될 수 없는 명령들의 모음
    -> 한꺼번에 수행되어야 할 일련의 연산 모음

목적과 특성

데이터베이스에서는 여러 클라이언트가 동시에 액세스하거나 응용프로그램이 갱신을 처리하는 과정에서 데이터 부정합을 방지하기 위해 트랜잭션이 사용된다. 트랜잭션은 데이터베이스 작업을 안전하게 수행하고, 완전성을 유지하는 데 중요하다. 각 트랜잭션은 데이터베이스 내에서 읽거나 쓰는 여러 쿼리를 요구하며, 중간 단계가 남지 않도록 보장되고, 각 트랜잭션은 서로 간섭하지 않아야하며, 실패 시 롤백된다.

데이터베이스의 트랜잭션이 안전하게 수행되기 위해서는 ACID 조건을 충족해야 한다. ACID란 Atomicity(원자성), Consistency(일관성), Isolation(고립성), Durability(지속성)의 약자로서, 데이터베이스의 트랜잭션이 안전하게 수행되기 위한 4가지 필수적인 성질을 말한다.

  • 원자성 (Atomicity): 트랜잭션의 모든 작업은 원자적으로 실행되어야 한다. 즉, 모든 작업이 성공적으로 완료되거나 아무것도 수행되지 않은 상태로 롤백되어야 한다. 이것은 작업의 부분적 완료나 중간 단계에서 실패에 대한 안전장치 역할을 한다.

  • 일관성 (Consistency): 트랜잭션이 테이블에 변경 사항을 적용할 때 미리 정의된, 예측할 수 있는 방식만 취한다. 트랜잭션 일관성이 확보되면 데이터 손상이나 오류 때문에 테이블 무결성에 의도치 않은 결과가 생기지 않는다.

  • 고립성 (Isolation): 여러 사용자가 같은 테이블에서 모두 동시에 읽고 쓰기 작업을 할 때, 각각의 트랜잭션을 격리하면 동시 트랜잭션이 서로 방해하거나 영향을 미치지 않는다. 각각의 요청이 실제로는 모두 동시에 발생하더라도, 마치 하나씩 발생하는 것처럼 볼 수 있다. 또한, 한 트랜잭션이 다른 트랜잭션에서 수행 중인 작업을 볼 수 없어야 한다.

  • 지속성 (Durability): 트랜잭션이 성공적으로 완료된 후에는 그 결과가 영구적으로 데이터베이스에 반영되어야 한다. 즉, 시스템이 고장나거나 다시 시작되더라도 트랜잭션의 결과가 보존되어야 한다.

트랜잭션의 ACID 속성은 데이터의 안정성과 무결성을 최대한 보장한다. 이는 작업이 일부만 완료되어 데이터가 일관성 없는 상태가 되는 일을 방지한다. 예를 들어, 정전이 발생하여 작업 중인 데이터가 일부만 저장되는 경우가 있을 수 있는데, ACID 트랜잭션은 이를 방지하여 데이터베이스가 일관성 없는 상태에 빠지는 것을 막고, 복구를 용이하게 한다.


⭐️트랜잭션 처리 과정

처리 과정이 모두 성공했을 때만 최종적으로 데이터베이스에 결과값을 반영하며, 트랜잭션이 중단되거나 서버/하드웨어 고장 등으로 인한 작업의 오류가 발생할 경우 트랜잭션 작업 전으로 돌아간다.

트랜잭션은 논리적으로 5가지 상태가 있을 수 있다.

  • 활동(Active): 트랜잭션이 실행중인 상태
  • 부분적 완료(Partially Commited): 트랜잭션의 마지막 연산까지 실행했으나 커밋 연산이 실행되기 직전의 상태
  • 완료(Commited): 트랜잭션이 성공적으로 종료되어 커밋 연산을 실행한 후의 상태
  • 실패(Failed): 트랜잭션 실행에 오류가 발생하여 중단된 상태
  • 철회(Acorted): 트랜잭션이 비정상적으로 종료되어 롤백 연산을 수행한 상태

트랜잭션의 개요와 상태에 대해 알아보았으니 이제 트랜잭션의 연산을 알아보자.
트랜잭션의 연산에는 사용자가 작성한 쿼리문과 데이터를 최종적으로 데이터베이스에 반영하는 커밋과 실패했을 경우 트랜잭션의 실행을 중단하고 이전으로 돌아가는 롤백이 있다.

커밋(Commit)

  • 모든 작업을 확정하여 데이터베이스에 영구적으로 저장하는 명령
  • 처리과정이 데이터베이스에 영구적으로 반영됨
  • 커밋 수행 시 해당 트랜잭션 과정이 완전히 종료됨
  • 이전 데이터가 완전히 업데이트되어야 함 (UPDATE 문으로 데이터 갱신, DELETE 문으로 기존 데이터 삭제, INSERT 문으로 데이터 삽입)
  • 모든 작업이 오류 없이 완료되었을 경우에만 수행됨

롤백(Rollback)

DML명령어 작업들을 취소시켜 commit 지점까지 원상복구 시킴

  • 트랜잭션 처리 중 문제가 발생할 경우, 트랜잭션의 변경 사항을 취소하는 명령
  • 원자성을 유지하기 위해 트랜잭션이 수행한 모든 연산을 취소함
  • 이전 커밋을 완료한 시점으로 돌아가며 저장된 것만 복구함
  • 롤백 시에는 해당 트랜잭션을 재시작하거나 폐기함

세이브포인트(SavePoint)

  • 특정 지점에서의 작업 상태를 임시로 저장하는 역할
  • 특정 부분에서 트랜잭션을 취소하기 위해 사용
  • 트랜잭션을 세분화하여 사용 가능
  • 여러 개의 SQL 문을 실행하는 트랜잭션의 중간 단계에서 실패할 경우 해당 지점으로 롤백하여 트랜잭션의 일부만 롤백 가능
  • 세이브포인트를 이용해 취소하려는 지점을 명시함으로써, 해당 지점까지 작업을 취소함

데이터베이스 회복 기법(Checkpoint Recovery)

트랜잭션들을 수행하는 도중 장애로 인해 손상된 데이터베이스를 손상되기 이전의 정상적인 상태로 복구시키는 작업

장애의 유형

  • 트랜잭션 장애: 트랜잭션의 실행 시 논리적인 오류로 발생할 수 있는 에러 상황
  • 시스템 장애: H/W 시스템 자체에서 발생할 수 있는 에러 상황
  • 미디어 장애: 디스크 자체의 손상으로 발생할 수 있는 에러 상황

UNDO와 REDO

  • Undo: 트랜잭션 로그를 이용하여 오류와 관련된 모든 변경을 취소하여 복구 수행
  • Redo: 트랜잭션 로그를 이용하여 오류가 발생한 트랜잭션을 재실행하여 복구 수행

로그파일

  • 트랜잭션이 반영한 모든 데이터의 변경사항을 데이터베이스에 기록하기 전에 미리 기록해두는 별도의 데이터베이스
  • 로그파일을 이용한 복구
    - 로그파일에 트랜잭션의 시작(START)과 종료(COMMIT)가 있는 경우 REDO 수행
    - 로그파일에 트랜잭션의 시작(START)은 있고 종료(COMMIT)는 없는 경우 UNDO 수행

로그 기반 회복 기법

  • 지연갱신 회복 기법(Deferred Update)
    - 트랜잭션의 부분 완료 상태에서 변경 내용을 로그 파일에만 저장하고 데이터베이스에 기록하지 않음
    - 커밋이 발생하기 전까지 변경 사항이 데이터베이스에 반영되지 않으므로 UNDO 작업이 필요하지 않음
    - 장애 발생 시에는 미실행된 로그를 폐기하면 되므로 회복이 간단함
  • 즉시갱신 회복 기법(Immediate Update)
    - 트랜잭션 수행 중에도 변경 내용을 즉시 데이터베이스에 기록함
    - 커밋 이전의 갱신은 원자성이 보장되지 않는 미완료 갱신이므로 장애 발생 시 UNDO 작업이 필요함

검사점 기반 회복 기법(Checkpoint Recovery)

  • 장애 발생 시 검사점 이전에 처리된 트랜잭션은 회복에서 제외되고, 검사점 이후에 처리된 트랜잭션만 회복 작업이 수행됨
  • 검사점 이후에 commit된 트랜잭션은 Redo 작업이 수행됨
  • 장애 발생 시점까지 commit되지 못한 경우 Undo 작업이 수행됨

...그림자 페이징 회복 기법, 미디어 회복 기법, ARIES 회복 기법


⭐️트랜잭션 격리 수준

트랜잭션의 격리 수준(Isolation Level)이란?
여러 트랜잭션이 동시에 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 여부를 결정하는 것이다. 트랜잭션의 격리 수준은 격리(고립) 수준이 높은 순서대로 SERIALIZABLE, REPEATABLE READ, READ COMMITTED, READ UNCOMMITTED가 존재한다. 참고로 아래의 예제들은 모두 자동 커밋(AUTO COMMIT)이 false인 상태에서만 발생한다.

격리 수준을 알아보기 이전에 중요한 키워드를 알아보자

  • 갭락(Gap Lock): 트랜잭션이 특정 범위의 레코드를 읽을 때, 그 사이에 다른 트랜잭션이 새로운 레코드를 삽입하는 것을 막는 잠금 메커니즘이다. 즉, 트랜잭션이 특정 범위의 레코드를 읽을 때 해당 범위 내의 레코드 사이에 "갭"을 잠그는 것이다. 이로 인해 다른 트랜잭션이 동일한 범위에 새로운 레코드를 삽입하는 것을 방지하여 일관된 읽기를 보장한다. 하지만 갭락은 범위를 읽는 동안에만 유지되므로 범위 이외의 레코드에는 영향을 미치지 않는다.

  • 팬텀 리드(Phantom Read): 한 트랜잭션이 동일한 쿼리를 실행할 때, 처음과 끝 사이에 다른 트랜잭션에 의해 새로운 레코드가 삽입되거나 삭제되는 것을 의미한다. 이로 인해 동일한 쿼리를 두 번 실행했을 때 결과가 달라질 수 있는 현상을 말한다. 팬텀 리드는 일관성 있는 읽기를 보장하기 위해 잠금을 사용하는 대신 데이터베이스의 격리 수준을 높여 해결할 수 있다.

SERIALIZABLE

SERIALIZABLE은 가장 엄격한 격리 수준으로, 트랜잭션을 순차적으로 진행시킨다. SERIALIZABLE에서 여러 트랜잭션이 동일한 레코드에 동시 접근할 수 없으므로 어떠한 데이터 부정합 문제도 발생하지 않지만, 트랜잭션이 순차적으로 처리되어야 하므로 동시 처리 성능이 매우 떨어진다.

MySQL의 SELECT FOR SHARE/UPDATE는 대상 레코드에 각각 읽기/쓰기 잠금을 거는데, 순수한 SELECT 작업은 아무런 레코드 잠금 없이 실행되며, 이를 잠금 없는 일관된 읽기(Non-locking consistent read)라고 한다.

하지만 SERIALIZABLE 격리 수준에서는 순수한 SELECT 작업에서도 대상 레코드에 넥스트 키 락을 읽기 잠금(공유락, Shared Lock)으로 걸어 다른 트랜잭션에서 추가/수정/삭제할 수 없게 된다. SERIALIZABLE은 가장 안전하지만 가장 성능이 떨어지므로, 극단적으로 안전한 작업이 필요한 경우가 아니라면 사용하지 않는 것이 좋다.

REPEATABLE READ

일반적인 RDBMS는 변경 전의 레코드를 언두 공간에 백업해둔다. 그러면 변경 전/후 데이터가 모두 존재하므로, 동일한 레코드에 대해 여러 버전의 데이터가 존재한다고 하여 이를 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)라고 부른다. MVCC를 통해 트랜잭션이 롤백된 경우에 데이터를 복원할 수 있을 뿐만 아니라, 서로 다른 트랜잭션 간에 접근할 수 있는 데이터를 세밀하게 제어할 수 있다. 각각의 트랜잭션은 순차 증가하는 고유한 트랜잭션 번호가 존재하며, 백업 레코드에는 어느 트랜잭션에 의해 백업되었는지 트랜잭션 번호를 함께 저장한다. 그리고 해당 데이터가 불필요해진다고 판단하는 시점에 주기적으로 백그라운드 쓰레드를 통해 삭제한다.

REPEATABLE READ는 MVCC를 이용해 한 트랜잭션 내에서 동일한 결과를 보장하지만, 새로운 레코드가 추가되는 경우에 부정합이 생길 수 있다.

  • REPEATABLE READ 작동 방식(1 - 레코드 데이터 수정)
  1. 사용자 B가 트랜잭션을 시작하여 id=50인 데이터를 조회(결과: MinKyu)
  2. 사용자 A가 트랜잭션을 시작하여 id=50인 데이터의 name을 MinKi로 변경
    -> 이때, MVCC를 통해 기존 데이터는 변경되지만, 백업된 데이터가 언두 로그에 남게 된다.
  3. 이전에 사용자 B가 데이터를 조회했던 트랜잭션은 아직 종료되지 않은 상황에서, 사용자 B가 다시 한번 동일한 SELECT 문을 실행(결과: MinKyu)

사용자 B의 트랜잭션(10)은 사용자 A의 트랜잭션(12)이 시작하기 전에 이미 시작된 상태다. 이때 REPEATABLE READ는 트랜잭션 번호를 참고하여 자신보다 먼저 실행된 트랜잭션의 데이터만을 조회한다. 만약 테이블에 자신보다 이후에 실행된 트랜잭션의 데이터가 존재한다면 언두 로그를 참고해서 데이터를 조회한다.
따라서 사용자 A의 트랜잭션이 시작되고 커밋까지 되었지만, 해당 트랜잭션(12)는 현재 트랜잭션(10)보다 나중에 실행되었기 때문에 조회 결과로 기존과 동일한 데이터를 얻게 된다. 즉, REPEATABLE READ는 어떤 트랜잭션이 읽은 데이터를 다른 트랜잭션이 수정하더라도 동일한 결과를 반환할 것을 보장해준다.

앞서 REPEATABLE READ는 새로운 레코드의 추가까지는 막지 않고, 이 때문에 데이터 부정합이 발생할 수 있다고하였다. 그렇다면, 한 트랜잭션이 시작된 후 다른 트랜잭션이 데이터를 삽입을 한다면 조회 결과가 어떻게 달라질까?

  • REPEATABLE READ 작동 방식(2 - 새로운 레코드 삽입)
  1. 사용자 B가 트랜잭션을 시작하여 id>=50인 데이터를 조회(결과: MinKyu(1건))
  2. 사용자 A가 트랜잭션을 시작하여 user에 새로운 레코드인 YeongHee 추가하고 커밋
  3. 트랜잭션이 종료되지않은 사용자 B가 다시 id>=50인 데이터를 조회(결과: MinKyu(1건))

조회 결과는 새로운 레코드가 테이블에 삽입됐음에도 불구하고, 레코드 삽입 전 조회 결과와 동일하다. 이 이유는 MVCC가 자신보다 나중에 실행된 트랜잭션이 추가한 레코드를 무시하기 때문이다.
REFEATABLE READ에서 단순 SELECT 명령에서는 유령읽기(Phantom Read)가 발생하지 않게된다.

  • 유령읽기(Phantom Read): 한 트랜잭션의 조회 작업이 다른 트랜잭션의 삽입/삭제 작업에 의해 다른 결과가 나오게 되는 현상

그렇다면 언제 유령 읽기(Phantom Read)가 발생하는 것일까? 바로 잠금이 사용되는 경우이다. MySQL은 다른 RDBMS와 다르게 특수한 갭 락이 존재하기 때문에, 동작이 다른 부분이 있으므로 일반적인 RDBMS 경우부터 살펴보도록 하자.

  • REPEATABLE READ 작동 방식(3 - 일반적인 RDBMS)
  1. 사용자 B가 트랜잭션을 시작하여 id>=50인 데이터를 SELECT FOR UPDATE를 이용해 조회(결과: MinKyu(1건))
    -> 여기서 SELECT … FOR UPDATE 구문은 베타적 잠금(비관적 잠금, 쓰기 잠금)을 거는 것이다. 읽기 잠금을 걸려면 SELECT FOR SHARE 구문을 사용해야 한다. 락은 트랜잭션이 커밋 또는 롤백될 때 해제된다. 
  2. 사용자 A가 트랜잭션을 시작하여 user에 새로운 레코드인 YeongHee 추가하고 커밋
    -> 일반적인 DBMS에서는 갭락이 존재하지 않으므로 id = 50인 레코드만 잠금이 걸린 상태이고, 사용자 A의 요청은 잠금 없이 즉시 실행된다. 
  3. 트랜잭션이 종료되지 않은 사용자 B가 동일한 쓰기 잠금 쿼리(SELECT ... FOR UPDATE)로 다시 데이터 조회(결과: MinKyu, YeonHee(2건))

이 경우에도 MVCC를 통해 해결될 것 같지만, SELECT FOR UPDATE 때문에 그럴 수 없다. 왜냐하면 잠금있는 읽기는 데이터 조회가 언두 로그가 아닌 테이블에서 수행되기 때문이다. 잠금있는 읽기는 테이블에 변경이 일어나지 않도록 테이블에 잠금을 걸고 테이블에서 데이터를 조회한다. 잠금이 없는 경우처럼 언두 로그를 바라보고 언두 로그를 잠그는 것은 불가능한데, 그 이유는 언두 로그가 append only 형태이므로 잠금 장치가 없기 때문이다.
따라서 SELECT FOR UPDATE나 SELECT FOR SHARE로 레코드를 조회하는 경우에는 언두 영역의 데이터가 아니라 테이블의 레코드를 가져오게 되고, 이로 인해 Phantom Read가 발생하는 것이다.

하지만 MySQL에는 갭 락이 존재하기 때문에 위의 상황에서 문제가 발생하지 않는다.

  • REPEATABLE READ 작동 방식(4 - MySQL)
  1. 사용자 B가 트랜잭션을 시작하여 id>=50인 데이터를 SELECT FOR UPDATE를 이용해 조회(결과: MinKyu(1건))
    -> SELECT FOR UPDATE로 데이터를 조회한 경우에 MySQL은 id가 50인 레코드에는 레코드 락, id가 50보다 큰 범위에는 갭 락으로 넥스트 키 락을 건다.
  2. 사용자 A가 트랜잭션을 시작하여 user에 새로운 레코드인 YeongHee 추가하고 커밋 대기
    -> 사용자 A가 id가 51인 member를 INSERT 시도한다면, B의 트랜잭션이 종료(커밋 또는 롤백)될 때 까지 기다리다가, 대기를 지나치게 오래 하면 락 타임아웃이 발생하게 된다.
  3. 트랜잭션이 종료되지않은 사용자 B가 동일한 쓰기 잠금 쿼리(SELECT ... FOR UPDATE)로 다시 데이터 조회(결과: MinKyu(1건))

따라서 일반적으로 MySQL의 REAPEATABLE READ에서는 Phantom Read가 발생하지 않는다. MySQL에서 Phantom Read가 발생하는 거의 유일한 케이스는 다음과 같다.

  • REPEATABLE READ 작동 방식(5 - MySQL(팬텀리드 발생))
  1. 사용자 B가 트랜잭션을 시작하여 잠금 없는 SELECT문으로 id>=50인 데이터 조회(결과: MinKyu(1건))
  2. 사용자 A가 트랜잭션을 시작하여 user에 새로운 레코드인 YeongHee 추가하고 커밋
    -> 잠금이 없으므로 바로 COMMIT 된다.
  3. 트랜잭션이 종료되지않은 사용자 B가 id>=50인 데이터를 SELECT FOR UPDATE를 이용해 다시 조회(결과: MinKyu, YeongHee(2건))
    -> SELECT FOR UPDATE로 조회를 하였으므로 언두 로그가 아닌 테이블로부터 레코드를 조회하게 되고 Phantom Read가 발생한다.

하지만 이러한 케이스는 거의 존재하지 않으므로, MySQL의 REPEATABLE READ에서는 PHANTOM READ가 발생하지 않는다고 봐도 된다. 아래는 MySQL 기준으로 정리된 내용이다.

SELECT FOR UPDATE 이후 SELECT: 갭락 때문에 팬텀리드 X
SELECT FOR UPDATE 이후 SELECT FOR UPDATE: 갭락 때문에 팬텀리드 X
SELECT 이후 SELECT: MVCC 때문에 팬텀리드 X
SELECT 이후 SELECT FOR UPDATE: 팬텀 리드 O

READ COMMITTED

READ COMMITTED는 커밋된 데이터만 조회할 수 있다. READ COMMITTED는 REPEATABLE READ에서 발생하는 Phantom Read에 더해 Non-Repeatable Read(반복 읽기 불가능) 문제까지 발생한다.

  • READ COMMITED 작동 방식
  1. 사용자 A가 트랜잭션을 시작하여 id=50인 데이터의 name을 변경
  2. 사용자 B가 id=50인 데이터를 조회(결과: MinKyu)
    -> READ COMMITTED는 커밋된 데이터만 조회할 수 있으므로 REPEATABLE READ와 마찬가지로 언두 로그에서 변경 전의 데이터를 찾아서 반환
  3. 사용자 A가 트랜잭션을 커밋하면 그때부터 다른 트랜잭션에서도 새롭게 변경된 값을 참조 가능
  • READ COMMITTED 한계
  1. 사용자 B가 name이 Minki인 데이터를 조회 -> 결과는 존재하지 않음
  2. 사용자 A가 트랜잭션을 시작하여 id=50인 데이터의 name을 Minki로 변경하고 COMMIT
  3. 사용자 B가 다시 name이 MinKi인 데이터를 조회 -> 결과 반환

READ COMMITTED에서 반복 읽기를 수행하면 다른 트랜잭션의 커밋 여부에 따라 조회 결과가 달라질 수 있으며, 이러한 데이터 부정합 문제를 Non-Repeatable Read(반복 읽기 불가능)라고 한다.
Non-Repeatable Read는 일반적인 경우에는 크게 문제가 되지 않지만, 하나의 트랜잭션에서 동일한 데이터를 여러 번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 생길 수 있다. 예를 들어 어떤 트랜잭션에서는 오늘 입금된 총 합을 계산하고 있는데, 다른 트랜잭션에서 계속해서 입금 내역을 커밋하는 상황이라고 하자. 그러면 READ COMMITTED에서는 같은 트랜잭션일지라도 조회할 때마다 입금된 내역이 달라지므로 문제가 생길 수 있는 것이다. 따라서 격리 수준이 어떻게 동작하는지, 그리고 격리 수준에 따라 어떠한 결과가 나오는지 예측할 수 있어야 한다.
READ COMMITTED 수준에서는 애초에 커밋된 데이터만 읽을 수 있기 때문에 트랜잭션 내에서 실행되는 SELECT와 트랜잭션 밖에서 실행되는 SELECT의 차이가 별로 없다.

READ UNCOMMITTED

READ UNCOMMITTED는 커밋하지 않은 데이터 조차도 접근할 수 있는 격리 수준이다. READ UNCOMMITTED에서는 다른 트랜잭션의 작업이 커밋 또는 롤백되지 않아도 즉시 보이게 된다.

  • READ UNCOMMITED 작동 방식
  1. 사용자 A가 트랜잭션을 시작하여 id=50인 데이터의 name을 변경하였고, 아직 COMMIT은 하지 않은 상태
  2. 사용자 B가 name이 Minki인 데이터를 조회(결과: MinKi)
    -> COMMIT 이나 ROLLBACK이 되지 않은 상태임에도 바뀐 결과 확인 가능
  • READ UNCOMMITTED 한계
  1. 사용자 A가 트랜잭션을 시작하여 id=50인 데이터의 name을 MinKi로 변경(COMMIT 또는 ROLLBACK을 하지않은 상태)
  2. 사용자 B가 name이 MinKi인 데이터를 조회(결과: MinKi)
  3. 사용자 A가 시작한 트랜잭션이 롤백이 되어 MinKi로 바뀐 id=50의 데이터의 name을 다시 Minkyu로 되돌림
  4. 사용자 B가 name이 MinKi인 데이터를 다시 조회(결과 없음)

이렇듯 어떤 트랜잭션의 작업이 완료되지 않았는데도, 다른 트랜잭션에서 볼 수 있는 부정합 문제를 Dirty Read(오손 읽기)라고 한다. Dirty Read는 데이터가 조회되었다가 사라지는 현상을 초래하므로 시스템에 상당한 혼란을 주게 된다.
사용자 B의 트랜잭션은 id = 50인 데이터를 계속 처리하고 있을 텐데, 다시 데이터를 조회하니 결과가 존재하지 않는 상황이 생긴다. 이러한 Dirty Read 상황은 시스템에 상당한 버그를 초래할 것이다.

앞서 살펴본 내용을 정리하면 다음과 같다. READ UNCOMMITTED는 부정합 문제가 지나치게 발생하고, SERIALIZABLE은 동시성이 상당히 떨어지므로 READ COMMITTED 또는 REPEATABLE READ를 사용하면 된다. 참고로 오라클에서는 READ COMMITTED를 기본으로 사용하며, MySQL에서는 REPEATABLE READ를 기본으로 사용한다. 

  • Dirty Read: 어떤 트랜잭션의 작업이 완료되지 않았는데도, 다른 트랜잭션에서 볼 수 있는 부정합 문제
  • Non-Repeatable Read: 반복 읽기를 수행하는 경우, 다른 트랜잭션의 커밋 여부에 따라 조회 결과가 달라지는 데이터 부정합 문제
  • Phantom Read: 한 트랜잭션이 동일한 쿼리를 실행할 때, 다른 트랜잭션에 의해 새로운 레코드가 삽입되거나 삭제되는 것을 의미 -> 이로 인해 동일한 쿼리를 두 번 실행했을 때 결과가 달라질 수 있는 현상

트랜잭션 처리 시스템의 구성 요소

트랜잭션 관리자

트랜잭션의 시작, 실행, 커밋 또는 롤백과 같은 트랜잭션 수명 주기를 관리하는 시스템 구성 요소이다. 트랜잭션 관리자는 트랜잭션의 일관성과 격리성을 보장하기 위해 데이터베이스에 대한 접근을 제어한다.

로그 관리자

트랜잭션의 실행 내역과 변경 내용을 기록하고 관리하는 시스템 구성 요소이다. 로그 관리자는 데이터베이스의 장애 복구를 지원하고, 트랜잭션의 원자성을 보장하기 위해 로그를 사용하여 트랜잭션의 성공 또는 실패를 추적한다.

버퍼 관리자

메모리 내의 데이터베이스 버퍼를 관리하고 최적화하는 시스템 구성 요소이다. 버퍼 관리자는 데이터베이스의 입출력 성능을 향상시키기 위해 데이터를 적절히 캐시하고 관리한다. 트랜잭션 처리 시스템에서 버퍼 관리자는 데이터베이스의 읽기 및 쓰기 작업을 효율적으로 처리한다.

데이터베이스 관리자

데이터베이스 관리자는 데이터베이스의 구성, 접근 권한, 백업 및 복구 등을 관리하는 시스템 구성 요소이다. 트랜잭션 처리 시스템에서 데이터베이스 관리자는 트랜잭션의 원자성, 일관성, 격리성, 지속성을 보장하기 위해 데이터베이스의 상태를 관리하고 조정한다.
이러한 구성 요소들은 트랜잭션 처리 시스템의 핵심 부분을 형성하며, 데이터의 일관성과 무결성을 유지하고 성능을 최적화하는 데 중요한 역할을 한다. 각 구성 요소는 데이터베이스 시스템의 안정성과 신뢰성을 보장하기 위해 조화롭게 동작해야 한다.


트랜잭션 관리에서 고려해야 할 사항

동시성 제어와 데드락

  • 동시성 제어: 여러 트랜잭션이 동시에 데이터베이스에 접근할 때 발생할 수 있는 문제를 관리하는 것. 동시성 문제로 인해 데이터 무결성이 깨질 수 있으므로, 트랜잭션 간의 충돌을 방지하고 일관성을 유지하기 위해 동시성 제어 메커니즘이 필요하다. 주요 기법으로 Locking, MVCC(Multi-Version Concurrency Control) 등이 있다.
  • 데드락: 두 개 이상의 트랜잭션이 서로 상대방이 소유한 자원을 대기하면서 진행이 멈춰있는 상태. 데드락을 방지하기 위해 트랜잭션 간의 자원 요청 순서를 조정하거나 타임아웃 등의 메커니즘을 사용한다.

성능 향상을 위한 최적화 기법

  • 인덱스 최적화: 트랜잭션 실행 속도를 향상시키기 위해 적절한 인덱스를 사용하여 쿼리 성능을 최적화한다. 인덱스를 효율적으로 사용하면 데이터 검색 및 조작 속도를 향상시킬 수 있다.
  • 쿼리 최적화: 복잡한 쿼리를 단순화하거나 쿼리 실행 계획을 최적화하여 데이터베이스의 부하를 줄이고 성능을 향상시킨다. 쿼리 성능을 분석하고 개선하기 위해 쿼리 실행 계획을 확인하고 쿼리 튜닝을 수행한다.

장애 복구 전략

  • 로그 기반 복구: 트랜잭션 실행 도중 시스템이 중단되었을 때, 트랜잭션 로그를 사용하여 이전 상태로 데이터베이스를 복구하는 메커니즘. 로그를 사용하여 트랜잭션의 변경 사항을 기록하고, 시스템 장애 발생 시 로그를 분석하여 변경 사항을 롤백하거나 재실행한다.
  • 점진적 복구: 데이터베이스의 일부가 손상되었을 때 전체 데이터베이스를 복구하는 대신, 손상된 부분만을 복구하는 방법. 트랜잭션 로그를 사용하여 손상된 부분을 식별하고 복구하는 과정을 점진적으로 수행한다.

이러한 사항들은 데이터베이스 시스템의 안정성과 성능을 유지하고 트랜잭션 관리를 효율적으로 수행하기 위해 고려되어야 한다.


실제 응용 프로그램에서의 트랜잭션 활용 사례

은행 업무

입출금 처리: 고객이 계좌에서 돈을 인출하거나 입금할 때, 트랜잭션을 사용하여 계좌 잔액을 업데이트하고 거래 내역을 기록한다. 이러한 작업은 한 번에 완전히 실행되거나 전혀 실행되지 않아야 한다. 따라서 트랜잭션은 입출금 과정에서 데이터 무결성을 보장하는 데 중요한 역할을 한다.

전자 상거래

주문 처리: 고객이 상품을 주문할 때, 주문 정보를 데이터베이스에 기록하고 결제를 처리하는 과정에서 트랜잭션을 사용한다. 주문이 완전히 처리되지 않은 경우에는 재고가 감소하지 않고 결제가 이루어지지 않아야 한다. 또한 주문 취소 또는 환불 시에도 트랜잭션을 사용하여 데이터의 일관성을 유지한다.

주문 처리 시스템

주문 및 배송 처리: 주문이 접수되면 주문 정보를 데이터베이스에 저장하고, 주문 상태를 변경하며, 재고를 감소시킨다. 이러한 작업은 한 번에 모두 실행되거나 전혀 실행되지 않아야 한다. 따라서 주문 처리 시스템에서는 트랜잭션을 사용하여 주문 관련 데이터의 일관성을 유지한다.

이러한 사례들에서 트랜잭션은 데이터의 일관성을 유지하고 데이터베이스의 안정성을 보장하는 데 필수적이다. 은행 업무, 전자 상거래, 주문 처리 시스템 등 다양한 응용 프로그램에서 트랜잭션을 활용하여 데이터의 무결성을 유지하고 신뢰성 있는 서비스를 제공한다.


nestjs에서 트랜잭션 사용법

클라이언트의 요청이 들어왔을 때, 다수의 엔티티 인스턴스를 생성 혹은 수정해야 하는 일이 생길 수 있다. 이럴 때에는 트랜잭션 단위로 묶어서 처리해 주어야 하는데, TypeORM에서는 어떻게 트랜잭션 처리를 할 수 있도록 제공하는지 알아보자.

트랜잭션 생성 및 사용

TypeORM에서는 트랜잭션을 DataSource 혹은 EntityManager를 통해 만들 수 있으며, 콜백 함수를 실행하여 원하는 동작을 처리할 수 있도록 제공하고 있다.

await myDataSource.manager.transaction(async (transactionalEntityManager) => {
    await transactionalEntityManager.save(users)
    await transactionalEntityManager.save(photos)
    // ...
})

QueryRunner를 이용한 트랜잭션 제어

queryRunner는 single database connection을 제공하기 때문에 트랜잭션 제어가 가능하다.
좀 더 세부적으로 직접 트랜잭션을 제어하고 싶을 때에는 QueryRunner를 사용하면 된다.

// queryRunner 생성
const queryRunner = dataSource.createQueryRunner()

// 새로운 queryRunner를 연결
await queryRunner.connect()

// 생성한 쿼리러너를 통해 쿼리문을 날림
await queryRunner.query("SELECT * FROM users")


// 새로운 트랜잭션을 시작
await queryRunner.startTransaction()

try {
    await queryRunner.manager.save(user1)
    await queryRunner.manager.save(user2)

    // 모든 동작이 정상적으로 수행되었을 경우 커밋을 수행
    await queryRunner.commitTransaction()
} catch (err) {
    // 동작 중간에 에러가 발생할 경우엔 롤백을 수행
    await queryRunner.rollbackTransaction()
} finally {
    // queryRunner는 생성한 뒤 반드시 release
    await queryRunner.release()
}

Transaction 데코레이터 사용

Transaction 데코레이터도 사용이 가능하지만 NestJS에서 권장하는 방법은 아니라고 한다.

import { Transactional } from 'typeorm-transactional-cls-hooked';

@Transactional()
async createUser(name: string): Promise<User> {
  const user = new User();
  user.name = name;
  return this.userRepository.save(user);
}

NestJS에서 Trasaction 데코레이터를 권장하지 않는 이유

  • 라이브러리 종속성: @Transaction() 데코레이터를 사용하려면 typeorm-transactional-cls-hooked와 같은 라이브러리에 의존해야 한다. 이는 프로젝트에 불필요한 종속성을 추가할 수 있으며, 관리와 유지보수를 어렵게 만들 수 있다.
  • 결합도: @Transaction() 데코레이터를 사용하면 서비스 계층이 데이터베이스 관련 라이브러리에 직접적으로 의존하게 된다. 이는 응용 프로그램의 레이어 간 결합도를 높이고, 테스트 용이성을 저하시킬 수 있다.
  • NestJS의 모듈성: NestJS는 모듈과 서비스의 분리를 장려한다. 데이터베이스 트랜잭션은 서비스 로직과 관련이 있지만, 이를 데코레이터로 직접 적용하는 것은 모듈성에 어긋나는 구현이다.

[참고문헌]

트랜잭션 - 해시넷
트랜잭션 과정
ACID
트랜잭션 데이터베이스 회복 기법
트랜잭션 격리수준
트랜잭션 격리수준2
nestjs에서 트랜잭션 사용법

profile
노는게 제일 좋아

0개의 댓글