채널톡의 DynamoDB 핫 파티션 연재 2편으로, 1편에서 설계한 인덱스 테이블 전파 파이프라인을 실제 운영 환경에 올린 순서와 그 과정에서 마주한 문제를 다룬다. 설계만 보면 변경 이벤트를 읽어 새 테이블에 쓰고 Export 이후 이벤트를 따라잡으면 끝날 것 같았지만, 기존 GSI가 알아서 처리하던 키 변경·키 삭제·처리 속도 제어를 전부 직접 구현해야 했다. 다섯 번의 배포를 거쳐 쓰기 속도 제어 방식을 Token Bucket에서 Leaky Bucket으로 바꾸고 나서야 안정됐다.
핵심 포인트- Rate Limiter를 DynamoDB 쓰기 뒤에 두면 실제 소모 WCU를 알 수 있어 자연스러워 보이지만, 이미 쓰기를 보낸 뒤라 핫 파티션을 막지 못한다.
- Token Bucket은 토큰이 쌓여 있으면 순간 버스트를 허용한다. 일반 API에는 괜찮지만 DynamoDB 핫 파티션은 평균보다 순간 쓰기 속도에 훨씬 민감했다.
- 필요했던 것은 평균 속도를 맞추는 Rate Limit이 아니라 DynamoDB가 받아들일 수 있는 속도로 꾸준히 흘려보내는 Leaky Bucket이었다.
- GSI는 키를 만들 수 있는 아이템만 인덱스에 올린다. 이 조건을 재현하지 않으면 조회 결과가 기존 GSI보다 많아진다.
- GSI는 같은 PK/SK 중복을 허용하지만 테이블의 primary key는 유니크해야 해서, SK에 원본 id를 함께 넣어야 했다.
상세 정리- 사전 준비 1 Streams: 인덱스 테이블은 새 값만 보고 쓰면 안 된다. GSI key가 바뀌거나 사라지면 이전 key 위치의 아이템도 지워야 하므로 변경 전후 이미지를 모두 볼 수 있게 설정했다.
- Kinesis 보관 기간도 늘렸다. Export와 Glue ETL, Import가 끝난 뒤에도 Export 시점 이후의 이벤트를 다시 읽어야 해서, 마이그레이션 중에는 Catch-Up을 시작할 시간을 충분히 남기는 쪽이 중요했다.
- 사전 준비 2 스키마: PK는 기존 GSI의 partition key와 동일하게 뒀지만 SK는 그대로 쓰지 않았다. GSI는 같은 PK/SK 조합의 아이템이 여러 개 있어도 되지만 테이블 primary key는 유니크해야 하기 때문이다.
- 그래서 SK 값에 기존 GSI sort key와 원본 User의 id를 함께 넣는 방식으로 설계했다.
- 사전 준비 3 권한과 rule: Kinesis 읽기, DynamoDB 쓰기, SQS 접근 권한을 서비스 계정에 추가하고, 사내 Rate Limiter 마이크로서비스에 파티션별 쓰기 속도 제한 rule을 등록했다.
- 1차 마이그레이션: DynamoDB Export로 PITR 스냅샷을 S3로 내보냈다. 이 단계에서 가장 중요한 값은 Export 시각으로, 나중에 Kinesis 이벤트를 다시 읽는 기준이 되므로 따로 기록해 뒀다.
- Glue Job이 한 네 가지: S3의 gzip JSON을 읽고, 인덱스 키를 만들 수 있는 아이템만 남기고, 인덱스 테이블의 PK/SK를 새로 쓰고, Import가 읽을 수 있는 형태로 다시 S3에 저장했다.
- DynamoDB Import는 S3 데이터를 기반으로 새 테이블을 만들기 때문에 빈 테이블을 미리 만들지 않고 Import 단계에서 쓸 테이블 이름과 PK/SK 스펙만 먼저 정했다.
- Catch-Up 배포: Import가 끝난 시점의 인덱스 테이블은 Export 시점 기준 데이터만 담고 있고 그동안에도 쓰기가 계속 들어왔다. Kinesis 시작 위치를 Export 시각보다 몇 분 이른 시각으로 잡아 근처 이벤트를 놓치지 않도록 했다.
- 따라잡은 뒤 2차 배포에서는 시작 위치를 timestamp가 아닌 방식으로 바꾸고 값을 비워, 새 consumer가 떠도 보관 중인 레코드 기준으로 읽고 checkpoint가 있으면 그 이후부터 이어 처리하게 했다.
- GSI 동작 재현 1 생성 조건: GSI는 원본 테이블의 모든 아이템을 복사하지 않고 GSI key를 만들 수 있는 아이템만 인덱스에 올린다. 그래서 이벤트의 new image를 보고 키를 만들 수 있을 때만 인덱스 아이템을 생성했다.
- GSI 동작 재현 2 키 변경: GSI key에 들어가는 값이 바뀌면 기존 위치의 아이템을 지우고 새 위치에 다시 써야 한다. old image로 이전 키를, new image로 새 키를 만들어 둘이 다르면 old를 삭제한 뒤 new에 쓴다. GSI가 내부에서 하던 Delete + Put을 대신 처리하는 구조다.
- GSI 동작 재현 3 삭제: remove 이벤트에는 new image가 없고 old image만 남으므로 old image로 기존 인덱스 키를 다시 계산해야 한다. old image에도 키를 만들 값이 없다면 애초에 인덱스에 올라간 적이 없으므로 아무 작업도 하지 않는다.
- 삭제 시에는 삭제된 상태를 나타내는 아이템을 조건부로 쓰고 TTL을 걸어 나중에 정리되게 했다. 오래된 이벤트가 늦게 도착해 삭제된 아이템을 되살리는 것을 막기 위해서다.
- GSI 동작 재현 4 순서: 핫 파티션으로 판단된 쓰기가 SQS로 우회되거나 배포·리밸런싱으로 지연되면 오래된 이벤트가 최신 인덱스 아이템을 덮어쓸 수 있다. 그래서 모든 쓰기에 원본 변경 순서를 비교할 값을 넣고, 지금 쓰려는 이벤트가 더 최신일 때만 반영되도록 조건부 PutItem을 보냈다.
- 1차 배포의 실패: Rate Limiter를 쓰기 뒤에 두고 성공한 쓰기의 실제 WCU로 토큰을 차감하도록 했는데, 배포 직후 Rate Limiter가 판단하기도 전에 DynamoDB 레벨에서 핫 파티션 쓰로틀링이 발생했다. 그래서 Rate Limit을 쓰기 앞으로 옮겨 통과한 요청만 쓰도록 바꿨다.
- 2차 배포: 순서를 바꾼 뒤에도 쓰로틀링이 남았다. 초당 1000개 수준으로 잡힌 rule이 Token Bucket이라 버킷에 남은 토큰과 다음 refill이 겹치면 짧은 구간에 훨씬 많은 쓰기가 들어갈 수 있었다. 버스트 폭을 줄였더니 순간 버스트는 줄었지만 바로 써도 되는 이벤트까지 우회됐다.
- 3·4차 배포: SQS로 들어가는 속도가 소비 속도보다 빨라 메시지가 쌓였다. 한 번 핫 파티션으로 판단되면 해당 partition key를 10초간 인메모리 맵에 마킹해 그동안 같은 키 이벤트를 모두 SQS로 빼는 동작과, Consumer가 rate limit에 걸리면 대기하던 방식을 문제로 봤다.
- 대기 대신 에러를 던지고 다음 메시지를 처리하도록 바꿨다. 처리 못 하는 메시지는 visibility timeout이 지나면 다시 보이게 되므로, 한 메시지 앞에서 기다리는 대신 뒤로 미루는 방식이다.
- 그래도 부족했던 이유: delete 속도가 send보다 조금 빨라진 정도로는 이미 쌓인 메시지를 SQS 보관 기간 안에 다 처리할지 확신하기 어려웠다. 한도를 낮추면 처리 가능한 트래픽까지 우회되고 높이면 버스트가 재발하는 딜레마였다.
- 5차 배포에서 Leaky Bucket으로 바꾼 뒤 delete 속도가 send 속도를 확실히 앞질렀다.
- 성과: 4월 한 달 기준 분리 전 운영 구간에서 발생하던 쓰로틀링이 분리 후 같은 기간에는 사라졌고, 사용자 목록 조회 기능은 유지하면서 GSI 한 파티션에 쓰기가 몰려 User 테이블 쓰기까지 막히는 구조를 제거했다.
- 관측성 개선: GSI를 쓸 때는 메인 테이블 변경이 언제 인덱스에 반영됐는지 DynamoDB 내부 처리라 알 수 없었다. 직접 만든 뒤로는 Kinesis 레코드 생성 시각, 처리 종료 시각, Rate Limiter 응답, SQS 상태를 커스텀 메트릭으로 적재해 stream 지연, 전파 소요 시간, 파티션 쓰기 한계 근접 여부, 우회된 이벤트 적체, 재시도 후 미처리 이벤트를 대시보드로 본다.
- 다음 편 예고: 이 글은 인덱스 테이블을 채우고 운영하는 과정이고, 실제 서비스 요청이 인덱스 테이블을 바라보게 하는 조회 코드 전환과 페이지네이션·삭제 데이터 처리는 다음 글에서 다룬다.
왜 읽나DynamoDB GSI를 별도 테이블로 떼어내려는 팀이 GSI가 대신해 주던 동작을 어디까지 직접 구현해야 하는지, 그리고 쓰기 속도 제어를 어떤 순서와 알고리즘으로 잡아야 하는지를 다섯 번의 실패와 함께 확인할 수 있다.