채널톡이 DynamoDB 핫 파티션을 해결하려 GSI를 별도 인덱스 테이블로 떼어낸 연재의 마지막 편으로, 조회 경로를 GSI에서 인덱스 테이블로 옮긴 과정을 다룬다. 2편에서 쓰기 파이프라인이 완성돼 WriteThrottleEvents가 월 110만 회에서 0회가 됐지만, 조회 코드가 여전히 GSI를 읽고 있어 GSI를 지울 수 없었다. GSI가 읽기에서 대신해주던 키 해석·정확 일치·삭제 필터·페이지 채우기를 사내 공통 라이브러리의 조회 규칙으로 흡수해, 호출부는 GSI든 인덱스 테이블이든 같은 코드를 쓰게 만들었다.
핵심 포인트- 조회가 GSI를 읽는 한 GSI는 삭제할 수 없고, GSI가 남으면 핫 파티션 위험도 남는다. 조회 전환이 마이그레이션의 마지막 조각이었다.
- GSI를 쿼리하는 세 곳의 요구가 존재 확인, 전량 페이지네이션, 최신순 목록으로 전부 달라 조회처마다 구현하면 같은 함정을 세 번 밟게 된다고 판단했다.
- 인덱스를 GSI와 테이블 위의 공통 개념으로 올리고, 모델 어노테이션의 type 파라미터 한 줄을 GSI/TABLE 스위치로 삼았다.
- 가장 큰 함정은 DynamoDB의 Limit이 FilterExpression보다 먼저 적용된다는 점이다. tombstone이 섞이면 페이지가 덜 찬 채 돌아와 존재 확인 로직이 오판한다.
- 조회의 종료 조건은 "응답이 비었다"가 아니라 "LastEvaluatedKey가 없다"이며, 커서는 존재가 아니라 전진 여부를 확인해야 무한 루프를 막는다.
- 첫 전환에서 함정을 규칙에 흡수해둔 덕에 다음 GSI 분리는 어노테이션 타입 한 줄 변경으로 끝난다.
상세 정리- 조회처 조사: managed GSI를 쿼리하는 애플리케이션 서버 코드는 세 곳이었고 접근 패턴이 전부 달랐다. 하나는 한 건이라도 있는지, 하나는 빠짐없이 전부, 하나는 최신순 정렬과 커서 호환을 요구했다.
- 부수 발견: 존재 확인 로직이 1,000건짜리 페이지를 읽고 첫 한 건만 확인하고 있어, 전환하면서 limit=1로 바꿔 읽기 비용을 줄였다.
- 라이브러리 설계: Index 타입이 GSI면 기존대로 GSI를 쿼리하고 TABLE이면 인덱스 테이블을 쿼리한다. 같은 쿼리 진입점, 합성 정렬 키 조립, EQ의 begins_with 변환, soft-delete 자동 필터를 라이브러리 뒤로 숨겼다.
- 키 모양 차이: GSI는 PK=ID, SK=managedKey(Number)였지만 인덱스 테이블은 SK가 "managedKey#userId" 형태의 String이다. GSI는 (PK, SK) 중복을 허용하지만 테이블 primary key는 유니크해야 해서 userId를 붙였다.
- 문자열 범위 비교: managedKey가 밀리초 epoch라 13자리 고정 길이여서 자릿수가 같은 숫자 문자열은 사전순과 숫자순 결과가 같다. 그래서 sortLessThan에 숫자를 문자열로 넘겨도 GSI 시절과 같은 범위 쿼리가 성립한다.
- 다만 이 성질은 암묵적 전제다. epoch가 14자리가 되는 시점은 멀지만, 자릿수가 변하는 값을 합성 키에 넣는 순간 전제가 깨진다. 규칙을 한곳에 모아 테스트로 지키는 이유다.
- 정확 일치 변환: SK 뒤에 #userId가 붙어 equals로는 잡히지 않는다. 키 빌더가 EQ 조건을 받으면 값 뒤에 구분자 #를 붙여 begins_with로 바꾸고, 값 자체의 #는 ##로 이스케이프한다.
- 구분자를 안 붙이면 begins_with("100")이 1000, 1001까지 잡는다. GSI 시절 DynamoDB가 타입 차원에서 보장하던 정확 일치가 합성 키 위에서는 이스케이프와 구분자라는 약속으로 바뀌었다.
- soft-delete 처리: 파이프라인이 REMOVE 이벤트에 물리 삭제 대신 _deleted=true 마킹을 하고 TTL이 정리하는 구조라, Index가 TABLE 타입이면 라이브러리가 _deleted FilterExpression을 자동으로 끼워 넣는다.
- limit과 필터의 순서: limit=5로 쿼리하면 DynamoDB가 5건을 읽고 RCU도 5건만큼 소모한 뒤 필터를 적용한다. 그중 2건이 tombstone이면 3건만 반환된다. 목록 화면이면 항목 수가 들쑥날쑥한 정도지만 has-any 로직에서는 limit=1로 읽은 한 건이 tombstone일 때 0건 응답을 "대상 없음"으로 오판한다.
- remaining 루프: 남은 건수를 기준으로 limit을 채우거나 데이터가 진짜 끝날 때까지 페이지를 순회한다. tombstone 구간에서는 아이템 0건에 LastEvaluatedKey만 있는 응답도 정상이므로 종료 조건은 LastEvaluatedKey의 부재여야 한다.
- tombstone은 필터로 제외돼도 RCU는 소모한다. TTL 정리로 시간이 지나면 수렴하지만, 물리 삭제를 tombstone으로 바꾼 대가를 조회가 나눠 내는 셈이다.
- 커서 신뢰 문제: LastEvaluatedKey는 Map 구조인데 조회 경로가 서비스 경계를 넘으며 JSON→Base64 커서 문자열로 HTTP를 타게 됐다. 디코딩한 startKey는 허용 필드 화이트리스트(ID, SK)로 검증하고 모르는 필드나 잘못된 타입이면 400으로 거절한다.
- 실전 버그 1: 응답의 next 커서가 요청 커서 since와 같은 값으로 돌아와, 커서 존재만 보고 다음 페이지를 요청하면 같은 페이지를 무한히 다시 읽었다. 커서가 전진하지 않으면 순회를 끝내도록 고쳤다.
- 실전 버그 2: 커서 생성 시 SK, 파싱 시 sk로 필드명 대소문자가 어긋난 직렬화 버그가 있었다. 증상이 "두 번째 페이지부터 이상하다"로 나타나 첫 페이지만 보는 테스트로는 안 잡혔다. 이후 limit=1, 빈 채널, 마지막 페이지 직후, 커서 재사용 같은 경계 케이스를 회귀 테스트로 고정했다.
- 롤아웃 4단계: 사내 채널 하나에만 분기를 넣어 새 경로를 태우고, 같은 조건에서 두 경로의 응답·정렬 순서·페이지 경계·soft-delete 제외 여부를 비교 검증한 뒤, 전 채널로 확장하며 분기 상수와 GSI 쿼리 코드를 제거하고, 조회 트래픽이 0으로 수렴한 것을 확인한 다음 인프라에서 GSI를 물리 삭제했다.
- 남은 일: 캐시 서버 우회를 걷어내고 애플리케이션 서버가 인덱스 테이블을 직접 쿼리하도록 되돌리는 작업이 남았다. 임시 코드는 지워질 때까지가 마이그레이션이라는 표현으로 정리했다.
왜 읽나DynamoDB GSI를 별도 테이블로 분리하거나 합성 정렬 키·tombstone·커서 페이지네이션을 직접 구현해야 하는 백엔드 엔지니어에게 실패 사례가 붙은 체크리스트가 된다.