Please enable JavaScript to view the comments powered by Disqus.데이터베이스 동시성 문제의 비밀: READ COMMITTED의 함정과 SERIALIZABLE의 한계
Search
📥

데이터베이스 동시성 문제의 비밀: READ COMMITTED의 함정과 SERIALIZABLE의 한계

태그
데이터베이스
트랜잭션
격리수준
동시성제어
백엔드
공개여부
작성일자
2026/05/17
(주의: 제공된 출처(sources) 문서들에는 DBMS의 기본 개념과 직렬화(Serializability)에 대한 단편적인 언급은 있으나, 구체적인 트랜잭션 격리 수준별 동작 원리와 이상 현상(Lost Update, Phantom Read)을 재현하는 상세한 SQL 메커니즘은 포함되어 있지 않습니다. 따라서 본 블로그 포스트의 핵심 내용과 예시 코드는 출처 외부의 일반적인 데이터베이스 전공 지식 및 실무 경험을 바탕으로 작성되었음을 알려드립니다. 기초적인 DBMS 용어와 락(Lock) 개념 등은 제공된 출처를 적극 인용하였습니다.)

서론: 무심코 믿어버린 데이터베이스 기본 설정의 함정

현대적인 백엔드 시스템을 개발할 때, 개발자들은 종종 애플리케이션의 비즈니스 로직과 API 응답 속도를 최적화하는 데 엄청난 시간을 쏟습니다. 반면, 데이터가 실제로 저장되는 데이터베이스 관리 시스템(DBMS) [1]의 트랜잭션 처리 방식에 대해서는 "데이터베이스가 알아서 안전하게 처리해주겠지"라는 막연한 믿음을 가지는 경우가 많습니다.
온라인 트랜잭션 처리(Online Transaction Processing, OLTP) [1] 환경에서는 수많은 사용자가 동시에 동일한 데이터에 접근하고 수정합니다. 이때 데이터의 일관성이 훼손되는 데이터 불일치(Data inconsistency) [1] 현상을 막기 위해 데이터베이스는 트랜잭션 격리 수준(Transaction Isolation Level)이라는 방어막을 제공합니다.
문제는 MySQL, PostgreSQL, Oracle 등 대다수의 상용 데이터베이스가 기본값(Default)으로 채택하고 있는 격리 수준이 READ COMMITTED라는 점입니다. 이 기본 설정은 치명적인 동시성 버그를 완벽하게 막아주지 못합니다. 그렇다고 해서 가장 엄격한 수준인 SERIALIZABLE을 적용하면 시스템의 성능이 끔찍하게 저하됩니다. 본 포스트에서는 실제 서비스에서 빈번하게 발생하는 Lost Update(갱신 손실)Phantom Read(유령 읽기) 현상을 직접 재현해 보고, 데이터베이스 격리 수준을 완벽하게 이해하여 안전한 아키텍처를 설계하는 원리를 깊이 있게 파헤쳐 보겠습니다.

READ COMMITTED의 배신: 갱신 손실(Lost Update)의 발생 원리

READ COMMITTED 격리 수준은 말 그대로 "커밋이 완료된 데이터만 읽을 수 있도록 허용"하는 설정입니다. 이 단계에서는 다른 트랜잭션이 아직 커밋하지 않은 임시 데이터를 읽어오는 'Dirty Read' 현상은 완벽하게 방지할 수 있습니다. 하지만 이 격리 수준이 보장하지 못하는 가장 대표적이고 치명적인 버그가 바로 Lost Update(갱신 손실)입니다.
Lost Update는 두 개의 트랜잭션이 동일한 데이터를 동시에 읽은 뒤, 각자의 메모리에서 연산을 수행하고 다시 데이터베이스에 덮어쓸 때 발생합니다. 나중에 실행된 업데이트가 먼저 실행된 업데이트의 결과를 무효화(덮어쓰기)해버리는 현상입니다.

Lost Update 버그 재현 시나리오

