icon

안동민 개발노트

11장 : 동시성 제어

동시성 문제

수백 명이 동시에 같은 데이터를 읽고 쓰면 어떤 일이 벌어질까요? 동시성 제어 없이 여러 트랜잭션이 동시에 같은 데이터에 접근하면 네 가지 문제가 발생합니다. 이 문제들은 단독 사용 환경에서는 절대 발생하지 않습니다. 오직 동시에 여러 트랜잭션이 실행될 때만 나타나기 때문에 동시성 문제(Concurrency Problem)라고 부릅니다.

단일 사용자 데이터베이스라면 걱정할 필요가 없습니다. 하지만 웹 서비스, 은행 시스템, 예약 시스템처럼 수많은 사용자가 동시에 접근하는 환경에서는 반드시 해결해야 합니다. 동시성 문제를 무시하면 데이터 손실, 잘못된 계산, 일관성 위반 같은 치명적인 결과를 초래합니다.


왜 동시성 문제가 발생하는가

데이터베이스의 읽기와 쓰기는 원자적(atomic)으로 보이지만, 실제로는 여러 단계로 나뉩니다.

하나의 UPDATE가 내부적으로 수행하는 작업
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가 작업 중간에 다른 프로세스로 전환하면 예상치 못한 결과가 나오는 것처럼, 데이터베이스에서도 트랜잭션 중간에 다른 트랜잭션이 개입하면 문제가 생깁니다.


갱신 분실 (Lost Update)

두 트랜잭션이 같은 데이터를 동시에 수정할 때, 한쪽의 수정이 사라지는 문제입니다. 네 가지 문제 중 가장 심각합니다.

T2가 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;
-- DBMS가 내부적으로 잠금을 걸어 원자적으로 처리

-- 방법 3: 낙관적 잠금 (CAS: Compare-And-Swap)
UPDATE accounts
SET balance = 90, version = version + 1
WHERE id = 1 AND version = 5;
-- 영향받은 행이 0이면: 다른 트랜잭션이 먼저 수정한 것 → 재시도

오손 읽기 (Dirty Read)

한 트랜잭션이 아직 커밋하지 않은 다른 트랜잭션의 변경을 읽는 문제입니다. 더티(dirty)라는 이름은 커밋되지 않은 불확실한 데이터를 더러운 데이터라고 부르는 데서 유래합니다.

존재하지 않는 데이터를 기반으로 계산한 결과는 당연히 틀립니다.

오손 읽기가 발생하는 격리 수준

격리 수준과 오손 읽기
READ UNCOMMITTED → 오손 읽기 발생 가능
READ COMMITTED   → 오손 읽기 방지 ✓
REPEATABLE READ  → 오손 읽기 방지 ✓
SERIALIZABLE     → 오손 읽기 방지 ✓

대부분의 DBMS는 기본 격리 수준이 READ COMMITTED 이상이므로
일반적으로 오손 읽기는 발생하지 않습니다.

오손 읽기의 실제 위험


반복 불가능 읽기 (Non-Repeatable Read)

같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 달라지는 문제입니다. Fuzzy Read라고도 부릅니다.

반복 불가능 읽기 vs 오손 읽기

두 문제의 차이
오손 읽기
  커밋되지 않은 데이터를 읽음 → ROLLBACK되면 존재하지 않는 데이터

반복 불가능 읽기
  커밋된 데이터를 읽음 → 데이터 자체는 정확하지만 일관성이 깨짐

핵심 차이: 커밋 여부
  오손 읽기 → 다른 트랜잭션이 커밋하지 않은 데이터를 읽음
  반복 불가능 읽기 → 다른 트랜잭션이 커밋한 변경이 반영됨

실제 피해 사례


팬텀 읽기 (Phantom Read)

같은 조건으로 조회했는데 결과 행의 수가 달라지는 문제입니다. 반복 불가능 읽기가 기존 행의 값 변경이라면, 팬텀 읽기는 행의 추가/삭제로 인한 문제입니다.

