트랜잭션 격리 수준과 PostgreSQL의 동시성 제어 방식(Lock, MVCC)

정은영·2025년 5월 5일
1

CS

목록 보기
19/24

4가지 트랜잭션 격리 수준(Transaction Isolation Levels)

여러 사용자가 동시에 같은 데이터를 읽고 수정할 때, 의도치 않은 결과가 발생할 수 있습니다. (예: 동시에 주문이 두 번 들어가서 재고가 마이너스가 되는 상황 발생)

이런 의도치 않은 결과를 예방하기 위해 트랜잭션 격리수준을 지정해줄 수 있습니다.

아래 4가지 트랜잭션 격리 수준에 대해 알아보겠습니다.

Read Uncommitted

가장 낮은 격리 수준입니다. 커밋 되지 않은 데이터를 읽을 수 있습니다.

예시

  • 트랜잭션 A가 어떤 값을 UPDATE 했지만 아직 커밋하지 않았음
  • 트랜잭션 B가 그 값을 읽을 수 있음
  • 이후 트랜잭션 A가 ROLLBACK 하면 B는 존재하지 않는 값을 읽은 셈이 됨

특징

예시처럼 데이터 정합성이 완전히 무너질 수 있고 실무에서는 거의 사용되지 않습니다.

Read Committed

이 격리 수준은 커밋된 데이터만 읽습니다. PostgreSQL의 기본 격리 수준입니다.

예시

  • 트랜잭션 A가 어떤 행을 조회했는데, 트랜잭션 B가 그 사이에 값을 바꿈
  • 트랜잭션 A가 같은 쿼리를 다시 실행하면 다른 결과를 얻음

특징

커밋된 데이터만 읽기 때문에 최소한의 정합성은 보장할 수 있습니다. 대부분의 실무에서는 충분한 수준입니다.

Repeatable Read

동일 row를 다시 조회했을 때 결과가 바뀌지 않습니다.

예시

  • 트랜잭션 A가 SELECT * FROM orders WHERE status = 'READY'를 실행했을 때
  • 트랜잭션 B가 특정 주문의 상태를 바꾸거나 새로운 주문을 넣더라도
  • 트랜잭션 A는 처음 본 상태 그대로 계속 조회함

Serializable

가장 높은 격리 수준이며 완전 직렬화를 보장합니다. 마치 트랜잭션들이 순차적으로 실행된 것처럼 보입니다.

예시

  • 두 사용자가 같은 시간에 재고를 구매하려고 해도 하나만 성공합니다.

Serializable 격리 수준에서는 둘 중 하나가 무조건 성공하고 다른 하나가 실패하는데, 어떤 기준으로 하나만 고르는걸까?


Optimistic Concurrency Control (낙관적 동시성 제어) 라고 불리는 기법을 사용해서 MVCC + 직렬성 검사를 사용해 트랜잭션 종료 시점에 충돌여부를 검사합니다.


예시
두 사용가자 동시에 같은 재고를 구매하려고 했을 때

  • 두 트랜잭션 모두 동시에 시작되고, 자기만의 스냅샷에서 재고 수량을 조회함
  • 동시에 재고를 차감하고 COMMIT 시도함
  • 먼저 COMMIT한 트랜잭션은 성공
  • 나중에 COMMIT 하려던 트랜잭션은 DB가 직렬성 깨짐을 감지하고 ERROR 또는 ROLLBACK
    -> 이 때, serialization failure가 발생하고 해당 트랜잭션은 COMMIT을 실패하게 됩니다. 따라서, 이 serialization failure를 잡아서 재시도 로직을 구현하여 COMMIT을 정상적으로 완료해야 합니다.

특징

성능 저하 가능성이 있습니다. 락이나 충돌 등으로 인해 serialization failure가 발생할 수 있습니다.

동시성 종류

Dirty Read

트랜잭션 A가 아직 커밋하지 않은 값을 트랜잭션 B가 읽는 행위을 말합니다.

Non-repeatable Read

트랜잭션 A가 같은 쿼리를 두 번 했는데 B가 그 사이에 값을 바꾸는 헹위을 말합니다.

Phantom Read

트랜잭션 A가 조건에 맞는 row 목록을 읽고, B가 중간에 새로운 row를 삽입하여 A가 두번째에 다른 결과를 받는 행위을 말합니다.

트랜잭션 수준에 따른 동시성 허용

수준Dirty ReadNon-repeatable ReadPhantom Read
Read Uncommitted허용됨허용됨허용됨
Read Committed❌ 차단허용됨허용됨
Repeatable Read❌ 차단❌ 차단허용됨 (PostgreSQL은 차단)
Serializable❌ 차단❌ 차단❌ 차단

PostgreSQL의 동시성 제어 방식

PostgreSQL은 MVCC 기반으로 Read Committed (default), Serializable (optional)을 구현합니다.
먼저 MVCC에 대해서 알아볼게요.

MVCC (Multi-Version Concurrency Control)

트랜잭션 격리와 동시성 제어를 Lock 없이 효율적으로 처리하는 기술입니다.

