← Posts

Database · Transaction · ACID

트랜잭션과 ACID - 네 글자의 무게가 다르다

원자성·일관성·격리성·지속성이 각각 무엇을 보장하는지, 그리고 왜 넷 중 하나만 협상 대상인지.

목차
  1. 묶는다는 것
  2. A - 원자성 (Atomicity)
  3. 되돌리는 건 전부가 아니어도 된다
  4. D - 지속성 (Durability)
  5. I - 격리성 (Isolation)
  6. C - 일관성 (Consistency)
  7. 트랜잭션은 어디서 시작되나
  8. 네 글자의 무게

A가 B에게 만 원을 보낸다. A에서 빼고 B에 더한다. 두 문장이다.

빼는 데 성공하고, 더하기 직전에 서버가 죽었다.

돈은 어디로 갔나. 아무 데도 안 갔다. 사라졌다. A의 잔고에서는 빠졌고 B의 잔고에는 안 들어왔다. 회계상 만 원이 증발한 것이다.

이런 일이 안 일어나게 만드는 장치가 트랜잭션이다. 그리고 트랜잭션이 정확히 무엇을 약속하는지가 ACID다.

묶는다는 것

트랜잭션은 여러 작업을 하나의 단위로 묶는다. 묶인 것들은 전부 되거나 전부 안 된다. 중간은 없다.

diagramdiagram

핵심은 COMMIT이라는 선이다. 그 선을 넘기 전에는 무슨 일이 있어도 없던 일로 되돌릴 수 있고, 넘고 나면 무슨 일이 있어도 남는다.

이 약속을 네 글자로 정리한 게 ACID다. 1983년에 나온 정리에서 붙은 이름이고, 지금도 그대로 쓴다.

A - 원자성 (Atomicity)

전부 아니면 전무. 위에서 본 그것이다.

어떻게 하나? 되돌릴 방법을 미리 적어둔다. A 잔고 -10000을 실행하기 전에 “원래 값이 얼마였는지” 를 따로 기록해둔다. 롤백해야 하면 그 기록을 거꾸로 재생한다.

원자성이란 결국 “안 한 척할 수 있는 능력” 이다. 실행을 안 하는 게 아니라, 해놓고 지울 수 있게 준비해두는 것이다.

되돌리는 건 전부가 아니어도 된다

“전부 아니면 전무”라고 했는데, 여기서 전부의 범위를 내가 정할 수 있다.

이체 하나에 알림 기록까지 같이 남긴다고 하자. 알림을 남기다 실패했다고 이체까지 되돌리는 건 과하다.

sql
BEGIN;
  UPDATE 계좌 SET 잔고 = 잔고 - 10000 WHERE id = 'A';
  UPDATE 계좌 SET 잔고 = 잔고 + 10000 WHERE id = 'B';

  SAVEPOINT 이체완료;                    -- 여기까지는 지키고 싶다

  INSERT INTO 알림 VALUES (...);         -- 실패하면?
  ROLLBACK TO SAVEPOINT 이체완료;        -- 알림만 없던 일로
COMMIT;                                  -- 이체는 살아서 커밋된다

SAVEPOINT는 트랜잭션 안에 되돌아갈 지점을 표시하는 것이다. ROLLBACK TO는 그 지점 이후만 취소하고 트랜잭션은 계속 살려둔다.

그래서 원자성의 정확한 표현은 “무조건 전부”가 아니라 **“내가 하나로 묶은 범위가 전부”**다. 그 범위를 통째로 잡을 수도 있고, 안에 작은 매듭을 지어둘 수도 있다.

참고

그렇다고 세이브포인트를 촘촘히 박는 게 좋은 습관은 아니다. 매듭이 많아질수록 “지금 어디까지가 확정이고 어디부터가 취소 가능한지”를 코드를 읽어야 알 수 있게 된다. 묶음을 애초에 작게 잡을 수 있으면 그게 낫다. 세이브포인트는 그렇게 못 나눌 때 쓰는 도구다.

D - 지속성 (Durability)

순서를 바꿔서 D를 먼저 본다. A와 뿌리가 같기 때문이다.

커밋했으면 사라지지 않는다. 커밋 응답을 받은 직후에 정전이 나도 그 데이터는 살아 있어야 한다.

