MySQL vs PostgreSQL 트랜잭션 격리 수준 차이와 실제 장애 사례(Phantom Read 중심으로)

Series: 데이터베이스

데이터베이스contains 4

들어가며

트랜잭션 격리 수준을 공부하다 보면 한 가지 의문이 생긴다.

REPEATABLE READ에서는 Phantom Read가 발생한다고 배웠는데, 왜 MySQL에서는 잘 안 보이지?
PostgreSQL은 같은 격리 수준인데 왜 동작이 다르지?

이 질문은 굉장히 중요하다. 왜냐하면 이 차이를 제대로 이해하지 못하면, 실제 서비스에서 버그나 장애로 이어질 수 있기 때문이다. 이번 글에서는 Phantom Read 개념, MySQL vs PostgreSQL의 실제 동작 차이, 그리고 이 차이 때문에 발생하는 실무 장애 사례까지 하나의 흐름으로 정리해보겠다.

1. Phantom Read란 무엇인가?

Phantom Read는 다음과 같은 상황에서 발생한다.

  • 트랜잭션 A가 특정 조건으로 데이터를 조회한다
  • 트랜잭션 B가 해당 조건에 맞는 데이터를 insert 후 commit
  • 트랜잭션 A가 다시 조회한다

이때 조회 결과의 row 수가 달라진다.

즉, 같은 조건으로 조회했는데 결과 집합이 바뀌는 현상이 Phantom Read이다.

2. MVCC와 DB의 선택

MySQL과 PostgreSQL 모두 MVCC(Multi-Version Concurrency Control)를 사용한다. 하지만 중요한 차이가 있다.

Phantom Read를 어떻게 처리할 것인가?

이 질문에 대해 두 DB는 완전히 다른 선택을 한다.

3. MySQL vs PostgreSQL 핵심 차이

3.1. MySQL(InnoDB)

MySQL의 기본 격리 수준은 REPEATABLE READ이며, 락으로 차단하여 Phantom을 막는다. 단, 락을 차단할 때 단순히 row만 잠그지 않고 gap(범위)까지 함께 잠근다.

예를 들어, 이 쿼리는

SELECT * FROM orders WHERE price > 100 FOR UPDATE;

기존 row, 그리고 그 사이의 범위에 해당하는 빈 공간까지 잠근다. 결과적으로 다른 트랜잭션은 아래 쿼리를 실행할 수 없다.

INSERT INTO orders (price) VALUES (150);

즉, MySQL은 Phantom이 발생할 가능성이 있으면 아예 insert 자체를 막아버린다.

문제가 생기기 전에 차단한다.

3.2. PostgreSQL

PostgreSQL의 기본 격리 수준은 READ COMMITTED이며, 트랜젝션이 시작될 때 snapshot이 생성된다. REPEATABLE READ 시 snapshot을 유지하여 Phantom을 보이지 않게 만든다.

예를 들어,

  1. 트랜잭션 A 시작
  2. A가 조회 → 10건
  3. 트랜잭션 B가 insert 후 commit
  4. A가 다시 조회

결과는 여전히 10건이다. 왜냐하면 A는 처음 snapshot만 보기 때문이다. 즉, PostgreSQL은 insert는 허용하지만, 기존 트랜잭션에서는 보이지 않게 처리한다.

문제를 허용하되, 관측되지 않게 만든다

3.3. 정리

구분MySQLPostgreSQL
기본 격리 수준REPEATABLE READREAD COMMITTED
Phantom 처리락으로 차단snapshot으로 숨김
접근 방식비관적낙관적

4. 이 차이로 발생하는 실제 장애 사례

이제 중요한 부분이다. 이 차이를 이해하지 못하면 실제로 어떤 문제가 발생할까?

4.1. 사례 1: 재고 감소 로직 버그(PostgreSQL)

재고를 차감하는 로직이 있다고 가정해보자.

SELECT * FROM stock WHERE product_id = 1;
-- 재고 확인 후 감소
  1. 트랜잭션 A → 재고 조회 (10개)
  2. 트랜잭션 B → 재고 조회 (10개)
  3. B → 재고 감소 후 commit (→ 9개)
  4. A → 여전히 10개로 인식
  5. A → 재고 감소 수행

그 결과 재고는 중복 차감된다. PostgreSQL에서는 snapshot 기반이기 때문에 A는 최신 데이터를 보지 못한다. 즉, Phantom을 막은 것이 아니라 숨긴 것이다.

4.2. 사례 2: 배치 중복 실행 문제

여러 서버에서 동일한 배치를 실행하는 구조를 한 번 생각해보자. 예를 들어, 준비 상태(READY)인 작업들을 가져와서 처리하는 배치가 있다고 가정한다.

SELECT * FROM jobs WHERE status = 'READY';

이 쿼리를 기반으로 배치가 실행되고, 처리된 작업은 상태를 DONE으로 변경한다고 해보자. 문제는 이 배치가 하나의 서버에서만 실행되는 것이 아니라, 여러 서버에서 동시에 실행되는 구조일 때 발생한다.

먼저, PostgreSQL인 경우 두 개의 서버가 동시에 이 배치를 실행하는 상황을 보자.

  1. 서버 A가 READY 상태인 작업들을 조회한다.
  2. 거의 동시에 서버 B도 동일한 쿼리를 실행한다.
  3. 두 서버 모두 같은 데이터를 가져오게 된다.
  4. 이후 A와 B는 각각 동일한 작업을 처리하게 된다.

