[DB] 트랜잭션 ACID 속성과 트랜잭션 격리 수준

소영·2025년 3월 28일
post-thumbnail

주제

트랜잭션의 ACID 속성 중 격리성(Isolation)이 보장되지 않을 때 발생할 수 있는 문제점들을 설명하고, 이를 해결하기 위한 트랜잭션 격리 수준에 대해 알아보고자 한다.

트랜잭션이란?

트랜잭션이란 데이터베이스의 상태를 변화시키기 해서 수행하는 하나의 논리적 작업 단위이다.

ACID

ACID는 트랜잭션을 정의하는 4가지 속성을 말한다.

원자성 A: Atomicity

트랜잭션은 원자적으로 수행되는 연산들의 묶은 하나의 단위이다.
원자적이란, 작업 수행 시 이 모든 연산들이 모두 성공하거나 또는 모두 수행되지 않아야 한다는 뜻이다.

부분적으로 성공하여 데이터 불일치가 생기지 않도록
모두 성공하여 데이터베이스에 커밋하거나 또는 부분적으로 성공한 경우는 롤백하여 아무것도 일어나지 않은 것처럼 보여야 한다.

예를 들어 계좌이체라는 하나의 작업 단위에는 두 단계가 있다.
1. A 계좌에서 100만원을 출금한다.
2. B 계좌에 100만원을 입금한다.

만약 1번만 부분적으로 성공하거나 2번만 성공하는 일은 없어야 한다.
1, 2번 모두 성공하여 계좌이체가 정상적으로 이뤄지거나(커밋)
1번을 성공했지만 2번에서 실패햇다면 1번과 2번 둘 다 실패로 돌아가야 한다.(롤백)

일관성 C: Consistency

트랜잭션이 일어나든 말든 데이터베이스는 항상 일관된 상태를 유지해야 한다.
데이터베이스는 데이터베이스의 제약조건이나 규칙을 만족해야 한다는 뜻이다.

NOT NULL이나 UNIQUE 이런 제약조건 외에도
주문 내역을 다루는 Order 테이블에 기록된 총 가격은 주문한 Item들의 가격의 합과 늘 같아야 한다는 요구사항 등이 포함된다.

격리성 I: Isolation

모든 트랜잭션은 다른 트랜잭션과 격리되어야 한다.
즉, 모든 트랜잭션은 서로 독립적이다.

트랜잭션 A와 트랜잭션 B가 동시에 실행되도 서로에게 영향을 끼치면 안 된다.

지속성 D: Durability

트랜잭션이 한 번 데이터베이스에 커밋되면 그 결과는 영구적으로 반영된다.
커밋된 이후에는 그 후에 시스템 장애 등이 일어나든 아니든 영구적으로 해당 데이터가 저장이 되어 있어야 한다.

격리성이 보장되지 않는다면?

격리성이 보장되지 않으면,
즉 한 트랜잭션이 사용 중인 데이터에 대해 다른 트랜잭션의 접근을 허용하면
트랜잭션이 데이터를 읽을 때 문제가 발생할 수 있다.
일어날 수 있는 문제는 총 3가지가 있다.

Dirty Read

The transaction reads a row that has been changed, but the change has not been commited. If the change is rolled back, the transaction has incorrect data.

트랜잭션이 수정된 데이터를 읽었는데
그 수정이 커밋되지 않고 롤백되면, 트랜잭션은 잘못된 데이터를 읽게 된다.

초기에 a = 100 으로 시작했다.
트랜잭션 A에서 a = 50으로 수정했다.

이때 트랜잭션 B가 시작하여 a를 읽으면 a = 50 이라고 읽는다.

하지만 트랜잭션 A가 실패하여 롤백이 일어나면
다시 a = 100으로 돌아간다.

트랜잭션 B가 읽은 a = 50이란 값은 존재해서는 안 되는, 잘못된 데이터가 된다.

Non-repeatable Read

The transaction rereads data that has been changed, and finds changes due to commited transactions.

한 트랜잭션에서 어느 데이터를 읽었다.
똑같은 트랜잭션에서 "다시" 그 데이터를 읽었는데 값이 달라져 있다.

트랜잭션 A에서 a를 읽었을 때 그 값이 100이었다.
A에서 다른 작업을 하고 있을 때 다른 트랜잭션 B에서 a = 50으로 수정한다.
트랜잭션 A가 다른 작업을 마치고, 다시 a를 읽었을 때 값이 50으로 아까 읽었던 값과 다르다.

만약 트랜잭션 A가 처음에 읽은 a 값을 바탕으로 작업을 하고 있었다면 곤란해진다.

Phantom Read

The transaction rereads data and finds new rows inserted by a commited transactions.

트랜잭션이 데이터를 다시 읽었는데 이전에 없던 새로운 데이터가 조회된다. 새로운 데이터는 다른 트랜잭션의 커밋으로 생긴다.

트랜잭션 A가 데이터베이스에서 User 테이블의 데이터를 읽었다. 이때 읽은 row 수는 1개다.

트랜잭션 A가 끝나지 않았는데 트랜잭션 B에서 User 테이블에 새로운 row를 추가하고 커밋한다.

트랜잭션 A가 다시 User 테이블을 읽었더니 이전 조회 때는 없던 데이터 Bob이 생겼다.

트랜잭션 격리 수준