MVCC는 여러 버전의 데이터를 동시에 유지하는 방식으로 하나의 row를 수정해도 기존 데이터를 즉시 덮어쓰지 않고, 새로운 버전을 만들어서 트랜잭션마다 snapshot으로 데이터를 보게 합니다.

따라서 Lock 기반의 트랜잭션 격리와 동시성 제어는 데이터를 읽거나 쓰는 동안 락을 걸어 다른 트랜잭션을 막는 반면, MVCC는 각 트랜잭션이 자기만의 데이터 버전을 읽기 때문에 충돌이 없습니다.

조금 더 자세히 알아볼까요?

MVCC는 충돌 가능성이 낮고, 동시성이 높다.

전통적인 Lock 방식 문제점
1. 트랜잭션 A가 row를 읽거나 수정 중일 때 row에 락이 걸림
2. 트랜잭션 B는 같은 row를 읽거나 수정하려고 하면 대기(block) 해야 함
3. 많은 트랜잭션이 동시에 오면 lock contention이 커지고 성능 저하

MVCC에서는?
1. 트랜잭션 A가 데이터를 수정하면 기존 row를 변경하지 않고 새로운 버전을 만듦
2.트랜잭션 B는 트랜잭션 A의 변경이 커밋되기 전까지는 볼 수 없음
3. 트랜잭션 B는 자기 시점의 snapshot을 계속 읽을 수 있기 때문에 락 대기 없음.
→ 결과적으로 많은 트랜잭션이 병렬로 실행 가능하여 동시성이 향상됩니다.

PostgreSQL의 Read Committed (default)

PostgreSQL에서는 쿼리마다 최신 커밋 데이터를 보여줍니다. 즉, 매 SELECT마다 스냅샷이 새로 생성됩니다.

BEGIN;
SELECT * FROM orders WHERE id = 1;
-- 트랜잭션 B가 값을 수정하고 COMMIT

SELECT * FROM orders WHERE id = 1;
-- 이전과 다른 값이 조회됨 (최신 커밋 반영됨)
COMMIT;
  • 하나의 트랜잭션 안에서도 읽는 값이 달라질 수 있습니다.
  • Repeatable Read 차단

PostgreSQL의 Serializable (optional)

트랜잭션 시작 시 하나의 스냅샷을 고정해서 끝날 때까지 일관된 snapshot만 보며, 충돌이 감지되면 serialization failure를 발생시킵니다. 겉으로는 직렬 실행처럼 보이지만, 실제로는 병렬 실행이죠.

BEGIN ISOLATION LEVEL SERIALIZABLE; --- serializable은 Optional
SELECT * FROM orders WHERE id = 1;

-- 트랜잭션 B가 같은 row를 수정하고 COMMIT

UPDATE orders SET ... WHERE id = 1;
COMMIT; --  실패: could not serialize access due to concurrent update

→ 트랜잭션 A의 snapsot과 충돌하는 변경이 생기면 실패

실무에서의 예시

예약 중복 삽입 방지

동시에 여러 사용자가 같은 좌석(id=1)을 예약하려고 할 때 한 명만 성공하고, 나머지는 실패해야 해요. 고전적인 방식인 Row-level Lock를 적용해서 해결해볼게요.

Row-level Lock을 이용한 해결 방법

BEGIN;
SELECT * FROM seats WHERE id = 1 FOR UPDATE;

-- 해당 row(id = 1)에 exclusive lock을 건다.
-- 다른 트랜잭션이 이 row를 수정하려고 하면 block

UPDATE seats SET reserved = true WHERE id = 1;
COMMIT;

-- 트랜잭션을 커밋하면 락이 해제되고, 다른 트랜잭션이 접근 가능

시나리오

사용자 A

BEGIN;
SELECT * FROM seats WHERE id = 1 FOR UPDATE;  -- 락 획득
-- 예약 처리 중...
UPDATE seats SET reserved = true WHERE id = 1;
COMMIT;  -- 락 해제

사용자 B

BEGIN;
SELECT * FROM seats WHERE id = 1 FOR UPDATE;  -- ❗ A가 커밋할 때까지 대기
-- 락 해제된 후 실행되지만 이미 예약됨
-- reserved = true인 것을 보고 실패 처리하거나 다른 좌석 권장 가능
ROLLBACK;

주의할 점

락 대기 시간이 길면 성능 저하나 데드락 위험이 있기 때문에 트랜잭션 재시도 로직, 또는 SAGA, 비관적/낙관적 락 전략 사용이 필요합니다.

Serializable을 이용한 해결 방법

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM seats WHERE id = 1 AND reserved = false;

-- 잠깐 다른 트랜잭션이 같은 좌석을 예약하고 COMMIT함

UPDATE seats SET reserved = true WHERE id = 1;
COMMIT;  -- 여기서 serialization failure 발생!

주의할 점

serialization failure 발생 시 트랜잭션 충돌로 인한 COMMIT 실패했으므로 재시도 로직을 구현하여 정상적으로 COMMIT 을 완료해야 합니다.

0개의 댓글