들어가기전에..

R1(x => a) : 1번 트랜잭션, x 변수에 대한 Read 작업,  결과값 a.
W2(y = a)  : 2번 트랜잭션, y 변수에 대한 Write 작업, a를 대입.
C1         : 1번 트랜잭션의 Commit
A2         : 2번 트랜잭션의 Rollback
  • 예제의 편의를 위해 위와 같은 약어를 사용하겠다.

오늘 하루동안 격리성 수준을 이해하기 위해 부단히도 애를 먹었다. 아직까지 전부 제대로된 이해를 하진 못했지만, 이 부분은 코드를 직접 작성하면서 경험을 하면서 익혀나가야 할 것 같다. 많이 부족해보일 수도 있겠지만, 이거 한번 이해해보려고 발버둥 쳤던 기록을 남기고 싶어서 이 글을 작성한다.

READ UNCOMMITTED

가장 낮은 단계의 격리수준이다. Commit 되지 않은 데이터를 읽을 수 있다.
이 단계에서는 Dirty Read라는 데이터 일관성이 깨지는 문제가 발생하기 때문에 거의 모든 RDBMS에서는 사용하지 않는다.

Dirty Read - Case 1

[초기값]
x = 10, y = 50

[목표]
T1: x = x + y : Commit
T2: y = 20    : Rollback

[예상되는 결과]
x = 60, y = 50

[과정]
R1(x => 10) W2(y = 20) R1(y => 20) W1(x = 10 + 20) C1 A2

[결과]
x = 30, y = 50

Dirty Read란, 트랜잭션 A가 진행되는 도중에 트랜잭션 B가 그 값을 변경하는 경우, 트랜잭션 B가 커밋을 하지 않았음에도 변경된 값을 트랜잭션 A가 사용하는 것을 의미한다.

위의 예제에서는 트랜잭션 A가 변경된 값을 사용하고 커밋을 했는데 트랜잭션 B가 어떠한 이유로 인해 롤백을 하게 되면, 위의 예제처럼 원하는 결과를 얻지 못하게 된다. (트랜잭션의 일관성이 깨지게 된다.)

과정
  1. 트랜잭션 A: x를 읽어서 10을 가져옴.
  2. 트랜잭션 B: 트랜잭션 B: y에 20을 씀.
  3. 트랜잭션 A: 변경된 y을 읽기 때문에 20을 가져옴.
  4. 트랜잭션 A: x에 아까 읽었던 10과 방금 읽은 20을 더해서 쓰고 Commit.
  5. 트랜잭션 B: Rollback → y는 변경전 50으로 다시 바뀐다.

Dirty Read - Case 2

[초기값]
x = 50, y = 50, sum(x,y) = 100

[목표]
T1: x = x - 40, y = y + 40
T2: R(x), R(y)

[예상되는 결과]
T1: x = 10, y = 90
T2-C1: x = 50, y = 50
T2-C2: x = 10, y = 90

[과정]
R1(x => 50) W1(x = x - 40)
R2(x => 10) R2(y => 50) C2
R1(y => 50) W1(y = y + 40) C1

[결과]
T1: x = 10, y = 90, sum(x,y) = 100
T2: x = 10, y = 50, sum(x,y) = 60

이 예제에서는 트랜잭션 A가 x값을 변경한 후에 커밋을 하지 않았음에도 트랜잭션 B가 x를 읽어버려 원하는 결과와 다른 값을 받게 되었다.

과정
  1. 트랜잭션 A: x를 읽어서 50을 가져옴.
  2. 트랜잭션 A: x에 10을 씀.
  3. 트랜잭션 B: 변경된 x를 읽기 때문에 10을 가져옴.
  4. 트랜잭션 B: 아직 변경되지 않은 y를 읽어서 50을 가져오고 Commit
  5. 트랜잭션 A: y를 읽어서 50을 가져옴.
  6. 트랜잭션 A: y에 90을 쓰고 Commit

READ COMMITTED

PostgreSQL의 기본적인 격리 수준이다. Commit된 데이터만 읽거나 쓸 수 있다.
이 단계에서는 위에서 설명한 Dirty Read는 해결하였지만, 또 다른 문제인 Non-Repeatable Read, Read Skew, Write Skew, Lost Update가 발생한다.

Non-Repeatable Read

[초기값]
x = 10

[목표]
T1: R(x) * 2
T2: x = 30

[과정]
R1(x => 10) 
W2(x = 30) C2 
R1(x => 30) C1

[결과]
T1-First-Read:  10
T1-Second-Read: 30

Non-Repeatable Read(반복되지 않는 읽기)란, 트랜잭션 A가 같은 값을 읽어오는 작업을 두 번이상 수행할 때, 그 중간에 트랜잭션 B가 그 값을 변경하고 Commit하는 경우, 처음 수행했을 때와 다른 값이 나타나는 현상을 의미한다.

