DynamoDB 핫 파티션을 해결하는 3가지 방법 (2): 인덱스 테이블로 GSI 떼어내기 구현편
인덱스 테이블 전파 파이프라인 구현과 운영 안정화 과정
Jayon • Jinyoung Park, Backend Enginner
- Backend
안녕하세요, 채널톡 백엔드 엔지니어 제이온입니다.
지난 1편, DynamoDB 핫 파티션을 해결하는 3가지 방법 (1): 인덱스 테이블로 GSI 떼어내기 설계편에서는 managed GSI의 핫 파티션이 왜 User 테이블 쓰기 실패로 이어지는지 분석하고, GSI를 별도 인덱스 테이블로 떼어내기로 했습니다.
DynamoDB Streams(Kinesis)와 ch-flow-shard 애플리케이션 서버를 이용해 User 변경분을 인덱스 테이블로 전파하는 파이프라인도 설계했죠.
1편의 설계만 놓고 보면, 남은 일은 꽤 단순해 보였습니다.
users 변경 이벤트를 읽어서 새 인덱스 테이블에 쓰고, Export 시점 이후의 이벤트를 따라잡으면 끝날 것 같았죠. 하지만 막상 구현해 보니 그렇게 간단하지 않았습니다.
약 22억 건의 기존 데이터를 옮기는 동안에도 User 테이블에는 계속 쓰기가 들어왔고, 기존 DynamoDB GSI가 알아서 처리하던 키 변경, 키 삭제, 처리 속도 제어도 ch-flow-shard 안에서 직접 구현해야 했습니다.
2편에서는 인덱스 테이블 전파 파이프라인을 실제로 배포한 순서와 배포 중 마주한 문제, 그리고 운영 환경에서 확인한 지표들을 구체적인 숫자와 함께 공유합니다.
온라인 인덱스 테이블 전파 파이프라인 수행
1편에서 설계한 파이프라인을 실제로 운영 환경에 올리는 순서는 다음과 같습니다.
AWS 인프라 세팅
ch-flow-shard 설정 추가
DynamoDB Export
AWS Glue ETL
DynamoDB Import
ch-flow-shard 1차 배포 -
AT_TIMESTAMP로 변경분 따라잡기ch-flow-shard 2차 배포 -
TRIM_HORIZON으로 시작 위치 되돌리기
AWS 인프라 세팅
먼저 ch-flow-shard가 User 테이블 변경 이벤트를 읽고, 인덱스 테이블에 쓸 수 있도록 AWS 리소스를 준비했습니다.
크게 보면 Streams, 인덱스 테이블 스키마 설계, 권한과 Rate Limiter Rule 세 가지입니다.
DynamoDB Streams(Kinesis) 활성화
첫 번째는 User 테이블의 변경 이벤트를 Kinesis Data Stream으로 흘려보내는 설정입니다.
여기서 중요한 설정은 두 가지였습니다.
하나는 StreamViewType=NEW_AND_OLD_IMAGES이고, 다른 하나는 Kinesis Data Stream의 보관 기간입니다.
인덱스 테이블은 새 값만 보고 쓰면 안 됩니다. 기존 GSI key가 바뀌거나 사라졌을 때는 이전 key 위치의 아이템도 지워야 하기 때문입니다. 그래서 ch-flow-shard가 변경 전후 이미지를 모두 볼 수 있도록 NEW_AND_OLD_IMAGES로 설정했습니다.
Kinesis Data Stream의 보관 기간은 7일 이상으로 잡았습니다. DynamoDB Export와 Glue ETL, DynamoDB Import가 끝난 뒤에도 Export 시점 이후의 변경 이벤트를 다시 읽어야 했기 때문입니다.
모든 작업이 끝난 뒤에는 다시 24시간으로 줄일 수 있지만, 마이그레이션 중에는 Catch-Up을 시작할 수 있는 시간을 충분히 남겨 두는 쪽이 중요했습니다.
인덱스 테이블 스키마 설계
두 번째는 DynamoDB Import로 만들 인덱스 테이블의 스키마를 정하는 일이었습니다. 이번 작업에서는 user_managed_index 테이블 하나를 만들기로 했습니다.
DynamoDB Import는 S3 데이터를 읽어 새 테이블을 생성하기 때문에, 이 단계에서 빈 테이블을 미리 만들지는 않았습니다. 대신 Import 단계에서 사용할 테이블 이름과 PK/SK 스펙을 먼저 정했습니다.
PK = channelId
SK = managedKey#userIdPK는 기존 managed GSI의 partition key와 동일하게 두었습니다. managed는 채널 단위로 조회하는 인덱스였기 때문에 인덱스 테이블의 PK도 channelId가 됩니다.
다만 SK는 기존 GSI의 sort key인 managedKey를 그대로 쓰지 않았습니다. GSI는 같은 PK/SK 값을 가진 아이템이 여러 개 있어도 되지만, DynamoDB 테이블의 primary key는 유니크해야 합니다.
그래서 인덱스 테이블의 sort key 이름은 SK로 고정하고, 값에는 기존 GSI sort key와 원본 User의 id를 함께 넣는 방식으로 설계했습니다.
이 규칙은 뒤의 인덱스 테이블을 GSI처럼 동작시키기 섹션에서 더 자세히 다루겠습니다. 여기서는 새 테이블이 기존 GSI와 같은 조회 결과를 내야 하지만, DynamoDB 테이블의 primary key 제약에 맞게 PK/SK를 다시 잡아야 했다는 점만 짚고 넘어가겠습니다.
IAM 권한과 ch-rate-limiter Rule 준비
세 번째는 IAM 권한과 ch-rate-limiter Rule입니다. ch-flow-shard는 Kinesis를 읽고, 인덱스 테이블에 쓰고, 핫 파티션 이벤트를 SQS로 우회시켜야 합니다. 그래서 Kinesis 읽기, DynamoDB 쓰기, SQS 접근 권한을 서비스 계정에 추가했습니다.
그리고 채널톡에서 사용하는 Rate Limiter 마이크로서비스인 ch-rate-limiter에 파티션별 쓰기 속도를 제한하기 위한 rule을 등록했습니다. rule 등록 API에는 다음 값을 넘겼습니다.
이 rule의 target은 gsi/{table}/{pk}입니다.
ch-flow-shard는 인덱스 테이블에 쓰기 전에 이 key로 ch-rate-limiter에 질의하고, 해당 파티션이 지금 쓰기를 받아도 되는 상태인지 확인합니다.
ch-flow-shard 설정 추가
AWS 리소스를 준비한 뒤에는 ch-flow-shard 설정을 추가했습니다. 어떤 source table을 읽고, 어떤 index table로 전파할지 알려주는 설정입니다.
tableName에는 source table인 users를 넣고, indexes에는 분리할 인덱스 테이블을 넣었습니다. 이 설정을 기준으로 ch-flow-shard는 User 변경 이벤트를 읽고, 어떤 인덱스 테이블에 반영해야 하는지 판단합니다.
여기서 initialPosition은 ch-flow-shard가 Kinesis Stream의 어느 위치부터 읽을지 정하는 값입니다.
1차 배포에서는 Export 시점 이후의 변경분을 따라잡아야 하므로 AT_TIMESTAMP를 사용하고, 2차 배포에서는 평상시 설정인 TRIM_HORIZON으로 전환합니다.
배포 단계에서 다시 설명하겠습니다.
1차 마이그레이션 - DynamoDB Export + AWS Glue ETL
이제 기존 User 테이블에 이미 쌓여 있던 데이터를 인덱스 테이블 형태로 옮길 차례입니다. 실시간 변경분은 뒤에서 ch-flow-shard가 따라잡게 하고, 먼저 특정 시점의 스냅샷을 기준으로 대량 데이터를 옮겼습니다.
DynamoDB Export
DynamoDB Export로 User 테이블의 PITR 스냅샷을 S3로 내보냈습니다.
이 단계에서 가장 중요한 값은 Export 시각입니다. ch-flow-shard 1차 배포에서 이 시각을 기준으로 Kinesis 이벤트를 다시 읽어야 하기 때문이죠.
Export가 시작된 시각을 따로 기록해 두고, 나중에 KCL_USERS_INITIAL_POSITION_TIMESTAMP 값으로 사용했습니다.
(사진과 달리 실제 Export job start time은 2026-02-25T11:22:20+09:00 이었습니다!)
Export 결과는 S3의 AWSDynamoDB/.../data/ 경로 아래에 gzip 압축된 JSON Lines 형태로 저장됩니다. 이 파일에는 User 테이블의 모든 필드가 들어 있습니다.
AWS Glue ETL
Export 결과에는 User 테이블의 모든 아이템이 DynamoDB Attribute JSON 형태로 들어 있습니다. 문자열은 { "S": "..." }, 숫자는 { "N": "..." }처럼 저장됩니다.
Glue ETL에서는 이 Export 파일을 읽고, user_managed_index에 들어갈 수 있는 아이템만 골라 Import용 데이터로 변환했습니다.
Glue Job에서는 네 가지 작업을 했습니다.
읽기: S3에 있는 DynamoDB Export 파일을 gzip JSON으로 읽습니다.
필터링:
id,channelId,managedKey를 만들 수 있는 User 아이템만 남깁니다.변환: User 아이템을 유지하되, 인덱스 테이블의 PK/SK를 새로 씁니다.
저장: DynamoDB Import가 읽을 수 있도록 변환 결과를 다시 S3에 저장합니다.
필터링을 통과한 아이템은 user_managed_index에 들어갈 형태로 바꿨습니다. PK는 기존 managed GSI와 같은 channelId를 사용했습니다.
SK는 기존 GSI sort key인 managedKey만으로 만들지 않았습니다. DynamoDB 테이블에서는 PK/SK 조합이 유니크해야 하므로, 원본 User의 id를 함께 섞었습니다.
PK = channelId
SK = managedKey#userId이 단계가 끝나면 S3에는 user_managed_index 테이블에 넣을 데이터가 준비됩니다.
2차 마이그레이션 - DynamoDB Import
DynamoDB Import로 Glue ETL 결과를 가져와 인덱스 테이블을 생성했습니다.
Import는 S3 데이터를 기반으로 새 DynamoDB 테이블을 만듭니다. 여기서 설정할 값은 인덱스 테이블 스키마 설계에서 논의한 값을 넣으면 되며, DynamoDB가 S3의 JSON 데이터를 읽어 테이블에 적재합니다.
Import가 끝난 시점의 인덱스 테이블은 Export 시점 기준의 User 데이터만 담고 있습니다.
하지만 Export가 도는 동안에도 User 테이블에는 계속 쓰기가 들어왔습니다. 이 차이를 메우기 위해 ch-flow-shard 애플리케이션 서버의 배포가 필요합니다.
1차 애플리케이션 배포 - AT_TIMESTAMP로 변경분 따라잡기
1차 배포에서는 AT_TIMESTAMP를 사용해 Export 시점 이후에 쌓인 변경 이벤트를 따라잡았습니다.
이때 KCL_USERS_INITIAL_POSITION은 AT_TIMESTAMP로 설정했습니다. KCL_USERS_INITIAL_POSITION_TIMESTAMP에는 Export 시각보다 조금 이른 시각을 넣었습니다. Export 시점 근처의 이벤트를 놓치지 않기 위해 몇 분 앞에서부터 다시 읽도록 한 것입니다.
KCL_USERS_INITIAL_POSITION=AT_TIMESTAMP
KCL_USERS_INITIAL_POSITION_TIMESTAMP=2026-02-25T11:22:20+09:00ch-flow-shard가 배포되면 Kinesis에 쌓여 있던 User 변경 이벤트를 읽기 시작합니다. 각 이벤트는 user_managed_index에 반영되고, 핫 파티션으로 판단되는 쓰기는 SQS로 우회합니다.
2차 애플리케이션 배포 - TRIM_HORIZON으로 시작 위치 되돌리기
Export 이후 변경분을 모두 따라잡은 뒤에는 ch-flow-shard의 Kinesis 시작 위치를 TRIM_HORIZON으로 되돌렸습니다.
2차 배포에서는 KCL_USERS_INITIAL_POSITION을 TRIM_HORIZON으로 설정하고, timestamp 값은 비웠습니다. 새 consumer가 떠도 보관 중인 레코드를 기준으로 다시 읽을 수 있고, 이미 KCL checkpoint가 있는 경우에는 checkpoint 이후부터 이어서 처리합니다.
KCL_USERS_INITIAL_POSITION=TRIM_HORIZON
KCL_USERS_INITIAL_POSITION_TIMESTAMP=2차 배포가 끝나면 User 테이블 최신 변경분은 계속 ch-flow-shard를 거쳐 인덱스 테이블로 반영됩니다.
여기까지 끝나면 인덱스 테이블에는 두 종류의 데이터가 합쳐집니다. DynamoDB Import로 옮긴 기존 데이터와, ch-flow-shard가 Export 이후부터 따라잡은 변경분입니다.
인덱스 테이블을 GSI처럼 동작시키기
여기까지는 인덱스 테이블을 만들고, 기존 데이터와 변경 이벤트를 채워 넣는 과정이었습니다.
하지만 데이터를 넣는 것만으로는 기존 GSI와 같은 결과가 나오지 않습니다.
GSI는 원본 아이템이 바뀔 때 인덱스 아이템을 만들고, 지우고, 옮기는 일을 DynamoDB 내부에서 처리합니다. 인덱스 테이블을 별도로 만들면 이 동작을 ch-flow-shard가 직접 계산해야 합니다.
GSI Item 생성 조건을 어떻게 재현할 것인가?
먼저 GSI가 어떤 아이템을 인덱스에 올리는지부터 확인했습니다.
GSI는 원본 테이블의 모든 아이템을 인덱스에 복사하지 않습니다. GSI key를 만들 수 있는 아이템만 인덱스에 나타납니다. managed GSI라면 channelId와 managedKey가 있는 User만 조회 결과에 포함됩니다.
그래서 ch-flow-shard도 모든 User 변경 이벤트를 user_managed_index에 쓰면 안 됩니다. 이벤트의 new image를 보고 channelId와 managedKey를 만들 수 있을 때만 인덱스 아이템을 생성했습니다.
정리하면 다음과 같습니다.
이 조건을 맞추지 않으면 GSI에는 없는 아이템이 인덱스 테이블에 생깁니다. 그렇게 되면 조회 결과가 기존 GSI보다 많아지기 때문에, 첫 단계부터 GSI의 생성 조건을 그대로 따라야 했습니다.
왜 SK에 원본 테이블의 PK가 필요한가?
다음 문제는 key의 유니크 제약이었습니다.
GSI에서는 같은 partition key와 sort key 조합을 가진 아이템이 여러 개 있을 수 있습니다. 예를 들어 같은 채널의 여러 User가 같은 managedKey를 가질 수 있습니다. GSI는 조회용 인덱스이기 때문에 이 중복을 허용합니다.
하지만 DynamoDB 테이블의 primary key는 유니크해야 합니다. user_managed_index를 별도 테이블로 만들면 (channelId, managedKey)만으로는 여러 User를 담을 수 없습니다.
그래서 인덱스 테이블의 SK에는 기존 GSI sort key와 원본 User의 id를 함께 넣었습니다.
기존 GSI
PK = channelId
SK = managedKey
인덱스 테이블
PK = channelId
SK = managedKey#userId예를 들어 채널 A에 같은 managedKey를 가진 User가 두 명 있다면, GSI에서는 두 아이템이 같은 key 아래에 존재할 수 있습니다. 인덱스 테이블에서는 SK에 User id가 붙기 때문에 각각 다른 아이템으로 저장됩니다.
이렇게 하면 DynamoDB 테이블의 primary key 제약을 만족하면서도, managedKey 기준 정렬은 유지할 수 있습니다.
GSI Key 변경을 어떻게 전파할 것인가?
생성 조건과 key 구조를 정한 뒤에는 변경 이벤트를 봐야 합니다.
GSI key가 바뀌지 않은 이벤트라면 기존 인덱스 아이템을 업데이트하면 됩니다. 문제는 managedKey처럼 GSI key에 들어가는 값이 바뀌는 경우입니다. 이때는 기존 위치의 아이템을 지우고, 새 위치에 다시 써야 합니다.
예를 들어 User의 managedKey가 100에서 200으로 바뀌었다고 가정해 보겠습니다.
기존 인덱스 아이템
PK = channel-A
SK = 100#user-1
새 인덱스 아이템
PK = channel-A
SK = 200#user-1이 이벤트를 새 값만 보고 처리하면 200#user-1은 생기지만, 100#user-1은 그대로 남습니다. 그래서 앞에서 DynamoDB Streams를 NEW_AND_OLD_IMAGES로 설정했습니다.
ch-flow-shard는 old image로 이전 인덱스 key를 만들고, new image로 새 인덱스 key를 만듭니다. 두 key가 다르면 old key 위치의 아이템을 삭제한 뒤, new key 위치에 아이템을 씁니다.
GSI가 내부에서 하던 Delete + Put을 ch-flow-shard가 대신 처리하는 구조입니다.
삭제 이벤트를 어떻게 처리할 것인가?
삭제 이벤트도 같은 원리입니다.
User가 삭제되면 DynamoDB Streams의 remove 이벤트에는 new image가 없습니다. 남아 있는 정보는 old image뿐입니다. 따라서 삭제 이벤트에서는 old image로 기존 인덱스 key를 다시 계산해야 합니다.
old image에 channelId와 managedKey가 있으면, ch-flow-shard는 해당 key의 인덱스 아이템을 삭제 처리합니다. old image에도 GSI key를 만들 수 있는 값이 없다면, 애초에 인덱스 테이블에 올라간 적이 없는 아이템이므로 아무 작업도 하지 않습니다.
여기서 삭제 처리는 hard delete가 아닙니다. 삭제된 상태를 나타내는 아이템을 조건부로 쓰고, TTL을 걸어 나중에 정리되도록 했습니다. 오래된 이벤트가 늦게 도착했을 때 삭제된 아이템을 다시 살리는 일을 막기 위해서입니다.
여기서도 핵심은 new image가 아니라 old image입니다. 삭제된 뒤의 값으로는 기존 인덱스 아이템의 위치를 알 수 없기 때문입니다.
늦게 도착한 이벤트를 어떻게 막을 것인가?
마지막으로 이벤트 순서를 고려해야 했습니다.
Kinesis는 같은 shard 안에서는 순서를 보장합니다. 하지만 핫 파티션으로 판단된 쓰기는 SQS로 우회될 수 있고, 배포나 리밸런싱 과정에서도 처리가 지연될 수 있습니다. 이때 오래된 이벤트가 나중에 도착해 최신 인덱스 아이템을 덮어쓰면 안 됩니다.
예를 들어 User 하나에 대해 다음 두 이벤트가 있었다고 가정해 보겠습니다.
1. managedKey = 100, SK = 100#user-1
2. managedKey = 200, SK = 200#user-1정상 순서라면 마지막 인덱스 아이템은 200#user-1이어야 합니다. 그런데 첫 번째 이벤트가 SQS에 오래 머물다가 두 번째 이벤트보다 늦게 처리되면, 예전 값인 100#user-1이 다시 살아날 수 있습니다.
그래서 인덱스 테이블에 쓰는 모든 이벤트에는 원본 변경의 순서를 비교할 수 있는 값을 함께 넣었습니다. ch-flow-shard는 Kinesis의 sequence number를 version으로 사용하고, 인덱스 테이블에 조건부 PutItem을 보냅니다.
조건은 단순합니다. 지금 쓰려는 이벤트가 인덱스 테이블에 남아 있는 version보다 최신일 때만 덮어씁니다.
따라서 인덱스 테이블은 비동기로 갱신되기 때문에, 쓰기 순서가 뒤바뀌어도 마지막 최신 상태가 남도록 보장할 수 있습니다.
여기까지가 인덱스 테이블을 GSI처럼 보이게 만들기 위해 ch-flow-shard 안에 넣은 기본 동작입니다. 생성, 변경, 삭제, 이벤트 순서까지 맞추고 나니 다음 문제는 쓰기 속도였습니다. 인덱스 테이블에 들어가는 쓰기를 어느 속도로 흘려보낼지가 남았습니다.
프로덕션 배포 중 마주한 문제
앞 섹션까지는 ch-flow-shard가 어떤 인덱스 아이템을 만들고 지울지 계산하는 규칙이었습니다.
프로덕션에서 더 까다로웠던 부분은 쓰기 속도였습니다. Kinesis는 밀린 레코드를 가능한 한 빨리 처리하려고 하고, managed 인덱스는 특정 channelId로 쓰기가 몰립니다. 인덱스 테이블을 별도로 만들었더라도 DynamoDB 파티션 한계를 넘겨서 쓰기 작업을 하면 같은 핫 파티션 문제가 발생합니다.
그래서 ch-flow-shard는 다섯 번에 걸쳐 배포하며 쓰기 속도 제어 방식을 바꿔야 했습니다.
1차 배포 - DynamoDB 쓰기 후 Rate Limit의 한계
1차 배포에서 처음 드러난 문제는 Rate Limit 로직의 위치였습니다.
처음에는 DynamoDB에 먼저 쓰고, 성공한 쓰기에서 실제로 소모된 WCU를 보고 ch-rate-limiter의 토큰을 차감했습니다. 설계만 보면 자연스러운 선택이었습니다. 실제 WCU를 알고 난 뒤에 제한하면, 아이템 크기를 따로 추정하지 않아도 되기 때문입니다.
하지만 프로덕션에서는 이 순서가 문제였습니다. 이미 DynamoDB에 쓰기를 보낸 뒤라면, ch-rate-limiter가 뒤에서 토큰을 차감해도 핫 파티션을 막을 수 없었습니다.
실제로 배포 직후 DynamoDB에 먼저 들어간 쓰기는 5000 TPS까지 올라갔습니다. ch-rate-limiter가 판단하기도 전에 DynamoDB 레벨에서 핫 파티션 쓰로틀링이 발생했고, 정작 ch-rate-limiter는 부하를 거의 받지 못했습니다.
그래서 1차 배포 후에는 순서를 바꿨습니다. PutItem과 DeleteItem을 보내기 전에 아이템 크기로 예상 WCU를 계산하고, ch-rate-limiter를 먼저 통과한 요청만 DynamoDB에 쓰도록 했습니다.
2차 배포 - Token Bucket 버스트 이슈
Rate Limit을 DynamoDB 쓰기 앞으로 옮긴 뒤에도 쓰로틀링은 완전히 사라지지 않았습니다.
이번에는 ch-rate-limiter의 Token Bucket 설정이 문제였습니다. 당시 rule은 초당 1000개 수준으로 쓰기를 허용하도록 잡혀 있었습니다.
capacity = 1000
fillAmount = 1000
fillPeriod = 1s이 설정은 평균적으로 보면 초당 1000개를 허용하는 것처럼 보입니다. 하지만 Token Bucket은 토큰이 쌓여 있으면 순간 버스트를 허용합니다. 버킷에 남아 있던 토큰과 다음 refill이 겹치면, 짧은 구간에서 2000 TPS에 가까운 쓰기가 DynamoDB로 들어갈 수 있습니다.
일반적인 API Rate Limit이라면 이 정도 버스트는 괜찮을 수 있습니다. 하지만 DynamoDB 핫 파티션은 평균보다 순간 쓰기 속도에 더 민감했습니다.
그래서 2차 배포에서는 버스트 폭을 줄이기 위해 capacity를 낮췄습니다.
[Before]
capacity = 1000
fillAmount = 1000
fillPeriod = 1s
[After]
capacity = 600
fillAmount = 1000
fillPeriod = 1s순간 버스트는 줄었습니다. 대신 DynamoDB에 바로 써도 되는 이벤트까지 SQS로 우회되는 비율이 증가하는 문제가 생겼습니다.
3, 4차 배포 - 핫 파티션 마킹과 SQS Consumer 조정
2차 배포 이후 DynamoDB 쓰로틀링은 줄었지만, SQS에 쌓인 메시지가 문제로 남았습니다.
SQS로 들어가는 속도가 소비되는 속도보다 3배 이상 빨랐습니다. SQS 메시지 보관 기간은 14일입니다. 이 상태가 계속되면 끝까지 처리되지 못하고 사라지는 메시지가 생길 수 있었습니다.
원인은 두 가지였습니다.
핫 파티션 마킹 시간: 한 번 핫 파티션으로 판단되면 해당 partition key를 10초 동안 인메모리 맵에 마킹했습니다. 이 시간 동안 같은 partition key로 들어오는 이벤트는 모두 SQS로 빠졌습니다.
SQS Consumer 블로킹: SQS Consumer가 다시 rate limit에 걸리면 해당 스레드는 기다렸다가 재시도했습니다. 그동안 다른 메시지를 처리하지 못했습니다.
그래서 3, 4차 배포에서는 두 지점을 바꿨습니다.
[핫 파티션 마킹 시간]
10초 → 1초
[SQS Consumer]
rate limit 시 대기 → 에러를 던지고 다음 메시지 처리SQS 메시지는 처리에 실패하면 잠시 invisible 상태가 되고, visibility timeout이 지나면 다시 visible 상태가 됩니다. 그래서 Consumer가 한 메시지 앞에서 기다리는 대신, 지금 처리할 수 없는 메시지는 뒤로 미루고 다른 메시지를 먼저 처리하도록 바꿨습니다.
이 배포 후에는 SQS로 들어오는 속도보다 삭제되는 속도가 조금 더 빨라졌습니다.
하지만 SQS delete 속도가 send 속도보다 조금 빨라진 것만으로는 충분하지 않았습니다. 이미 SQS에는 많은 메시지가 쌓여 있었고, 줄어드는 속도도 빠르지 않았습니다.
5차 배포 - Leaky Bucket 적용
5차 배포 전에는 SQS에 약 6천만 건의 메시지가 쌓여 있었습니다.
3, 4차 배포 이후 삭제 속도가 들어오는 속도보다 조금 빨라지긴 했지만, SQS 보관 기간인 14일 안에 모두 처리할 수 있을지는 여전히 확신하기 어려웠습니다.
Token Bucket 버스트를 막으려고 capacity를 낮추면 DynamoDB는 보호할 수 있지만, 실제로 처리할 수 있는 트래픽까지 SQS로 우회될 수 있습니다. 반대로 capacity를 높이면 다시 순간 버스트가 생겨 핫파티션 문제가 재발합니다. 일종의 가불기인 셈이죠...
저희에게 필요했던 것은 평균 속도를 맞추는 Rate Limit이 아니라, DynamoDB가 받아들일 수 있는 속도로 꾸준히 흘려보내는 Rate Limit이었습니다.
그래서 ch-rate-limiter에 Leaky Bucket을 도입했습니다.
Leaky Bucket으로 바꾼 뒤에는 SQS delete 속도가 send 속도보다 2배 빨라졌습니다.
5번의 배포를 거치고 나서야 쓰기 속도 제어의 기준이 분명해졌습니다.
인덱스 테이블을 별도로 만들더라도, DynamoDB 파티션이 받아들일 수 있는 속도보다 빠르게 쓰면 같은 문제가 반복됩니다.
결국 핵심은 쓰기를 막는 것이 아니라, DynamoDB가 버틸 수 있는 속도로 계속 흘려보내는 일이었습니다.
성과
managed 인덱스 핫 파티션 제거
1편에서 문제가 됐던 지점은 managed GSI의 특정 파티션으로 쓰기가 몰린다는 점이었습니다. 한 채널의 사용자 정보가 짧은 시간에 많이 바뀌면, User 테이블의 managed GSI에도 같은 channelId 기준으로 쓰기가 집중됐습니다.
인덱스 테이블 분리 이후에는 쓰기와 읽기 양쪽이 모두 새 구조로 넘어갔습니다.
쓰기 쪽에서는 ch-flow-shard가 User 테이블 변경분을 user_managed_index에 반영했고, 읽기 쪽에서는 서비스의 managed 사용자 목록 조회가 기존 managed GSI 대신 user_managed_index를 바라보도록 바뀌었습니다.
구분 | 분리 전 | 분리 후 |
|---|---|---|
쓰기 대상 | User 테이블의 | user_managed_index 테이블 |
쓰기 경로 | DynamoDB 내부 GSI 전파 | ch-flow-shard 전파 파이프라인 |
서비스 조회 | User 테이블의 | user_managed_index 기반 조회 |
또한 핫파티션 문제도 해결되었습니다. 비교 기준은 CloudWatch의 WriteThrottleEvents로 진행했습니다.
4월 한 달 기준, 분리 전 운영 구간에서 managed GSI의 WriteThrottleEvents는 약 110만 회였습니다. 반면 분리 후 같은 기간에 user_managed_index 테이블의 WriteThrottleEvents는 0회였습니다.
managed 사용자 목록 조회 기능은 유지하면서도, 기존처럼 managed GSI 한 파티션에 쓰기가 몰려 User 테이블 쓰기까지 막히는 구조를 제거할 수 있었습니다.
인덱스 전파 지연 측정
DynamoDB GSI를 사용할 때는 메인 테이블 변경이 GSI에 언제 반영됐는지 알기 어려웠습니다. GSI 전파는 DynamoDB 내부에서 처리되기 때문에, 저희가 볼 수가 없었죠.
인덱스 테이블로 분리한 뒤에는 운영에서 보고 싶은 값을 직접 정할 수 있었습니다. Kinesis 레코드 생성 시각, ch-flow-shard 처리 종료 시각, Rate Limiter 응답, SQS 상태를 Mimir 커스텀 메트릭으로 적재했고, 대시보드에서는 주로 아래 값을 봤습니다.
지표 | 보는 내용 |
|---|---|
Kinesis Iterator Age | ch-flow-shard가 Stream의 최신 레코드에서 얼마나 뒤처져 있는지 |
인덱스 전파 처리 시간 | 변경 이벤트를 읽고 인덱스 테이블 쓰기를 끝내기까지 걸린 시간 |
Rate Limiter 429 | 특정 인덱스 파티션이 쓰기 한계에 가까워졌는지 |
SQS depth | 핫 파티션으로 우회된 이벤트가 얼마나 쌓였는지 |
DLQ 유입량 | 재시도 후에도 처리하지 못한 이벤트가 있는지 |
특히, managed 인덱스를 분리한 뒤, 인덱스 전파 지연은 P95 500ms, P99 950ms였습니다.
마무리
이번 글에서는 managed GSI를 별도 인덱스 테이블로 떼어내고, 실제 운영 환경에서 user_managed_index에 변경분을 전파하기까지의 과정을 다뤘습니다.
이번 작업에서 한 일은 크게 다섯 가지였습니다.
DynamoDB Export, Glue ETL, DynamoDB Import로 기존 데이터를 인덱스 테이블 형태로 옮겼습니다.
Export 이후 들어온 변경분은 Kinesis와 ch-flow-shard로 따라잡았습니다.
GSI가 내부에서 처리하던 생성 조건, 키 변경, 삭제, 늦게 도착한 이벤트 처리를 ch-flow-shard 안에서 직접 재현했습니다.
운영 중에는 Token Bucket의 버스트와 SQS 적체 문제를 겪었고, 최종적으로 Leaky Bucket으로 쓰기 속도를 제어했습니다.
읽기 쪽까지
user_managed_index기반으로 넘어간 뒤에는, 기존managedGSI에서 발생하던 핫 파티션 구조를 끊을 수 있었습니다.
다만 이 글에서 주로 다룬 것은 인덱스 테이블을 채우고 운영하는 과정이었습니다. 실제 서비스 요청이 기존 managed GSI 대신 user_managed_index를 바라보게 하려면 애플리케이션 서버의 조회 코드도 함께 바뀌어야 했습니다.
애플리케이션 서버의 조회 코드 전환은 이노가 이어서 다룰 예정입니다.
managed 사용자 목록 조회를 어떻게 인덱스 테이블 기반으로 바꿨는지, 그리고 조회 코드 전환 과정에서 페이지네이션과 삭제 데이터 처리를 어떻게 맞췄는지는 다음 글에서 이어가겠습니다.
DynamoDB 같은 관리형 서비스는 많은 문제를 대신 해결해 주지만, 서비스 규모와 트래픽 패턴이 커지면 그 경계가 드러나는 순간도 있습니다. 채널톡 백엔드 팀은 그런 지점에서 멈추지 않고, 필요한 구조를 직접 설계하고 운영하며 문제를 풀어갑니다.
수십억 건 규모의 데이터, 운영 중인 시스템의 무중단 마이그레이션, 핫 파티션 같은 까다로운 문제를 함께 다뤄보고 싶다면 채널톡 엔지니어링 팀에 합류해 주세요! https://channel.io/ko/careers
