데이터베이스에서는 여러 클라이언트가 동시에 액세스하거나 응용프로그램이 갱신을 처리하는 과정에서 데이터 부정합을 방지하기 위해 트랜잭션이 사용된다. 트랜잭션은 데이터베이스 작업을 안전하게 수행하고, 완전성을 유지하는 데 중요하다. 각 트랜잭션은 데이터베이스 내에서 읽거나 쓰는 여러 쿼리를 요구하며, 중간 단계가 남지 않도록 보장되고, 각 트랜잭션은 서로 간섭하지 않아야하며, 실패 시 롤백된다.
데이터베이스의 트랜잭션이 안전하게 수행되기 위해서는 ACID 조건을 충족해야 한다. ACID란 Atomicity(원자성), Consistency(일관성), Isolation(고립성), Durability(지속성)의 약자로서, 데이터베이스의 트랜잭션이 안전하게 수행되기 위한 4가지 필수적인 성질을 말한다.
원자성 (Atomicity): 트랜잭션의 모든 작업은 원자적으로 실행되어야 한다. 즉, 모든 작업이 성공적으로 완료되거나 아무것도 수행되지 않은 상태로 롤백되어야 한다. 이것은 작업의 부분적 완료나 중간 단계에서 실패에 대한 안전장치 역할을 한다.
일관성 (Consistency): 트랜잭션이 테이블에 변경 사항을 적용할 때 미리 정의된, 예측할 수 있는 방식만 취한다. 트랜잭션 일관성이 확보되면 데이터 손상이나 오류 때문에 테이블 무결성에 의도치 않은 결과가 생기지 않는다.
고립성 (Isolation): 여러 사용자가 같은 테이블에서 모두 동시에 읽고 쓰기 작업을 할 때, 각각의 트랜잭션을 격리하면 동시 트랜잭션이 서로 방해하거나 영향을 미치지 않는다. 각각의 요청이 실제로는 모두 동시에 발생하더라도, 마치 하나씩 발생하는 것처럼 볼 수 있다. 또한, 한 트랜잭션이 다른 트랜잭션에서 수행 중인 작업을 볼 수 없어야 한다.
지속성 (Durability): 트랜잭션이 성공적으로 완료된 후에는 그 결과가 영구적으로 데이터베이스에 반영되어야 한다. 즉, 시스템이 고장나거나 다시 시작되더라도 트랜잭션의 결과가 보존되어야 한다.
트랜잭션의 ACID 속성은 데이터의 안정성과 무결성을 최대한 보장한다. 이는 작업이 일부만 완료되어 데이터가 일관성 없는 상태가 되는 일을 방지한다. 예를 들어, 정전이 발생하여 작업 중인 데이터가 일부만 저장되는 경우가 있을 수 있는데, ACID 트랜잭션은 이를 방지하여 데이터베이스가 일관성 없는 상태에 빠지는 것을 막고, 복구를 용이하게 한다.
처리 과정이 모두 성공했을 때만 최종적으로 데이터베이스에 결과값을 반영하며, 트랜잭션이 중단되거나 서버/하드웨어 고장 등으로 인한 작업의 오류가 발생할 경우 트랜잭션 작업 전으로 돌아간다.
트랜잭션은 논리적으로 5가지 상태가 있을 수 있다.
트랜잭션의 개요와 상태에 대해 알아보았으니 이제 트랜잭션의 연산을 알아보자.
트랜잭션의 연산에는 사용자가 작성한 쿼리문과 데이터를 최종적으로 데이터베이스에 반영하는 커밋과 실패했을 경우 트랜잭션의 실행을 중단하고 이전으로 돌아가는 롤백이 있다.
커밋(Commit)
롤백(Rollback)
DML명령어 작업들을 취소시켜 commit 지점까지 원상복구 시킴
세이브포인트(SavePoint)
트랜잭션들을 수행하는 도중 장애로 인해 손상된 데이터베이스를 손상되기 이전의 정상적인 상태로 복구시키는 작업
장애의 유형
UNDO와 REDO
로그파일
...그림자 페이징 회복 기법, 미디어 회복 기법, ARIES 회복 기법
트랜잭션의 격리 수준(Isolation Level)이란?
여러 트랜잭션이 동시에 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 여부를 결정하는 것이다. 트랜잭션의 격리 수준은 격리(고립) 수준이 높은 순서대로 SERIALIZABLE, REPEATABLE READ, READ COMMITTED, READ UNCOMMITTED가 존재한다. 참고로 아래의 예제들은 모두 자동 커밋(AUTO COMMIT)이 false인 상태에서만 발생한다.
격리 수준을 알아보기 이전에 중요한 키워드를 알아보자
갭락(Gap Lock): 트랜잭션이 특정 범위의 레코드를 읽을 때, 그 사이에 다른 트랜잭션이 새로운 레코드를 삽입하는 것을 막는 잠금 메커니즘이다. 즉, 트랜잭션이 특정 범위의 레코드를 읽을 때 해당 범위 내의 레코드 사이에 "갭"을 잠그는 것이다. 이로 인해 다른 트랜잭션이 동일한 범위에 새로운 레코드를 삽입하는 것을 방지하여 일관된 읽기를 보장한다. 하지만 갭락은 범위를 읽는 동안에만 유지되므로 범위 이외의 레코드에는 영향을 미치지 않는다.
팬텀 리드(Phantom Read): 한 트랜잭션이 동일한 쿼리를 실행할 때, 처음과 끝 사이에 다른 트랜잭션에 의해 새로운 레코드가 삽입되거나 삭제되는 것을 의미한다. 이로 인해 동일한 쿼리를 두 번 실행했을 때 결과가 달라질 수 있는 현상을 말한다. 팬텀 리드는 일관성 있는 읽기를 보장하기 위해 잠금을 사용하는 대신 데이터베이스의 격리 수준을 높여 해결할 수 있다.
SERIALIZABLE은 가장 엄격한 격리 수준으로, 트랜잭션을 순차적으로 진행시킨다. SERIALIZABLE에서 여러 트랜잭션이 동일한 레코드에 동시 접근할 수 없으므로 어떠한 데이터 부정합 문제도 발생하지 않지만, 트랜잭션이 순차적으로 처리되어야 하므로 동시 처리 성능이 매우 떨어진다.
MySQL의 SELECT FOR SHARE/UPDATE는 대상 레코드에 각각 읽기/쓰기 잠금을 거는데, 순수한 SELECT 작업은 아무런 레코드 잠금 없이 실행되며, 이를 잠금 없는 일관된 읽기(Non-locking consistent read)라고 한다.
하지만 SERIALIZABLE 격리 수준에서는 순수한 SELECT 작업에서도 대상 레코드에 넥스트 키 락을 읽기 잠금(공유락, Shared Lock)으로 걸어 다른 트랜잭션에서 추가/수정/삭제할 수 없게 된다. SERIALIZABLE은 가장 안전하지만 가장 성능이 떨어지므로, 극단적으로 안전한 작업이 필요한 경우가 아니라면 사용하지 않는 것이 좋다.
일반적인 RDBMS는 변경 전의 레코드를 언두 공간에 백업해둔다. 그러면 변경 전/후 데이터가 모두 존재하므로, 동일한 레코드에 대해 여러 버전의 데이터가 존재한다고 하여 이를 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)라고 부른다. MVCC를 통해 트랜잭션이 롤백된 경우에 데이터를 복원할 수 있을 뿐만 아니라, 서로 다른 트랜잭션 간에 접근할 수 있는 데이터를 세밀하게 제어할 수 있다. 각각의 트랜잭션은 순차 증가하는 고유한 트랜잭션 번호가 존재하며, 백업 레코드에는 어느 트랜잭션에 의해 백업되었는지 트랜잭션 번호를 함께 저장한다. 그리고 해당 데이터가 불필요해진다고 판단하는 시점에 주기적으로 백그라운드 쓰레드를 통해 삭제한다.
REPEATABLE READ는 MVCC를 이용해 한 트랜잭션 내에서 동일한 결과를 보장하지만, 새로운 레코드가 추가되는 경우에 부정합이 생길 수 있다.
사용자 B의 트랜잭션(10)은 사용자 A의 트랜잭션(12)이 시작하기 전에 이미 시작된 상태다. 이때 REPEATABLE READ는 트랜잭션 번호를 참고하여 자신보다 먼저 실행된 트랜잭션의 데이터만을 조회한다. 만약 테이블에 자신보다 이후에 실행된 트랜잭션의 데이터가 존재한다면 언두 로그를 참고해서 데이터를 조회한다.
따라서 사용자 A의 트랜잭션이 시작되고 커밋까지 되었지만, 해당 트랜잭션(12)는 현재 트랜잭션(10)보다 나중에 실행되었기 때문에 조회 결과로 기존과 동일한 데이터를 얻게 된다. 즉, REPEATABLE READ는 어떤 트랜잭션이 읽은 데이터를 다른 트랜잭션이 수정하더라도 동일한 결과를 반환할 것을 보장해준다.
앞서 REPEATABLE READ는 새로운 레코드의 추가까지는 막지 않고, 이 때문에 데이터 부정합이 발생할 수 있다고하였다. 그렇다면, 한 트랜잭션이 시작된 후 다른 트랜잭션이 데이터를 삽입을 한다면 조회 결과가 어떻게 달라질까?
조회 결과는 새로운 레코드가 테이블에 삽입됐음에도 불구하고, 레코드 삽입 전 조회 결과와 동일하다. 이 이유는 MVCC가 자신보다 나중에 실행된 트랜잭션이 추가한 레코드를 무시하기 때문이다.
REFEATABLE READ에서 단순 SELECT 명령에서는 유령읽기(Phantom Read)가 발생하지 않게된다.
- 유령읽기(Phantom Read): 한 트랜잭션의 조회 작업이 다른 트랜잭션의 삽입/삭제 작업에 의해 다른 결과가 나오게 되는 현상
그렇다면 언제 유령 읽기(Phantom Read)가 발생하는 것일까? 바로 잠금이 사용되는 경우이다. MySQL은 다른 RDBMS와 다르게 특수한 갭 락이 존재하기 때문에, 동작이 다른 부분이 있으므로 일반적인 RDBMS 경우부터 살펴보도록 하자.
이 경우에도 MVCC를 통해 해결될 것 같지만, SELECT FOR UPDATE 때문에 그럴 수 없다. 왜냐하면 잠금있는 읽기는 데이터 조회가 언두 로그가 아닌 테이블에서 수행되기 때문이다. 잠금있는 읽기는 테이블에 변경이 일어나지 않도록 테이블에 잠금을 걸고 테이블에서 데이터를 조회한다. 잠금이 없는 경우처럼 언두 로그를 바라보고 언두 로그를 잠그는 것은 불가능한데, 그 이유는 언두 로그가 append only 형태이므로 잠금 장치가 없기 때문이다.
따라서 SELECT FOR UPDATE나 SELECT FOR SHARE로 레코드를 조회하는 경우에는 언두 영역의 데이터가 아니라 테이블의 레코드를 가져오게 되고, 이로 인해 Phantom Read가 발생하는 것이다.
하지만 MySQL에는 갭 락이 존재하기 때문에 위의 상황에서 문제가 발생하지 않는다.
따라서 일반적으로 MySQL의 REAPEATABLE READ에서는 Phantom Read가 발생하지 않는다. MySQL에서 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는 REPEATABLE READ에서 발생하는 Phantom Read에 더해 Non-Repeatable Read(반복 읽기 불가능) 문제까지 발생한다.
READ COMMITTED에서 반복 읽기를 수행하면 다른 트랜잭션의 커밋 여부에 따라 조회 결과가 달라질 수 있으며, 이러한 데이터 부정합 문제를 Non-Repeatable Read(반복 읽기 불가능)라고 한다.
Non-Repeatable Read는 일반적인 경우에는 크게 문제가 되지 않지만, 하나의 트랜잭션에서 동일한 데이터를 여러 번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 생길 수 있다. 예를 들어 어떤 트랜잭션에서는 오늘 입금된 총 합을 계산하고 있는데, 다른 트랜잭션에서 계속해서 입금 내역을 커밋하는 상황이라고 하자. 그러면 READ COMMITTED에서는 같은 트랜잭션일지라도 조회할 때마다 입금된 내역이 달라지므로 문제가 생길 수 있는 것이다. 따라서 격리 수준이 어떻게 동작하는지, 그리고 격리 수준에 따라 어떠한 결과가 나오는지 예측할 수 있어야 한다.
READ COMMITTED 수준에서는 애초에 커밋된 데이터만 읽을 수 있기 때문에 트랜잭션 내에서 실행되는 SELECT와 트랜잭션 밖에서 실행되는 SELECT의 차이가 별로 없다.
READ UNCOMMITTED는 커밋하지 않은 데이터 조차도 접근할 수 있는 격리 수준이다. READ UNCOMMITTED에서는 다른 트랜잭션의 작업이 커밋 또는 롤백되지 않아도 즉시 보이게 된다.
이렇듯 어떤 트랜잭션의 작업이 완료되지 않았는데도, 다른 트랜잭션에서 볼 수 있는 부정합 문제를 Dirty Read(오손 읽기)라고 한다. Dirty Read는 데이터가 조회되었다가 사라지는 현상을 초래하므로 시스템에 상당한 혼란을 주게 된다.
사용자 B의 트랜잭션은 id = 50인 데이터를 계속 처리하고 있을 텐데, 다시 데이터를 조회하니 결과가 존재하지 않는 상황이 생긴다. 이러한 Dirty Read 상황은 시스템에 상당한 버그를 초래할 것이다.
앞서 살펴본 내용을 정리하면 다음과 같다. READ UNCOMMITTED는 부정합 문제가 지나치게 발생하고, SERIALIZABLE은 동시성이 상당히 떨어지므로 READ COMMITTED 또는 REPEATABLE READ를 사용하면 된다. 참고로 오라클에서는 READ COMMITTED를 기본으로 사용하며, MySQL에서는 REPEATABLE READ를 기본으로 사용한다.
트랜잭션의 시작, 실행, 커밋 또는 롤백과 같은 트랜잭션 수명 주기를 관리하는 시스템 구성 요소이다. 트랜잭션 관리자는 트랜잭션의 일관성과 격리성을 보장하기 위해 데이터베이스에 대한 접근을 제어한다.
트랜잭션의 실행 내역과 변경 내용을 기록하고 관리하는 시스템 구성 요소이다. 로그 관리자는 데이터베이스의 장애 복구를 지원하고, 트랜잭션의 원자성을 보장하기 위해 로그를 사용하여 트랜잭션의 성공 또는 실패를 추적한다.
메모리 내의 데이터베이스 버퍼를 관리하고 최적화하는 시스템 구성 요소이다. 버퍼 관리자는 데이터베이스의 입출력 성능을 향상시키기 위해 데이터를 적절히 캐시하고 관리한다. 트랜잭션 처리 시스템에서 버퍼 관리자는 데이터베이스의 읽기 및 쓰기 작업을 효율적으로 처리한다.
데이터베이스 관리자는 데이터베이스의 구성, 접근 권한, 백업 및 복구 등을 관리하는 시스템 구성 요소이다. 트랜잭션 처리 시스템에서 데이터베이스 관리자는 트랜잭션의 원자성, 일관성, 격리성, 지속성을 보장하기 위해 데이터베이스의 상태를 관리하고 조정한다.
이러한 구성 요소들은 트랜잭션 처리 시스템의 핵심 부분을 형성하며, 데이터의 일관성과 무결성을 유지하고 성능을 최적화하는 데 중요한 역할을 한다. 각 구성 요소는 데이터베이스 시스템의 안정성과 신뢰성을 보장하기 위해 조화롭게 동작해야 한다.
이러한 사항들은 데이터베이스 시스템의 안정성과 성능을 유지하고 트랜잭션 관리를 효율적으로 수행하기 위해 고려되어야 한다.
입출금 처리: 고객이 계좌에서 돈을 인출하거나 입금할 때, 트랜잭션을 사용하여 계좌 잔액을 업데이트하고 거래 내역을 기록한다. 이러한 작업은 한 번에 완전히 실행되거나 전혀 실행되지 않아야 한다. 따라서 트랜잭션은 입출금 과정에서 데이터 무결성을 보장하는 데 중요한 역할을 한다.
주문 처리: 고객이 상품을 주문할 때, 주문 정보를 데이터베이스에 기록하고 결제를 처리하는 과정에서 트랜잭션을 사용한다. 주문이 완전히 처리되지 않은 경우에는 재고가 감소하지 않고 결제가 이루어지지 않아야 한다. 또한 주문 취소 또는 환불 시에도 트랜잭션을 사용하여 데이터의 일관성을 유지한다.
주문 및 배송 처리: 주문이 접수되면 주문 정보를 데이터베이스에 저장하고, 주문 상태를 변경하며, 재고를 감소시킨다. 이러한 작업은 한 번에 모두 실행되거나 전혀 실행되지 않아야 한다. 따라서 주문 처리 시스템에서는 트랜잭션을 사용하여 주문 관련 데이터의 일관성을 유지한다.
이러한 사례들에서 트랜잭션은 데이터의 일관성을 유지하고 데이터베이스의 안정성을 보장하는 데 필수적이다. 은행 업무, 전자 상거래, 주문 처리 시스템 등 다양한 응용 프로그램에서 트랜잭션을 활용하여 데이터의 무결성을 유지하고 신뢰성 있는 서비스를 제공한다.
클라이언트의 요청이 들어왔을 때, 다수의 엔티티 인스턴스를 생성 혹은 수정해야 하는 일이 생길 수 있다. 이럴 때에는 트랜잭션 단위로 묶어서 처리해 주어야 하는데, TypeORM에서는 어떻게 트랜잭션 처리를 할 수 있도록 제공하는지 알아보자.
TypeORM에서는 트랜잭션을 DataSource 혹은 EntityManager를 통해 만들 수 있으며, 콜백 함수를 실행하여 원하는 동작을 처리할 수 있도록 제공하고 있다.
await myDataSource.manager.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.save(users)
await transactionalEntityManager.save(photos)
// ...
})
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 데코레이터도 사용이 가능하지만 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에서 트랜잭션 사용법