🔥 GSI와 LSI: 다른 키로 조회하기

2634자
29분

GSI와 LSI 표지 이미지. 가운데 base table 하나가 있고, 같은 항목이 위쪽 GSI와 아래쪽 LSI에 서로 다른 키 기준으로 한 번 더 정리돼 있다. GSI는 base와 다른 partition 공간에 있고, LSI는 base와 같은 partition 안에 있다.

처음 사이드 프로젝트에서 사용자 항목을 DynamoDB에 적었을 때, partition key를 userId로, sort key를 createdAt으로 잡았다. 이 키 한 쌍이면 한 사용자의 항목을 시간 순으로 읽는 일은 한 호출로 끝났다. 어느 날 운영자 화면에서 이메일로 사용자를 조회하는 기능을 붙여 달라고 했다. 사용자 한 명을 이메일 한 줄로 찾는 일이 베이스 테이블에서는 안 되는 일이었다. partition key가 userId인 테이블에 email = "alice@example.com" 조건으로 Query를 보낼 길이 없었다. partition key 값을 모르고서는 어디 partition을 읽어야 할지 라우팅 계층이 정하지 못한다.

처음에는 Scan을 한 번 돌리고 캐싱하면 된다고 생각했다. 그런데 사용자 수가 만 명을 넘으면서 조회 한 번에 몇 분씩 걸리기 시작했다. 그제야 같은 데이터를 다른 키로 조회하려면 secondary index가 필요하다는 걸 알았다. 이메일을 partition key로 쓰는 인덱스를 base 테이블 옆에 하나 더 두면 됐다.

secondary index는 같은 데이터를 다른 키로 다시 본다

secondary index는 같은 base 테이블 데이터를 다른 키 schema로 정렬해 두는 부수적 데이터 구조다. 클라이언트가 base 테이블을 Query하면 base의 partition key + sort key 조합으로 읽고, secondary index를 Query하면 인덱스의 partition key + sort key 조합으로 읽는다. 그래서 DynamoDB는 같은 항목을 base 테이블에 한 번 적고, 인덱스를 만든 수만큼 인덱스에도 한 번씩 더 적는다.

DynamoDB란 무엇인가: 키-값 스토어의 관점에서 본 라우팅 계층은 인덱스에서도 같은 방식으로 작동한다. 라우팅 계층은 base의 partition key 대신 인덱스가 정의한 partition key로 partition을 찾는다. 그래서 base 테이블 키로는 처리하지 못하던 조회도 인덱스 키로는 처리할 수 있다. 위의 "이메일로 사용자 찾기"가 그 예다.

DynamoDB가 제공하는 secondary index는 두 종류다. Global Secondary Index, 줄여서 GSI와, Local Secondary Index, 줄여서 LSI. 둘은 같은 일(다른 키로 조회)을 하지만, 안에서 어떻게 만들어져 있는지가 꽤 다르다. 이 차이가 무엇을 자동으로 해 주고 무엇을 직접 짊어져야 하는지를 결정한다.

GSI는 base 테이블 옆에 다시 partition된 사본이다

GSI는 base 테이블과 별도의 partition 공간에 DynamoDB가 따로 적어 두는 사본이다. partition key를 base 테이블이 쓰는 키와 다른 속성으로 잡을 수 있고, sort key도 자유롭게 정한다. AWS DynamoDB Developer Guide의 "Improving data access with secondary indexes"가 이걸 "global"이라 부르는 이유는, GSI를 Query하면 base 테이블의 모든 partition을 가로질러 데이터에 닿을 수 있기 때문이다. base 테이블에서는 한 partition key 값 안의 항목만 한 Query로 읽을 수 있지만, GSI에서는 GSI의 partition key 값 하나가 base의 어느 partition에 원래 있었던 항목인지를 따지지 않는다.

이 구조가 가능한 이유는 DynamoDB가 GSI 항목을 base 테이블과 무관한 자기 partition에 따로 적어 두기 때문이다. 위의 사용자 항목을 예로 들면, base는 userId로 partition돼 있고, email GSI는 같은 항목들을 email hash로 다시 partition한다. base에서는 한 사용자를 찾고, GSI에서는 한 이메일을 찾는다. 숫자로 쓰면 더 분명하다. 사용자가 100만 명이고 base partition이 50개라면 email GSI도 hash 분포에 따라 비슷한 수의 partition으로 나뉜다. 같은 항목 하나를 base partition에 한 번 적고, GSI partition에도 GSI 키 기준으로 한 번 더 적는 구조다.