어떤 쇼핑몰에서 상품의 재고를 차감하는 로직이 있다고 가정해 봅시다. 현재 상품 A의 재고는 100개입니다. 두 명의 사용자(트랜잭션 A, 트랜잭션 B)가 동시에 상품 A를 1개씩 구매하려 합니다.
시간
트랜잭션 A (사용자 1)
트랜잭션 B (사용자 2)
상품 A의 재고
T1
SELECT stock FROM items WHERE id=1; (재고 100개 읽음)
100
T2
SELECT stock FROM items WHERE id=1; (재고 100개 읽음)
100
T3
UPDATE items SET stock=99 WHERE id=1; (100 - 1 = 99)
99
T4
COMMIT;
99
T5
UPDATE items SET stock=99 WHERE id=1; (100 - 1 = 99)
99
T6
COMMIT;
99 (오류!)
분명 두 번의 구매가 발생하여 재고가 98개가 되어야 하지만, 최종 결과는 99개가 되어버렸습니다. 트랜잭션 A의 업데이트가 손실(Lost)된 것입니다.
왜 이런 일이 발생할까요?
READ COMMITTED 격리 수준에서 SELECT 쿼리는 데이터를 읽는 그 짧은 순간에만 공유 락(Shared locks) [2]을 획득했다가 즉시 해제합니다. 따라서 트랜잭션 A가 데이터를 읽고 업데이트를 준비하는 동안에도, 트랜잭션 B는 아무런 제약 없이 동일한 초기 데이터(100)를 읽어갈 수 있습니다. 애플리케이션 메모리 레벨에서 계산된 값을 단순 덮어쓰기 방식으로 처리하기 때문에 동시성 제어가 무너지는 것입니다.
이를 해결하기 위해서는 애플리케이션 단에서 버전을 비교하는 낙관적 락(Optimistic Locking)을 사용하거나, SELECT ... FOR UPDATE 구문을 통해 데이터를 읽을 때부터 독점 락(Exclusive locks) [2]을 강제로 거는 비관적 락(Pessimistic Locking)을 사용해야만 합니다.

데이터베이스의 유령: Phantom Read가 쿼리를 망치는 원리

Lost Update 문제를 겪은 개발자는 격리 수준을 REPEATABLE READ로 한 단계 올리는 것을 고려하게 됩니다. REPEATABLE READ는 트랜잭션이 시작된 후 동일한 쿼리를 여러 번 실행해도 항상 같은 결과를 반환함을 보장합니다. 하지만 이 수준조차도 Phantom Read(유령 읽기)라는 기묘한 현상을 허용합니다.
Phantom Read란, 트랜잭션 내에서 특정 조건(Range)으로 데이터를 조회할 때, 이전 조회에는 없던 '유령(Phantom)' 같은 새로운 행(Row)이 갑자기 나타나거나 사라지는 현상을 말합니다.

Phantom Read 버그 재현 시나리오

한 회사의 인사 관리 시스템에서 특정 부서의 직원 수를 세고, 그 수에 비례하여 예산을 할당하는 트랜잭션이 있다고 가정해 보겠습니다.
시간
트랜잭션 A (예산 관리팀)
트랜잭션 B (인사 채용팀)
T1
SELECT COUNT(*) FROM employees WHERE dept='Sales'; (결과: 5명)
T2
INSERT INTO employees (name, dept) VALUES ('Alice', 'Sales');
T3
COMMIT;
T4
UPDATE budget SET amount = 5 * 1000 WHERE dept='Sales'; (5명을 기준으로 예산 업데이트)
T5
SELECT COUNT(*) FROM employees WHERE dept='Sales'; (결과: 6명!)
T6
COMMIT;
트랜잭션 A는 분명 5명을 기준으로 예산을 책정했는데, 트랜잭션이 끝나기 직전 다시 조회해보니 6명이 되어 데이터 무결성이 깨졌습니다.
원리가 무엇일까요?
일반적인 레코드 락(Row Lock)이나 독점 락 [2]은 이미 존재하는 행(Row)에 대해서만 잠금을 걸 수 있습니다. 즉, 기존 5명의 직원에 대해서는 락이 걸려 변경을 막을 수 있지만, 'Sales' 부서라는 조건(범위)을 만족하는 새로운 행이 INSERT되는 것까지는 막지 못합니다. 존재하지도 않는 데이터에 미리 자물쇠를 채울 수는 없기 때문입니다.

완벽해 보이는 SERIALIZABLE, 왜 실무에서는 기피할까?

