🔥 트랜잭션과 조건부 쓰기: ACID의 한계

1980자
22분

DynamoDB TransactWriteItems 한 호출이 최대 100개 액션과 4 MB까지 묶어 같은 region 안 여러 표를 prepare → commit 두 단계로 처리하는 표지 다이어그램. 왼쪽엔 클라이언트 박스에 'TransactWriteItems up to 100 actions, 4 MB' 라벨. 가운데엔 두 DynamoDB 표 박스가 점선 묶음 안에 들어 있고 'Phase 1: prepare / Phase 2: commit' 라벨. 오른쪽 끝엔 'all-or-nothing' 도장 모양 라벨, 그 옆에 'WCU x2 per item' 메모.

처음 DynamoDB 한 표에 두 항목을 같이 갱신해야 했던 일이 있었다. 사용자 잔액 항목과 주문 기록 항목이었다. 첫 시도는 단순했다. UpdateItem으로 잔액을 차감한 다음 PutItem으로 주문 기록을 적었다. 며칠 운영한 뒤 한 사용자에게서 잔액은 빠졌는데 주문 기록은 없는 상태가 한 번 나왔다. 두 호출 사이 어딘가에서 Lambda 함수가 timeout 으로 죽은 시점이 있었다. 잔액 차감은 이미 commit 됐고 주문 쓰기는 시도조차 하지 못했다. 그때 처음 TransactWriteItems로 갈아탔다. 두 항목을 한 호출에 묶고 나서야 같은 사고가 다시 나오지 않았다.

한 항목 조건부 쓰기와 여러 항목 트랜잭션 쓰기는 같은 용량 체계 위에서 동작한다. RCU·WCU 단가와 partition 한도는 DynamoDB란 무엇인가: 키-값 스토어의 관점Partition Key와 Sort Key: 데이터 분산의 원리에서 정리한 그대로다. 여기서는 트랜잭션에만 붙는 추가 한도와 청구만 본다.

ConditionExpression은 한 항목의 쓰기에 조건을 붙인다

PutItem, UpdateItem, DeleteItem 셋 다 ConditionExpression 인자를 받는다. 조건이 참이면 쓰기가 진행되고, 거짓이면 ConditionalCheckFailedException이 발생한다. AWS Developer Guide "DynamoDB condition expression CLI example"는 attribute_not_exists(Id) 조건을 PutItem에 붙여 같은 partition key 가 이미 있으면 덮어쓰지 않도록 막는 예제를 든다. 조건이 거짓일 때 응답 메시지는 "The conditional request failed."다. 조건부 요청이 실패했다는 뜻이다.

조건 함수는 여러 종류가 있다. attribute_exists / attribute_not_exists로 속성 존재 여부를 확인하고, attribute_type으로 타입을 비교한다. begins_with / contains로 문자열·집합을 검사하고, size로 바이트 길이를 잰다. 비교 연산자(=, <>, <, <=, >, >=)와 논리 연산자(AND, OR, NOT), IN, BETWEEN을 합쳐 한 줄짜리 조건식을 만든다. AWS Developer Guide 의 같은 페이지는 (ProductCategory IN (:cat1, :cat2)) and (Price between :lo and :hi) 같은 합성 조건을 그대로 보여 준다.

조건 검사가 실패해도 DynamoDB 는 capacity 를 그대로 청구한다. AWS Developer Guide "Working with items"는 실패한 조건부 쓰기의 capacity 소비량이 기존 항목 크기에 따라 달라지며 최소 1 WCU 라고 적는다. 의도한 갱신을 막아 주는 안전 장치이지만, 실패 빈도가 높은 패턴, 예를 들어 hot-key 위 compare-and-set 경합 같은 곳에서는 그 자체가 throttle 원인이 된다. 실패 응답에 직전 항목 값을 같이 받고 싶으면 2023년 6월에 추가된 ReturnValuesOnConditionCheckFailure 인자를 ALL_OLD로 적는다. PutItem / UpdateItem / DeleteItem API 레퍼런스의 Valid Values 는 ALL_OLD | NONE 두 값이고, 인자를 생략하면 실패 응답에 항목 값이 들어오지 않는다.