트랜잭션 격리 수준이란,
한 트랜잭션이 사용 중인 다른 데이터에 대해 다른 트랜잭션의 접근 허용 정도를 말한다.

트랜잭션의 격리 수준을 조정하여
위 문제들을 해결할 수 있다.

트랜잭션의 격리 수준은 총 4가지가 있다.

  • Lv 0: Read Uncommited
  • Lv 1: Read Commited
  • Lv 2: Repeatable Read
  • Lv 3: Serializable

격리 수준이 높을수록 격리성이 보장되어 데이터 일관성은 높아지고, 위에서 언급한 문제들이 발생할 일이 줄어든다.
그러나 동시성(동시 사용자 접속율)은 낮아진다.

Read Uncommited

커밋되지 않은 데이터를 다른 트랜잭션이 조회할 수 있도록 허용한다.

트랜잭션 T2가 트랜잭션 T1이 커밋하기 전 데이터를 읽는다.
커밋되지 않은 데이터를 읽은 것이다.(Read Uncommited)

이 경우 트랜잭션 간의 격리가 전혀 되지 않았으므로
Dirty Read, Nonrepeatable Read, Phantom Read 문제가 모두 발생할 수 있다.

Read Commited

커밋된 변경만 읽도록 수정하면
Dirty Read를 방지할 수 있다.

트랜잭션 T2에서 어느 데이터를 조회하려고 할 때
만약 그 데이터가 트랜잭션 T1에서 수정되고 있다면
트랜잭션 T2는 트랜잭션 T1이 커밋할 때까지 값을 읽지 않는다.

커밋이 되지 않은 값을 읽지 않으므로 잘못된 데이터를 읽을 일이 없어 Dirty Read가 방지된다.


그러나 아직 트랜잭션 T1이 끝나지 않은 상태에서 트랜잭션 T2가 커밋을 하면 T1이 값을 다시 읽었을 때
이전에 읽은 값과 다를 수 있다.

즉, Non-Repeatable Read 문제는 아직 발생한다.
비슷하게 Phantom Read 역시 일어날 수 있다.

  • PostgreSQL의 기본 격리 수준 단계이다.

Repeatable Read

Read Commited에서 더 나아가
한 트랜잭션 내에서 이전에 읽은 데이터를 다시 읽었을 때 그 결과가 항상 똑같음을 보장한다.

Non-Repeatable Read를 방지할 수 잇다.

MySQL, PostgreSQL 데이터베이스의 경우 스냅샷을 사용하여 이 단계를 구현한다.

트랜잭션 T1에서 처음으로 a를 읽을 때 스냅샷을 만들어 두고, 이후 a를 다시 조회할 때는 스냅샷에서 읽어 한 트랜잭션 내에서는 언제나 같은 값을 읽을 수 있다.

-> Non-Repeatable Read 문제가 해결된다.


그러나 수정된 값은 상관 없지만
다른 트랜잭션에서 추가한 데이터는 여전히 볼 수 있다.

때문에 Phantom Read 문제는 계속 발생할 수 있다.

  • MySQL의 기본 격리 수준 단계이다.

Serializable

Serializable은 지금까지의 모든 문제를 해결하는 격리 수준으로,
한 트랜잭션이 어느 데이터에서 작업하고 있을 때
다른 트랜잭션이 이 데이터에 접근할 수 없게 락(lock)을 건다.

데이터 일관성 문제는 해결되지만
여러 트랜잭션에서 한 데이터에 동시 접근하는 게 불가능하므로
동시 처리 능력이 떨어져 성능 저하가 발생할 수 있다.

데드락

Seriablizable에서 락을 걸면 동시성을 제어해줄 수 있지만 그 부작용으로 데드락이 발생할 수 있다.

데드락이란 여러 개의 트랜잭션들이 실행을 하지 못하고, 서로 무한정 기다리는 상태를 의미한다.

트랜잭션 T1에서는 User 테이블의 id=1, id=2인 순서대로 수정하려 하고,
트랜잭션 T2에서는 User 테이블의 id=2, id=1 순서대로 수정하려고 한다.

T1에서 id=2인 row에 접근하려고 하면 이미 그 데이터의 락은 T2가 가지고 있다. T2에서 id=1인 row에 접근하려고 하면 이미 그 데이터의 락은 T1이 가지고 있다.
데이터에 접근하기 위해서는 락을 먼저 선점하고 있던 트랜잭션에게서 락의 소유권을 얻어야 한다.

그러나 두 트랜잭션 모두 커밋되거나 롤백되지 않아 락을 반환할 수도 없어 락을 계속 가지고 있으면서도 다음 데이터에 접근할 수 없어 무한 대기 상태에 빠진다.

스프링에서 격리 수준 설정

@Transactional 애노테이션에서 isolation 설정으로 격리 수준을 설정할 수 있다.

@Transactional(isolation=Isolation.REPEATABLE_READ)
public void someMethod() { ... }

격리 수준 정리

트랜잭션 격리 수준은 데이터의 일관성을 지키기 위해
트랜잭션 간의 격리 정도를 조정하는 것이다.

각 격리 수준에서 발생할 수 있는 문제는 다음과 같다.

격리 수준이 높아질수록 문제들이 해결되어 데이터 일관성이 높아지지만
동시성은 낮아져 성능 저하가 발생할 수 있다.

참고자료

profile
블로그 이전: https://syleeblog.tistory.com/

0개의 댓글