GSI 구조: 왼쪽에 base 테이블이 partition key userId로 5개 partition에 흩어져 있고, 오른쪽에 email GSI가 같은 항목들을 email hash로 5개 partition에 다시 분산해 둔 모습. 화살표 한 줄이 base의 한 항목과 GSI의 같은 항목을 연결한다.

GSI를 만든다는 건 같은 데이터를 base와 GSI에 각각 한 번씩 더 적는다는 뜻이다. base에 PutItem을 보내면 DynamoDB가 먼저 base partition에 적고, GSI 키 schema나 projection에 영향이 있으면 GSI partition에도 따로 반영한다. 이 반영은 첫 번째 쓰기와 비동기로 일어난다. AWS Developer Guide "Using Global Secondary Indexes in DynamoDB"가 적은 그대로 "eventually consistent model"이다. base에 쓴 직후 GSI를 읽으면 그 항목이 아직 안 보일 수 있다. 보통은 짧은 propagation delay 뒤에 따라오지만, 방금 쓴 항목을 GSI에서 바로 확인해야 하는 워크로드에는 맞지 않다.

LSI는 base와 같은 partition을 공유한다

LSI는 같은 base 테이블의 같은 partition 안에서 sort key만 다른 두 번째 정렬을 추가한다. partition key는 base와 같아야 하고(고를 수 없다), sort key만 다른 base 테이블 속성으로 정한다. 그래서 "local"이다. 한 partition key 값이 어느 partition에 있는지가 base와 같으므로, LSI를 Query하면 그 한 partition만 읽고 끝난다.

같은 사용자 항목을 다시 예로 들면, base는 userId PK + createdAt SK로 시간 순서를 가졌고, LSI는 userId PK + lastLoginAt SK로 같은 사용자의 항목을 다른 시간 축으로 정렬해 둔다. base와 LSI가 같은 partition을 공유하니까, DynamoDB는 base에 쓰는 순간 그 partition 안의 LSI도 함께 바꾼다. AWS Developer Guide "Local secondary indexes"는 이 동기화를 "DynamoDB automatically keeps all local secondary indexes synchronized with their respective base tables"로 적는다. (DynamoDB가 LSI를 base 테이블과 자동으로 동기화한다.) base 쓰기 직후 LSI를 strong read로 읽으면 방금 쓴 항목을 그대로 읽는다. LSI가 strongly consistent read를 옵션으로 고를 수 있는 것도 이 동기화 덕분이다.

LSI 구조: 한 partition 안에 같은 항목들이 두 가지 정렬 키로 정리돼 있다. 왼쪽 정렬은 base의 createdAt 순서, 오른쪽 정렬은 LSI의 lastLoginAt 순서. 두 정렬이 같은 partition slice 안에 함께 들어 있고, base 쓰기 한 번이 두 정렬을 모두 갱신한다.

LSI는 base 쪽 partition 한 곳에서 모든 일이 끝나는 인덱스다. 그래서 AWS는 LSI throughput 비용을 base 테이블 비용에 합쳐서 청구한다. AWS Developer Guide "Local secondary indexes"는 이 부분을 "Queries or scans on a local secondary index consume read capacity units from the base table"로 적어 둔다. (LSI 쿼리·스캔은 base 테이블의 read capacity units를 소비한다.) base 테이블에 1,000 RCU를 줬으면 LSI 쿼리도 그 1,000 RCU를 함께 쓴다. GSI는 base와 별도의 RCU/WCU를 자기 인덱스에 두고 따로 청구한다. 같은 100만 항목에 LSI 두 개 + GSI 두 개를 만들었다고 하면, LSI 둘은 base의 capacity를 같이 쓰고, GSI 둘은 각각 자기 capacity를 추가로 챙겨야 한다.

projection은 인덱스에 무엇을 복사할지 정한다

