동시성 문제
수백 명이 동시에 같은 데이터를 읽고 쓰면 어떤 일이 벌어질까요? 동시성 제어 없이 여러 트랜잭션이 동시에 같은 데이터에 접근하면 네 가지 문제가 발생합니다. 이 문제들은 단독 사용 환경에서는 절대 발생하지 않습니다. 오직 동시에 여러 트랜잭션이 실행될 때만 나타나기 때문에 동시성 문제(Concurrency Problem)라고 부릅니다.
단일 사용자 데이터베이스라면 걱정할 필요가 없습니다. 하지만 웹 서비스, 은행 시스템, 예약 시스템처럼 수많은 사용자가 동시에 접근하는 환경에서는 반드시 해결해야 합니다. 동시성 문제를 무시하면 데이터 손실, 잘못된 계산, 일관성 위반 같은 치명적인 결과를 초래합니다.
왜 동시성 문제가 발생하는가
데이터베이스의 읽기와 쓰기는 원자적(atomic)으로 보이지만, 실제로는 여러 단계로 나뉩니다.
UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
내부 동작
1. 디스크에서 id=1인 행을 찾는다 (READ)
2. 메모리에 행을 로드한다
3. balance 값을 읽는다 (현재값: 100000)
4. 100000 - 10000 = 90000을 계산한다
5. 새 값 90000을 메모리에 쓴다 (WRITE)
6. 언두 로그를 기록한다
7. 리두 로그를 기록한다
8. (COMMIT 시) 디스크에 반영한다이 단계들 사이에 다른 트랜잭션이 끼어들면 문제가 발생합니다. 운영체제의 프로세스 스케줄링과 비슷합니다. CPU가 작업 중간에 다른 프로세스로 전환하면 예상치 못한 결과가 나오는 것처럼, 데이터베이스에서도 트랜잭션 중간에 다른 트랜잭션이 개입하면 문제가 생깁니다.
┌─────────────────────────────────────────────────────────┐
│ 동시성 문제 = 인터리빙(Interleaving) 실행의 부작용 │
│ │
│ 직렬 실행: T1 → T2 (문제 없음, 하지만 느림) │
│ 병렬 실행: T1과 T2가 번갈아 실행 (빠르지만 위험) │
│ │
│ 목표: 병렬 실행하면서도 직렬 실행한 것처럼 보이게 하기 │
│ → 이것이 "직렬 가능성(Serializability)" │
└─────────────────────────────────────────────────────────┘갱신 분실 (Lost Update)
두 트랜잭션이 같은 데이터를 동시에 수정할 때, 한쪽의 수정이 사라지는 문제입니다. 네 가지 문제 중 가장 심각합니다.
잔고: 100만원
T1 (ATM 출금 10만원) T2 (온라인 결제 20만원)
───────────── ─────────────
READ(잔고) → 100만원
READ(잔고) → 100만원
WRITE(잔고) = 90만원
WRITE(잔고) = 80만원
결과: 잔고 = 80만원
기대: 잔고 = 70만원 (100 - 10 - 20)
→ T1의 출금 10만원이 분실됨!T2가 WRITE할 때, T1이 이미 변경한 값(90만원)을 무시하고 자신이 읽었던 값(100만원)을 기준으로 계산합니다. 이를 덮어쓰기(Overwrite)라고도 합니다.
갱신 분실의 변형
갱신 분실은 "읽기 → 수정 → 쓰기" 패턴에서 발생합니다.
패턴 1: 은행 계좌 (위 예시)
READ → 계산 → WRITE
패턴 2: 재고 관리
READ(재고=10) → 재고-1 → WRITE(재고=9)
두 고객이 동시 주문하면 재고가 1만 줄어듦
패턴 3: 좌석 예약
READ(좌석='빈 좌석') → WRITE(좌석='예약')
두 사람이 같은 좌석을 동시에 예약 가능
패턴 4: 카운터 증가
READ(조회수=100) → 조회수+1 → WRITE(조회수=101)
동시 조회 시 조회수 누락갱신 분실 방지 방법
-- 방법 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;
-- DBMS가 내부적으로 잠금을 걸어 원자적으로 처리
-- 방법 3: 낙관적 잠금 (CAS: Compare-And-Swap)
UPDATE accounts
SET balance = 90, version = version + 1
WHERE id = 1 AND version = 5;
-- 영향받은 행이 0이면: 다른 트랜잭션이 먼저 수정한 것 → 재시도┌─────────────────┬─────────────────┬───────────────────┐
│ 방법 │ 장점 │ 단점 │
├─────────────────┼─────────────────┼───────────────────┤
│ SELECT FOR │ 확실한 잠금 │ 대기 시간 발생 │
│ UPDATE │ │ 데드락 가능 │
├─────────────────┼─────────────────┼───────────────────┤
│ 원자적 갱신 │ 간단, 성능 좋음 │ 복잡한 계산 불가 │
│ (balance= │ │ │
│ balance-N) │ │ │
├─────────────────┼─────────────────┼───────────────────┤
│ 낙관적 잠금 │ 잠금 없이 처리 │ 충돌 시 재시도 │
│ (버전 번호) │ 높은 동시성 │ 필요 │
└─────────────────┴─────────────────┴───────────────────┘오손 읽기 (Dirty Read)
한 트랜잭션이 아직 커밋하지 않은 다른 트랜잭션의 변경을 읽는 문제입니다. 더티(dirty)라는 이름은 커밋되지 않은 불확실한 데이터를 더러운 데이터라고 부르는 데서 유래합니다.
T1 T2
───────────── ─────────────
UPDATE 잔고 = 200만원
(아직 COMMIT 안 함)
READ(잔고) → 200만원 ← 커밋 안 된 값!
ROLLBACK
(잔고 = 원래 100만원)
200만원 기준으로 처리 ← 잘못된 데이터!존재하지 않는 데이터를 기반으로 계산한 결과는 당연히 틀립니다.
오손 읽기가 발생하는 격리 수준
READ UNCOMMITTED → 오손 읽기 발생 가능
READ COMMITTED → 오손 읽기 방지 ✓
REPEATABLE READ → 오손 읽기 방지 ✓
SERIALIZABLE → 오손 읽기 방지 ✓
대부분의 DBMS는 기본 격리 수준이 READ COMMITTED 이상이므로
일반적으로 오손 읽기는 발생하지 않습니다.오손 읽기의 실제 위험
상황 1: 은행 이체
T1: A계좌에서 100만원 차감 → B계좌에 100만원 추가 (미커밋)
T2: A계좌 잔고 조회 → 이미 100만원 줄어든 값을 봄
T1: ROLLBACK (이체 실패)
→ T2는 줄어든 잔고를 기준으로 대출 심사를 함 (잘못된 판단)
상황 2: 재고 확인
T1: 재고 100개 → 0개로 수정 (미커밋)
T2: 재고 조회 → 0개로 표시 → "품절" 안내
T1: ROLLBACK
→ 실제로는 재고가 있는데 품절로 표시됨
상황 3: 통계 집계
T1: 금액을 수정 중 (미커밋)
T2: SUM(금액) 집계 → 커밋 안 된 값 포함
T1: ROLLBACK
→ 잘못된 통계 결과반복 불가능 읽기 (Non-Repeatable Read)
같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 달라지는 문제입니다. Fuzzy Read라고도 부릅니다.
T1 T2
───────────── ─────────────
READ(잔고) → 100만원
UPDATE 잔고 = 50만원
COMMIT
READ(잔고) → 50만원
→ 같은 트랜잭션에서 같은 데이터를 읽었는데 값이 다름!반복 불가능 읽기 vs 오손 읽기
오손 읽기
커밋되지 않은 데이터를 읽음 → ROLLBACK되면 존재하지 않는 데이터
반복 불가능 읽기
커밋된 데이터를 읽음 → 데이터 자체는 정확하지만 일관성이 깨짐
핵심 차이: 커밋 여부
오손 읽기 → 다른 트랜잭션이 커밋하지 않은 데이터를 읽음
반복 불가능 읽기 → 다른 트랜잭션이 커밋한 변경이 반영됨실제 피해 사례
시나리오: 잔고 확인 후 출금
T1 (사용자)
1. READ(잔고) → 100만원 ('100만원 있네, 출금 가능')
2. 다른 로직 수행 중...
(이 사이에 T2가 잔고를 50만원으로 변경하고 COMMIT)
3. UPDATE 잔고 = 잔고 - 80만원 → 결과: -30만원!
→ 잔고 부족인데 출금이 실행됨
방지: READ와 UPDATE를 하나의 트랜잭션으로 묶고,
적절한 격리 수준 또는 잠금 사용팬텀 읽기 (Phantom Read)
같은 조건으로 조회했는데 결과 행의 수가 달라지는 문제입니다. 반복 불가능 읽기가 기존 행의 값 변경이라면, 팬텀 읽기는 행의 추가/삭제로 인한 문제입니다.
T1 T2
───────────── ─────────────
SELECT COUNT(*) FROM orders
WHERE user_id = 1 → 5건
INSERT INTO orders (user_id, ...)
VALUES (1, ...)
COMMIT
SELECT COUNT(*) FROM orders
WHERE user_id = 1 → 6건
→ 없던 행이 갑자기 나타남 (팬텀!)팬텀 읽기 vs 반복 불가능 읽기
반복 불가능 읽기
동일한 "행"을 다시 읽었을 때 값이 변함
→ UPDATE에 의해 발생
→ 행 단위 잠금으로 방지 가능
팬텀 읽기
동일한 "조건"으로 조회했을 때 행의 수가 변함
→ INSERT/DELETE에 의해 발생
→ 행 단위 잠금으로는 방지 불가 (새로운 행은 잠글 수 없음)팬텀 방지의 어려움
팬텀 읽기가 다른 문제보다 방지하기 어려운 이유는, 아직 존재하지 않는 행을 잠글 수 없기 때문입니다. 행 잠금은 이미 존재하는 행에만 적용되므로, INSERT로 새로 생기는 행은 기존 잠금의 범위 밖입니다.
행 잠금의 한계
T1이 "user_id = 1인 모든 행"에 잠금을 걸어도,
아직 존재하지 않는 미래의 행은 잠글 수 없음
해결 방법
1. 테이블 잠금: 테이블 전체를 잠금 → 동시성 극도로 저하
2. 범위 잠금: 인덱스의 범위에 잠금 (갭 락, Gap Lock)
3. MVCC: 스냅샷 기반 읽기 (PostgreSQL의 REPEATABLE READ)
InnoDB의 갭 락 (Gap Lock)
인덱스의 값 사이 "간격"에 잠금을 걸어
새로운 행이 삽입되는 것을 방지
예: user_id 인덱스에서 (1, 2) 사이에 갭 락을 걸면
user_id=1인 새로운 행 삽입이 차단됨네 가지 문제의 관계
동시성 문제는 독립적으로 발생하는 것이 아니라, 서로 포함 관계를 가집니다. 오손 읽기를 방지하면 반복 불가능 읽기도 일부 방지되고, 반복 불가능 읽기를 방지하면 팬텀 읽기 가능성도 줄어듭니다. 격리 수준이 높아질수록 더 많은 문제가 방지되지만, 그만큼 동시 처리 성능은 떨어집니다.
심각도
높음 ────────────────────────────── 낮음
│ │
▼ ▼
갱신 분실 > 오손 읽기 > 반복불가 > 팬텀
(쓰기-쓰기) (읽기-쓰기) (읽기-쓰기) (읽기-쓰기)
데이터 손실 잘못된 데이터 일관성 위반 결과셋 변화┌─────────────────────────────────────────────────────┐
│ 쓰기-쓰기 충돌 │
│ 갱신 분실: 두 트랜잭션이 같은 행을 동시에 수정 │
├─────────────────────────────────────────────────────┤
│ 읽기-쓰기 충돌 │
│ 오손 읽기: 커밋 전 데이터를 읽음 │
│ 반복 불가능 읽기: 커밋된 UPDATE가 읽기에 영향 │
│ 팬텀 읽기: 커밋된 INSERT/DELETE가 읽기 범위에 영향 │
└─────────────────────────────────────────────────────┘격리 수준별 허용 여부
┌──────────────────┬────────┬──────┬────────┬────────┐
│ 격리 수준 │갱신분실│ 오손 │반복불가│ 팬텀 │
│ │ │ 읽기 │ 읽기 │ 읽기 │
├──────────────────┼────────┼──────┼────────┼────────┤
│ READ UNCOMMITTED │ × │ ○ │ ○ │ ○ │
│ READ COMMITTED │ × │ × │ ○ │ ○ │
│ REPEATABLE READ │ × │ × │ × │ ○ │
│ SERIALIZABLE │ × │ × │ × │ × │
└──────────────────┴────────┴──────┴────────┴────────┘
× = 방지, ○ = 발생 가능
참고: 갱신 분실은 모든 격리 수준에서 방지됩니다.
대부분의 DBMS는 기본적으로 쓰기 잠금을 사용하기 때문입니다.동시성 문제의 실무 체험
-- 터미널 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;
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)
UPDATE accounts SET balance = 50 WHERE id = 1;
COMMIT;
-- 터미널 1에서 다시 조회
SELECT balance FROM accounts WHERE id = 1;
-- → 50 (같은 트랜잭션인데 값이 바뀜!)
COMMIT;동시성 제어의 두 가지 접근
┌──────────────────────────────────────────────────────┐
│ 비관적 동시성 제어 (Pessimistic) │
│ "충돌이 일어날 거라고 가정하고 미리 잠금" │
│ 방법: SELECT ... FOR UPDATE, 행 잠금 │
│ 장점: 충돌이 잦은 환경에서 안전 │
│ 단점: 잠금 대기, 데드락 가능 │
│ 적합: 은행, 예약 시스템 등 충돌 빈번한 환경 │
├──────────────────────────────────────────────────────┤
│ 낙관적 동시성 제어 (Optimistic) │
│ "충돌이 드물 거라고 가정하고 나중에 검증" │
│ 방법: 버전 번호, 타임스탬프 비교 (CAS) │
│ 장점: 잠금 없이 높은 동시성 │
│ 단점: 충돌 시 재시도 비용 │
│ 적합: 조회 위주, 충돌이 드문 환경 │
└──────────────────────────────────────────────────────┘DBMS별 동시성 처리 전략
각 DBMS는 동시성 문제를 해결하기 위해 서로 다른 전략을 채택합니다. 잠금 기반과 MVCC(다중 버전 동시성 제어)가 대표적인 두 전략입니다. 현대 DBMS 대부분은 MVCC를 기본 또는 옵션으로 지원합니다.
┌──────────────┬───────────────────────────────────────┐
│ DBMS │ 방식 │
├──────────────┼───────────────────────────────────────┤
│ PostgreSQL │ MVCC (다중 버전 동시성 제어) │
│ │ 읽기는 절대 잠금을 걸지 않음 │
│ │ 각 트랜잭션이 데이터의 스냅샷을 봄 │
│ │ 기본 격리: READ COMMITTED │
├──────────────┼───────────────────────────────────────┤
│ MySQL │ InnoDB: MVCC + 행 잠금 + 갭 락 │
│ (InnoDB) │REPEATABLE READ에서 갭 락으로 팬텀 방지│
│ │ 기본 격리: REPEATABLE READ │
├──────────────┼───────────────────────────────────────┤
│ Oracle │ MVCC (읽기 일관성) │
│ │ 언두 세그먼트로 과거 버전 유지 │
│ │ 기본 격리: READ COMMITTED │
├──────────────┼───────────────────────────────────────┤
│ SQL Server │ 잠금 기반 (기본) │
│ │ SNAPSHOT ISOLATION 옵션 제공 │
│ │ 기본 격리: READ COMMITTED │
└──────────────┴───────────────────────────────────────┘MVCC (Multi-Version Concurrency Control)
전통적 잠금: "읽는 동안 쓰지 마라" (읽기-쓰기 충돌 발생)
MVCC: "읽는 동안 써도 된다. 나는 예전 버전을 읽겠다"
동작 방식:
1. 데이터를 수정하면 기존 버전을 보존하고 새 버전을 생성
2. 각 트랜잭션은 시작 시점의 "스냅샷"을 봄
3. 읽기 트랜잭션은 잠금 없이 자신의 스냅샷에서 읽음
4. 쓰기 트랜잭션만 잠금을 사용
장점:
* 읽기와 쓰기가 서로 차단하지 않음
* 높은 동시성 (읽기 위주 워크로드에서 특히 효과적)
* 오손 읽기, 반복 불가능 읽기 자연스럽게 방지
단점:
* 과거 버전 저장을 위한 추가 공간 필요
* 오래된 버전 정리(Vacuum/Purge) 작업 필요
* 갱신 분실에 대해서는 별도 처리 필요동시성 문제를 예방하는 설계 원칙
동시성 문제는 발생한 뒤 디버깅하기 매우 어렵습니다. 재현 조건이 타이밍에 의존하기 때문입니다. 따라서 설계 단계에서 원칙을 세우고 예방하는 것이 중요합니다.
1. 트랜잭션을 짧게 유지한다
긴 트랜잭션 = 긴 잠금 보유 = 다른 트랜잭션 대기 증가
사용자 입력을 트랜잭션 안에서 기다리지 않는다
네트워크 호출(API)을 트랜잭션 밖으로 분리한다
2. 잠금 순서를 통일한다
여러 테이블을 잠글 때 항상 같은 순서로 잠근다
A→B 순서로 통일하면 데드락 방지
3. 적절한 격리 수준을 선택한다
무조건 SERIALIZABLE은 성능 위험
비즈니스 요구에 맞는 최소 수준을 선택
4. 원자적 연산을 활용한다
UPDATE balance = balance - N (원자적)
vs READ → 계산 → WRITE (비원자적, 위험)
5. 재시도 로직을 구현한다
데드락이나 낙관적 잠금 실패 시 자동 재시도
지수 백오프(exponential backoff) 적용
최대 재시도 횟수를 설정하여 무한 루프 방지동시성 문제는 테스트에서 발견되지 않지만 운영에서 터지는 대표적인 버그입니다. 단위 테스트로 발견하기 어려우므로, 설계 단계에서 방지하는 것이 최선입니다.
핵심 정리
┌─────────────────────────────────────────────────────────┐
│ 4대 동시성 문제 │
│ │
│ 1. 갱신 분실: 동시 쓰기 → 한쪽 수정 사라짐 (가장 심각) │
│ 2. 오손 읽기: 커밋 전 데이터를 읽음 (잘못된 데이터) │
│ 3. 반복 불가능 읽기: 같은 행을 다시 읽으면 값 변경 │
│ 4. 팬텀 읽기: 같은 조건 조회 시 행 수 변경 │
├─────────────────────────────────────────────────────────┤
│ 발생 원인 │
│ 인터리빙 실행: 트랜잭션이 번갈아 실행되며 간섭 │
│ 순서: 쓰기-쓰기 > 커밋전 읽기 > UPDATE영향 > INSERT영향│
├─────────────────────────────────────────────────────────┤
│ 해결 수단 │
│ 격리 수준: READ UNCOMMITTED → SERIALIZABLE │
│ 높을수록 안전하지만 동시성(성능)이 떨어짐 │
│ 비관적 제어: 잠금으로 미리 방지 (충돌 잦을 때) │
│ 낙관적 제어: 버전 비교로 사후 검증 (충돌 적을 때) │
└─────────────────────────────────────────────────────────┘이 문제들을 해결하는 것이 격리 수준(Isolation Level)입니다.
격리 수준은 동시성과 일관성 사이의 트레이드오프를 조절하는 설정입니다. 다음 절에서 자세히 다루겠습니다.