자주 쓰는 두 가지 패턴은 분명하다. 첫째, 같은 키로 항목을 만들 때 덮어쓰기를 막는 insert-if-absent 패턴은 PutItemattribute_not_exists(pk) 조건을 붙인다. AWS PutItem API 레퍼런스는 기본 키로 쓰는 속성 이름을 attribute_not_exists 안에 적으라고 안내한다. 복합 키 표에서 두 키 모두 비어 있는지 확인하려면 두 조건을 AND로 합쳐 함께 검사한다. 둘째, 동시 갱신 사고를 막는 compare-and-set 패턴은 항목에 버전 번호 속성을 두고 UpdateItemversion = :expected 같은 조건을 단다. 둘 모두 한 항목 안에서 끝나는 일이라 트랜잭션이 따로 필요하지 않다.

ConditionExpression 동작 다이어그램. 왼쪽엔 PutItem/UpdateItem/DeleteItem 세 박스가 세로로 늘어서 있고 모두 'ConditionExpression: attribute_not_exists / attribute_exists / 비교 / AND / OR' 라벨 부착. 가운데엔 한 항목 박스, 그 위에 평가 분기. true 가지에는 '쓰기 진행 + WCU 1 소비', false 가지에는 'ConditionalCheckFailedException + WCU 1 소비'. 오른쪽 끝엔 'ReturnValuesOnConditionCheckFailure: NONE | ALL_OLD' 메모.

TransactWriteItems는 100개 액션을 한 번에 묶는다

여러 항목을 같은 운명으로 묶어야 하면 TransactWriteItems로 간다. AWS Developer Guide "Amazon DynamoDB Transactions: How it works"는 이 호출이 한 번에 최대 100개 쓰기 액션을 all-or-nothing 으로 묶는다고 적는다. 같은 호출에서 같은 AWS 계정과 같은 region 안 1개 이상의 표를 횡단할 수 있고, 한 트랜잭션 안 항목들의 합계 크기는 4 MB 를 넘지 않는다. AWS 는 2022년 9월 100개 액션 한도를 25에서 100으로 상향했다.

액션 종류는 네 가지다. Put, Update, Delete 는 각각 같은 이름의 단일 호출과 같은 의미이고, 네 번째 ConditionCheck 는 항목을 바꾸지 않고 조건만 평가한다. 어떤 액션도 같은 트랜잭션 안에서 같은 항목을 두 번 건드릴 수 없다. AWS Developer Guide 의 같은 페이지는 ConditionCheckUpdate를 같은 항목에 같이 거는 호출을 DynamoDB 가 거부한다고 분명하게 적어 둔다. 같은 항목에 조건 검사와 갱신을 같이 걸고 싶으면 Update 액션의 ConditionExpression에 그 조건을 합쳐 적어야 한다.

idempotency 도구로는 ClientRequestToken이 들어 있다. 같은 토큰으로 보낸 동일한 요청은 처음 한 번만 적용되고, 이후 같은 토큰의 호출은 변경 없이 성공 응답을 돌려준다. 토큰 유효 기간은 호출이 끝난 시점부터 10분이다. 같은 10분 윈도우 안에 같은 토큰으로 보냈는데 다른 인자가 섞여 있으면 IdempotentParameterMismatch 예외가 발생한다. AWS Developer Guide 는 SDK 기본 동작이 일시적 실패에 대해 트랜잭션을 재시도한다고 적는다. 토큰 없이 보내면 그 재시도가 같은 트랜잭션을 다시 적용할 가능성이 남으므로, SDK 외 도구로 직접 호출할 때는 토큰을 명시적으로 넣어 두는 편이 안전하다.

ClientRequestToken idempotency 다이어그램. 가로 timeline 위에 첫 호출이 토큰 abc123 과 함께 들어가고 '적용됨' 표시. 10분 구간 안 같은 토큰과 같은 요청 본문은 '성공 응답, 재적용 없음'으로 이어진다. 같은 10분 안 같은 토큰인데 요청 본문이 다르면 'IdempotentParameterMismatch' 경고 박스가 뜬다. 10분이 지난 뒤에는 새 요청으로 처리된다.