인덱스가 base 테이블의 모든 속성을 자동으로 담는 것은 아니다. 인덱스를 만들 때 어떤 속성을 복사할지 정한다. 이 결정이 projection이고, AWS Developer Guide의 Projection API 정의는 세 가지 옵션을 둔다. KEYS_ONLY는 base의 PK·SK와 인덱스의 PK·SK만 복사한다. 인덱스 항목이 가장 작아진다. INCLUDE는 KEYS_ONLY에 내가 지정한 비-키 속성을 더 복사한다. 자주 같이 읽는 속성 두세 개만 골라 둘 때 쓴다. ALL은 base의 모든 속성을 그대로 인덱스에 복사한다. 인덱스 한 항목이 base 한 항목만큼 커진다.

Projection 종류: 같은 base 항목이 KEYS_ONLY, INCLUDE(name·email 두 속성 추가), ALL 세 가지 projection으로 인덱스 안에 어떻게 보이는지를 세 박스로 나눠 그린 비교. KEYS_ONLY 박스는 키 두 줄, INCLUDE 박스는 키 + 두 속성, ALL 박스는 base 항목 전체가 그대로 들어 있다.

projection 결정이 비용에 직접 영향을 미친다. 이유는 두 가지다. 하나는 저장 공간이다. ALL을 고른 인덱스는 base 항목의 사본을 통째로 한 번 더 적어 두므로 저장 공간이 base만큼 더 든다. ALL 인덱스 5개를 만들면 같은 데이터를 base 포함 6번 보관하는 셈이다. 다른 하나는 write throughput이다. base 항목에서 어떤 속성이 바뀌었는지가 DynamoDB가 인덱스를 다시 쓰는지 여부를 결정한다. AWS Developer Guide "Using Global Secondary Indexes in DynamoDB"는 projection된 속성이 바뀐 경우에만 DynamoDB가 인덱스를 다시 쓴다고 설명하고, 인덱스 키 자체가 바뀌면 쓰기가 두 번 필요하다고 단서를 둔다. 이때는 옛 키 항목을 지우고 새 키 항목을 다시 넣는다. ALL projection 인덱스는 어떤 속성 변경에도 인덱스를 건드릴 가능성이 가장 크고, KEYS_ONLY 인덱스는 인덱스 키와 base 키만 갖고 있어서 그 키들이 바뀌지 않으면 갱신되지 않는다. 다만 KEYS_ONLY라도 인덱스 키 속성 자체가 바뀌면 인덱스 쓰기가 두 번 따라온다.

INCLUDE를 고를 때 한 가지 한도가 있다. AWS의 "Quotas in Amazon DynamoDB"는 "the total count of NonKeyAttributes summed across all of the secondary indexes must not exceed 100"이라고 적는다. (모든 secondary index의 NonKeyAttributes 총합이 100을 넘을 수 없다.) 인덱스 5개에 INCLUDE 속성을 20개씩 넣었으면 합이 100이 돼 한도에 부딪힌다. 이 한도는 INCLUDE 인덱스에만 세고, KEYS_ONLY와 ALL은 카운트하지 않는다. INCLUDE를 무한정 늘려 가며 "그냥 다 넣어 두자"가 안 되는 이유다.

GSI는 비-projection 속성을 base에서 못 가져온다

projection을 짜다 보면 자연스럽게 떠오르는 의문이 있다. 인덱스에 안 적힌 속성을 그래도 받아 보고 싶다면 어떻게 되는가. AWS Developer Guide "Improving data access with secondary indexes"의 "Projected Attributes" 행이 이 질문에 두 줄로 답한다.

GSI 쪽 줄: "With global secondary index queries or scans, you can only request the attributes that are projected into the index. DynamoDB does not fetch any attributes from the table." (GSI 쿼리·스캔에서는 인덱스에 projection된 속성만 요청할 수 있다. DynamoDB는 base 테이블에서 어떤 속성도 가져오지 않는다.)

LSI 쪽 줄: "If you query or scan a local secondary index, you can request attributes that are not projected in to the index. DynamoDB automatically fetches those attributes from the table." (LSI 쿼리·스캔에서는 인덱스에 projection되지 않은 속성도 요청할 수 있다. DynamoDB가 base 테이블에서 그 속성들을 자동으로 가져온다.)