결과적으로 하나의 작업이 두 번 실행되는 문제가 발생한다. 이 문제는 PostgreSQL의 snapshot 기반 동작 방식과 관련이 있다. 각 트랜잭션은 자신만의 snapshot을 기준으로 데이터를 조회하기 때문에, 다른 트랜잭션이 해당 데이터를 이미 처리 중인지 여부를 인지하지 못한다. 즉, PostgreSQL에서는 다른 트랜잭션이 이 데이터를 처리하려고 하고 있다는 사실 자체를 알 수 없다는 것이 핵심이다.

그렇다면, MySQL에서는 어떻게 동작할까? 같은 상황에서 MySQL(InnoDB)을 사용하고, 조회 시점에 FOR UPDATE를 사용한다고 가정해보자.

SELECT * FROM jobs WHERE status = 'READY' FOR UPDATE;

이 경우 MySQL은 단순히 데이터를 읽는 것이 아니라, 해당 row와 범위에 대해 락을 걸게 된다.

따라서,

  1. 서버 A가 먼저 데이터를 조회하면서 락을 획득한다.
  2. 서버 B는 동일한 데이터를 조회하려고 하지만 락에 의해 대기 상태에 들어간다.
  3. A가 작업을 완료하고 commit 하면
  4. 그제서야 B가 조회를 진행한다.

이 과정에서 이미 A가 상태를 변경했기 때문에 B는 동일한 데이터를 가져오지 않게 된다. 결과적으로, MySQL에서는 락을 통해 동시에 같은 작업을 가져가는 상황 자체를 막아버린다는 차이가 발생한다.

4.3. 사례 3: 결제 중복 처리

이번에는 조금 더 민감한 상황인 결제 처리 로직을 생각해보자. 결제는 일반적으로 다음과 같은 흐름을 가진다.

  1. 결제 요청을 받는다
  2. 외부 PG(Payment Gateway) API를 호출한다
  3. 결과를 DB에 저장한다

여기서 중요한 점은, 외부 API 호출이 포함되어 있다는 것이다.

먼저, PostgreSQL인 경우 사용자가 동일한 결제 요청을 두 번 보냈다고 가정해보자.

더블 클릭, 네트워크 재시도 등 충분히 발생 가능한 상황이다

  1. 트랜잭션 A가 결제 요청을 처리 시작
  2. 동시에 트랜잭션 B도 동일한 요청 처리 시작
  3. 두 트랜잭션 모두 아직 처리 중인 상태이기 때문에 서로를 인지하지 못함
  4. 각각 외부 PG API 호출
  5. 결제가 두 번 수행됨

이 문제 역시 snapshot 기반 동작에서 비롯된다. PostgreSQL에서는 다른 트랜잭션의 진행 중 상태를 알 수 없기 때문에 이미 누군가 처리 중인 요청이라는 개념 자체가 존재하지 않는다.

그렇다면, MySQL에서는 어떻게 달라질까? MySQL에서 동일한 로직을 FOR UPDATE 기반으로 처리한다고 가정해보자.

  1. 트랜잭션 A가 결제 대상 row를 조회하면서 락 획득
  2. 트랜잭션 B는 동일한 row에 접근하려고 하지만 락에 의해 대기
  3. A가 결제 완료 후 commit
  4. 이후 B가 실행되지만 이미 상태가 변경되어 있음

결과적으로 두 번째 요청은 실질적인 처리를 하지 않게 된다. 즉, MySQL에서는 락이 동시에 동일 로직이 실행되는 것 자체를 막아준다.

5. 결론

5.1. PostgreSQL

PostgreSQL은 기본적으로 snapshot 기반으로 동작하기 때문에, 동시에 실행되는 트랜잭션 간의 충돌을 직접 제어해주지 않는다. 따라서 반드시 추가적인 제어 로직이 필요하다. 대표적으로는 다음과 같은 방식이 있다.

  • SELECT ... FOR UPDATE를 통한 명시적 락
  • 멱등성 키를 통한 중복 요청 제어
  • Redis 기반 분산 락

즉, 단순히 DB에 맡겨서는 안 되고, 애플리케이션 레벨에서 보완해야 한다는 점이 중요하다.

5.1. MySQL

MySQL은 기본적으로 락을 적극적으로 활용하여 동시성을 제어하기 때문에, 상대적으로 안전한 동작을 보인다. 하지만 이 방식에도 단점이 존재한다. 락을 많이 사용할수록 트랜잭션 간 대기 시간이 길어지고, 경우에 따라 데드락이 발생할 가능성도 높아진다. 즉, MySQL은 안전성을 확보하는 대신, 성능 비용을 지불하는 구조라고 볼 수 있다.

6. 개인적 견해

이 차이를 단순히 어느 DB가 더 좋다로 해석하는 것은 의미가 없다고 생각한다. 오히려 중요한 것은 두 DB가 서로 다른 철학을 가지고 있다는 점이다. MySQL은 문제가 발생할 가능성이 있다면, 미리 차단하자는 접근에 가깝고, PostgreSQL은 문제를 허용하되, 애플리케이션이 제어할 수 있게 하자는 방향에 가깝다. 이 구조는 낙관적 락과 비관적 락의 차이와도 굉장히 유사하다.

마치며

트랜잭션 격리 수준은 단순히 개념을 외우는 것으로 끝나는 영역이 아니다.

특히 Phantom Read는

  • MySQL → 락으로 차단
  • PostgreSQL → snapshot으로 숨김

이라는 근본적인 차이를 가지고 있다. 이 차이를 이해하지 못하면 배치가 중복 실행되거나 결제가 두 번 발생하거나 재고가 꼬이는 문제와 같은 실제 장애로 이어질 수 있다. 결국 중요한 것은 '이 격리 수준이 무엇을 보장하는가?'가 아니라 '내가 사용하는 DB가 실제로 어떻게 동작하는가?' 라고 생각한다.