BatchWriteItem과 자주 헷갈리는데 두 호출의 보장이 다르다. BatchWriteItem은 한 호출에 여러 쓰기를 묶어 보내지만 부분 성공이 가능하다. AWS Developer Guide 는 BatchWriteItem 의 일부 액션은 성공하고 일부는 실패할 수 있다고 명시한다. TransactWriteItems는 그렇지 않다. 모든 액션이 성공하거나, 모두가 실패한다. bulk 적재처럼 항목 사이 일관성이 필요 없는 흐름에는 BatchWriteItem을 쓰는 편이 빠르고 싸다. 항목들이 같은 운명이어야 할 때만 TransactWriteItems로 간다.

TransactWriteItems 다이어그램. 위쪽 영역에 '한 호출' 점선 박스 안 'TransactWriteItems up to 100 actions, 4 MB' 라벨, 그 안에 Put/Update/Delete/ConditionCheck 네 종 작은 카드. 화살표가 'Phase 1: prepare per-item WCU' → 'Phase 2: commit per-item WCU' → 'all-or-nothing 결과' 순서로 진행한다. 우측 사이드바에 'TransactionCanceledException → CancellationReasons array' 메모. 아래쪽 영역엔 BatchWriteItem 비교 박스, '부분 성공 가능 / not serializable as a unit' 라벨.

트랜잭션은 항목 단위로 직렬화 격리를 보장한다

TransactWriteItemsTransactGetItems는 다른 단일 항목 호출과의 격리를 직렬화 수준으로 보장한다. AWS Developer Guide "Isolation levels for DynamoDB transactions"는 DeleteItem / PutItem / UpdateItem / GetItem 모두가 트랜잭션과 직렬화 격리에 들어간다고 명시한다. 두 트랜잭션 사이도 같다. 한 트랜잭션이 진행 중일 때 다른 트랜잭션이 같은 항목을 건드리면 DynamoDB 가 둘 중 하나를 TransactionCanceledException으로 취소한다.

여기서 중요한 단서가 두 가지 있다. 첫째, BatchGetItem / Query / Scan 같이 여러 항목을 묶어 읽는 작업과 트랜잭션 사이의 격리 수준은 작업 전체로는 read-committed 다. 같은 가이드는 Query 도중 트랜잭션이 일부 항목을 갱신하면 그 한 호출의 결과 안에 옛 값과 새 값이 섞여 들어올 수 있다고 적는다. 묶음 단위 강한 일관성이 필요하면 Query가 답이 아니라 TransactGetItems로 간다. TransactGetItems 도 한 호출에 최대 100개 항목, 합계 4 MB 한도를 같이 갖는다.

둘째, 트랜잭션 진행 중의 비-트랜잭션 read 도 항목 단위로는 직렬화되지만, 여러 항목을 따로 읽으면 일부는 새 값, 일부는 옛 값이 섞여 보일 수 있다. 트랜잭션 응답이 돌아오기 전에는 여러 항목을 한 시점의 값으로 묶어 받지 못한다는 뜻이다. 응답이 돌아온 뒤에도 eventually consistent read 는 짧은 동안 옛 값을 돌려줄 수 있어, 직후 강한 일관성이 필요하면 ConsistentRead: true를 명시한다.

TransactionCanceledException이 발생하면 응답에 CancellationReasons 배열이 같이 온다. AWS Developer Guide 는 SDK for Java 의 경우 이 배열이 TransactItems 인자 순서를 그대로 따르고, 다른 언어 SDK 는 같은 정보가 예외 메시지 안 문자열로 들어온다고 적는다. 어느 쪽이든 이 배열을 읽어야 어떤 액션이 어떤 사유로 취소됐는지 알 수 있다. CloudWatch 의 TransactionConflict 메트릭은 항목 단위로 실패 횟수를 따로 집계하므로, 경합이 잦은 키를 모니터링할 때의 출발점이 된다.

격리 수준 비교 표 다이어그램. 표 형태로 가로축 'Other operation', 세로축 'Transactional operation'. 행 다섯 개 — GetItem (Serializable), PutItem/UpdateItem/DeleteItem (Serializable), Other transactional (Serializable), BatchGetItem as a unit (Read-committed), BatchWriteItem as a unit (NOT serializable). 표 아래에 sub-box 'Per-item within Batch* are individually serializable' 메모. 우측 사이드바에 'In-flight read across multiple items: mixed old/new states possible' 메모.