같은 두 줄이 같은 일을 다르게 처리한다. LSI는 base와 partition을 공유하니까, 인덱스에 없는 속성을 요청하면 DynamoDB가 같은 partition 안의 base 항목을 한 번 더 읽어 그 속성을 채운다. 이 fetch는 RCU를 더 쓰고 응답 시간도 조금 늘리지만, 나중에 필요해진 속성 때문에 인덱스를 다시 만들 필요는 없다. GSI는 base와 partition이 분리돼 있어서 DynamoDB가 base 속성을 같이 가져오지 않는다. 그래서 GSI 쿼리에서는 projection된 속성만 읽는다.

이 차이는 운영 단계에서 바로 부담으로 돌아온다. GSI에 나중에 필요해진 속성이 생기면 새 GSI를 만들고 옛 GSI를 지워야 한다. AWS Developer Guide "Managing Global Secondary Indexes in DynamoDB"와 UpdateTable API 정의가 보여 주는 그대로, 기존 GSI는 provisioned throughput 같은 일부 설정만 운영 중에 바꿀 수 있고 projection 자체는 인덱스를 만들 때 정하고 그 뒤에는 바꾸지 못한다. 새 GSI를 만드는 일은 base 테이블 위에 다시 한 번 partition 단위로 데이터를 적는 일이라 시간이 오래 걸리고, 그동안 GSI에 추가 throughput도 필요하다. projection 단계에서 어떤 부담을 떠안을지를 이때 함께 결정한다.

GSI 쓰기는 비동기, LSI 쓰기는 base와 같이 일어난다

이 차이 때문에 읽기 일관성 선택도 다르다. AWS Developer Guide "DynamoDB read consistency"는 두 가지를 선언한다. "Queries on global secondary indexes support eventual consistency only." (GSI 쿼리는 최종적 일관성만 지원한다.) 그리고 "When you query a local secondary index, you can choose either eventual consistency or strong consistency." (LSI 쿼리는 최종적 일관성과 강한 일관성 중에 고를 수 있다.) 같은 조회라도 인덱스 종류에 따라 답이 다르다.

GSI는 base 쓰기를 먼저 끝내고, 그다음에 DynamoDB가 GSI partition에 비동기로 반영한다. AWS Developer Guide "Using Global Secondary Indexes in DynamoDB"는 그 사이에 "short propagation delay between a write to the parent table and the time when the written data appears in the index"가 있다고 적는다. (parent 테이블에 쓴 뒤 인덱스에 데이터가 보일 때까지 짧은 propagation delay가 있다.) 평소에는 짧은 시간 안에 따라오지만, 트래픽이 폭주하거나 GSI 쪽 throughput이 부족하면 지연 시간도 더 늘어난다. 직전에 쓴 항목을 인덱스에서 바로 읽어야 하는 작업이라면 GSI로는 그 요구를 만족하지 못한다.

GSI 비동기 복제: 클라이언트가 base PutItem을 보내면 base partition에 먼저 적힌다. 그 직후 다른 partition 공간의 GSI partition으로 변경 사항이 비동기로 전달되어 인덱스가 갱신된다. 두 번째 단계가 비동기여서, 클라이언트가 곧바로 GSI를 읽으면 옛날 값이 보일 수 있다.

LSI는 base 쓰기와 LSI 갱신을 같은 partition 안에서 함께 처리하니까 strong consistency를 줄 수 있다. base에 쓴 직후 LSI를 strong read로 읽으면 방금 쓴 항목을 그대로 읽는다. 이 보장 때문에 LSI는 base와 같은 일관성을 가지는 부수적 정렬로 쓰기 좋다. 같은 사용자의 항목을 다른 시간 축으로 정렬해서 방금 막 적은 항목까지 빠짐없이 읽고 싶을 때 LSI가 들어맞는다.

LSI는 같은 partition 키 값에 10 GB 한도를 끌어들인다

LSI는 base와 partition을 공유하기 때문에 partition key 값마다 추가 제한이 붙는다. AWS Developer Guide "Improving data access with secondary indexes"가 "Size Restrictions Per Partition Key Value" 행에 적은 한도다. "For each partition key value, the total size of all indexed items must be 10 GB or less." (한 partition key 값마다 인덱스에 들어간 모든 항목의 총합이 10 GB 이하여야 한다.) 한 partition key 값 아래에서 base 항목과 LSI 사본을 합친 크기가 10 GB를 넘으면 그 partition은 더 이상 쓰기를 받지 못한다.

