🟣 절반만 성공하면 재앙이다
계좌 이체를 생각해 보자. A의 잔액에서 만 원을 빼고, B의 잔액에 만 원을 더한다. 두 번의 갱신이다. 그런데 첫 번째 갱신을 끝내고 두 번째를 실행하기 직전에 서버가 죽으면, A의 돈은 사라졌는데 B는 받지 못한 상태로 남는다. 만 원이 세상에서 증발한다.
이런 작업은 전부 성공하거나 전부 없던 일이 되어야 한다. 절반만 반영된 중간 상태가 남으면 안 된다. 이렇게 논리적으로 하나로 묶여, 쪼갤 수 없는 작업 단위가 트랜잭션이다. 데이터를 넣고 조회하는 법은 앞에서 갖췄지만, 그 변경을 언제 확정하고 언제 통째로 되돌릴지를 다루지 않으면 실제 서비스에는 못 쓴다. 이 장은 그 안전장치를 다룬다.
🟣 ACID: 트랜잭션이 지키는 네 가지 약속
트랜잭션이 보장하는 성질을 네 글자로 묶은 것이 ACID다.
원자성은 방금 본 전부 아니면 전무다. 트랜잭션 안의 모든 문장이 다 반영되거나, 하나라도 실패하면 전부 취소된다. 절반의 성공이란 없다. 일관성은 트랜잭션 전후로 데이터가 늘 규칙을 만족한다는 뜻이다. 외래키나 잔액이 음수가 될 수 없다는 제약이 트랜잭션이 끝난 시점에 항상 지켜진다. 격리성은 동시에 실행되는 트랜잭션들이 서로의 중간 상태를 보지 않는다는 것이다. 남이 이체하는 도중의 어정쩡한 잔액을 내가 읽으면 안 된다. 지속성은 한 번 확정된 트랜잭션은 서버가 죽어도 살아남는다는 보장이다. 확정 순간 디스크에 안전하게 기록된다.
네 가지 중 앞뒤의 원자성과 지속성은 데이터베이스가 로그를 써서 비교적 곧게 보장한다. 정작 까다롭고 실무에서 매일 부딪히는 것은 가운데의 격리성이다. 여러 트랜잭션이 같은 데이터를 동시에 건드릴 때 어디까지 서로를 안 보이게 할지가 성능과 정확성 사이의 저울질이기 때문이다. 이 장의 뒷부분은 대부분 이 격리성에 관한 이야기다.
🟣 TCL: 확정하거나 되돌린다
트랜잭션의 경계를 긋는 명령이 TCL이다. 변경을 확정하는 COMMIT, 통째로 되돌리는 ROLLBACK, 되돌릴 지점을 표시하는 SAVEPOINT가 있다.
BEGIN;
UPDATE accounts SET balance = balance - 10000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 10000 WHERE id = 'B';
COMMIT; -- 둘 다 성공했을 때만 여기 도달, 확정
-- 중간에 오류가 나면 ROLLBACK 으로 둘 다 취소COMMIT 전까지의 변경은 아직 임시다. 나만 볼 수 있고, 언제든 ROLLBACK으로 없던 일로 만들 수 있다. COMMIT을 부르는 순간에야 변경이 확정되고 다른 트랜잭션에게도 보인다.
원자성이 추상적인 약속이라면, COMMIT과 ROLLBACK은 그 약속을 개발자가 직접 거는 손잡이다. 어디서 트랜잭션을 시작하고 어디서 확정할지를 정하는 것이 곧 원자성의 범위를 정하는 일이다.
🟣 동시에 건드리면 생기는 세 가지 어긋남
트랜잭션을 하나씩만 돌리면 문제가 없다. 문제는 여러 트랜잭션이 겹쳐 돌 때다. 서로의 중간 상태가 새어 나가면 세 가지 대표적인 어긋남이 생긴다.
첫째는 확정되지 않은 값을 읽는 것이다. A 트랜잭션이 잔액을 고쳤지만 아직 COMMIT하지 않았는데 B가 그 값을 읽는다. 그런데 A가 ROLLBACK하면 B는 존재한 적 없는 값을 읽은 셈이 된다. 이를 더티 리드라 한다. 둘째는 같은 행을 두 번 읽었는데 값이 달라지는 것이다. B가 어떤 회원의 등급을 읽고, 그사이 A가 그 등급을 바꿔 확정하고, B가 다시 읽으니 값이 변해 있다. 한 트랜잭션 안에서 같은 질의의 답이 흔들린다. 반복 불가능한 읽기다. 셋째는 조건에 맞는 행의 개수가 달라지는 것이다. B가 "VIP 회원 수"를 세고, 그사이 A가 새 VIP를 추가해 확정하면, B가 다시 세었을 때 없던 행이 나타난다. 유령처럼 나타난다고 해서 팬텀 리드라 한다.
세 어긋남은 심각도 순으로 늘어선다. 확정 안 된 값을 읽는 더티 리드가 가장 위험하고, 개수가 바뀌는 팬텀 리드가 가장 미묘하다. 어디까지 막을지를 고르는 것이 다음의 격리 수준이다.
🟣 격리 수준: 어디까지 막을지 고른다
세 어긋남을 전부 막으면 가장 안전하지만, 그러려면 트랜잭션들을 사실상 한 줄로 세워야 해서 동시 처리량이 떨어진다. 그래서 표준 SQL은 얼마나 격리할지를 네 단계로 골라 쓰게 한다. 위로 갈수록 빠르지만 어긋남을 허용하고, 아래로 갈수록 안전하지만 느리다.
| 격리 수준 | 더티 리드 | 반복 불가능한 읽기 | 팬텀 리드 |
|---|---|---|---|
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 막음 | 허용 | 허용 |
| REPEATABLE READ | 막음 | 막음 | 허용 |
| SERIALIZABLE | 막음 | 막음 | 막음 |
대부분의 데이터베이스가 기본으로 두는 것은 확정된 값만 읽는 READ COMMITTED다. 더티 리드는 막되 나머지는 허용해 성능과 안전의 균형을 잡은 지점이다. 재고나 잔액처럼 같은 값을 한 트랜잭션 안에서 여러 번 읽어야 하는 자리에서는 REPEATABLE READ까지 올린다. SERIALIZABLE은 가장 안전하지만 그만큼 동시성을 희생하므로, 정말로 순서가 어긋나면 안 되는 좁은 구간에만 쓴다.
격리 수준을 실제로 구현하는 도구가 락이다. 데이터를 읽거나 쓰기 전에 그 데이터에 잠금을 걸어 남이 못 건드리게 한다. 락을 오래, 넓게 걸수록 격리는 강해지지만 남들이 기다리는 시간이 길어진다. 여기서 앞 책의 세마포어와 똑같은 그림이 다시 나온다. 공유 자원에 순서를 강제하려면 누군가는 기다려야 한다는 원리는, 프로세스 사이의 공유 메모리에서든 데이터베이스의 행에서든 변하지 않았다.
두 트랜잭션이 서로가 쥔 락을 맞물려 기다리면 영원히 못 푸는 교착 상태에 빠진다. 데이터베이스는 이를 감지해 한쪽 트랜잭션을 강제로 ROLLBACK시켜 매듭을 끊는다. 그래서 여러 행을 잠글 때는 늘 같은 순서로 잠그도록 코드를 맞추는 것이 교착을 줄이는 실전 원칙이다.
🟣 절차형 SQL: 여러 문장을 하나의 단위로 묶는다
지금까지의 SQL은 문장 하나가 곧 명령 하나였다. 그런데 "재고가 있으면 주문을 넣고 재고를 줄이되, 없으면 대기 목록에 넣어라" 같은 로직은 조건 분기와 반복이 필요하다. 이를 데이터베이스 안에서 처리하는 것이 절차형 SQL이고, 오라클에서는 PL/SQL이라 부른다.
CREATE PROCEDURE place_order(p_member INT, p_product INT) AS
BEGIN
UPDATE products SET stock = stock - 1
WHERE product_id = p_product AND stock > 0;
IF SQL%ROWCOUNT = 0 THEN
INSERT INTO waitlist(member_id, product_id) VALUES (p_member, p_product);
ELSE
INSERT INTO orders(member_id, product_id) VALUES (p_member, p_product);
END IF;
COMMIT;
EXCEPTION
WHEN OTHERS THEN ROLLBACK;
END;절차형 블록의 값어치는 트랜잭션과 만날 때 드러난다. 여러 문장을 하나의 블록으로 묶고, 오류가 나면 EXCEPTION에서 통째로 ROLLBACK한다. 조건 분기, 반복, 예외 처리를 데이터에 가장 가까운 곳에서 원자적으로 처리하는 것이다. 로직을 애플리케이션과 데이터베이스 왕복 없이 한 번에 끝내니 네트워크 왕복도 준다.
다만 로직을 데이터베이스 안에 두면 버전 관리와 테스트가 애플리케이션 코드보다 번거롭고, 데이터베이스에 부하가 몰린다. 그래서 요즘은 복잡한 비즈니스 로직을 애플리케이션에 두고, 절차형 SQL은 데이터 정합성이 핵심인 좁은 구간에만 쓰는 쪽으로 기운다. 어디에 로직을 둘지는 정합성과 유지보수성 사이의 선택이다.
🟣 정리
- 트랜잭션은 전부 성공하거나 전부 없던 일이 되어야 하는, 쪼갤 수 없는 작업 단위다. 절반만 반영된 중간 상태를 막는다.
- ACID 중 원자성·지속성은 로그로 곧게 보장되고, 실무에서 매일 부딪히는 까다로운 성질은 격리성이다.
COMMIT전의 변경은 임시이며 나만 본다.COMMIT·ROLLBACK이 원자성의 범위를 직접 거는 손잡이다.- 동시 실행은 더티 리드, 반복 불가능한 읽기, 팬텀 리드를 낳는다. 격리 수준으로 어디까지 막을지 고르며, 강할수록 안전하고 느리다.
- 격리는 락으로 구현되고, 락은 교착 상태를 부른다. 공유 자원에 순서를 강제하면 누군가는 기다린다는 원리는 세마포어와 같다.
트랜잭션과 격리 수준은 모바일 앱에서도 그대로 쓰인다. 앱이 오프라인에서 로컬 데이터베이스에 쌓은 변경을 나중에 서버와 맞출 때, 여러 화면이 같은 레코드를 동시에 고치는 상황은 서버의 동시성 문제와 똑같다. 결제나 재고 같은 자리에서 어떤 격리 수준을 골랐고 왜 그랬는지는 면접에서 자주 나오는데, 이는 "정확성과 성능 중 무엇을 얼마나 포기했는지"를 판단할 수 있는지 보는 질문이었다.
이제 데이터를 안전하게 다루는 법까지 갖췄다. 남은 문제는 속도다. 데이터가 수백만 건으로 쌓이면 같은 조회도 느려진다. 데이터베이스가 질의를 실제로 어떻게 실행하는지, 그리고 인덱스로 어떻게 빨라지는지가 마지막 장의 주제다.