백엔드
데이터베이스
트랜잭션과 ACID: 원자성과 격리성, 갱신 유실

트랜잭션과 ACID: 원자성과 격리성, 갱신 유실

들어가며

재고가 1개 남은 상품에 주문 두 건이 거의 동시에 들어왔는데, 두 건 모두 성공 처리된 적이 있으신가요? 코드를 열어 보면 분명 트랜잭션으로 감싸 두었는데도 이런 일이 생깁니다.

트랜잭션이 무엇을 보장하고 무엇을 보장하지 않는지 구분하면 원인이 보입니다. 이번 글에서는 ACID 네 가지를 짚습니다. 특히 자주 섞어 쓰는 원자성과 격리성의 차이, 그리고 트랜잭션만으로는 막히지 않는 갱신 유실을 정리합니다.


1. 트랜잭션: 전부 되거나, 하나도 안 되거나

트랜잭션은 여러 작업을 하나로 묶어, 전부 반영되거나 전부 취소되게 하는 단위입니다. 대표적인 예가 계좌 이체입니다. 출금만 되고 입금이 안 된 상태가 남으면 안 됩니다.

트랜잭션은 '둘 다 아니면 아무것도'인 묶음 택배예요.

ACTIVE작업 중PARTIALLY마지막 문장 끝COMMITTED영구 반영ABORTED전부 되돌림FAILED중간 실패다 실행로그 기록오류기록 실패ROLLBACK
끝은 COMMITTED 아니면 ABORTED 둘뿐이다. 출금만 되고 입금이 안 된 반쪽 상태로는 끝나지 않는다(원자성). 커밋은 로그가 디스크에 기록된 뒤에야 성공으로 응답한다(지속성).
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;   -- 중간에 실패하면 ROLLBACK 으로 둘 다 취소

COMMIT 전까지의 변경은 확정되지 않은 상태입니다. 중간에 오류가 나거나 서버가 죽으면 데이터베이스는 그 트랜잭션의 변경을 모두 되돌립니다.

2. ACID 네 가지

트랜잭션이 지켜야 하는 성질을 앞 글자를 따서 ACID 라고 부릅니다.

  • 원자성(Atomicity): 전부 반영되거나 전부 취소됩니다. 반쪽 상태가 남지 않습니다.
  • 일관성(Consistency): 트랜잭션 전후로 제약 조건 같은 규칙이 깨지지 않습니다.
  • 격리성(Isolation): 동시에 도는 트랜잭션끼리 서로의 중간 상태를 얼마나 볼지 정합니다.
  • 지속성(Durability): 커밋된 결과는 장애가 나도 사라지지 않습니다.

지속성은 로그로 지킵니다. 커밋할 때 변경 내용을 먼저 로그(PostgreSQL 의 WAL, MySQL InnoDB 의 redo log)에 디스크까지 기록한 뒤 성공을 돌려줍니다. 장애가 나면 그 로그를 다시 재생해 복구합니다.

3. 원자성과 격리성은 무엇이 다른가

둘 다 "중간 상태를 막는다"처럼 들려서 자주 섞입니다. 하지만 보는 대상이 다릅니다. 원자성은 내 작업이 반쪽이 되지 않는가, 격리성은 동시에 도는 남의 작업과 어떻게 섞이는가의 문제입니다.

격리성은 옆 손님이 내 주문서를 언제부터 보느냐예요.

세션 A출금입금COMMIT세션 B이체 전 값이체 후 값A 커밋050100ms
원자성은 A의 출금·입금이 함께 반영되느냐, 격리성은 B가 그 중간을 보느냐의 문제다. READ COMMITTED에서 B는 커밋 전엔 이체 전 값을, 뒤엔 이체 후 값을 본다(시간은 어림).

같은 이체를 두 세션에서 보면 차이가 드러납니다.

-- 세션 A: 원자성 (둘 다 반영 또는 둘 다 취소)
BEGIN;
UPDATE acct SET bal = bal - 100 WHERE id = 1;
UPDATE acct SET bal = bal + 100 WHERE id = 2;
COMMIT;
 
-- 세션 B: 격리성 (READ COMMITTED)
-- A 가 커밋하기 전에 읽으면 이체 전 값, 커밋 후에야 바뀐 값이 보인다
SELECT bal FROM acct WHERE id = 1;

격리 수준

격리성은 켜고 끄는 것이 아니라 단계로 조절합니다. SQL 표준은 네 단계를 정의합니다. 높을수록 막아 주는 이상 현상이 늘어나는 대신 대기와 충돌이 많아집니다.

격리 수준Dirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTED생길 수 있음생길 수 있음생길 수 있음
READ COMMITTED막음생길 수 있음생길 수 있음
REPEATABLE READ막음막음생길 수 있음
SERIALIZABLE막음막음막음

위 표는 표준이 허용하는 최소 기준이고, 실제 동작은 제품마다 조금씩 다릅니다. 기본값도 PostgreSQL 은 READ COMMITTED, MySQL InnoDB 는 REPEATABLE READ 입니다. PostgreSQL 의 REPEATABLE READ 는 표준보다 강해서 phantom read 도 막습니다.

4. 트랜잭션으로 감싸도 생기는 갱신 유실