과정
  1. 트랜잭션 A: x를 읽어서 10을 가져옴.
  2. 트랜잭션 B: x에 30을 씀. Commit.
  3. 트랜잭션 A: 커밋된 x를 다시 읽었는데 30을 가져옴.
  4. → 처음에 읽었던 데이터와 다르다.

Read Skew

[초기값]
x = 50, y = 50, sum(x,y) = 100

[목표]
T1: x = x - 40, y = y + 40
T2: R(x), R(y)

[예상되는 결과]
T1:    x = 10, y = 90
T2-C1: x = 50, y = 50
T2-C2: x = 10, y = 90

[과정]
R2(x => 50)
R1(x => 50) W1(x = x - 40) R1(y => 50) W1(y = y + 40) C1
R2(y => 90) C2

[결과]
T1: x = 10, y = 90, sum(x,y) = 100
T2: x = 50, y = 90, sum(x,y) = 140

위에서 본 Dirty Read - Case 2에서와 같은 예제이지만, 이번에는 트랜잭션 B가 커밋된 데이터를 읽었음에도 원하는 결과를 받을 수 없었다. 결과적으로 데이터 병합성이 깨지게 된다.

과정
  1. 트랜잭션 B: x를 읽어서 50을 가져옴.
  2. 트랜잭션 A: x를 읽어서 50을 가져옴. 그리고 x에 10을 씀.
  3. 트랜잭션 A: y를 읽어서 50을 가져옴. 그리고 y에 90을 씀. Commit.
  4. 트랜잭션 B: 커밋된 y를 읽어서 90을 가져옴. Commit.
  5. → 트랜잭션 A의 x, y합은 100인데, 트랜잭션 B의 x, y합이 140이다.

Write Skew

[초기값]
x = 50, y = 50

[조건]
x + y >= 0

[목표]
T1: x = x - 80
T2: y = y - 90

[예상되는 결과]
T1: x = -30, y =  50
T2: x =  50, y = -40

[과정]
R1(x => 50) R1(y => 50) 
R2(x => 50) R2(y => 50)
W1(x = 50 - 80) 
W2(y = 50 - 90)
C1
C2

[결과]
x = -30, y = -40

서로 다른 두 트랜잭션이 조건에 맞게 다른 데이터를 썼음에도 결과적으로 보면 조건에 맞지 않아 일관성이 깨지게 되었다. 이를 Write Skew 라고 한다.

과정
  1. 트랜잭션 A: x, y를 읽어 50, 50을 가져온다.
  2. 트랜잭션 B: x, y를 읽어 50, 50을 가져온다.
  3. 트랜잭션 A: x에 -30을 쓴다. 두 수의 합이 20이라 조건에 맞다. Commit.
  4. 트랜잭션 B: y에 -40을 쓴다. 두 수의 합이 10이라 조건에 맞다. Commit.
  5. → 결과를 확인해보니 두 수의 합이 -70이 되었다 !

Lost Update

[초기값]
x = 50

[목표]
T1: x = x + 100
T2: x = x + 150

[예상되는 결과값]
x = 300

[과정]
R1(x => 50)
R2(x => 50)
W1(x = 50 + 100) C1
W2(x = 50 + 150) C2

[결과]
x = 200

Lost Update란, 위의 예제처럼 트랜잭션 A의 결과가 아예 사라지는 현상을 말한다.

다른 트랜잭션이 같은 데이터에 접근하여 쓰기 작업을 진행한 경우, 트랜잭션 A가 x에 대해 쓰기 전에 트랜잭션 B가 x를 읽고 그에 대해 쓰는 것이다. 이렇게 되면 결과적으로 두 개의 트랜잭션 중에서 가장 마지막에 커밋한 내용만 남게 되며, 그 이전에 커밋한 내용은 사라지게 된다.

과정
  1. 트랜잭션 A: x값을 읽어서 50을 가져온다.
  2. 트랜잭션 B: x값을 읽어서 50을 가져온다.
  3. 트랜잭션 A: x에 150을 쓰고 Commit한다.
  4. 트랜잭션 B: x에 200을 쓰고 Commit한다.
  5. → 트랜잭션 A의 업데이트가 사라졌다.

REPEATABLE READ - DB LOCK

트랜잭션 내에서 데이터를 반복적으로 읽어와도 동일한 결과를 받아옴을 보장한다.

이 단계를 구현하는 방법은 DB Lock을 이용한다. DB Lock이란, 트랜잭션에서 Read와 Write작업을 진행할 때 다른 트랜잭션이 해당 데이터를 사용하지 못하도록 잠금을 걸어두는 방식을 말한다. 크게 Read와 Write 2개의 Lock으로 구성되며, Read Lock이 걸린 데이터에 대해서는 다른 트랜잭션은 Read만 가능하고 Write는 하지 못한다. 그리고 Write Lock이 걸리면 다른 트랜잭션은 Read, Write 둘 다 불가능하다.

