중급4분 분량

DynamoDB 참조 카운트

참조 카운트는 얼마나 많은 자식 아이템이 부모를 가리키는지 추적하기 위해 부모 아이템에 저장하는 숫자입니다 — 게시물의 좋아요, 워크스페이스의 구성원, 댓글의 답글. 매 읽기마다 자식을 세는 것이 너무 비싸기 때문에 이를 유지합니다.

DynamoDB에서 카운트를 어떻게 유지하나요?

부모 아이템에 실행 중인 총합을 숫자로 저장하고, 자식을 생성하는 쓰기와 같은 연산에서 이를 업데이트하세요. 를 사용하면 두 작업이 모두 반영되거나 하나도 반영되지 않으며, 자식 쓰기에 조건을 걸면 재시도로 인한 이중 카운팅을 막을 수 있습니다 — 따라서 단일 GetItem으로 정확한 카운트를 반환할 수 있습니다.

  • 읽기 시점에 자식을 세지 마세요. 좋아요를 세는 Query는 훑는 모든 좋아요 아이템에 과금됩니다. 총합을 게시물에 저장하고 아이템 하나만 읽으세요.
  • 자식이 쓰이는 곳에서 카운트를 유지하세요, 나중이 아니라요. 자식을 생성하는 같은 연산에서 카운트를 올려 둘이 절대 어긋나지 않게 하세요.
  • 쓰기와 카운트 증가가 서로 다른 아이템을 건드릴 때는 트랜잭션을 사용하세요. 좋아요는 한 아이템이고 카운트는 다른 아이템에 있습니다 — TransactWriteItems가 둘 다 반영되거나 둘 다 반영되지 않게 합니다.
  • 지뢰는 이중 카운팅입니다. 재시도되거나 중복된 좋아요가 증가를 다시 실행하면 숫자가 부풀려집니다. 자식 쓰기를 조건으로 보호하세요.

애초에 왜 세는가

SQL에서 왔다면 좋아요 수를 저장할 일이 없을 겁니다 — SELECT COUNT(*) FROM likes WHERE post_id = ?로 인덱스가 그것을 값싸게 만들도록 하면 됩니다. DynamoDB에는 아이템 읽기를 건너뛰는 COUNT(*)가 없습니다.

게시물의 좋아요에 대한 Query는 숫자만 원하더라도 그 파티션의 모든 좋아요 아이템을 읽고 — 또 거기에 과금합니다. 바이럴 게시물에서는 "좋아요가 몇 개야?"에 답하는 데 수천 RCU가 듭니다. 그게 바로 참조 카운트가 없애려고 존재하는 읽기 지뢰입니다.

그래서 비정규화합니다. 진행 중인 총합을 게시물 자체에 저장합니다. 카운트 읽기는 단일 GetItem이 됩니다. 그 대가는 이제 그것을 정확하게 유지하는 책임을 여러분이 진다는 것입니다.

아이템 모델링하기

게시물과 그 좋아요가 하나의 아이템 컬렉션에 함께 자리하도록 두 아이템 타입이 파티션을 공유합니다. 임의의 키:

Post item
PKSKattributes
POST#a91fMETAlikeTally (Number), body, authorId, createdAt
Like item
PKSKattributes
POST#a91fLIKE#USER#7c20likedAt

META 아이템의 likeTally 속성이 참조 카운트입니다. 각 LIKE# 아이템은 자식입니다. 둘 다 PK = "POST#a91f" 아래에 두면, 목록을 실제로 원할 때 하나의 Query로 게시물과 좋아요 누른 사람들을 함께 가져올 수 있습니다.

카운트를 원자적으로 올리기

DynamoDB는 ADD(또는 SET x = x + :n) 업데이트 표현식으로 숫자를 증가시킵니다 — 이것이 원자적 카운터입니다. DynamoDB는 현재 값을 먼저 읽지 않고 서버 측에서 델타를 적용하므로, 동시 증가가 서로를 덮어쓰지 않습니다. (AWS: 원자적 카운터)

문제는 이것입니다. 게시물에 좋아요를 누르는 것은 아이템에 대한 번의 쓰기입니다 — LIKE# 아이템을 생성하고, METAlikeTally1을 더합니다. 좋아요는 반영됐는데 증가가 실패하면, 집계는 영원히 틀립니다. 둘 다거나 둘 다 아니어야 합니다.

그게 TransactWriteItems가 보장하는 것입니다 — 여러 아이템에 걸친 전부 아니면 전무이며, 어떤 아이템이라도 동시에 수정되면 전체 트랜잭션을 취소합니다 (AWS: 트랜잭션을 사용한 비관적 잠금):

