DynamoDB 핫 파티션을 해결하는 3가지 방법 (3): 조회를 인덱스 테이블로 옮기기
GSI가 조용히 해주던 일들, 이제는 라이브러리가 대신합니다
Ino
- Backend
들어가며
안녕하세요, 채널톡 백엔드 엔지니어 이노입니다.
1편에서는 managed GSI 한 곳의 핫 파티션이 User 테이블 전체의 쓰기를 막아 세운 장애를 분석하고, GSI를 별도 인덱스 테이블로 떼어내는 설계를 세웠습니다.
2편에서는 22억 건을 옮기고, ch-flow-shard 파이프라인이 GSI가 쓰기에서 하던 일을 대신하도록 만들어, user_managed_index 테이블이 실시간으로 동기화되는 상태까지 왔죠. WriteThrottleEvents는 월 110만 회에서 0회가 되었습니다.
그런데 여기서 끝이 아닙니다. 조회 코드는 여전히 GSI를 보고 있었거든요. 조회가 GSI를 읽는 한 GSI는 지울 수 없고, GSI가 남아 있는 한 핫 파티션의 위험도 남습니다.
"파이프라인이 다 만들어졌는데, 조회만 옮기면 되는 거 아닌가요?"
저도 처음엔 그렇게 생각했습니다. 그런데 GSI가 읽기 경로에서 몰래 해주던 일이 생각보다 많았습니다. 키 해석, 정확히 일치하는 값 찾기, 삭제된 데이터 걸러내기, 페이지 채우기.. 전부 DynamoDB가 알아서 해주던 것들이죠. 인덱스 테이블은 "그냥 테이블"이라서, 이제 누군가는 이 일을 직접 해야 합니다.
저희는 그 "누군가"를 각 조회처가 아니라 사내 공통 라이브러리로 정했습니다. GSI가 읽기에서 해주던 일을 라이브러리의 공통 조회 규칙으로 모아서, 호출부는 GSI를 읽든 인덱스 테이블을 읽든 같은 코드를 쓰게 만든 거죠.
2편이 GSI의 쓰기를 파이프라인이 재현한 이야기였다면, 3편은 GSI의 읽기를 라이브러리가 재현한 이야기입니다. 예고해 드렸던 페이지네이션과 삭제 데이터 처리의 함정들이 바로 그 규칙 안에 들어 있습니다.
먼저, GSI를 읽는 곳부터 찾았습니다
애플리케이션 서버에서 managed GSI를 쿼리하는 곳은 세 곳이었는데, 접근 패턴이 전부 달랐습니다.
접근 패턴 | 핵심 요구 |
|---|---|
데이터 존재 확인 | 대상을 놓치면 안 됨 |
전량 페이지네이션 | 페이지가 끊기면 안 됨 |
최신순(desc) 목록 획득 | 정렬·커서 호환 |
같은 GSI를 보고 있어도 하나는 "한 건이라도 있나", 하나는 "빠짐없이 전부", 하나는 "최신 순서로 보기 좋게"를 원하는 상황.
이렇게 놓고 보니 분명해졌습니다. 키 변환, 삭제 필터, 페이지 채우기 같은 어려운 부분을 조회처마다 각자 구현하면 같은 함정을 세 번 밟게 됩니다. 게다가 managed는 시작일 뿐, 분리를 기다리는 GSI가 더 있었습니다. 조회처를 한 곳씩 고칠 게 아니라, 모두가 함께 쓸 공통 조회 규칙 한 벌을 만들어 풀 문제였던 거죠.
여담이지만, 조회처를 훑다가 비효율도 하나 발견했습니다. 존재 확인 로직이 1,000건짜리 페이지를 읽어 놓고 첫 한 건만 확인하고 있었거든요. 전환하면서 limit=1로 바꿔 읽기 비용을 줄였습니다. 마이그레이션은 오래된 코드를 다시 읽게 만드는 좋은 핑계이기도 합니다.
GSI가 해주던 일을 라이브러리로 모았습니다
그래서 그 공통 조회 규칙을 사내 공통 라이브러리에 담았습니다. 아이디어는 단순합니다. "인덱스"를 GSI와 인덱스 테이블 위의 공통 개념으로 끌어올리고, 차이는 전부 라이브러리 뒤로 숨긴다.
// 호출부가 보는 것: 인덱스가 GSI인지 테이블인지는 모른다
Iterable<Page<M>> pages = client.query(index, queryRequest);
Index는 두 타입을 가집니다. GSI 타입이면 기존처럼 GSI를 쿼리하고, TABLE 타입이면 별도 인덱스 테이블을 쿼리합니다. TABLE 타입일 때 라이브러리가 대신 해주는 일이, 정확히 GSI가 읽기에서 몰래 해주던 목록입니다.
같은 쿼리 진입점: GSI든 인덱스 테이블이든 호출 코드는 같고, 분기는 라이브러리가 합니다
합성 정렬 키 조립: 키를 합치고 변환하는 규칙을 라이브러리가 갖고 있습니다
EQ를 begins_with로 변환: 합성 키 위에서도 "정확히 일치" 조회가 원래 의도대로 동작합니다
soft-delete 자동 필터: 삭제 표시된 데이터를 알아서 걸러냅니다
인덱스 정의는 모델에 어노테이션으로 붙입니다. 이 어노테이션의 type 파라미터가 나중에 등장할 스위치입니다.
@Index(name = "managed", type = GSI, // ← 이 한 줄이 스위치
partitionKey = "ID", sortKey = "managedKey")
그림 1. 조회 규칙을 라이브러리로
달라진 키 모양에 맞춰 조회합니다
라이브러리가 흡수한 것 중 첫 번째, 키입니다. 인덱스 테이블은 GSI와 키 모양부터 다릅니다.
managed GSI: PK = ID, SK = managedKey (Number)
user_managed_index: PK = ID, SK = "managedKey#userId" (String)
예) managedKey=1699900000000, userId=user-aaa
→ SK = "1699900000000#user-aaa"
GSI는 같은 (PK, SK) 중복을 허용하지만 테이블의 primary key는 유니크해야 해서, SK 뒤에 userId를 붙여 키가 겹치지 않게 만들었습니다(2편). 쓰기에서는 이걸로 끝났지만 읽기에서는 숙제가 됩니다. 호출부는 여전히 managedKey로 묻는데, 테이블의 키는 합성 문자열이니까요. 이 간극을 메우는 변환이 라이브러리의 키 규칙입니다.
그림 2. 같은 데이터, 다른 키
문자열 키에서도 숫자 범위 비교가 됩니다
관리 대상 사용자(Managed User, MU) 한도 초과 체크·삭제의 쿼리 조건은 managedKey < cutoff입니다. GSI에서는 SK가 Number라 그대로 됐지만, 인덱스 테이블의 SK는 "1699900000000#user-aaa" 같은 문자열입니다. 문자열 위에서 숫자 범위 비교가 성립할까요?
성립합니다. managedKey는 밀리초 epoch 타임스탬프라서 13자리 고정 길이거든요. 자릿수가 같은 숫자 문자열은 사전순으로 비교해도 숫자로 비교해도 결과가 같습니다. 그래서 범위 조건(sortLessThan)에 숫자를 문자열로 넘기면 GSI 시절과 같은 범위 쿼리가 됩니다.
// SK가 "managedKey#userId" 문자열이지만, managedKey가 13자리 고정이라
// 사전순 == 숫자순. sortLessThan(문자열)이 범위 조건으로 성립한다.
QueryConditional.sortLessThan(
Key.builder().partitionValue(id)
.sortValue(Long.toString(cutoffEpochMillis)).build())
다만 이 성질은 암묵적인 전제입니다. 13자리 epoch ms가 14자리가 되는 2286년은 아직 멀었지만, 자릿수가 변하는 값을 합성 키에 넣는 순간 전제는 깨집니다. 전제는 흩어져 있을 때는 깨지기 쉽고, 한곳에 있을 때는 테스트로 지킬 수 있습니다. 이것도 규칙을 라이브러리에 두는 이유입니다.
"정확히 일치"는 begins_with로 바꿉니다
"특정 managedKey와 정확히 일치"는 어떨까요? SK에는 #userId가 따라붙어 있으니 equals로는 잡을 수 없습니다. 라이브러리의 키 빌더는 EQ 조건을 받으면 값 뒤에 구분자를 붙여 begins_with로 바꿉니다.
// EQ: escape 후 구분자 "#"를 붙여 begins_with로 매칭
// "1699900000000" → "1699900000000#"
// → "16999000000001..." 같은 더 긴 값과 섞이지 않는다
static String transformForEq(String sortValue) {
return escape(sortValue) + "#";
}
// 값 자체에 들어있는 #는 ##로 이스케이프 (구분자와 구별)
static String escape(String value) {
return value.replace("#", "##");
}
구분자를 붙이지 않으면 begins_with("100")이 1000, 1001까지 잡아버립니다. GSI 시절 DynamoDB가 타입 차원에서 보장하던 "정확히 일치"가, 합성 키 위에서는 이스케이프와 구분자라는 작은 약속으로 바뀌는 셈이죠. 이 변환을 호출부에 맡기면 언젠가 누군가는 구분자를 빼먹습니다. 그래서 호출부는 "정확히 일치"라고만 말하고, 변환은 라이브러리가 합니다.
삭제된 데이터를 걸러냅니다
라이브러리가 흡수한 것 중 두 번째, 삭제입니다.
2편을 기억하시는 분은 user_managed_index의 삭제가 물리 삭제가 아니라는 것도 기억하실 겁니다. 파이프라인은 REMOVE 이벤트를 받으면 아이템을 지우는 대신 _deleted=true를 마킹합니다(늦게 도착한 이벤트가 삭제된 아이템을 되살리는 것을 막기 위해서였죠). 이 tombstone은 TTL이 시간을 두고 정리합니다.
쓰기에서는 우아한 해법이었지만, 조회에는 숙제가 남습니다. 테이블에 섞여 있는 삭제 데이터를 걸러야 하죠. 거르는 것 자체는 라이브러리가 처리합니다. Index가 TABLE 타입이면 _deleted FilterExpression을 자동으로 끼워 넣어서, 호출부는 필터의 존재조차 모릅니다. 문제는, 필터로도 재현되지 않는 것이 하나 있었다는 겁니다.
limit은 필터보다 먼저 적용됩니다
DynamoDB의 Limit 파라미터는 필터를 적용하기 전, 읽은 아이템 수에 걸립니다. FilterExpression은 읽기가 끝난 뒤에 적용되고요. 무슨 일이 벌어질까요?
limit=5로 쿼리
→ DynamoDB가 5건을 읽음 (RCU도 5건만큼 소모)
→ 그중 2건이 tombstone → 필터로 제외
→ 반환: 3건 (+ LastEvaluatedKey)
여기서 처음 등장한 LastEvaluatedKey를 잠깐 설명하고 갈게요. DynamoDB가 응답에 실어 주는 "여기까지 읽었다"는 위치 표식입니다. 이 값이 있으면 아직 읽지 않은 데이터가 남아 있다는 뜻이고, 다음 요청에 그대로 넘기면 그 지점부터 이어서 읽습니다. 없으면 끝까지 다 읽었다는 뜻이죠.
다시 위 상황으로 돌아가면, limit=5를 요청했는데 3건이 옵니다. 페이지가 "덜 찬 채" 도착하는 거죠. 목록 화면이라면 페이지마다 항목 수가 들쑥날쑥한 정도지만, 존재 확인(has-any) 로직에서는 잘못된 판단으로 이어집니다. limit=1로 읽은 한 건이 하필 tombstone이면 응답은 0건인데, 그걸 "삭제 대상 없음"으로 해석하면 오판이니까요.
그림 3. limit은 필터 이전에 적용된다
페이지가 찰 때까지 다시 읽습니다: remaining 루프
그래서 조회 루프는 한 번의 query로 끝나지 않고, 남은 건수(remaining)를 기준으로 limit을 채우거나 데이터가 진짜 끝날 때까지 페이지를 순회합니다.
그림 4. remaining 루프
핵심은 종료 조건입니다. "응답이 비었다"는 끝이 아닙니다. LastEvaluatedKey가 없어야 끝입니다. tombstone 구간을 지나는 동안에는 아이템 0건에 LastEvaluatedKey만 있는 응답도 정상이거든요. has-any 체크가 안전한 이유도 이 루프 덕분입니다. 필터를 통과한 1건을 찾거나, 테이블 끝에 닿아야 멈추니까요.
이 루프야말로 라이브러리에 넣길 잘했다고 생각한 부분입니다. limit과 필터의 순서는 문서를 정독한 사람만 아는 함정이라, 세 곳이 각자 구현했다면 하나쯤은 분명 query 한 번으로 끝냈을 테니까요. 하필 그 하나가 has-any였을 거고요.
덧붙여 tombstone은 필터로 제외돼도 읽기 비용(RCU)은 소모합니다. TTL이 정리해 주므로 시간이 지나면 수렴하지만, 물리 삭제를 tombstone으로 바꾼 대가를 조회가 잠시 나눠 내는 셈입니다.
외부에서 설정하는 커서는 믿지 않습니다
전량 순회와 목록 조회에는 페이지네이션 커서가 필요합니다. 앞서 본 위치 표식 LastEvaluatedKey는 Map<String, AttributeValue> 구조인데, 조회 경로가 서비스 경계를 넘으면서 이 값이 API 응답에 실려 HTTP를 넘게 됐습니다. JSON으로 직렬화하고 Base64로 감싸 커서 문자열로 주고받았죠.
LastEvaluatedKey { ID: "...", SK: "1699900000000#user-aaa" }
→ JSON → Base64 → "eyJJRCI6Ii4uLiIs..." (다음 요청의 startKey)
프로세스 안에 머물던 값이 네트워크를 타는 순간, 성격이 바뀝니다. 되돌아온 커서는 더 이상 신뢰할 수 있는 값이 아닙니다. 그래서 디코딩한 startKey는 허용 필드 화이트리스트(ID, SK)로 검증하고, 모르는 필드나 잘못된 타입이 있으면 즉시 400으로 거절합니다.
그리고, 실전에서 버그 두 개를 만났습니다.
하나. 전진하지 않는 커서. 응답의 다음 커서(next)가 요청 커서(since)와 같은 값으로 돌아오는 경우가 있었습니다. "커서가 있으니 다음 페이지가 있겠지"라고만 판단하면 같은 페이지를 영원히 다시 읽는 무한 루프가 됩니다. 수정은 한 줄로 요약됩니다. 커서의 존재가 아니라 커서의 전진을 확인하라.
// Before: 커서가 있으면 무조건 다음 페이지 요청 → next == since면 무한 루프
// After: 커서가 전진하지 않으면 순회를 끝낸다
if (next != null && next.equals(since)) {
return; // no progress → 종료
}
둘. 대소문자가 다른 커서. 커서를 만들 때와 파싱할 때의 필드명이 한쪽은 SK, 한쪽은 sk로 어긋난 직렬화 버그도 있었습니다. 원인은 단순하지만 증상이 "두 번째 페이지부터 이상하다"로 나타나서, 첫 페이지만 보는 테스트로는 잡히지 않습니다. 이후로 페이지 경계 케이스들(limit=1, 빈 채널, 마지막 페이지 직후, 커서 재사용)을 회귀 테스트로 고정해 뒀습니다.
두 버그의 교훈은 같습니다. 페이지네이션 버그는 대부분 첫 페이지가 아니라 경계에서 삽니다. 그리고 GSI 시절에는 이 커서가 클라이언트 라이브러리 안에 숨어 있어서 우리가 실수할 기회조차 없었다는 것. 이것도 GSI가 몰래 해주던 일 중 하나였던 거죠.
조금씩 적용하고, 마지막에 GSI를 지웠습니다
규칙이 준비됐으니, 이제 실제 트래픽에 적용할 시간입니다.
그림 5. 조회 경로 전환 전/후
조회 전환은 되돌릴 길을 열어둔 채 단계적으로 진행했습니다. 조회가 GSI를 보는 동안에는 파이프라인에 무슨 일이 생겨도 프로덕션 영향이 없지만, 조회를 옮기는 순간부터 인덱스 테이블이 프로덕션 경로가 됩니다. 신중할 이유가 충분했죠.
채널 하나로 시작: 조회처에 채널 분기를 넣어, 사내 채널 하나만 새 경로를 타게 했습니다. 문제가 생기면 분기 하나만 걷어내면 됩니다.
검증: 같은 조건에 대해 GSI 경로와 인덱스 테이블 경로의 응답이 같은지 비교하고, 정렬 순서·페이지 경계·soft-delete 제외 여부를 확인했습니다.
전 채널 확장 + GSI 코드 제거: 분기 상수와 GSI 쿼리 코드를 지우고 새 경로 하나로 통합했습니다. 이 시점부터 애플리케이션 서버에는
managedGSI를 아는 코드가 없습니다.GSI 물리 삭제: 조회 트래픽이 0으로 수렴한 것을 확인하고, 인프라에서
managedGSI를 삭제했습니다.
그림 6. 점진 롤아웃 4단계
4번이 이 연재의 진짜 마침표입니다. 핫 파티션의 근원은 GSI 파티션의 물리 한계였고, GSI가 존재하는 한 그 위험은 잠재해 있으니까요. 이제 User 테이블에는 managed GSI가 없고, 쓰기는 파이프라인이, 읽기는 라이브러리가 감당합니다. Back-Pressure의 사슬이 구조적으로 끊긴 것입니다.
다음 GSI부터는 어노테이션 한 줄이면 됩니다
이 작업의 진짜 보상은 그 다음에 왔습니다. managed가 개척한 규칙 위에서, 다른 도메인의 GSI 분리가 이미 같은 패턴으로 진행되고 있습니다. 새 마이그레이션이 할 일은 어노테이션의 타입 한 줄을 바꾸는 것입니다.
// Before
@Index(name = "xxx", type = GSI, ...)
// After: 백엔드 스위치. 호출부는 무수정
@Index(name = "xxx", type = TABLE, tableName = "xxx_index_table", ...)
합성 키 변환도, soft-delete 필터도, 덜 찬 페이지도, 첫 전환에서 규칙에 흡수해 둔 함정들은 두 번 다시 각 호출부의 몫이 아닙니다. 첫 마이그레이션은 탐험이었지만, 다음 마이그레이션은 스위치입니다.
남은 일도 물론 있습니다. 캐시 서버 우회를 걷어내고, 애플리케이션 서버가 인덱스 테이블을 직접 쿼리하도록 복귀하는 것. 우회로는 만들 때보다 지울 때 더 의지가 필요하더라고요. 그래서 저희는 이렇게 말하곤 합니다. 임시 코드는 지워질 때까지가 마이그레이션이다.
마무리
세 편에 걸친 여정을 조회의 관점에서 요약하면 이렇습니다.
조회 전환이 마지막 조각이었습니다. 파이프라인이 아무리 잘 돌아도, 조회 코드가 GSI를 읽는 한 GSI를 지울 수 없습니다. GSI가 남아 있으면 핫 파티션의 위험도 남습니다.
GSI가 읽기에서 해주던 일을 이제 누군가 대신해야 합니다. 키 해석, 정확히 일치하는 값 찾기, 삭제된 데이터 거르기, 페이지 채우기. 저희는 이 일을 조회처마다 맡기는 대신 공통 라이브러리의 조회 규칙 하나로 모았고, 덕분에 호출부는 GSI를 읽든 인덱스 테이블을 읽든 같은 코드를 씁니다.
가장 큰 함정은 limit과 필터의 순서였습니다. DynamoDB는 필터를 적용하기 전에 limit만큼만 읽기 때문에, 페이지가 덜 찬 채 돌아올 수 있습니다. 그래서 조회의 끝은 "응답이 비었다"가 아니라 "LastEvaluatedKey가 없다"로 판단해야 합니다.
밖에서 돌아온 커서는 사용자 입력처럼 다뤄야 합니다. 허용한 필드만 통과시키고, 커서가 있는지가 아니라 커서가 앞으로 나아갔는지를 확인해야 무한 루프를 막을 수 있습니다.
공통화의 보상은 다음 마이그레이션에서 옵니다. 첫 전환에서 이 함정들을 전부 규칙에 흡수해 둔 덕분에, 다음 GSI 분리는 어노테이션 한 줄을 바꾸는 일이 됐습니다.
이 시리즈는 "핫 파티션을 해결하는 3가지 방법" 중 인덱스 테이블 분리를 설계(1편) → 쓰기 파이프라인(2편) → 조회 전환(3편)으로 완주했습니다. 다음에는 1편에서 예고했던, 단건 조회 위주 GSI에 키 샤딩을 적용한 사례로 찾아뵙겠습니다.