LOCKREADWRITE
READ허용X
WRITEXX

이 단계에서는 위에서 말한 Non-Repeatable Read, Read Skew, Write Skew가 해결되지만, 또 다른 문제인 Phantom Read는 아직 해결이 안된다. 특히 Lost Update의 경우 DeadLock에 걸릴 위험이 있다.

Phantom Read - Case 1

[초기값]
t1(v = 10), t2(v = 50)

[목표]
T1: R(*.v == 10) * 2
T2: W(t2.v = 10)

[과정]
R1(*.v == 10 => t1)
W2(t2.v = 10) C2
R1(*.v == 10 => t1, t2) C1

[결과]
T1-First-Read:  t1
T1-Second-Read: t1, t2

Phantom Read란, 검색을 원하는 범위의 레코드에서 일정 조건에 따라 검색을 두 번이상 수행할 때 처음 수행했을 때 없던 레코드가 나타나거나 있던 레코드가 없어지는 현상을 말한다.

Non-Repeatable Read와 비슷해보이지만, Non-Repeatable Read은 데이터 단위에서 일어나는 일이며, Phantom Read는 레코드 단위로 일어나는 일이라고 생각하면 된다.

잘보면 트랜잭션 A가 읽은 데이터와 다른 범위의 데이터를 트랜잭션 B가 쓰는 것을 볼 수 있다. 그런데 트랜잭션 B가 데이터를 수정함으로써 트랜잭션 A가 사용하는 범위에 변경이 일어난 것이다.

과정
  1. 트랜잭션 A: 레코드에 대해 v 값이 10인 데이터들을 조회한다. ⇒ [ t1 ]
  2. 트랜잭션 B: t2 tuple에 v 값을 10으로 변경한다. Commit.
  3. 트랜잭션 A: 1번의 조건과 동일하게 재조회를 한다. ⇒ [ t1, t2 ]
  4. → 처음의 조회 내용과 다르다.

Phantom Read - Case 2

[초기값]
t1(v = 7), cnt(*.v > 10) = 0

[목표]
T1: R(*.v > 10), R(cnt)
T2: W(t2.v = 15), W(cnt = cnt + 1)

[예상되는 결과]
T1-C1: null, cnt = 0
T1-C2: t2,   cnt = 1

[과정]
R1(*.v > 10 => null)
W2(t2.v = 15) 
R2(cnt => 0)      # T2_READ_LOCK(cnt) 
W2(cnt = cnt + 1) # T2_WRITE_LOCK(cnt)
C2                # T2_WRITE_LOCK_해제(cnt)
R1(cnt => 1) C1

[결과]
t1(v = 7), t2(v = 15), cnt(*.v > 10) = 1
T1: null, cnt = 1

이 예제에서는 하나의 트랜잭션이 서로 다른 데이터를 읽어오지만, 그 데이터가 서로 연관이 있을때 발생하는 현상을 볼 수 있다. cnt는 조회하고자하는 레코드에서 tuple의 v 값이 10 초과인 갯수를 나타내는 값이다.

트랜잭션 A는 먼저 데이터들을 조회한 다음 cnt의 값을 조회한다고 가정할때, 그 사이에 트랜잭션 B가 시작되어 v값이 15인 새로운 tuple을 추가하고 cnt에 1을 증가시키는 로직이 발생한 것이다.

그런데 트랜잭션 A가 cnt를 조회하기 전에, 트랜잭션 B에서 먼저 cnt에 대한 WRITE_LOCK을 걸어버리면 트랜잭션 A는 LOCK이 풀릴때까지 기다려야 한다. (트랜잭션 B에서 READ_LOCK을 걸었을때 트랜잭션 A가 조회하거나 트랜잭션 A가 먼저 READ_LOCK을 선점했으면 문제가 없다.)

결과적으로 트랜잭션 A는 조회한 데이터들은 null인데 반해, cnt의 값이 1인 이상한 결과를 얻게 된다.

과정
  1. 트랜잭션 A: 레코드에 대해 v 값이 10 초과인 데이터들을 조회한다 ⇒ null
  2. 트랜잭션 B: 레코드에 v = 15인 tuple을 새로 추가한다.
  3. 트랜잭션 B: cnt의 조건을 맞추기 위해서 cnt를 조회한다. READ_LOCK(cnt)
  4. 트랜잭션 B: cnt에 1을 더한 값을 쓴다. WRITE_LOCK(cnt)
  5. 트랜잭션 A: 이어서 cnt를 읽어오려고 했더니 WRITE_LOCK이 걸려있어서 대기한다.
  6. 트랜잭션 B: Commit. WRITE_LOCK을 해제한다.
  7. 트랜잭션 A: LOCK이 풀렸기 때문에 cnt를 읽어서 1을 가져온다.
  8. → 트랜잭션 A는 조회된 데이터들의 목록이 null인데 cnt가 1인 이상한 결과를 얻게 된다.

