중급6분 분량

DynamoDB GSI가 최종적 일관성인 이유

아이템을 쓰고, 곧바로 글로벌 보조 인덱스에서 그것을 쿼리하는데 아무것도 돌아오지 않습니다 — 쓰기가 성공했고 베이스 테이블 GetItem은 그 아이템을 멀쩡히 돌려주는데도요.

망가진 건 없습니다. GSI의 가장 놀라운 속성에 부딪힌 겁니다. GSI의 모든 읽기는 최종적 일관성입니다. 쓰기 직후, 인덱스가 아직 따라잡지 못한 짧은 창이 있습니다.

DynamoDB GSI는 최종적 일관성인가요?

네 — 글로벌 보조 인덱스의 모든 읽기는 최종적 일관성이며, 이를 거부할 방법은 없습니다. 여러분의 쓰기는 베이스 테이블에 먼저 커밋된 다음 비동기로 인덱스에 전파되므로, 쓰기 직후 발행한 쿼리는 묵은 행이나 빠진 행을 돌려줄 수 있습니다. DynamoDB는 GSI에 대한 ConsistentRead 플래그를 제공하지 않습니다.

  • GSI는 별개의, 비동기로 복제되는 테이블입니다 — 여러분의 쓰기는 베이스 테이블에 먼저 커밋되고, 그다음 인덱스로 전파됩니다.
  • GSI에는 ConsistentRead 플래그가 없습니다. 베이스 테이블과 달리, 간극을 메우려고 강력한 읽기를 강제할 수 없습니다.
  • 자신의 쓰기는 GSI가 아니라 베이스 테이블에서 다시 읽으세요. 쓰기 직후에는 이미 기본 키를 손에 쥐고 있습니다.
  • 고유성은 GSI 쿼리가 아니라 조건부 쓰기로 강제하세요. 전파 간극이 "이거 쓰였나?" 검사를 경쟁 조건으로 바꿉니다.

증상: "자기 자신을 못 찾는" 가입

사용자 계정 서비스의 Members 테이블을 봅시다. 베이스 테이블은 내부 id로 키잉되지만, 사용자는 이메일로 로그인하므로 이메일 조회 GSI가 있습니다:

Members (베이스 테이블)
PKSKemaildisplayName
ACC#a1f9cPROFILEada@northwind.testAda L.
EmailIndex (GSI)
GSI1PKGSI1SK
ada@northwind.testACC#a1f9c

가입 흐름은 두 가지를 연달아 합니다. 새 멤버를 PutItem하고, 그다음 Query EmailIndex WHERE GSI1PK = "ada@northwind.test"로 그 주소를 아무도 차지하지 않았는지 확인하고 프로필을 불러옵니다.

그 두 호출을 몇 밀리초 차이로 실행하면 Query0개의 아이템을 돌려줄 수 있습니다. 1초 뒤에 다시 하면 행이 거기 있습니다. 쓰기가 실패한 게 아니라 — 인덱스가 아직 갱신되지 않았을 뿐입니다.

왜 이런 일이 생기나: GSI는 비동기로 복제됩니다

GSI는 자체 파티션과 자체 키 스키마를 가진 별개의, 내부적으로 관리되는 테이블입니다. 베이스 테이블 쓰기와 같은 트랜잭션 안에서 유지되지 않습니다.

PutItem하면 DynamoDB는 베이스 테이블에 영속적으로 커밋하고, 여러분의 쓰기를 확인 응답한 뒤, 그제서야 비동기로 각 GSI에 변경을 전파합니다. AWS GSI 문서 가 단순명료하게 말합니다. GSI는 최종적 일관성 읽기만 지원합니다.

베이스 테이블 쓰기와 인덱스 갱신 사이의 전파 지연은 보통 1초의 일부지만 — 부하 아래에서는 보장되지도, 제한되지도 않습니다. 제한된다고 가정하고 설계하는 게 함정입니다.

이건 버그가 아니라 원래 Dynamo 설계의 절충입니다. 2007년 Amazon Dynamo 논문 은 강력한 일관성보다 가용성과 분할 내성을 택했습니다.

GSI는 그 계보를 물려받습니다. 느슨한 결합이 인덱스가 베이스 테이블과 독립적으로 확장하고 쓰기 가능하게 해 주는 것입니다.

EmailIndex베이스 테이블EmailIndex베이스 테이블비동기 전파PutItem (새 멤버)200 OK이메일로 Query0개 아이템 (묵음)변경 복제이메일로 Query1개 아이템 (따라잡음)

200 OK와 "변경 복제" 사이의 간극이 인덱스 읽기가 묵는 창입니다. 그것을 메우는 일관 읽기 플래그는 없습니다.

베이스 테이블과 달리 — 거기서는 ConsistentRead = true를 넘겨 강력한 일관성의 GetItem/Query를 강제합니다 — GSI는 그 옵션을 단호히 거부합니다.

LSI는 베이스 테이블의 파티션을 공유하므로 강하게 읽을 있습니다. 그 구분이 존재하는 이유는 GSI vs LSI를 보세요.

더 미묘한 함정: 빠진 새 값뿐 아니라 묵은

빠진 행의 경우가 명백한 쪽입니다. 더 조용한 버그는 묵은 이전 값을 읽는 것입니다.

