콘텐츠로 이동

ACID는 어떤 실패를 막는가

ACID 네 단어를 암기하는 대신, 이체 도중 장애와 동시 요청에서 각각 어떤 약속이 필요한지 따라갑니다.

예상 읽기 시간 8분 · 핵심 질문: 커밋 성공을 언제 믿을 수 있는가

A 계좌에서 1만 원을 빼 B 계좌에 더하는 이체를 생각해 보자. A의 잔액을 줄인 뒤 서버가 꺼질 수 있고, 다른 요청이 이체 중간 값을 읽을 수 있으며, 데이터베이스가 성공을 응답한 직후 저장 장치에 장애가 날 수도 있다. UPDATE 문 두 개가 정상적으로 실행되는 것만으로는 충분하지 않다.

트랜잭션은 함께 성공하거나 함께 실패해야 하는 조회와 쓰기를 하나의 업무 경계로 묶는다. ACID는 그 경계를 믿기 위해 필요한 네 가지 약속을 나눈 이름이다.

원자성: 부분 성공을 업무 결과로 남기지 않는다

Atomicity(원자성)는 트랜잭션의 변경을 전부 반영하거나 전부 취소한다는 약속이다. A의 돈만 빠지고 B에는 들어가지 않은 상태를 최종 결과로 인정하지 않는다. 내부에서는 여러 페이지와 로그가 순서대로 바뀌지만, 커밋되지 않은 중간 결과가 완료된 업무처럼 보이지 않게 한다.

원자성이 여러 API 호출을 자동으로 하나로 묶어 주는 것은 아니다. 한 데이터베이스 트랜잭션 안에 들어온 변경에 적용된다. 주문 데이터베이스와 외부 결제 시스템처럼 경계를 넘으면 메시지 재시도, 멱등성, 보상 처리 같은 별도 설계가 필요하다.

일관성: 데이터베이스는 업무 규칙을 추측하지 않는다

Consistency(일관성)는 트랜잭션 전후에 정의된 불변식이 참이어야 한다는 뜻이다. 주문 번호는 중복될 수 없고, 주문은 존재하는 회원을 가리켜야 하며, 취소 합계는 승인 금액을 넘을 수 없다는 규칙이 불변식이다.

데이터베이스는 UNIQUE, FOREIGN KEY, CHECK 같은 제약과 적절한 격리 방식으로 규칙을 지킬 수 있다. 하지만 “쿠폰은 한 사람당 한 번만 사용한다”는 규칙을 스키마와 트랜잭션 로직 어디에도 표현하지 않으면 ACID 제품도 이를 알아서 보호하지 못한다.

여기서 일관성은 CAP의 일관성과 다르다. ACID의 일관성은 업무 규칙이 보존되는지를, CAP의 일관성은 분산 복제본이 하나의 최신 순서처럼 보이는지를 주로 다룬다.

격리성: 동시에 실행해도 어떤 결과까지 허용할 것인가

Isolation(격리성)은 여러 트랜잭션이 겹쳐 실행될 때 서로의 중간 상태를 얼마나 보고, 최종 결과가 어떤 순서를 허용하는지 정한다. 가장 강한 직관은 동시에 실행된 결과가 어떤 순서로 하나씩 실행한 결과와 같아 보이는 직렬 가능성이다.

강한 격리는 업무 추론을 단순하게 하지만 충돌 검사, 잠금 대기, 취소와 재시도 비용을 늘릴 수 있다. 그래서 데이터베이스는 여러 격리 수준을 제공한다. 중요한 것은 이름을 외우는 일이 아니라 현재 업무에서 막아야 할 이상 현상을 찾는 것이다.

지속성: 성공 응답 뒤 무엇까지 살아남는가

Durability(지속성)는 COMMIT 성공을 알린 변경이 프로세스나 서버 장애 뒤에도 복구된다는 약속이다. 데이터 페이지 전체를 매번 저장 장치에 쓰면 느리므로 보통 WAL(Write-Ahead Log, 선행 기록 로그)이나 redo log(재실행 로그)를 먼저 기록하고 데이터 페이지는 나중에 반영한다. 재시작할 때 로그를 재생해 커밋된 변경을 복원한다.

지속성의 실제 범위는 설정과 장애 모델에 따라 달라진다. 운영체제 캐시에만 기록했는지, 저장 장치에 플러시했는지, 동기 복제본의 확인까지 기다렸는지에 따라 프로세스 장애·호스트 장애·지역 장애에서 결과가 다르다. 지속성은 백업과도 다르다. 로그는 최근 장애 복구를 돕고, 백업은 잘못 삭제한 데이터를 과거 시점에서 되돌리거나 저장소 전체 손실에 대비한다.