팬텀 읽기 vs 반복 불가능 읽기

팬텀 방지의 어려움

팬텀 읽기가 다른 문제보다 방지하기 어려운 이유는, 아직 존재하지 않는 행을 잠글 수 없기 때문입니다. 행 잠금은 이미 존재하는 행에만 적용되므로, INSERT로 새로 생기는 행은 기존 잠금의 범위 밖입니다.


네 가지 문제의 관계

동시성 문제는 독립적으로 발생하는 것이 아니라, 서로 포함 관계를 가집니다. 오손 읽기를 방지하면 반복 불가능 읽기도 일부 방지되고, 반복 불가능 읽기를 방지하면 팬텀 읽기 가능성도 줄어듭니다. 격리 수준이 높아질수록 더 많은 문제가 방지되지만, 그만큼 동시 처리 성능은 떨어집니다.

동시성 문제 간의 관계와 심각도
          심각도
  높음 ────────────────────────────── 낮음
    │                                  │
    ▼                                  ▼
  갱신 분실  >  오손 읽기  >  반복불가  >  팬텀
  (쓰기-쓰기)  (읽기-쓰기)  (읽기-쓰기)  (읽기-쓰기)

  데이터 손실    잘못된 데이터   일관성 위반   결과셋 변화

격리 수준별 허용 여부


동시성 문제의 실무 체험

동시성 문제를 직접 재현하는 방법 (MySQL)
-- 터미널 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은 존재하지 않았던 값)
반복 불가능 읽기 재현 (PostgreSQL)
-- 터미널 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;

동시성 제어의 두 가지 접근


DBMS별 동시성 처리 전략

각 DBMS는 동시성 문제를 해결하기 위해 서로 다른 전략을 채택합니다. 잠금 기반과 MVCC(다중 버전 동시성 제어)가 대표적인 두 전략입니다. 현대 DBMS 대부분은 MVCC를 기본 또는 옵션으로 지원합니다.

MVCC (Multi-Version Concurrency Control)

MVCC의 기본 원리
전통적 잠금: "읽는 동안 쓰지 마라" (읽기-쓰기 충돌 발생)
MVCC:        "읽는 동안 써도 된다. 나는 예전 버전을 읽겠다"

동작 방식:
  1. 데이터를 수정하면 기존 버전을 보존하고 새 버전을 생성
  2. 각 트랜잭션은 시작 시점의 "스냅샷"을 봄
  3. 읽기 트랜잭션은 잠금 없이 자신의 스냅샷에서 읽음
  4. 쓰기 트랜잭션만 잠금을 사용

장점:
  * 읽기와 쓰기가 서로 차단하지 않음
  * 높은 동시성 (읽기 위주 워크로드에서 특히 효과적)
  * 오손 읽기, 반복 불가능 읽기 자연스럽게 방지

단점:
  * 과거 버전 저장을 위한 추가 공간 필요
  * 오래된 버전 정리(Vacuum/Purge) 작업 필요
  * 갱신 분실에 대해서는 별도 처리 필요

동시성 문제를 예방하는 설계 원칙

동시성 문제는 발생한 뒤 디버깅하기 매우 어렵습니다. 재현 조건이 타이밍에 의존하기 때문입니다. 따라서 설계 단계에서 원칙을 세우고 예방하는 것이 중요합니다.

동시성 문제는 테스트에서 발견되지 않지만 운영에서 터지는 대표적인 버그입니다. 단위 테스트로 발견하기 어려우므로, 설계 단계에서 방지하는 것이 최선입니다.


핵심 정리

이 문제들을 해결하는 것이 격리 수준(Isolation Level)입니다.

격리 수준은 동시성과 일관성 사이의 트레이드오프를 조절하는 설정입니다. 다음 절에서 자세히 다루겠습니다.