SQL: 구조적으로 잘못되었는가?

Alice가 Bob에게 10달러를 보내는 송금은 교과서적인 예시로, 매우 간단해 보입니다. Alice의 잔액을 확인하고, 충분한 자금이 있는지 확인한 뒤, 그녀의 계좌에서 금액을 차감하고 Bob의 계좌에 추가하면 됩니다. 대부분의 프로그래밍 언어에서 이 로직은 직관적입니다. 하지만 관계형 데이터베이스의 세계에서 이 "합리적인" 코드는 종종 동시성 버그의 지뢰밭이 됩니다.

개발자들이 SQL을 동시성 환경에서 공유 상태를 관리하는 시스템이 아니라 단순한 스크립팅 언어로 취급할 때, 세 가지 주요 함정에 직면하게 됩니다: 원자성 실패, Time-of-Check to Time-of-Use (TOCTOU) 버그, 그리고 데드락(deadlock).

동시성 실패의 해부

기본적인 T-SQL 송금 구현을 살펴보겠습니다:

DECLARE @balance INT;

SET @balance = (  
    SELECT balance  
    FROM accounts
    WHERE owner = 'alice'
);

IF @balance >= 10  
BEGIN  
    UPDATE accounts  
    SET balance = balance - 10  
    WHERE owner = 'alice';  
  
    UPDATE accounts  
    SET balance = balance + 10  
    WHERE owner = 'bob';  
END

1. 원자성 격차 (The Atomicity Gap)

만약 첫 번째 UPDATE 이후에 시스템이 충돌하거나 프로시저가 중단되고 두 번째 UPDATE가 실행되기 전이라면, 돈은 Bob에게 전달되지 못한 채 Alice의 계좌에서 사라져 버립니다. 두 업데이트가 모두 일어나거나 혹은 둘 다 일어나지 않도록 보장하려면, 해당 작업은 트랜잭션(BEGIN TRANSACTIONCOMMIT TRANSACTION)으로 묶여야 합니다.

2. TOCTOU 레이스 컨디션

트랜잭션을 사용하더라도 Time-of-Check to Time-of-Use (TOCTOU) 버그는 여전히 남습니다. 만약 Alice가 동시에 두 개의 송금을 시작한다면(T1과 T2), 두 트랜잭션 모두 출금이 수행되기 전에 그녀의 잔액을 읽을 수 있습니다. 두 트랜잭션 모두 충분한 잔액을 확인하고 출금을 진행하게 되어, 결국 Alice의 계좌는 마이너스 잔액이 됩니다.

이를 해결하기 위해 개발자는 락(locking) 힌트를(T-SQL의 UPDLOCK 또는 다른 방언의 SELECT FOR UPDATE와 같은) 사용하여, 행이 읽히는 즉시 락을 걸어 현재 트랜잭션이 완료될 때까지 다른 트랜잭션이 이를 수정하지 못하도록 보장해야 합니다.

3. 데드락 함정

락을 사용하는 것은 새로운 문제를 야입니다: 데드락(deadlock)입니다. 만약 Alice가 Bob에게 송금하는 동안 Bob이 동시에 Alice에게 송금한다면, T1은 Alice의 계좌를 락하고 Bob의 계좌를 기다리는 동안, T2는 Bob의 계좌를 락하고 Alice의 계좌를 기다릴 수 있습니다. 어느 쪽도 진행할 수 없게 됩니다.

저자는 모든 락을 미리 획득하는 것을 제안하지만, 더 강력한 업계 표준은 일관되고 결정론적인 순서(예: 계좌 ID 순으로 정렬)로 락을 획득하는 것입니다.

SQL은 "구조적으로 잘못되었는가?"

제시된 핵심 논점은 SQL의 설계가 잘못된 코드를 작성하기 너무 쉽게 만든다는 것입니다. 저자는 의료 투약량 관리와 같이 정확성이 중요한 시스템의 경우, Rust와 유사하게 올바른 동작이 기본값이고 "unsafe" 작업은 명시적인 "fearless concurrency" 모델이 필요하다고 제안합니다.

하지만 이 주장은 숙련된 데이터베이스 엔지니어들 사이에서 상당한 논쟁을을 불러일으켰습니다. 많은 이들은 이러한 문제가 SQL의 구조적 결함이 아니라, 오히려 "SQL 101" 개념이라고 주장합니다.

반론

커뮤니티에서는 인지된 "결함"에 대해 몇 가지 핵심적인 포인트가 제기되었습니다:

  • 격리 수준 (Isolation Levels): 설명된 많은 문제들은 약한 기본 격리 수준(예: READ COMMITTED)의 결과입니다. 진정한 SNAPSHOT 격리 또는 SERIALIZABLE 수준은 충돌을 재시도(retry)로 전환하여 조용한 실패 대신 레이스 컨디션을 제거할 수 있습니다.
  • 엔진 지능 (Engine Intelligence): 현대적인 데이터베이스 엔진(MySQL, SQL Server)은 데드락의 수동적인 관찰자가 아닙니다. 이들은 데드락 사이클을 능동적으로 감지하고, 롤백할 "희생자(victim)" 트랜잭션을 선택하여 특정 에러를 반환하며(예: SQL Server의 Error 1205), 애플리케이션이 재시도할 것을 기대합니다.
  • 관용적 패턴 (Idiomatic Patterns): 숙련된 개발자들은 "확인 후 실행(check-then-act)" 패턴을을 완전히 피합니다. 대신, 조건부 업데이트를 사용합니다:
    UPDATE accounts SET balance = balance - 10 
    WHERE owner = 'alice' AND balance >= 10;
    
    이는 엔진 레벨에서 확인과 실행을 하나의 원자적 작업으로 결합합니다.

아키텍처적 대안

T-SQL 구문을 수정하는 것 외에도, 이 논의는 금융 데이터에 대한 두 가지 우수한 아키텍처적 패턴을 강조했습니다:

원장 패턴 (The Ledger Pattern, Append-Only)

레이스 컨디션에 취약한 가변 상태인 balance 컬럼을 업데이트하는 대신, 많은 이들은 추가 전용(append-only) 원장 방식을 제안합니다. 이 모델에서는 잔액을 UPDATE하지 않고, 트랜잭션 행(예: Alice: -10, Bob: +10)만 INSERT합니다. 현재 잔액은 모든 원장 항목의 합계로 계산됩니다.

애플리케이션 계층 동시성 제어

일부 개발자들은 동시성 제어를 애플리케이션 계층으로 옮기거나 분산 락(distributed locking) 메커니즘을 사용하여 한 번에 하나의 프로세스만 특정 리소스를 변경할 수 있도록 하여, 데이터베이스의 내부 락 메커니즘에 대한 의존도를 낮춥니다.

결론

SQL이 "구조적으로 잘못되었는가" 아니면 단순히 ACID 속성에 대한 규율 있는 이해가 필요할 뿐인지는 중요하지 않습니다. 교훈은 명확합니다: 동시성 시스템에서 "합리적으로 보이는 코드"와 "정확한 코드" 사이의 간격은 매우 큽니다. 중요한 시스템에서는 기본값에 의존하는 것만으로는 부족합니다; 명시적인 락 전략, 엄격한 격리 수준, 또는 불변의 원장 아키텍처가 필수적입니다.

Sources