당연해 보이지만 구현은 까다롭다. 소박하게 생각하면 커밋할 때 데이터 파일에 다 쓰고 응답하면 될 것 같다. 그런데 그러면 너무 느리다.

한 트랜잭션이 건드린 행들은 디스크 여기저기 흩어져 있다. 커밋마다 그걸 다 찾아가서 쓰면 흩어진 곳을 하나씩 두드리는 셈이다. 그게 왜 그렇게 비싼지는 뒤에 나올 블록 글에서 다룬다.

그래서 발상을 바꾼다.

diagramdiagram

로그에만 쓰고 커밋을 끝낸다. 로그는 파일 끝에 이어붙이기만 하니 순차 쓰기다. 흩어진 곳을 찾아다닐 필요가 없어서 훨씬 빠르다.

실제 데이터 파일은 나중에 느긋하게 반영한다. 그 사이에 서버가 죽어도 상관없다. 로그가 디스크에 있으니 재시작할 때 로그를 읽어서 다시 적용하면 된다.

이걸 WAL(Write-Ahead Logging) 이라고 부른다. 이름 그대로 “쓰기보다 로그를 먼저”. 커밋의 정의를 “데이터 파일에 반영됐다”에서 “로그에 안전하게 적혔다”로 바꾼 것이 핵심이다.

그리고 여기서 A와 D가 만난다. 되돌리려면 “원래 뭐였는지”가 필요하고, 다시 하려면 “뭘 바꿨는지”가 필요하다. 둘 다 로그가 떠받친다. 기록을 남겨두는 것 하나로 원자성과 지속성이 같이 선다.

참고

그 기록을 어떻게 나눠 두는지는 제품마다 다르다. “다시 하기”와 “되돌리기”를 각각 다른 저장소에 두는 쪽도 있고(MySQL InnoDB의 redo 로그와 undo 로그), 아예 되돌리기용 기록을 안 두고 옛 값을 그냥 남겨두는 것으로 롤백을 대신하는 쪽도 있다(PostgreSQL). 뒤쪽 방식이 남긴 숙제는 MVCC 글에서 다시 만난다.

참고

“커밋이 느리다”는 문제가 종종 로그를 디스크에 확실히 내려쓰는 그 한 순간에 걸려 있다. 이 단계를 건너뛰면 빨라지지만 그만큼 D를 포기하는 것이다. 죽는 순간 커밋했다고 응답한 데이터가 날아갈 수 있다. 트레이드오프이지 최적화가 아니다.

I - 격리성 (Isolation)

동시에 돌아도 혼자 돈 것처럼 보인다.

앞의 A와 D는 “혼자 있을 때”의 이야기였다. 나 하나가 중간에 죽거나, 나 하나가 커밋한 뒤 정전이 나거나. I는 다르다. 남이 있을 때 무슨 일이 생기는지를 다룬다.

그리고 넷 중에 눈금이 가장 뚜렷하다.

A는 “80%만 원자적” 같은 게 없다. 되돌릴 수 있거나 없거나다. D는 앞에서 봤듯 어디까지 기다릴지를 설정으로 고를 수 있지만, 그건 성능을 위해 보장을 깎는 뒷문에 가깝다. 반면 I는 DB가 대놓고 “어디까지 격리할지 고르라”고 이름 붙은 선택지를 내놓는다. 완벽하게 격리할수록 느려지기 때문이다.

그게 격리 수준이고, 이어지는 글들이 그 이야기다.

  • 격리 수준 - 눈금을 낮추면 정확히 무엇이 깨지는가
  • - 잠가서 지키는 방식
  • MVCC - 버전을 쌓아서 지키는 방식

C - 일관성 (Consistency)

마지막으로 C인데, 넷 중에 성격이 혼자 다르다.

A·I·D는 데이터베이스가 하는 일이다. 로그를 쓰고, 락을 걸고, 버전을 관리한다. 전부 DB의 구현이다.

그런데 C는 그렇지 않다. C가 말하는 건 “트랜잭션 전에 규칙을 지키고 있었다면, 트랜잭션 후에도 지키고 있어야 한다”는 것이다. 여기서 규칙이 뭔지는 DB가 모른다.

“계좌 잔고는 음수가 될 수 없다”는 규칙을 DB가 어떻게 아나? 알려줘야 안다. CHECK 제약을 걸거나, 외래키를 걸거나, 유니크를 걸어야 한다. 안 걸었으면 DB는 잔고를 -5000으로 만들어달라는 요청을 군말 없이 들어준다.

