DynamoDB에서 여러 속성의 고유성
DynamoDB는 정확히 한 가지에 대해서만 고유성을 보장합니다: . UNIQUE (email)
제약도, UNIQUE (username)도, 두 속성에 걸치는 것도 없습니다. SQL에서 넘어오면
그 부재가 첫 번째 놀라움이고 — 사람들이 조용히 경쟁 조건을 출시하는 첫 번째
지점입니다.
DynamoDB에서 여러 속성에 고유 제약을 어떻게 강제하나요?
DynamoDB에는 너머의 UNIQUE 제약이 없으므로, 고유성은 직접 강제합니다: 보호 대상 값 각각을 그 값 _자체_가 키인 마커 항목으로 모델링한 뒤, 레코드와 모든 마커를 하나의 TransactWriteItems로 함께 쓰고, 각 put을 attribute_not_exists로 가드합니다. 엔진이 이미 강제하는 충돌이 여러분의 제약이 됩니다.
- 고유 제약은 없습니다 — 오직 기본 키만 엔진이 고유하게 강제합니다. "고유해야 하는" 다른 모든 속성은 여러분의 몫입니다.
- 고유성 규칙마다 자체 항목으로 모델링하세요. 보호하려는 값 _자체_가 키인 전용 마커 항목이 "이 이메일은 이미 쓰였나?"를 엔진이 이미 강제하는 키 충돌로 바꿉니다.
TransactWriteItems로 원자적으로 쓰세요. 하나의 , 각 put을attribute_not_exists로 가드하므로, 모든 마커와 실제 레코드가 함께 커밋되거나 아무것도 안 됩니다.- 먼저 확인 후 쓰지 마세요. 삽입 전 읽기는 교과서적 경쟁입니다. 두 동시 가입이 모두 "비었음"을 읽고 둘 다 씁니다.
뻔한 접근이 틀린 이유
본능은 이메일을 Query(더 나쁘게는 Scan)해서 아무것도 없음을 보고, 그런 다음
새 계정을 PutItem하는 것입니다. 그것은 확인 후 실행 경쟁입니다.
두 사람이 같은 밀리초에 ada@lovelace.io를 등록합니다. 두 읽기 모두 비어서
반환됩니다. 두 쓰기 모두 성공합니다. 이제 하나의 이메일에 두 계정이 있고 —
테이블 안 그 무엇도 이를 표시하지 않습니다.
email에 대한 도 여러분을 구하지 못합니다. GSI는
최종적 일관성을 따르므로, 쓰기를 통제하는 읽기가 설계상
낡을 수 있습니다. 해법은 더 빠른 확인이 아니라, 쓰기 자체가 이미 쓰인 값에
안착하기를 거부하게 만드는 것입니다.
각 제약을 마커 항목으로 모델링하기
엔진은 이미 하나의 고유성 규칙을 공짜로 강제합니다: 같은 키를 가진 두 항목을 쓸 수 없습니다. 그러니 모든 고유성 규칙을 키로 인코딩하세요.
실제 계정 항목과 나란히, 보호 대상 속성마다 하나의 마커 항목을 쓰세요. 마커의 파티션 키는 네임스페이스가 붙은 값 _자체_입니다. 그 값이 이미 쓰였으면 키가 존재하고, 가드된 put은 그것을 덮어쓸 수 없습니다.
email과 username을 모두 고유하게 유지해야 하는 가입에서는, 세 항목이 함께
움직입니다 — 싱글 테이블 레이아웃으로 키를 잡습니다(
싱글 테이블 디자인을 보세요):
| 항목 | PK | SK | 목적 |
|---|---|---|---|
| 계정 레코드 | ACCT#a1f9c3 | PROFILE | 실제 계정 |
| 이메일 잠금 | UNIQ#EMAIL#ada@lovelace.io | LOCK | 이메일을 예약 |
| 사용자명 잠금 | UNIQ#HANDLE#ada | LOCK | 사용자명을 예약 |
계정 자체의 PK는 생성된 id(ACCT#a1f9c3)이며 — 이메일이 아닙니다 — 그래서
사용자가 나중에 기본 키를 다시 쓰지 않고 이메일을 바꿀 수 있습니다. 잠금 항목은
프로필 데이터를 담지 않습니다. 오직 그 _키_가 점유되도록 존재할 뿐입니다.
셋을 모두 원자적으로 쓰기
TransactWriteItems는
최대 100개의 쓰기를 하나의 전부-아니면-전무 단위로 적용합니다. 각 put을
attribute_not_exists(PK)로 가드해, 그 키가 이미 있으면 실패하게 하세요.
어느 하나의 조건이라도 실패하면 — 이메일 잠금, 핸들 잠금, 또는 계정 자체 —
DynamoDB가 트랜잭션 전체를 롤백하고 TransactionCanceledException을 던집니다.
부분 가입도, 고아 잠금도 없습니다.
{
"TransactItems": [
{
"Put": {
"TableName": "accounts",
"Item": {
"PK": {"S": "ACCT#a1f9c3"},
"SK": {"S": "PROFILE"},
"email": {"S": "ada@lovelace.io"},
"username": {"S": "ada"}
},
"ConditionExpression": "attribute_not_exists(PK)"
}
},
{
"Put": {
"TableName": "accounts",
"Item": {
"PK": {"S": "UNIQ#EMAIL#ada@lovelace.io"},
"SK": {"S": "LOCK"}
},
"ConditionExpression": "attribute_not_exists(PK)"
}
},
{
"Put": {
"TableName": "accounts",
"Item": {
"PK": {"S": "UNIQ#HANDLE#ada"},
"SK": {"S": "LOCK"}
},
"ConditionExpression": "attribute_not_exists(PK)"
}
}
]
}조건이 메커니즘 전체입니다. attribute_not_exists가 없으면, 같은 이메일로 하는
두 번째 가입이 첫 잠금을 조용히 덮어씁니다. 그것이 있으면, put이 거부되고,
트랜잭션이 취소되며, 여러분의 앱이 "이미 사용 중인 이메일입니다"를 드러냅니다.
ConditionExpression과 값 맵을 손으로 만드는 곳이 오타가 스며드는 지점입니다.
DynamoDB 표현식 빌더는 각 put에 대한 조건과
타입이 붙은 Item을 만들어 주므로, 올바른 트랜잭션을 SDK 호출에 바로 붙여넣을 수
있습니다.
실패를 추측하지 말고 읽으세요
트랜잭션이 취소되면, DynamoDB는 CancellationReasons 배열을 위치별로 —
항목당 하나씩, 요청 순서로 — 반환합니다. 슬롯 1의 ConditionalCheckFailed는
이메일이 이미 쓰였다는 뜻이고, 슬롯 2는 사용자명입니다. 슬롯을 정확한 필드
수준 오류로 되짚어 매핑하세요. 두루뭉술한 "가입 실패" 대신에요.
DynoTable에서 잠금 살펴보기
마커 항목은 여러분 앱의 UI에는 보이지 않습니다 — 배관입니다. 가입이 알 수 없게 실패할 때, 잠금이 실제로 존재하는지 봐야 합니다.
DynoTable에서 테이블을 열고 UNIQ# 접두사를 Query하세요. 계정과 그 두 잠금
항목이 함께 앉아 있으므로, 멈춰 버린 가입(엉망이 된 삭제가 남긴 잠금)이 한눈에
드러납니다.

변경과 삭제에서 잠금을 정직하게 유지하기
잠금은 한 번 쓰고 마는 것이 아닙니다. 라이브 값을 반영하므로, 생명주기가 그것을 동기화 상태로 유지해야 합니다 — 보호 대상 속성을 건드리는 모든 연산 또한 트랜잭션입니다.
- 이메일 변경. 하나의 트랜잭션: 새
UNIQ#EMAIL#…잠금을attribute_not_exists로 put하고, 옛 잠금을 삭제하고, 계정을 갱신합니다. 같은 전부-아니면-전무 보장. - 계정 삭제. 계정 항목 그리고 두 잠금 항목을 하나의 트랜잭션에서 삭제하세요. 안 그러면 그 값을 영원히 막는 잠금을 좌초시킵니다.
- 안전하게 재시도.
ClientRequestToken을 넘겨, 재전송된 트랜잭션(네트워크 끊김 이후)이 이중 쓰기가 아니라 멱등이 되게 하세요.
함정은 잠금을 만들고 잊는 것으로 취급하는 것입니다. 가입 시 만들어졌지만 계정 제거 시 절대 삭제되지 않은 잠금은 아무도 다시 쓸 수 없는 값이며 — 실제 사용자가 자기 옛 핸들을 되찾지 못할 때까지 드러나지 않습니다.
다음 단계
고유성 마커는 싱글 테이블 패턴이라, 다른 항목들 옆에 자연스럽게 앉습니다 — 키
레이아웃을 위해 싱글 테이블 디자인을 읽고, 잠금을
확인하려 절대 Scan을 꺼내 들지 않도록 Query 대 Scan을
읽으세요. 이 패턴은 AWS의 re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions
세션에서 처음 짚어졌습니다.
DynamoDB 표현식 빌더로 조건 가드된 put을 초안하고, DynoTable을 사용해 여러분의 테이블에 대해 잠금 항목을 살펴보세요.