동시성으로 인한 모든 문제를 해결하는 궁극의 방법은 바로 트랜잭션 격리 수준을 `SERIALIZABLE`로 설정하는 것입니다. 직렬화(Serializability)는 컴퓨터 과학에서 분산 시스템이나 데이터베이스가 여러 트랜잭션을 동시에 실행하더라도, 그 결과가 트랜잭션들을 순차적으로(하나씩 직렬로) 실행했을 때의 결과와 동일함을 보장하는 매우 강력한 개념입니다 [3].
SERIALIZABLE 격리 수준에서는 앞서 언급한 Phantom Read조차 발생하지 않습니다. 데이터베이스는 이를 구현하기 위해 조건 조회 쿼리가 실행될 때, 해당 조건을 만족하는 인덱스 범위 전체에 락을 거는 갭 락(Gap Lock)이나 넥스트 키 락(Next-Key Lock)을 사용하거나, 심한 경우 테이블 전체를 잠가버립니다.
하지만 실무에서 SERIALIZABLE을 기본으로 사용하는 개발자는 거의 없습니다. 그 이유는 바로 극단적인 성능 저하 때문입니다.
시스템의 확장성과 처리량(Throughput)은 병렬 처리 능력에 직결됩니다. 하지만 트랜잭션이 SERIALIZABLE하게 실행되면, 여러 스레드가 동시에 읽기/쓰기를 수행하려 할 때 락(Lock) 경합이 극심해집니다. 데이터를 조금이라도 읽으려는 트랜잭션조차 앞선 트랜잭션이 끝날 때까지 큐(Queue)에 갇혀 무한정 대기(Blocking)해야 합니다.
네트워크와 디스크 I/O를 기다리는 동안 CPU가 다른 트랜잭션을 처리하며 유휴 자원을 활용해야 하지만, 락으로 인해 병목이 발생하면 데이터베이스 서버의 연결(Connection) 풀이 순식간에 고갈되고 애플리케이션 전체가 마비되는 서비스 거부(DoS) 상태에 빠지게 됩니다. 데이터 일관성을 지키려다 시스템의 가용성(Availability) 자체를 포기하게 되는 셈입니다.

결론 및 요약: 데이터 무결성과 성능 사이의 외줄 타기

데이터베이스의 트랜잭션 격리 수준은 단순히 코드를 설정하는 문제가 아니라, 서비스의 데이터 무결성과 응답 성능 사이의 치열한 트레이드오프(Trade-off)를 결정하는 아키텍처의 핵심입니다.
핵심 내용을 요약하면 다음과 같습니다.
1.
READ COMMITTED의 한계: 대다수 데이터베이스의 기본 격리 수준이지만, 갱신 손실(Lost Update)을 방어하지 못해 돈이나 재고가 증발하는 치명적인 버그를 유발할 수 있습니다.
2.
Phantom Read의 위협: 데이터 변경은 막아도, 특정 범위 내에 새로운 데이터가 삽입(INSERT)되는 것은 막지 못해 집계 쿼리의 일관성을 무너뜨립니다.
3.
SERIALIZABLE의 딜레마: 완벽한 데이터 일관성을 보장하지만 [3], 과도한 락(Lock) 점유와 대기로 인해 대규모 트래픽을 처리해야 하는 현대 웹 서비스에서는 심각한 성능 병목을 초래합니다.
고급 백엔드 개발자라면 데이터베이스가 제공하는 기본 설정에 의존하지 말아야 합니다. 비즈니스 요구사항에 따라 조회 위주의 로직은 낮은 격리 수준으로 처리량을 극대화하고, 재고 차감이나 송금과 같은 임계 구역(Critical Section)은 애플리케이션 레벨의 분산 락, 혹은 낙관적/비관적 락을 세밀하게 적용하여 동시성 문제를 제어하는 통찰력이 필요합니다. 데이터베이스의 내부 원리를 정확히 이해하는 것만이 견고한 애플리케이션을 완성하는 유일한 지름길입니다.

참고문헌

[1] Database-System-Concepts-7th-Edition — Review Terms Database-management system (DBMS) Database-system applications Online transaction processing Data analytics File-processing systems Data inconsistency Consistency constraints Data abstraction ° Physical level ° Logical level ° View level Instance Schema ° Physical schema ° Logical schem…
[2] Database-System-Concepts-7th-Edition — ° System catalog Database buffer ° Buffer manager ° Pinned blocks ° Evicted blocks ° Forced output of blocks ° Shared and exclusive locks Buffer-replacement strategies ° Least recently used (LRU) ° Toss-immediate ° Most recently used (MRU) Output of blocks Forced output of blocks Log disk Journaling…
[3] designing-data-intensive-applications — using quorums, 334 relying on, 330-332 constraints and uniqueness, 330 cross-channel timing dependencies, 331 locking and leader election, 330 stronger than causal consistency, 342 using to implement total order broadcast, 351 versus serializability, 329 LinkedIn Azkaban (workflow scheduler), 402 Da…