Ada가 이메일을 ada@northwind.test에서 ada.l@northwind.test로 바꾼다고 합시다. 베이스 테이블은 원자적으로 갱신되지만, 잠시 GSI는 여전히 인덱스 항목을 돌려줄 수 있습니다.

새 값에 대한 조회는 빗나가는데, 버려진 값은 여전히 해석됩니다.

더 나쁘게: GSI를 쿼리하고 읽은 것에 기반해 다시 쓰면, 더 이상 존재하지 않는 값에 따라 행동할 수 있습니다. 모든 GSI 읽기를 현실보다 늦을 수 있는 스냅샷으로 취급하세요.

맞서지 말고 우회 설계하세요

전파 창은 실재하므로, 해결책은 토글하는 재시도 손잡이가 아니라 아키텍처입니다. 선호도 대략적 순서로 네 가지 패턴:

  1. 자신의 쓰기는 베이스 테이블에서 다시 읽으세요. 쓰기 직후에는 이미 기본 키(ACC#a1f9c)를 쥐고 있으므로, GSI를 쿼리하는 대신 베이스 테이블에 강력한 일관성의 GetItem을 하세요.

    GSI는 다른 액세스 패턴 — "이메일이 있는데 계정을 찾아라" — 을 위한 것이지, 방금 한 쓰기를 확인하기 위한 게 아닙니다.

  2. 고유성은 GSI가 아니라 가드 아이템으로 강제하세요. 이메일이 차지되지 않았음을 증명하려고 GSI 쿼리를 결코 믿지 마세요 — 전파 간극이 그것을 두 동시 가입이 둘 다 질 수 있는 경쟁 조건으로 만듭니다.

    대신, 이메일 자체로 키잉된 전용 고유성 아이템(PK = "EMAIL#ada@northwind.test")을 attribute_not_exists(PK)ConditionExpression과 함께 TransactWriteItems 안에서 쓰세요.

    강력한 일관성의 베이스 테이블 조건을, 원자적으로 적용한 것이 고유성을 실제로 강제하는 것입니다.

    TransactWriteItems:
      - Put 멤버 아이템     (PK = ACC#a1f9c, SK = PROFILE)
      - Put 고유성 아이템   (PK = EMAIL#ada@northwind.test)
          ConditionExpression: attribute_not_exists(PK)

    두 번째 가입이 같은 주소를 두고 경쟁하면 그 조건이 실패하고 트랜잭션 전체가 거부됩니다 — GSI도, 전파 지연도, 이중 차지도 없습니다.

    코드에 엮기 전에 그 attribute_not_exists 조건을 DynamoDB 표현식 빌더로 만들고 미리 보세요.

  3. UX에서 지연을 허용하세요. GSI 읽기가 진짜로 옳은 도구일 때(기존 사용자의 이메일 로그인) 그 창은 1초 미만이고 무해합니다 — 자리 잡은 계정은 오래전에 전파되었으니까요.

    강력한 일관성의 베이스 테이블 경로는 쓰기 직후 읽는 순간에만 아껴 쓰세요.

  4. 추정하지 말고 다시 쿼리하세요. 워크플로가 갓 생긴 아이템을 GSI를 통해 관찰해야 한다면, 빈 결과를 "존재하지 않음"이 아니라 "아직 보이지 않음"으로 취급하고, 짧은 백오프 후 다시 쿼리하세요.

    하지만 추측을 완전히 없애는 패턴 1과 2를 선호하세요.

전파 간극을 직접 보세요

직관을 쌓는 가장 빠른 길은 그것이 일어나는 걸 지켜보는 것입니다. DynoTable에서 베이스 테이블에 아이템을 넣고, 두 번째 탭에서 곧바로 GSI를 쿼리하세요.

부하가 걸린 테이블에서는 인덱스가 베이스 데이터를 뒤따르는 걸 이따금 포착하고, 다음 새로 고침에서 수렴하는 걸 지켜볼 수 있습니다.

여러분의 데이터로 그 지연을 보는 것이, 어떤 다이어그램보다도 "자신의 쓰기는 베이스 테이블에서 다시 읽어라" 규칙을 훨씬 잘 각인시킵니다.

함정과 다음 단계

  • GSI의 쓰기 직후 읽기에 로직을 거지 마세요. 고유성 검사, "내 쓰기가 안착했나" 확인, 읽기-수정-쓰기 루프는 강력한 일관성의 베이스 테이블에 속합니다.
  • GSI에 ConsistentRead를 손대지 마세요 — 허용되지 않고 오류가 납니다.
  • 베이스 키가 이미 답하는 액세스 패턴을 GSI로 모델링하지 마세요. 기본 키에서 읽기를 처리하면 전파 창을 통째로 건너뜁니다.

올바른 키 형태를 고르는 것이 싱글 테이블 디자인의 게임 전부이고, QueryScan을 이기는 때를 아는 것이 애초에 인덱스를 멀리하게 합니다 (Query vs Scan).

여러분의 고유성 ConditionExpressionDynamoDB 표현식 빌더에서 만들고 테스트하세요. 그런 다음 DynoTable을 써보세요. 베이스 테이블 쓰기가 GSI로 실시간 전파되는 걸 지켜보고, 최종적 일관성 창이 결코 발목을 잡지 않도록 키를 설계하세요.

업데이트됨