동시성 문제
수백 명이 동시에 같은 데이터를 읽고 쓰면 어떤 일이 벌어질까요? 동시성 제어 없이 여러 트랜잭션이 같은 데이터나 같은 조건 범위에 접근하면 갱신 분실, 오손 읽기, 반복 불가능 읽기, 팬텀 읽기 같은 이상 현상이 발생할 수 있습니다. 이 문제들은 단독 사용 환경에서는 드러나지 않고, 여러 트랜잭션이 겹쳐 실행될 때 나타나기 때문에 동시성 문제(Concurrency Problem)라고 부릅니다.
단일 사용자 데이터베이스라면 걱정할 필요가 없습니다. 하지만 웹 서비스, 은행 시스템, 예약 시스템처럼 수많은 사용자가 동시에 접근하는 환경에서는 반드시 해결해야 합니다. 동시성 문제를 무시하면 데이터 손실, 잘못된 계산, 일관성 위반 같은 치명적인 결과를 초래합니다.
왜 동시성 문제가 발생하는가
동시성 문제는 대개 읽기와 쓰기가 하나의 보호된 작업으로 묶이지 않을 때 드러납니다. 특히 서비스 코드에서 값을 읽고, 애플리케이션에서 계산한 뒤, 나중에 절대값으로 다시 저장하는 read-modify-write 패턴은 주의해야 합니다.
T1
1. SELECT balance FROM accounts WHERE id = 1; -- 100000
2. 애플리케이션에서 100000 - 10000 = 90000 계산
3. UPDATE accounts SET balance = 90000 WHERE id = 1;
T2
1. SELECT balance FROM accounts WHERE id = 1; -- 100000
2. 애플리케이션에서 100000 - 20000 = 80000 계산
3. UPDATE accounts SET balance = 80000 WHERE id = 1;
두 트랜잭션이 같은 기준값 100000을 읽고 서로 다른 절대값을 저장하면
나중에 저장한 값이 먼저 저장한 값을 덮어쓸 수 있습니다.실제 문제가 되는지는 격리 수준, 잠금, MVCC, SQL 작성 방식에 따라 달라집니다. 단일 SQL 문으로 balance = balance - 10000처럼 갱신하면 DBMS가 행 잠금과 원자적 연산으로 보호하는 경우가 많지만, 애플리케이션에서 읽기 → 계산 → 쓰기를 분리하면 그 사이에 다른 트랜잭션이 끼어들 수 있습니다. 운영체제의 프로세스 스케줄링처럼 실행 순서가 섞일 수 있고, 적절한 제어가 없으면 예상치 못한 결과가 나올 수 있습니다.
갱신 분실 (Lost Update)
두 트랜잭션이 같은 데이터를 동시에 수정할 때, 한쪽의 수정이 사라지는 문제입니다. 실제 데이터 손실로 이어질 수 있어 특히 치명적입니다.
T2가 SET balance = 80000처럼 자신이 계산한 절대값을 WRITE할 때, T1이 이미 변경한 값(90만원)을 무시하고 자신이 읽었던 값(100만원)을 기준으로 저장합니다. 이를 덮어쓰기(Overwrite)라고도 합니다.
갱신 분실의 변형
갱신 분실 방지 방법
-- 방법 1: 명시적 잠금 (SELECT ... FOR UPDATE)
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 다른 트랜잭션이 이 행을 수정하려면 대기해야 함
UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
COMMIT;
-- 방법 2: 원자적 조건부 갱신
UPDATE accounts
SET balance = balance - 10000
WHERE id = 1 AND balance >= 10000;
-- 영향받은 행이 0이면: 잔액 부족 또는 동시 갱신 충돌로 보고 재확인
-- 방법 3: 낙관적 잠금 (CAS: Compare-And-Swap)
UPDATE accounts
SET balance = :new_balance, version = version + 1
WHERE id = 1 AND version = 5;
-- :new_balance는 방금 읽은 balance와 업무 규칙으로 계산한 결과
-- 영향받은 행이 0이면: 다른 트랜잭션이 먼저 수정한 것 → 재시도SELECT ... FOR UPDATE는 명시적 트랜잭션 안에서 짧게 사용해야 하며, DBMS와 인덱스 조건에 따라 행 잠금뿐 아니라 gap/next-key lock처럼 범위 잠금이 걸릴 수도 있습니다. 단순 차감, 재고 감소, 카운터 증가처럼 계산식이 명확한 경우에는 애플리케이션에서 값을 미리 계산해 덮어쓰기보다 원자적 UPDATE를 우선 검토하는 편이 안전합니다.
오손 읽기 (Dirty Read)
한 트랜잭션이 아직 커밋하지 않은 다른 트랜잭션의 변경을 읽는 문제입니다. 더티(dirty)라는 이름은 커밋되지 않은 불확실한 데이터를 더러운 데이터라고 부르는 데서 유래합니다.
존재하지 않는 데이터를 기반으로 계산한 결과는 당연히 틀립니다.
오손 읽기가 발생하는 격리 수준
READ UNCOMMITTED → 오손 읽기 발생 가능
READ COMMITTED → 오손 읽기 방지 ✓
REPEATABLE READ → 오손 읽기 방지 ✓
SERIALIZABLE → 오손 읽기 방지 ✓
대부분의 DBMS는 기본 격리 수준이 READ COMMITTED 이상이므로
일반적으로 오손 읽기는 발생하지 않습니다.
단, PostgreSQL처럼 READ UNCOMMITTED를 사실상 READ COMMITTED로
취급하는 제품도 있어 세부 동작은 DBMS별 문서를 확인해야 합니다.Oracle은 READ UNCOMMITTED 격리 수준을 제공하지 않고, SQL Server의 READ UNCOMMITTED 또는 NOLOCK 계열 읽기는 롤백될 값뿐 아니라 중복, 누락, 이동 중인 행 관찰 같은 더 넓은 불일치를 만들 수 있습니다. 그래서 실무에서는 "오손 읽기만 조심하면 된다"보다 "일관되지 않은 읽기를 의도적으로 허용하는가"를 먼저 판단해야 합니다.
오손 읽기의 실제 위험
반복 불가능 읽기 (Non-Repeatable Read)
같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 달라지는 문제입니다. Fuzzy Read라고도 부릅니다.
반복 불가능 읽기 vs 오손 읽기
오손 읽기
커밋되지 않은 데이터를 읽음 → ROLLBACK되면 존재하지 않는 데이터
반복 불가능 읽기
커밋된 데이터를 읽음 → 데이터 자체는 정확하지만 일관성이 깨짐
핵심 차이: 커밋 여부
오손 읽기 → 다른 트랜잭션이 커밋하지 않은 데이터를 읽음
반복 불가능 읽기 → 다른 트랜잭션이 커밋한 변경이 반영됨MVCC 기반 DBMS의 READ COMMITTED는 보통 문장마다 새 스냅샷을 보기 때문에, 같은 트랜잭션 안에서도 두 번째 SELECT가 더 최신 커밋 값을 볼 수 있습니다. REPEATABLE READ 계열은 대개 트랜잭션 단위 스냅샷을 유지해 같은 행을 다시 읽을 때 같은 값을 보게 하지만, 쓰기 충돌이나 직렬화 가능성까지 자동으로 보장한다는 뜻은 아닙니다.
실제 피해 사례
팬텀 읽기 (Phantom Read)
같은 조건으로 조회했는데 결과 행의 수나 구성이 달라지는 문제입니다. 반복 불가능 읽기가 이미 읽은 행의 값 변경이라면, 팬텀 읽기는 INSERT, DELETE, 또는 조건을 만족하도록 바뀌는 UPDATE로 인해 조건 결과 집합이 달라지는 문제입니다.
팬텀 읽기 vs 반복 불가능 읽기
팬텀 방지의 어려움
팬텀 읽기가 다른 문제보다 방지하기 어려운 이유는, 단일 행이 아니라 조건 범위를 보호해야 하기 때문입니다. 행 잠금은 이미 존재하는 행에는 잘 맞지만, 조건에 새로 들어올 INSERT까지 막으려면 range lock, gap lock, predicate lock, Serializable Snapshot Isolation 같은 추가 전략이 필요합니다.
팬텀의 실제 동작도 DBMS마다 다릅니다. PostgreSQL의 REPEATABLE READ는 스냅샷 반복 읽기로 같은 조건 SELECT 결과를 안정적으로 보여주지만, 모든 직렬화 이상을 막으려면 SERIALIZABLE이 필요합니다. MySQL InnoDB의 REPEATABLE READ는 일반 consistent read에서는 스냅샷을 사용하고, 잠금 읽기나 범위 갱신에서는 next-key lock으로 새 행 진입을 막는 식으로 동작합니다.
네 가지 문제의 관계
동시성 문제는 원인이 서로 겹치지만, 단순한 상하 단계로만 이해하면 위험합니다. 일반적으로 격리 수준이 높아질수록 더 많은 이상 현상을 막지만, READ COMMITTED가 오손 읽기는 막아도 반복 불가능 읽기는 허용하는 것처럼 단계별 보장 범위가 다릅니다. 또한 갱신 분실 방지는 DBMS의 잠금 방식, 원자적 UPDATE, 낙관적 잠금 사용 여부에 영향을 받습니다.
심각도
위험도 ─────────────────────────── 관찰 범위
│ │
▼ ▼
갱신 분실 오손 읽기 반복불가 팬텀
(쓰기-쓰기) (미커밋 읽기) (행 값 변화) (결과셋 변화)
데이터 손실 존재하지 않는 값 같은 행 변화 조건 범위 변화격리 수준별 허용 여부
격리 수준 표는 개념을 잡기 위한 기준표입니다. SQL 표준의 이상 현상 표와 실제 DBMS 구현은 완전히 일치하지 않을 수 있고, 특히 갱신 분실은 격리 수준 하나만으로 결정되지 않습니다. 원자적 UPDATE, 명시적 잠금, 낙관적 잠금, 충돌 감지 방식까지 함께 봐야 합니다.
동시성 문제의 실무 체험
-- 터미널 1 (T1)
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
-- 아직 COMMIT하지 않음
-- 터미널 2 (T2)
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- → 200 (커밋 안 된 값이 보임 = 오손 읽기!)
-- 터미널 1에서 ROLLBACK
ROLLBACK;
-- 터미널 2에서 다시 조회
SELECT balance FROM accounts WHERE id = 1;
-- → 원래 값 (100) (아까 본 200은 존재하지 않았던 값)-- 터미널 1 (T1)
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- → 100
-- 터미널 2 (T2)
BEGIN;
UPDATE accounts SET balance = 50 WHERE id = 1;
COMMIT;
-- 터미널 1에서 다시 조회
SELECT balance FROM accounts WHERE id = 1;
-- → 50 (같은 트랜잭션인데 값이 바뀜!)
COMMIT;동시성 제어의 두 가지 접근
DBMS별 동시성 처리 전략
각 DBMS는 동시성 문제를 해결하기 위해 서로 다른 전략을 채택합니다. 잠금 기반과 MVCC(다중 버전 동시성 제어)가 대표적인 두 축이고, 실제 제품은 격리 수준에 따라 행 잠금, range/gap lock, 스냅샷 읽기, 충돌 감지를 조합합니다.
MVCC (Multi-Version Concurrency Control)
전통적 잠금: "읽는 동안 쓰지 마라" (읽기-쓰기 충돌 발생)
MVCC: "읽는 동안 써도 된다. 나는 예전 버전을 읽겠다"
동작 방식:
1. 데이터를 수정하면 기존 버전을 보존하고 새 버전을 생성
2. 트랜잭션 또는 문장은 특정 시점의 "스냅샷"을 봄
(READ COMMITTED는 문장마다, REPEATABLE READ 계열은 트랜잭션 단위인 경우가 많음)
3. 일반 읽기는 잠금 없이 자신의 스냅샷에서 읽음
4. 쓰기, SELECT FOR UPDATE 같은 잠금 읽기, 직렬화 충돌 감지는 별도 제어를 사용
장점:
* 읽기와 쓰기가 서로 차단하지 않음
* 높은 동시성 (읽기 위주 워크로드에서 특히 효과적)
* 오손 읽기를 막고, 스냅샷 범위에 따라 반복 불가능 읽기를 줄이거나 방지
단점:
* 과거 버전 저장을 위한 추가 공간 필요
* 오래된 버전 정리(Vacuum/Purge) 작업 필요
* 갱신 분실, write skew 같은 쓰기 충돌은 별도 제어가 필요할 수 있음즉 MVCC는 "락이 없는 방식"이 아니라 "일반 읽기가 쓰기를 덜 막게 하는 방식"에 가깝습니다. 쓰기끼리는 여전히 잠금, 대기, 충돌 감지가 필요하고, 오래 열린 트랜잭션은 과거 버전 정리를 지연시켜 성능 문제를 만들 수 있습니다.
동시성 문제를 예방하는 설계 원칙
동시성 문제는 발생한 뒤 디버깅하기 매우 어렵습니다. 재현 조건이 타이밍에 의존하기 때문입니다. 따라서 설계 단계에서 원칙을 세우고 예방하는 것이 중요합니다.
동시성 문제는 일반 단위 테스트에서는 잘 드러나지 않지만 운영에서 터지는 대표적인 버그입니다. 타이밍과 격리 수준에 의존하므로, 설계 단계에서 방지하고 통합/부하 테스트로 주요 경합 경로를 확인하는 것이 좋습니다.
핵심 정리
이 문제들을 줄이거나 방지하기 위해 격리 수준(Isolation Level)과 동시성 제어 전략을 선택합니다.
격리 수준은 동시성과 일관성 사이의 트레이드오프를 조절하는 설정입니다. 다음 절에서 자세히 다루겠습니다.