DeadLock

[초기값]
x = 50

[목표]
T1: x = x + 100
T2: x = x + 150

[예상되는 결과값]
x = 300

[과정]
R1(x => 50)      # T1_READ_LOCK(x)
R2(x => 50)      # T2_READ_LOCK(x)
W1(x = 50 + 100) # T2가 READ_LOCK을 걸고 있기 때문에 WRITE_LOCK을 얻을 수 없다. 
W2(x = 50 + 150) # T1이 READ_LOCK을 걸고 있기 때문에 WRITE_LOCK을 얻을 수 없다.

위의 Lost Update와 같은 상황이지만, 이번에는 조금 다르다.

다른 두 트랜잭션이 같은 데이터를 읽어오는 것은 문제가 없다. 그런데, 두 개의 트랜잭션이 모두 같은 데이터를 쓰려고 할 때 모두 READ_LOCK이 걸려있다면 두 트랜잭션은 WRITE_LOCK을 얻기 위해 서로 기다리게 된다. 이 현상을 DeadLock이라고 한다.

위에 상황을 해결하기 위해선, 최초에 LOCK을 얻어올 때 READ_LOCK이 아닌 WRITE_LOCK을 획득하면 해결이 된다.

과정
  1. 트랜잭션 A: x의 값을 읽어서 50을 가져온다. READ_LOCK(x)
  2. 트랜잭션 B: x의 값을 읽어서 50을 가져온다. READ_LOCK(x)
  3. 트랜잭션 A: x의 값을 쓰기 위해 WRITE_LOCK 획득을 시도한다.
    그런데, 트랜잭션 B가 READ_LOCK을 선점하고 있다.
  4. 트랜잭션 B: x의 값을 쓰기 위해 WRITE_LOCK 획득을 시도한다.
    그런데, 트랜잭션 A가 READ_LOCK을 선점하고 있다.
  5. → DeadLock

REPEATABLE READ - SNAPSHOT

각 트랜잭션마다 별도의 저장 공간을 만들고 트랜잭션이 완료되기 전까지 그 저장 공간에서만 변경이 일어나도록 제한하는 방법이다. 커밋의 경우 임시 저장 공간에 반영된 값들을 모두 저장하고, 롤백의 경우 임시 저장 공간에 반영된 값들을 모두 삭제한다. 이를 다른 말로 MVCC(Multi Version Concurrency Control)라고 한다.

Lost Update 해결

[초기값]
x = 50

[목표]
T1: x = x + 100
T2: x = x + 150

[과정]
R1(x => 50)      # T1.V.x = 50
R2(x => 50)      # T2.V.x = 50
W1(x = 50 + 100) # T1.V.x = 50 + 100
C1               # Original.x = T1.V.x
W2(x = 50 + 150) # T2.V.x = 50 + 150
C2               # 실패! T2는 롤백된다.

PostgreSQL에서는 위와 같이 동일한 데이터에 다른 트랙잭션이 접근하는 경우, 둘 중에 먼저 커밋되는 트랜잭션만 실제로 반영이 되고, 나중에 커밋되는 트랜잭션은 Rollback이 발생한다.

SERIALIZABLE

가장 높은 단계의 격리성 수준이다.

한 트랜잭션이 특정 테이블을 조회 및 변경할 경우, 다른 트랜잭션은 그 테이블에 접근할 수 없음을 보장한다. 이 특성으로 인해 위에서 말한 모든 데이터 부합성 문제가 사라지지만, 동시 처리 능력이 현저하게 낮아져 READ UNCOMMITTED와 마찬가지로 대부분의 RDBMS에서 사용되지 않는다.

정리

Dirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTEDOOO
READ COMMITTEDXOO
REPEATABLE READXXO
SERIALIZABLEXXX

주의할 것이 이 4가지 격리성 수준은 SQL 표준일 뿐이다. 각 RDBMS 회사들은 자신들의 구현체에 맞춰서 해당 격리성 수준을 구현하지만, 세부적으로는 작동방식이 다르다. 때문에 자신이 사용하고 있는 DB에 맞춰서 생각하는 것이 더 좋다.

READ UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE
OracleDBX - 지원하지 않음defaultSERIALIZABLESERIALIZABLE
MySQL (innoDB)OOdefault, LOCKINGO
SQL serverOdefaultLOCKING, SNAPSHOTO
PostgreSQLREAD COMMITTEDdefaultSNAPSHOTSNAPSHOT

참고

profile
백엔드 개발자 지망생

0개의 댓글