flowchart TD
  A["A 계좌 차감과 B 계좌 증가"] --> B["업무 불변식과 제약 확인"]
  B --> C["새 행 버전과 변경 내용 생성"]
  C --> D["WAL 또는 재실행 로그 기록"]
  D --> E["동시 트랜잭션 충돌 확인"]
  E --> F["로그를 안정 저장 장치에 반영"]
  F --> G["커밋 성공 응답"]
  C -. "실패하면 전부 취소" .-> H["원자성"]
  B -. "규칙 보존" .-> I["일관성"]
  E -. "허용할 동시 결과" .-> J["격리성"]
  F -. "장애 뒤 복구" .-> K["지속성"]

격리 수준은 막아야 할 현상에서 고른다

더티 리드는 다른 트랜잭션이 아직 커밋하지 않은 값을 읽는 현상이다. 값을 쓴 쪽이 롤백하면 실제로 존재하지 않았던 상태를 근거로 업무를 처리한 셈이 된다.

반복 불가능 조회는 같은 트랜잭션에서 같은 행을 두 번 조회하는 사이 다른 트랜잭션이 값을 바꿔 두 결과가 달라지는 현상이다. 팬텀 리드는 같은 범위 조건으로 다시 조회했을 때 다른 트랜잭션의 삽입·삭제로 행 집합이 달라지는 현상이다.

업데이트 손실은 두 요청이 같은 이전 값을 조회하고 새 값을 계산한 뒤 나중 쓰기가 앞선 변경을 덮는 현상이다. 재고 10에서 주문 두 건이 각각 1을 뺐는데 최종 재고가 8이 아니라 9가 되는 경우다.

쓰기 왜곡은 서로 다른 행을 변경해 직접 충돌하지 않지만 여러 행이 함께 지켜야 할 규칙이 깨지는 현상이다. 당직 의사가 최소 한 명 남아야 할 때 두 의사가 같은 스냅샷에서 상대가 근무 중임을 확인하고 각자 자신의 근무를 취소하면 최종 당직자가 0명이 될 수 있다.

MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 조회 트랜잭션에 적절한 과거 행 버전을 보여 주어 조회와 쓰기의 직접 충돌을 줄인다. 그러나 MVCC만으로 쓰기 왜곡까지 자동으로 사라지는 것은 아니다.

제품마다 같은 이름의 세부 동작이 다르다

Read Committed는 보통 각 SQL 문이 시작할 때 커밋된 값만 보여 더티 리드를 막는다. 하지만 같은 트랜잭션에서도 다음 SQL은 더 새로운 스냅샷을 볼 수 있다. Repeatable Read는 트랜잭션 동안 더 안정된 스냅샷을 제공하지만 팬텀과 잠금의 세부 동작은 PostgreSQL과 InnoDB가 다르다.

Serializable은 동시에 실행된 결과가 어떤 직렬 순서와 같도록 보장한다. 이를 위해 잠금을 더 잡거나 위험한 의존 관계를 감지해 트랜잭션 하나를 취소할 수 있다. 따라서 격리 수준을 올리면 끝나는 것이 아니라, 직렬화 실패가 발생했을 때 전체 트랜잭션을 처음부터 안전하게 재시도할 수 있어야 한다.

격리 수준을 정할 때는 다음 순서가 유용하다.

  1. 먼저 깨지면 안 되는 불변식을 문장으로 적는다.
  2. 그 규칙이 한 행인지 여러 행의 관계인지 확인한다.
  3. 조건부 업데이트, 고유 제약, 행 잠금으로 직접 보호할 수 있는지 본다.
  4. 같은 데이터와 동시 요청으로 PostgreSQL 또는 MySQL의 실제 동작을 확인한다.
  5. 충돌로 취소될 때 재시도해도 중복 결제나 중복 발행이 생기지 않게 한다.

트랜잭션 경계가 넓으면 여러 규칙을 한 번에 지키기 쉽지만 잠금과 과거 행 버전을 오래 유지한다. 너무 작으면 중간 상태와 보상 처리를 애플리케이션이 떠안는다. 좋은 경계는 “코드 한 메서드”가 아니라 함께 성공해야 업무적으로 의미가 있는 변경의 범위다.

참고 자료