고급5분 분량

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도 당신을 구하지 못합니다. GSI는 최종적 일관성을 가지므로, 당신의 쓰기를 통제하는 읽기가 설계상 낡을 수 있습니다. 해법은 더 빠른 확인이 아니라, 쓰기 자체가 사용 중인 값에 떨어지기를 거부하게 만드는 것입니다.

각 제약을 마커 아이템으로 모델링하기

엔진은 이미 하나의 고유성 규칙을 거저 강제합니다: 같은 키로 두 아이템을 쓸 수 없습니다. 그러니 모든 고유성 규칙을 키로 인코딩하세요.

실제 계정 아이템과 나란히, 보호된 속성마다 하나의 마커 아이템을 쓰세요. 마커의 파티션 키 자체가 네임스페이스가 붙은 값입니다. 값이 사용 중이면 키가 존재하고, 가드를 건 put은 그것을 덮어쓸 수 없습니다.

emailusername을 둘 다 고유하게 유지해야 하는 가입에서는, 세 아이템이 함께 움직입니다 — 단일 테이블 레이아웃으로 키가 지정됩니다( 단일 테이블 설계 참고):

아이템PKSK목적
계정 레코드ACCT#a1f9c3PROFILE실제 계정
이메일 잠금UNIQ#EMAIL#ada@lovelace.ioLOCK이메일을 예약
사용자명 잠금UNIQ#HANDLE#adaLOCK사용자명을 예약

계정 자체의 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 Expression Builder는 각 put에 대한 조건과 타입이 지정된 Item을 내보내므로, 올바른 트랜잭션을 SDK 호출에 바로 붙여넣을 수 있습니다.

추측하지 말고 실패를 읽어라

트랜잭션이 취소되면, DynamoDB는 CancellationReasons 배열을 위치에 따라 반환합니다 — 요청 순서대로 아이템당 하나의 항목. 슬롯 1의 ConditionalCheckFailed는 이메일이 사용 중임을, 슬롯 2는 사용자명이 그렇다는 것을 의미합니다. 슬롯을 일반적인 "가입 실패"가 아니라 정밀한 필드 수준 오류로 되매핑하세요.

DynoTable에서 잠금 검사하기

마커 아이템은 앱의 UI에서 보이지 않습니다 — 배관입니다. 가입이 알 수 없이 실패하면, 잠금이 실제로 존재하는지 볼 필요가 있습니다.

DynoTable에서 테이블을 열고 UNIQ# 접두사를 Query하세요. 계정과 그 두 잠금 아이템이 함께 앉아 있어서, 막힌 가입(잘못된 삭제로 남겨진 잠금)이 한눈에 분명합니다.

DynoTable이 테이블을 스캔하는 모습 — 계정 아이템과 그 UNIQ#EMAIL 및 UNIQ#HANDLE 잠금 아이템이 번갈아 나타남.
DynoTable이 테이블을 스캔하는 모습 — 계정 아이템과 그 UNIQ#EMAIL 및 UNIQ#HANDLE 잠금 아이템이 번갈아 나타남.

변경과 삭제 시 잠금을 정직하게 유지하기

잠금은 한 번 쓰고 끝이 아닙니다. 살아 있는 값을 반영하므로, 생명주기가 그것을 동기화 상태로 유지해야 합니다 — 보호된 속성을 건드리는 모든 작업도 트랜잭션입니다.

  • 이메일 변경. 하나의 트랜잭션: 새 UNIQ#EMAIL#… 잠금을 attribute_not_exists로 put, 옛 잠금을 삭제, 계정을 업데이트. 같은 전부-또는-전무 보장.
  • 계정 삭제. 계정 아이템 두 잠금 아이템을 하나의 트랜잭션으로 삭제하세요. 그러지 않으면 그 값을 영원히 막는 잠금을 좌초시킵니다.
  • 안전하게 재시도. ClientRequestToken을 전달해 (네트워크 깜박임 후) 다시 보낸 트랜잭션이 이중 쓰기가 아니라 멱등이 되게 하세요.

함정은 잠금을 보내고 잊는 것으로 취급하는 것입니다. 가입 시 만들어졌지만 계정 삭제 시 절대 삭제되지 않은 잠금은 아무도 다시 쓸 수 없는 값이며 — 실제 사용자가 자기 옛 핸들을 주장할 수 없을 때까지 드러나지 않습니다.

다음 단계

고유성 마커는 단일 테이블 패턴이므로 다른 아이템 옆에 자연스럽게 앉습니다 — 키 레이아웃은 단일 테이블 설계를, 잠금을 확인하려고 절대 Scan에 손을 뻗지 않도록 Query vs Scan을 읽으세요. 이 패턴은 AWS의 re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions 세션에서 처음 자세히 다뤄졌습니다.

DynamoDB Expression Builder로 조건으로 가드를 건 put을 작성한 다음, DynoTable을 사용해 자신의 테이블에서 잠금 아이템을 검사하세요.

업데이트됨