C는 A·I·D의 결과물에 가깝다. 원자성으로 중간 상태를 안 남기고, 격리성으로 남과 안 엉키고, 지속성으로 안 날아가고, 거기에 개발자가 제약 조건과 로직으로 규칙을 알려주면 결과적으로 일관성이 유지된다.

그래서 C를 두고 “머릿글자를 ACID로 맞추려고 넣은 글자”라는 지적이 오래전부터 있었다. 좀 심한 말 같지만, 실무에서 C를 이해하는 데는 이 관점이 오히려 도움이 된다. 일관성은 DB에 맡겨두면 알아서 되는 게 아니라, 내가 제약으로 표현한 만큼만 지켜진다.

트랜잭션은 어디서 시작되나

지금까지 BEGIN으로 열고 COMMIT으로 닫는 그림을 봤다. 그런데 실무 코드에는 BEGIN이 안 보이는 경우가 훨씬 많다.

안 쓴 게 아니라 자동으로 열리고 닫힌 것이다. 대부분의 DB는 기본적으로 자동 커밋(autocommit)으로 동작해서, 문장 하나를 던지면 그 문장 하나가 곧 트랜잭션 하나가 된다.

sql
UPDATE 계좌 SET 잔고 = 잔고 - 10000 WHERE id = 'A';
-- 이 한 줄이 열리고, 실행되고, 커밋된다

문장 하나짜리라면 이걸로 충분하다. 문제는 두 줄이 하나로 묶여야 할 때다. 이체가 그랬다. 자동 커밋이면 첫 줄이 커밋된 직후 서버가 죽어도 되돌릴 방법이 없다. 그래서 묶고 싶으면 경계를 명시적으로 그어야 한다.

애플리케이션에서는 그 경계를 대개 프레임워크가 그어준다. 이름은 달라도 하는 일은 같다.

어디서경계를 긋는 방법
SQL 직접BEGINCOMMIT / ROLLBACK
Java · Spring메서드에 @Transactional
그 밖대개 “이 블록을 한 트랜잭션으로 묶는다”는 선언 하나

여기서 알아둘 게 하나 있다. 그 경계가 곧 DB가 자원을 붙잡고 있는 구간이다. 트랜잭션을 열어둔 동안 로그가 쌓이고, 다음 글에서 볼 락도 그 구간 동안 잡혀 있다.

그래서 “트랜잭션을 짧게 유지하라”는 조언이 나온다. 묶는 건 필요해서 묶는 것이지만, 필요 없는 것까지 그 안에 넣으면 대가만 커진다. 외부 API를 부르거나 파일을 올리는 일을 트랜잭션 안에 두면, 그 응답을 기다리는 내내 DB가 붙잡혀 있는다.

자동 커밋에서 한 가지 더. BEGIN 없이 던진 UPDATE도 트랜잭션이므로 틀렸다고 ROLLBACK을 부를 수 없다. 이미 커밋됐기 때문이다. 손으로 데이터를 고칠 때 겁이 나면 먼저 BEGIN을 치고 시작하는 게 안전하다. 확인하고 나서 커밋하면 된다.

네 글자의 무게

정리하면 이렇다.

무엇을 막나누가 하나협상 가능?
A 원자성중간 상태가 남는 것DB (로그)아니오
C 일관성규칙이 깨지는 것개발자 + DB-
I 격리성남과 엉키는 것DB (락/MVCC)예 - 격리 수준
D 지속성커밋한 게 날아가는 것DB (로그)설정으로 깎을 수는 있다

같은 줄에 나란히 적혀 있지만 성격이 제각각이다. A와 D는 로그가 떠받치고, C는 절반이 내 몫이고, I만 이름 붙은 눈금이 있다.

트랜잭션 문제가 유독 I에서 자주 터지는 이유가 이거다. 나머지 셋은 켜고 끄는 스위치라 헷갈릴 게 없는데, I만 손잡이가 달려 있고 그 손잡이를 어디 두느냐에 따라 뭐가 깨질지가 달라진다.

참고

그 손잡이 이야기가 다음 글이다. 눈금을 하나씩 내리면서 무엇이 깨지는지 본다.

관련 글