숫자로 쓰면 한도가 얼마나 빨리 차는지 더 분명하다. 한 사용자의 항목 하나가 4 KB이고 LSI 두 개가 ALL projection이면, DynamoDB는 같은 partition key 값 아래에 항목 하나를 base 4 KB + LSI 두 개 × 4 KB = 12 KB로 저장한다. 10 GB / 12 KB ≈ 83만 개. 한 사용자가 항목 80만 개를 쌓는 일은 흔하지 않지만, partition key가 큰 단위(예: tenant 한 명, 회사 한 개) 아래에 항목이 무한히 쌓이는 access pattern이라면 그 한도에 닿는다. AWS 같은 문서가 "If you expect that the sum of table and index items for a particular partition key value might exceed 10 GB, consider whether you should avoid creating the index"라고 분명하게 적는 이유다. (한 partition key 값의 base + 인덱스 합이 10 GB를 넘을 가능성이 있으면 그 인덱스를 안 만드는 쪽을 고려하라.) base에는 이런 한도가 없고, GSI에는 partition 공간이 별개라 partition key 값 단위 같은 것도 없다.

LSI를 가진 테이블은 한 partition key 값 아래에서 얼마나 자라날 수 있는가 자체가 다르게 막혀 있다는 뜻이다. 처음 LSI를 만들기로 결정하는 단계는, 사실은 이 PK 값에 LSI를 더한 합계가 10 GB를 안 넘는가를 같이 결정하는 단계이기도 하다.

GSI 한도는 20개, LSI 한도는 5개. LSI는 테이블을 만들 때만 정한다

서비스 한도 두 개도 같이 본다. AWS "Quotas in Amazon DynamoDB"는 GSI를 "up to 20 global secondary indexes per table (default quota)"로, LSI를 "up to 5 local secondary indexes per table"로 적어 둔다. GSI 20은 default quota라 AWS Service Quotas로 상향을 신청할 수 있다. LSI 5는 fixed quota라 상향 자체가 없다.

수보다 더 운영에 영향을 주는 건 언제 만들 수 있느냐다. AWS Developer Guide "Online Index Operations"는 GSI는 테이블을 만들 때 같이 만들 수도 있고, 기존 테이블에 새로 추가하거나 삭제할 수도 있다고 적는다. 반면 LSI는 테이블을 만들 때만 정의할 수 있고, 기존 테이블에는 추가도 삭제도 할 수 없다.

LSI는 테이블을 만들 때 한 번 정하면 끝난다. 나중에 새 LSI가 필요해지면 base 테이블을 다시 만들고 데이터를 마이그레이션해야 한다. GSI는 운영 중에도 인덱스를 추가하고 뺄 수 있다. 그래서 정렬 요건이 바뀔 가능성이 크면 LSI보다 GSI 쪽이 운영 변화에 버티기 쉽다.

인덱스 선택 비교. base PK로는 처리하지 못하는 조회가 있을 때, 같은 PK를 유지한 채 sort key만 바꾸고 strong consistency가 필요하면 LSI로 간다. PK 자체를 다른 속성으로 바꿔야 하거나 운영 중에 추가해야 하면 GSI로 간다. LSI 쪽에는 partition key 값당 10 GB 제한과 테이블 생성 시점에만 정의할 수 있다는 조건이 붙고, GSI 쪽에는 별도 capacity와 비동기 복제가 따라온다.

인덱스를 무한히 늘리지 않는다

GSI는 운영 중에도 만들 수 있고 base와 별도의 capacity를 가지기 때문에, 새 access pattern이 떠오를 때마다 나는 인덱스를 하나 더 만들고 싶다는 충동을 느낀다. RDBMS에 인덱스를 무한히 만들지 않는 것과 같은 이유로, DynamoDB GSI도 같은 방식으로 늘려선 안 된다. AWS Developer Guide "Using Global Secondary Indexes in DynamoDB"는 한 base 쓰기의 총 throughput 비용을 base 쓰기와 영향 받은 GSI 갱신의 합으로 계산한다고 적는다. 영향 받는 GSI 수가 늘수록 그 합도 같이 늘어난다. 인덱스 항목의 크기, 인덱스 키가 바뀌었는지(이 경우 두 번의 인덱스 쓰기), projection된 속성이 바뀌었는지가 합산식의 변수다. 결과적으로 GSI 수와 projection 폭은 한 번의 클라이언트 쓰기를 partition 단위에서 얼마나 부풀리는지를 정하는 두 입력값이 된다. 인덱스를 늘리는 결정은 그 부풀림을 그대로 비용으로 받겠다는 결정이다.

