데이터베이스: 트랜잭션과 동시성 — ACID, 격리 수준, 데드락, 그리고 인덱스
2026.08.19. DB
- 데이터베이스
- 트랜잭션
- 동시성
- 인덱스
지난 글 마지막에 “트랜잭션과 격리 수준은 UPDATE/DELETE의 동시성 문제를 어떻게 막아줄까?“라는 질문을 남겨뒀었죠. 이번엔 그 답을 찾아 트랜잭션, 동시성, 그리고 이 둘과 떼려야 뗄 수 없는 인덱스까지 정리해볼게요.
예제는 계좌이체로 통일할게요. 계좌(account) 테이블에 id, balance 두 컬럼만 있다고 가정해요.
트랜잭션(Transaction)이란
트랜잭션은 하나의 논리적 작업 단위로 묶인 여러 연산이에요. 계좌이체를 예로 들면, “A잔액 차감”과 “B잔액 증가”라는 두 연산이 하나의 트랜잭션이에요. 이 둘은 항상 함께 성공하거나 함께 실패해야 해요 — A 잔액만 깎이고 B 잔액은 안 늘어나면 돈이 허공으로 사라지는 셈이니까요.
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'A';
UPDATE account SET balance = balance + 100 WHERE id = 'B';
COMMIT;
ACID — 트랜잭션이 지켜야 할 4가지 성질
| 속성 | 의미 | 계좌이체 예시 |
|---|---|---|
| Atomicity(원자성) | 트랜잭션 안의 모든 연산이 전부 성공하거나 전부 실패해야 해요 | A잔액 차감과 B잔액 증가 중 하나라도 실패하면 둘 다 롤백돼요 |
| Consistency(일관성) | 트랜잭션 전후로 데이터베이스가 정의된 제약조건을 항상 만족해야 해요 | 이체 전후로 A+B 잔액의 총합은 항상 같아야 해요 |
| Isolation(고립성) | 동시에 실행되는 트랜잭션들이 서로의 중간 상태에 영향을 주지 않아야 해요 | A만 깎이고 B는 아직 안 늘어난 “중간 상태”를 다른 트랜잭션이 보면 안 돼요 |
| Durability(지속성) | 커밋된 트랜잭션의 결과는 장애가 나도 사라지지 않아야 해요 | 이체 완료 직후 정전이 나도, 재부팅 후에 이체 결과가 남아있어야 해요 |
ACID가 없으면, 동시성(여러 사용자가 동시에 접근)과 장애(서버 다운, 정전)라는 두 가지 현실 조건 속에서 데이터가 언제든 깨질 수 있어요. 특히 금융·재고·주문처럼 “정확함이 곧 신뢰”인 도메인에서는 ACID가 시스템 설계의 전제 조건이에요.
참고로 NoSQL은 확장성을 위해 이 중 일부, 특히 강한 Consistency를 의도적으로 완화하는 경우가 많아요 — 이 트레이드오프는 *『Designing Data-Intensive Applications』*라는 책에서 자세히 다뤄요.
Isolation — 격리성 이야기
ACID 네 가지 중에서도 **Isolation(격리성)**은 “동시에 실행되는 여러 트랜잭션이 서로의 중간 상태를 얼마나 볼 수 있게 허용할지”를 정하는 정책이에요. 고립성을 완벽하게(Serializable) 지키면 안전하지만 느려지고, 느슨하게 풀면 빨라지지만 이상 현상이 생겨요 — 그래서 단계별로 선택할 수 있는 격리 수준(Isolation Level)이 있어요.
고립성이 부족할 때 생기는 3가지 현상
1. Dirty Read (더티 리드)
커밋되지 않은 다른 트랜잭션의 변경사항을 읽어버리는 거예요.
-- T1: 아직 커밋 안 함
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'A'; -- A: 1000 -> 900
-- T2: T1이 커밋하기 전에 900을 읽어버림
SELECT balance FROM account WHERE id = 'A'; -- 900 조회
-- T1: 문제가 생겨서 롤백
ROLLBACK; -- A는 다시 1000으로
T2는 실제로는 존재한 적 없는 값(900)을 읽은 셈이에요.
2. Non-Repeatable Read (반복 불가능한 읽기)
한 트랜잭션 안에서 같은 행을 두 번 읽었는데, 그 사이 다른 트랜잭션이 커밋해버려서 값이 달라지는 거예요.
-- T1
BEGIN;
SELECT balance FROM account WHERE id = 'A'; -- 1000 조회
-- 그 사이 T2가 커밋
-- UPDATE account SET balance = 800 WHERE id = 'A'; COMMIT;
SELECT balance FROM account WHERE id = 'A'; -- 같은 트랜잭션인데 800 조회
COMMIT;
3. Phantom Read (팬텀 리드)
같은 조건으로 두 번 조회했는데, 그 사이 다른 트랜잭션이 행을 추가·삭제해서 결과 집합(행 수)이 달라지는 거예요.
-- T1
BEGIN;
SELECT COUNT(*) FROM account WHERE balance > 500; -- 3건
-- 그 사이 T2가 새 계좌를 INSERT하고 커밋
SELECT COUNT(*) FROM account WHERE balance > 500; -- 같은 트랜잭션인데 4건
COMMIT;
Non-Repeatable Read는 “같은 행의 값이 바뀌는 것”, Phantom Read는 “조회되는 행의 개수 자체가 바뀌는 것”이라고 구분하면 헷갈리지 않아요.
격리 수준(Isolation Level)
낮음에서 높음 순으로 4단계예요. 수준이 높아질수록 위 세 현상을 더 많이 막아주지만, 그만큼 락을 오래·넓게 잡거나 버전 관리 비용이 커져서 동시성(처리량)은 떨어져요.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | 발생 가능 | 발생 가능 | 발생 가능 |
| Read Committed | 방지 | 발생 가능 | 발생 가능 |
| Repeatable Read | 방지 | 방지 | 표준상 발생 가능 (MySQL InnoDB는 갭 락으로 대부분 방지) |
| Serializable | 방지 | 방지 | 방지 |
- Read Uncommitted: 커밋 안 된 데이터도 그냥 읽어요. 가장 빠르지만 가장 위험해요.
- Read Committed: 커밋된 데이터만 읽어요. Dirty Read는 막지만, 트랜잭션 도중 다른 트랜잭션의 커밋이 반영될 수 있어요.
- Repeatable Read: 트랜잭션이 시작된 시점의 스냅샷을 트랜잭션 내내 유지해요. 같은 행을 여러 번 읽어도 항상 같은 값이 나와요.
- Serializable: 모든 트랜잭션이 마치 한 줄로 순서대로 실행된 것처럼 동작해요. 가장 안전하지만 가장 느려요.
MVCC 참고
PostgreSQL, MySQL(InnoDB) 등은 락을 최소화하면서 Isolation을 구현하기 위해 MVCC(Multi-Version Concurrency Control)를 써요 — 데이터를 수정할 때 덮어쓰지 않고 버전을 여러 개 유지해서, 읽기 작업이 쓰기 작업을 기다리지 않게 하는 방식이에요. PostgreSQL 공식 문서의 MVCC 소개에서는 이걸 “각 SQL 문이 그 시점의 데이터 스냅샷을 보게 해서, 읽기가 쓰기를 막지 않고 쓰기도 읽기를 막지 않는다”고 설명해요.
Deadlock — 데드락
**데드락(Deadlock)**은 두 개 이상의 트랜잭션이 서로 상대방이 가진 락(lock)을 기다리며 영원히 멈춰있는 상태예요.
발생 조건 (4가지가 동시에 성립할 때)
- 상호 배제(Mutual Exclusion): 한 번에 하나의 트랜잭션만 락을 가질 수 있어요.
- 점유 대기(Hold and Wait): 락을 쥔 채로 다른 락을 기다려요.
- 비선점(No Preemption): 다른 트랜잭션의 락을 강제로 뺏을 수 없어요.
- 순환 대기(Circular Wait): A→B→A 형태로 서로가 서로를 기다리는 사이클이 생겨요.
-- 트랜잭션 1
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'A'; -- A 락 획득
-- 이 시점에 트랜잭션 2가 끼어듦
UPDATE account SET balance = balance + 100 WHERE id = 'B'; -- B 락 필요 (대기)
-- 트랜잭션 2 (거의 동시에 실행)
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 'B'; -- B 락 획득
UPDATE account SET balance = balance + 50 WHERE id = 'A'; -- A 락 필요 (대기)
트랜잭션 1은 B의 락을 기다리고, 트랜잭션 2는 A의 락을 기다려요. 서로가 서로를 기다리는 순환 구조라 둘 다 영원히 끝나지 않아요.
DB는 이걸 어떻게 처리할까
직접 막지는 못하지만 **감지(detect)**해서 해결해요.
- Wait-for graph(누가 누구를 기다리는지 그래프로 추적) 또는 타임아웃 방식으로 데드락을 감지해요.
- 감지되면 둘 중 하나(보통 롤백 비용이 적은 쪽, “victim”)를 강제로 롤백시켜서 순환을 끊어요 → 해당 트랜잭션은 에러를 받고, 애플리케이션에서 재시도 로직을 짜야 해요.
예방하는 실무 방법
- 락 획득 순서를 항상 동일하게 통일해요 (예: 항상 ID가 작은 계좌부터 락을 잡도록 코드 규칙 정하기) → 순환 대기 자체를 원천 차단해요.
- 트랜잭션을 짧게 유지해요. 락을 오래 쥐고 있을수록 충돌 확률이 올라가요.
- 가능하면 불필요하게 넓은 락 대신 필요한 최소 범위만 락을 걸어요.
ACID, Isolation, Deadlock 이 세 개념은 서로 강하게 연결돼 있어요 — Isolation은 ACID의 한 축이고, Isolation을 락으로 구현하는 과정에서 Deadlock이 발생해요.
락의 종류
락 모드는 크게 공유락과 배타락 두 가지예요.
| 종류 | 이름 | 용도 |
|---|---|---|
| S-Lock | 공유락(Shared Lock) | 읽기(SELECT)할 때 사용. 여러 트랜잭션이 동시에 가질 수 있어요 |
| X-Lock | 배타락(Exclusive Lock) | 쓰기(INSERT/UPDATE/DELETE)할 때 사용. 한 번에 하나의 트랜잭션만 가질 수 있어요 |
호환성 표
| S-Lock | X-Lock | |
|---|---|---|
| S-Lock | 허용 (읽기끼리는 서로 안 막음) | 불가 |
| X-Lock | 불가 | 불가 |
즉 “읽기끼리는 서로 안 막지만, 쓰기가 하나라도 끼면 나머지는 다 기다린다”는 원칙이에요. 이게 바로 앞서 본 데드락이 발생하는 배경이기도 해요.
-- 명시적으로 X-Lock을 거는 예 (다른 트랜잭션이 이 행을 못 건드리게)
BEGIN;
SELECT * FROM account WHERE id = 'A' FOR UPDATE; -- X-Lock 획득
UPDATE account SET balance = balance - 100 WHERE id = 'A';
COMMIT;
락의 범위(Granularity): 얼마나 넓게 잠글 것인가
| 범위 | 설명 | 트레이드오프 |
|---|---|---|
| 행(Row) 단위 | 특정 행 하나만 잠가요 | 동시성은 최대화되지만, 잠글 행이 많아지면 락을 관리하는 오버헤드가 커져요 |
| 페이지(Page) 단위 | 디스크 페이지(여러 행 묶음) 단위로 잠가요 | 관리 오버헤드는 줄지만, 서로 관련 없는 행끼리도 같이 막힐 수 있어요 |
| 테이블(Table) 단위 | 테이블 전체를 잠가요 | 관리는 가장 단순하지만 동시성이 크게 떨어져요 |
실무에서 쓰는 대부분의 RDBMS(PostgreSQL, MySQL InnoDB)는 기본적으로 행 단위 락을 써요 — 동시성을 최대한 살리기 위해서예요. 다만 인덱스 없이 WHERE절 조건으로 UPDATE를 하면, DB가 어떤 행이 대상인지 찾기 위해 테이블 전체를 스캔하면서 더 넓은 범위(때로는 테이블 전체)를 잠가버릴 수 있어요 — 이게 바로 다음 주제인 인덱스와 직접 연결돼요.
참고: Intent Lock(의도 락) — 행 단위 락을 쓰는 DB도, “이 테이블 안의 어떤 행에 X-Lock을 걸 예정”이라는 걸 테이블 레벨에 가볍게 표시해두는 의도 락(IS/IX)을 함께 써요. 그래야 다른 트랜잭션이 테이블 전체 락을 시도할 때 행 단위로 일일이 검사하지 않고 빠르게 충돌을 판단할 수 있어요.
인덱스가 성능에 미치는 영향
인덱스가 없으면?
WHERE, JOIN, ORDER BY에 조건이 걸리면 DB는 조건에 맞는 행을 찾기 위해 테이블을 처음부터 끝까지 훑어요 (Full Table Scan, O(N)). 테이블 행이 100만 개면 최악의 경우 100만 번을 다 확인해야 해요. 그리고 앞서 봤듯, 이 스캔 과정에서 락이 필요 이상으로 넓게 걸릴 수도 있어요.
인덱스가 있으면?
인덱스는 “특정 컬럼 값 → 그 값을 가진 행의 위치”를 미리 정렬된 자료구조(대부분 B-Tree)로 만들어둔 거예요. 책의 “찾아보기(색인)“와 같은 원리예요.
CREATE INDEX idx_account_balance ON account(balance);
B-Tree는 정렬된 트리 구조라, 원하는 값을 찾을 때 O(log N) — 100만 행이어도 20번 정도의 비교면 도달해요. Full Scan(O(N))과 비교하면 압도적인 차이예요.
Clustered Index vs Non-Clustered Index
| 구분 | Clustered Index | Non-Clustered(Secondary) Index |
|---|---|---|
| 데이터 저장 방식 | 인덱스 자체가 실제 데이터 행을 정렬된 순서로 저장해요 | 별도 구조에 “값 → 실제 행 위치(포인터)“만 저장해요 |
| 개수 | 테이블당 보통 1개만 가능해요 (InnoDB는 PK 기준) | 테이블당 여러 개 만들 수 있어요 |
| 대표 예 | InnoDB의 기본 키(PK) | account(balance)처럼 PK가 아닌 컬럼에 건 인덱스 |
| 조회 비용 | 인덱스 탐색만으로 바로 데이터에 도달해요 (1단계) | 인덱스에서 위치를 찾고 → 실제 테이블에서 행을 다시 읽어요 (2단계) |
Secondary 인덱스로 조회하면 “인덱스에서 위치 찾기 → 실제 테이블에서 행 읽기” 2단계를 거치는 경우가 많아, PK 기반 조회보다 느릴 수 있어요. 이때 커버링 인덱스(Covering Index) — 필요한 컬럼을 전부 인덱스 안에 포함시켜서 테이블 본체를 아예 안 읽고 인덱스만으로 결과를 반환하는 기법 — 을 쓰면 이 2단계를 1단계로 줄일 수 있어요.
공짜가 아니다 — 인덱스의 비용
| 비용 | 이유 |
|---|---|
| 쓰기 비용 증가 | INSERT/UPDATE/DELETE로 데이터가 바뀔 때마다 인덱스(B-Tree)도 함께 갱신해야 해요 |
| 저장 공간 증가 | 인덱스 자체가 별도의 자료구조로 디스크에 저장돼요 |
| 옵티마이저 비용 증가 | 인덱스가 많을수록 쿼리 실행계획을 세울 때 고려할 경우의 수가 늘어나, 계획을 세우는 비용도 함께 커져요 |
그래서 “조회는 빈번하지만 쓰기는 드문 컬럼”(예: 검색 조건으로 자주 쓰이는 컬럼) 위주로 인덱스를 걸어요. 반대로 자주 갱신되는 컬럼이나 카디널리티(고유값의 다양성)가 낮은 컬럼(예: 성별처럼 값이 2~3종류뿐인 컬럼)은 인덱스를 걸어도 효율이 떨어져요 — 값이 몇 종류 안 되면 B-Tree로 좁혀봤자 여전히 많은 행이 남기 때문이에요.
복합 인덱스와 최좌측 접두 규칙(Leftmost Prefix Rule)
CREATE INDEX idx_name_grade ON student(name, grade);
이 인덱스는 WHERE name = ?이나 WHERE name = ? AND grade = ?에는 쓰이지만, WHERE grade = ?만 있는 조건에는 쓰이지 않아요. 인덱스가 name을 기준으로 먼저 정렬되어 있기 때문에, 맨 앞 컬럼을 건너뛰고 뒤쪽 컬럼만으로는 정렬 구조를 활용할 수 없어요. 컬럼 순서를 어떻게 잡느냐가 복합 인덱스 설계의 핵심 판단 포인트예요.
정리하며
트랜잭션은 여러 연산을 하나의 단위로 묶고, ACID로 그 단위가 안전하다는 걸 보장해요. 그중 Isolation은 동시에 실행되는 트랜잭션들이 서로를 얼마나 볼 수 있게 할지를 정하는데, 이걸 락으로 구현하는 과정에서 데드락이라는 부작용이 생길 수 있어요. 그리고 이 락은 인덱스가 없으면 필요 이상으로 넓게 걸릴 수 있어서, 결국 트랜잭션·동시성·인덱스는 하나로 이어진 이야기예요.
격리 수준을 올리거나 락 범위를 넓히면 안전해지지만 느려지고, 반대로 풀면 빨라지지만 위험해지는 트레이드오프는 이 글 전체를 관통하는 주제였어요.
다음에 더 파볼 것
- **실행계획(EXPLAIN)**으로 실제로 어떤 인덱스가 쓰였는지, Full Scan이 일어났는지 어떻게 확인할까?
- MySQL InnoDB의 **갭 락(Gap Lock)**은 정확히 어떻게 Phantom Read를 막아줄까?
- 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)은 언제 각각 선택해야 할까?
- 복합 인덱스에서 컬럼 순서를 정할 때, 카디널리티 말고 또 어떤 기준을 고려해야 할까?