들어가며의 재고 문제로 돌아가 보겠습니다. 트랜잭션은 자기 작업을 원자적으로 묶을 뿐, 다른 트랜잭션과의 순서를 정해 주지 않습니다. READ COMMITTED 에서는 두 트랜잭션이 같은 "재고 1" 을 읽고, 각자 판매 가능하다고 판단해 둘 다 커밋해 버립니다.

한쪽의 판단이 다른 쪽의 결과를 덮어써 사라지는 이 현상을 갱신 유실(lost update) 이라고 합니다.

동시 주문은 진열대 마지막 빵 하나를 두 손님이 동시에 보는 일이에요.

읽고 판단하고 쓰기2건 판매
주문 A재고=1 판매커밋주문 B재고=1 판매커밋04080ms

둘 다 같은 '재고 1'을 읽고 각자 커밋한다. 갱신 유실이다.

FOR UPDATE 로 잠금1건만
주문 A잠금·판매커밋주문 B잠금 대기재고=0품절04080ms

B는 A가 커밋할 때까지 기다린 뒤 바뀐 재고를 읽는다.

트랜잭션은 내 작업을 묶어 줄 뿐, 남과의 순서는 정해 주지 않는다. 잠그면 확실하지만 뒷사람이 기다려 몰리면 느려진다(시간은 어림).

원인은 "읽고, 애플리케이션이 판단하고, 쓰는" 사이에 틈이 있다는 점입니다. 막는 방법은 크게 두 가지입니다.

-- 문제: 읽고 → 앱이 판단 → 쓰기 (둘 다 1 을 읽고 통과)
SELECT stock FROM items WHERE id = 7;
UPDATE items SET stock = 0 WHERE id = 7;
 
-- 해결 1: 판단을 DB 한 문장에 넣는 조건부 갱신
UPDATE items SET stock = stock - 1
 WHERE id = 7 AND stock > 0;
-- 갱신된 행이 0 이면 품절 처리
 
-- 해결 2: 먼저 잠그고 읽기 (비관적 잠금)
SELECT stock FROM items WHERE id = 7 FOR UPDATE;

FOR UPDATE 로 잠그면 뒤에 온 트랜잭션은 앞 트랜잭션이 끝날 때까지 기다립니다. 확실하지만 요청이 몰리면 대기가 길어집니다. 한 행에 요청이 몰리는 상황이라면 잠금 구간이 짧은 조건부 갱신으로 푸는 일이 많습니다.

헷갈리기 쉬운 점

  • 트랜잭션으로 감싸면 동시 요청도 안전하다는 오해. 원자성은 반쪽 반영만 막습니다. 동시 갱신은 격리 수준과 잠금이 따로 다룹니다.
  • 격리성은 트랜잭션이 완전히 따로 돈다는 뜻이라는 오해. 격리 수준에 따라 일부 이상 현상은 허용됩니다.
  • 격리 수준은 높을수록 좋다는 오해. 높일수록 대기와 직렬화 오류가 늘어 동시 처리량이 줄어듭니다.

면접에서 자주 받는 질문

READ COMMITTED 에서 같은 SELECT 를 두 번 하면 값이 달라질 수 있나요?

네. 두 읽기 사이에 다른 트랜잭션이 커밋하면 두 번째 읽기는 새 값을 봅니다. 이것이 반복 불가능 읽기(non-repeatable read)이고, REPEATABLE READ 이상에서 막습니다.

MVCC 는 무엇인가요?

행의 여러 버전을 두는 동시성 제어 방식입니다. 읽는 쪽은 자기 시점의 버전을 보기 때문에, 읽기와 쓰기가 서로를 기다리지 않습니다. PostgreSQL 과 MySQL InnoDB 모두 MVCC 를 씁니다.

비관적 잠금과 낙관적 잠금은 언제 고르나요?

비관적 잠금은 먼저 잠그고 작업하는 방식으로, 충돌이 잦을 때 맞습니다. 대신 대기와 데드락 위험이 있습니다. 낙관적 잠금은 버전 번호를 두고 쓸 때만 바뀌었는지 확인하는 방식으로, 충돌이 드물 때 유리하고 충돌하면 재시도합니다.

격리 수준을 SERIALIZABLE 로 올리면 갱신 유실이 해결되나요?

PostgreSQL 에서는 충돌한 쪽을 직렬화 오류로 실패시켜 막아 줍니다. 대신 애플리케이션이 그 오류를 받아 재시도해야 하고, 충돌이 많으면 처리량이 줄어듭니다.

정리

  • 트랜잭션은 여러 작업을 묶어 전부 반영하거나 전부 취소합니다. ACID 는 원자성, 일관성, 격리성, 지속성입니다.
  • 원자성은 내 작업이 반쪽이 되지 않는 것, 격리성은 동시에 도는 남의 작업과 섞이는 정도입니다.
  • 트랜잭션만으로는 갱신 유실을 막지 못합니다. 조건부 갱신이나 잠금을 함께 씁니다.

다 되거나 하나도 안 되거나, 트랜잭션이 주는 것은 거기까지입니다. 원자성은 나 혼자의 반쪽, 격리성은 남과의 섞임이라고 기억하면 헷갈리지 않습니다.