{
  "TransactItems": [
    {
      "Put": {
        "TableName": "Social",
        "Item": {
          "PK": {"S": "POST#a91f"},
          "SK": {"S": "LIKE#USER#7c20"},
          "likedAt": {"N": "1750636800"}
        },
        "ConditionExpression": "attribute_not_exists(SK)"
      }
    },
    {
      "Update": {
        "TableName": "Social",
        "Key": {
          "PK": {"S": "POST#a91f"},
          "SK": {"S": "META"}
        },
        "UpdateExpression": "ADD likeTally :one",
        "ExpressionAttributeValues": {":one": {"N": "1"}}
      }
    }
  ]
}

PutUpdate는 함께 커밋됩니다. 둘 중 하나라도 실패하면, DynamoDB는 둘 다 롤백하고 TransactionCanceledException을 반환합니다.

이중 카운팅 방지

진짜 버그는 절반만 쓰인 좋아요가 아닙니다 — 트랜잭션이 그걸 막아 줍니다. 그건 같은 사용자가 두 번 좋아요를 누르는 것, 또는 클라이언트 재시도가 요청을 다시 재생하는 것입니다. 각 재생이 또 1을 더하고, likeTally는 조용히 실제 카운트 위로 떠내려갑니다.

PutConditionExpression: attribute_not_exists(SK)가 그 보호 장치입니다. 그 사용자의 LIKE# 아이템이 이미 존재하면 Put의 조건이 실패하고, 전체 트랜잭션이 취소되며 — 결정적으로 — ADD가 절대 실행되지 않습니다. 사용자당 좋아요 하나, 키로 강제됩니다.

이 업데이트 및 조건 표현식을 — 올바른 ExpressionAttributeValuesattribute_not_exists 보호 장치와 함께 — JSON을 손으로 조립하는 대신 DynamoDB 표현식 빌더에서 만들고 복사하세요.

좋아요 취소, 그리고 비용

좋아요를 제거하는 것은 거울상입니다. ConditionExpression: attribute_exists(SK)LIKE# 아이템을 Delete하고, 같은 트랜잭션에서 ADD likeTally :minusOne을 합니다. 조건은 이중 좋아요 취소가 집계를 음수로 몰고 가는 것을 막습니다.

비용을 아세요. 트랜잭션 쓰기는 1 KB까지의 아이템에 대해 아이템당 2 WCU가 듭니다 — 하나는 준비, 하나는 커밋 — 일반 쓰기의 1 WCU에 비해서요. 좋아요는 두 아이템이므로, 각 좋아요는 대략 4 WCU입니다. 동작당으로는 싸지만, 유명인 게시물이 좋아요 폭풍을 맞기 전에 알아 둘 만합니다.

DynoTable에서 보기

집계가 어긋났다고 의심될 때, 저장된 likeTally를 실제 LIKE# 자식의 개수와 비교하고 싶을 겁니다 — 프로덕션에서 카운트 쿼리를 돌리지 않고서요.

하나의 아이템 컬렉션 안에서 게시물 META 아이템이 그 LIKE# 자식들과 나란히 놓여, 저장된 집계를 실제 자식 개수와 눈으로 대조할 수 있습니다.
하나의 아이템 컬렉션 안에서 게시물 META 아이템이 그 LIKE# 자식들과 나란히 놓여, 저장된 집계를 실제 자식 개수와 눈으로 대조할 수 있습니다.

한정된 게시물 집합에 걸친 진정한 정합성 검사 — "어느 집계가 자식 개수와 안 맞지?" — 를 위해, DynoTable의 SQL Workbench는 여러분이 불러온 행에 대해 GROUP BY와 조인을 클라이언트 측에서 실행합니다. 이는 일반 PartiQL이 표현할 수 없는 것입니다.

함정과 다음 단계

  • 카운트를 밖에서 유지하지 마세요(밤마다 다시 세는 Lambda 같은 것). 그건 애초에 트랜잭션으로 했어야 할 쓰기 경로에 붙인 반창고입니다.
  • 핫 파티션을 주의하세요. 한 게시물이 폭발적으로 인기를 끌면 모든 좋아요 — 그리고 모든 집계 증가 — 가 하나의 파티션 키에 집중됩니다. 카운트는 정확합니다. 파티션은 여전히 스로틀될 수 있습니다.
  • 드물게 정합성을 맞추고, 외과적으로 복구하세요. 모든 변경이 조건으로 보호된다면 어긋남은 거의 0이어야 합니다. 불일치는 덮어쓸 숫자가 아니라 찾아내야 할 버그로 다루세요.

관련 읽을거리: 게시물과 좋아요가 파티션을 공유하는 이유에 관한 단일 테이블 설계, 그리고 읽기 시점에 자식을 세는 것이 여러분이 피하려는 패턴인 이유에 관한 Query vs Scan.

그런 다음 DynoTable을 다운로드해 이 아이템 컬렉션을 점검하고 여러분 자신의 테이블에 대해 집계를 검증하세요.

업데이트됨