한 번의 쓰기 비용은 두 배다

트랜잭션을 켠다고 추가 청구가 따로 붙지 않는다. AWS Developer Guide "Capacity management for transactions" 페이지는 트랜잭션 자체에 추가 비용은 없고 트랜잭션 안에서 일어난 read 와 write 만 청구한다고 적는다. 다만 한 트랜잭션 안의 한 항목 쓰기는 안에서 두 번의 read 또는 write 가 일어난다. 같은 페이지의 표현 그대로다. 한 번은 트랜잭션을 prepare 하기 위해, 한 번은 commit 하기 위해 일어난다.

AWS 는 prepare 와 commit 을 둘 다 청구한다. AWS 예시대로 초당 한 번의 트랜잭션이 표에 500바이트 항목 3개를 쓰면 6 WCU 가 필요하다. 항목 한 개당 prepare 1 WCU, commit 1 WCU, 합 2 WCU 다. CloudWatch 메트릭이 두 호출을 따로 집계한다. 그래서 On-Demand vs Provisioned: 가격 모델에서 정리한 AWS 는 트랜잭션 항목 한 개당 단가의 두 배를 청구한다고 보면 된다. on-demand 모드도 같고, RRU·WRU 환산도 트랜잭션 항목 한 개당 두 번 발생한다.

DAX 가 같은 표에 붙어 있으면 추가 비용이 한 번 더 따라온다. AWS Developer Guide 는 DAX 가 TransactWriteItems 호출을 그대로 통과시키고, 캐시를 갱신하려고 같은 항목들을 TransactGetItems로 다시 읽는다고 적는다. 이 추가 읽기는 한 항목당 2 RCU 다. 위 예시에 DAX 를 붙이면 6 RCU 가 더해져, 표는 6 WCU 와 6 RCU 를 함께 갖춰야 한다.

한 partition 의 1,000 WCU/sec 한도는 트랜잭션 위에서도 그대로 작용한다. AWS Developer Guide 의 데이터 모델링 페이지가 그 한도를 single physical partition 기준으로 적는다. 그 한도와 hot key 처리는 Partition Key와 Sort Key: 데이터 분산의 원리에서 정리한 그대로다. 한 트랜잭션 안에서 같은 partition key 의 여러 항목을 한꺼번에 갱신하면 그 partition 의 prepare/commit 비용이 두 배로 곱해져 더 빨리 한도에 닿는다.

한 번의 트랜잭션 쓰기 비용 다이어그램. 가로 timeline 형태로 한 트랜잭션의 두 단계를 표시. 단계 1 'Prepare phase' — 작은 표 박스 세 개에 각각 'WCU 1' 라벨. 단계 2 'Commit phase' — 같은 세 개 항목에 다시 'WCU 1' 라벨. 위쪽엔 'Total: 6 WCU for 3-item transaction' 합계 라벨. 오른쪽엔 DAX 부착 시 추가 박스, 'Background TransactGetItems(2 RCU per item)' 작은 sub-그래프. 하단엔 CloudWatch 메트릭 마커 'Two write calls visible per item'.

트랜잭션이 답이 아닌 경우도 분명하다

첫째, region 사이 원자성 보장은 없다. AWS Developer Guide "Using transactional APIs with global tables"는 ACID 보장이 호출이 일어난 region 안에서만 유효하다고 명시한다. global table 의 다른 region 에는 트랜잭션 일부 항목만 먼저 복제되어 보이는 짧은 시점이 있을 수 있다고도 적어 둔다. region 사이에서 같은 시점의 값을 함께 보장해야 하면 DynamoDB 트랜잭션은 답이 아니다.