AWS Developer Guide "Best practices for using secondary indexes in DynamoDB"는 인덱스를 늘리는 결정에 storage 비용과 인덱스 갱신에 따른 I/O 비용이 같이 따라온다는 점을 짚는다. 인덱스 한 개를 추가하기 전에 이 access pattern에 정말 인덱스가 필요한지, 아니면 base 테이블의 키 설계로 풀 수 있는지, 또는 access pattern 빈도가 인덱스 storage·write 비용을 정당화하는지 하나씩 확인해야 한다. "그냥 만들어 두면 편하니까"라는 이유는 비용을 정당화하지 못한다.

인덱스 추가 비용: 가운데 base 항목 한 개에 PutItem 한 번이 들어오는 모습. 그 PutItem이 base partition에 먼저 적히고, 영향을 받는 GSI 쪽으로 화살표가 비동기로 뻗어 나간다. 그림 위쪽엔 영향 받는 GSI가 적은 일반 시나리오, 아래쪽엔 더 많은 GSI가 영향을 받는 시나리오가 두 줄로 비교돼 있어 한 번의 클라이언트 쓰기가 영향 받는 인덱스 수에 따라 어디까지 부풀려질 수 있는지를 보여 준다.

DynamoDB 인덱스가 맞지 않는 경우

같은 조회 요구라도 DynamoDB GSI/LSI가 맞지 않는 경우가 있다. 임의 조합으로 ad-hoc 질문을 던지는 분석 워크로드가 그렇다. "지난 30일 매출을 region 별로, 카테고리 별로, 가입 코호트 별로 다 같이 본다"는 RDBMS의 GROUP BY와 인덱스 조합으로 처리할 일이지, GSI 다섯 개로 풀 문제는 아니다. 이런 질문은 미리 키 schema를 정할 수 없다는 뜻인데, GSI/LSI는 키 schema가 먼저 정해져야 만든다.

처음 보는 데이터에 대해 ad-hoc 쿼리를 자주 던지는 BI 워크로드도 마찬가지다. DynamoDB는 GetItem/PutItem/Query처럼 미리 정한 access pattern을 빠르게 처리하는 도구지, 아직 정하지 않은 질문을 받아 주는 도구는 아니다. AWS Developer Guide도 access pattern을 먼저 정한 뒤 키 설계로 들어가라고 권장한다. 인덱스는 미리 정한 access pattern을 처리하는 도구다.

다음에 인덱스를 만들 때는 access pattern을 먼저 한 줄로 적는다. 어떤 키 값으로 어떤 정렬 결과를 받고 싶은지 먼저 적어 두지 않으면 GSI를 쓸지 LSI를 쓸지 판단이 서지 않는다. 그러면 10 GB 한도와 비동기 복제 같은 문제를 운영 단계에서 뒤늦게 처리해야 한다. base 테이블의 PK로 처리하지 못하는 조회가 보이면, 그다음에는 같은 partition 안에서 다른 정렬이 필요한지, 아니면 partition 분포 자체를 바꿔야 하는지 묻는다. 이 질문에 답하면 LSI를 쓸지 GSI를 쓸지 정할 수 있다.

YouTube 영상

채널 보기
Trie 자료구조 파이썬 구현: Search와 Starts With 연산 | Trie 자료구조 이야기
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학
직교성과 벡터 투영 | 선형대수학
마지막편, 트라이 노드를 50% 이상 줄이는 방법? 압축 트라이 성능 분석 | Trie 자료구조 이야기
스칼라 곱셈과 내적의 기하학적 의미 | 선형대수학
행렬의 기본 연산 - 행렬 덧셈, 스칼라 곱, 전치 | 선형대수학
트라이(Trie)에서 단어를 삭제하는 방법 | Trie 자료구조 이야기
투영과 예측, 그리고 선형 결합 | 선형대수학