둘째, long-running 트랜잭션이나 행 잠금이 필요하면 RDBMS 영역이다. DynamoDB 의 트랜잭션 호출은 한 요청 안에서 끝난다. PartiQL transactions 페이지도 트랜잭션을 한 요청 안 statement 배열로 정의한다. BEGIN/COMMIT 사이에 다른 쿼리를 끼워 분 단위로 열어 두는 RDBMS 형식은 DynamoDB API 에 노출되지 않는다. SELECT FOR UPDATE 같은 명시적 row lock 호출도 API 에 없고, MVCC 기반의 long-running snapshot read · foreign key cascade · 트리거 · savepoint 같은 RDBMS 개념도 직접 노출되지 않는다. 그런 형태의 ACID 가 필요하면 Aurora 나 RDS 영역으로 넘어간다.

셋째, bulk 적재에는 트랜잭션이 맞지 않다. AWS Developer Guide "Best practices for transactions"는 bulk 쓰기에 BatchWriteItem 사용을 권하고 트랜잭션 사용을 권하지 않는다고 분명히 적는다. 트랜잭션은 같은 운명이어야 할 항목 묶음에만 쓰고, 항목 사이 독립이면 BatchWriteItem 으로 처리한다. 같은 best practice 페이지는 한 트랜잭션 안의 액션 수도 필요한 최소로 줄이라고 권한다. 액션이 적을수록 prepare/commit 두 번의 비용도 줄고 경합 확률도 낮다.

스트림과 함께 쓸 때 한 가지 함정이 있다. AWS Developer Guide 의 TransactWriteItems 항목은 트랜잭션 변경이 GSI · DynamoDB Streams · backup 으로 점진적으로 전파되고, 같은 트랜잭션의 레코드도 스트림에서는 시간차를 두고 도착할 수 있다고 적는다. 그래서 스트림 컨슈머는 같은 트랜잭션의 원자성이나 순서를 가정하면 안 된다. 이 비동기 전파의 의미는 DynamoDB Streams: 변경 이벤트 스트림에서 정리한 그대로다. 같은 트랜잭션의 여러 항목을 한 시점의 값으로 다시 읽고 싶으면 TransactGetItems를 쓴다.

트랜잭션이 답이 아닌 세 경우 비교 다이어그램. 가로로 세 패널. 패널 1 'Cross-region (global table)' — TransactWriteItems 박스 위에 X, 그 옆에 region 두 개를 잇는 별도 동기 채널 박스 + 'Replication is async per region' 라벨. 패널 2 'Long-running / row locks / MVCC' — TransactWriteItems 박스 위에 X, 그 옆에 Aurora·RDS 박스 + 'BEGIN/COMMIT/SELECT FOR UPDATE/savepoint' 라벨. 패널 3 'Bulk ingest' — TransactWriteItems 박스 위에 X, 그 옆에 BatchWriteItem 박스 + '부분 성공 OK / 더 빠르고 저렴' 라벨.

다음에 두 항목을 같이 갱신해야 하는 일이 있으면 나는 먼저 두 가지를 정한다. 두 항목이 꼭 함께 성공하거나 함께 실패해야 하는가. 아니면 한쪽은 나중에 따라와도 충분한가. 꼭 함께 성공하거나 함께 실패해야 하면 TransactWriteItems로 묶고, 그 외에는 한 항목씩 ConditionExpression으로 막아 두는 편이 비용도 경합도 낮다. 실패 응답에서 ConditionalCheckFailedExceptionTransactionCanceledException을 따로 잡아 다른 처리로 분기하고, 후자에는 CancellationReasons 배열을 로그에 같이 남긴다. region 사이 일관성이 필요하면 처음부터 RDBMS 가 답인지 다시 검토한다.

YouTube 영상

채널 보기
행렬의 기본 연산 - 행렬 덧셈, 스칼라 곱, 전치 | 선형대수학
내적의 기하학적 의미와 코사인 유사도 원리 | 선형대수학
우리가 매일 쓰는 맞춤법 검사기와 라우터 속에 숨겨진 알고리즘은? | Trie 자료구조 이야기
Trie 자료구조 파이썬 구현: Search와 Starts With 연산 | Trie 자료구조 이야기
투영과 예측, 그리고 선형 결합 | 선형대수학
트라이(Trie)에서 단어를 삭제하는 방법 | Trie 자료구조 이야기
직교성과 벡터 투영 | 선형대수학
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학