DynamoDB 핫 파티션을 해결하는 3가지 방법 (1): 인덱스 테이블로 GSI 떼어내기 설계편

GSI Back-Pressure 장애의 구조와 인덱스 테이블을 통한 해결 전략

Jayon • Jinyoung Park, Backend Enginner

  • Backend

안녕하세요, 채널톡 백엔드 엔지니어 제이온입니다.

대규모 서비스의 데이터베이스를 운영하다 보면, 테이블 전체 용량은 충분한데도 유독 특정 요청들만 느려지거나 막히는 순간이 찾아옵니다. 데이터의 일부 구간에 트래픽이 몰리면서 주변 요청까지 함께 영향을 받는 현상인데요, 흔히 핫 파티션 문제라고 부릅니다.

DynamoDB 이야기로 자주 언급되지만, Cassandra나 HBase 같은 분산 NoSQL도, 샤딩을 적용한 MySQL/PostgreSQL도, 데이터를 여러 파티션에 나누어 저장하는 시스템이라면 형태만 조금씩 달리해 비슷하게 겪는 문제입니다.

저희 팀도 User 테이블을 수 년째 DynamoDB 위에서 운영하면서 여러 형태의 핫 파티션을 만나 왔고, 케이스마다 조금씩 다른 방식으로 풀어 왔습니다. 각 사례를 연재 형식으로 작성해보려고 합니다.

이번 편에서 다룰 첫 사례는, GSI 하나에 쓰기가 몰리면서 테이블 전체의 쓰기까지 막혀 버린 Back-Pressure 장애 이야기입니다.

User 테이블에는 다양한 조회 패턴을 지원하기 위한 GSI(Global Secondary Index) 가 여러 개 있었는데, 그중 managed라는 GSI에 쓰기가 몰리면서 테이블의 다른 쓰기까지 줄줄이 막히는 일이 반복되고 있었습니다.

더 큰 문제는 이 GSI와는 아무 관련이 없는 Boot 요청까지 함께 거부됐다는 점이었습니다. Boot는 고객사 웹사이트나 앱에 방문한 사용자를 채널톡에 연결해 주는 과정이라, 이게 막히면 해당 고객사 사이트에서는 채널톡 자체가 동작하지 않는 장애로 번졌습니다.

"managed GSI 쓰기 때문에 Boot까지 막힌다고요?"

결론부터 말씀드리면, 저희는 managed GSI 자체를 없애고, 그 역할을 대신할 별도의 인덱스 테이블과 쓰기 전파 파이프라인을 직접 만드는 방식으로 이 문제를 풀었습니다. "조회 기능은 그대로 두되, GSI의 쓰기 병목만 우리가 관리할 수 있는 구조로 옮기자"는 방향이었습니다.

managed GSI를 없애는 선택을 했는지, 그리고 그 자리를 대신할 구조를 어떻게 설계했는지를 다뤄 보겠습니다.


장애 분석: managed GSI로 인해 Boot가 실패한 이유

이것을 이해하려면 DynamoDB의 GSI가 어떻게 동작하는지, 특히 쓰기 쪽 성질부터 짚어야 합니다.

꼭 알아야 할 DynamoDB의 기초 개념

DynamoDB는 테이블의 데이터를 기본적으로 Primary Key로 조회합니다. Primary Key는 파티션 키(Partition Key, 줄여서 PK) 단독으로 구성하거나, 그 안에서 정렬 기준을 잡는 정렬 키(Sort Key, 줄여서 SK) 를 함께 써서 구성합니다. PK는 아이템을 어느 파티션에 저장할지 결정하고, SK는 같은 PK 파티션 안에서 아이템을 정렬해 둡니다.

DynamoDB는 테이블마다 초당 처리할 수 있는 쓰기 양의 한도가 정해져 있습니다. 이 한도를 WCU(Write Capacity Unit) 단위로 표현하는데, 1 WCU는 대략 1KB 이하 아이템 하나를 쓸 수 있는 양으로 생각하시면 됩니다. 테이블이든 각 GSI든 각자 초당 WCU 한도를 따로 갖고 있고, 이 한도를 넘으면 쓰기가 거부되기 시작합니다.

실제 서비스에서는 Primary Key가 아닌 다른 속성으로 조회가 필요할 때가 많습니다. 이럴 때 쓰는 게 GSI(Global Secondary Index) 입니다. GSI는 메인 테이블과 별개로 자기만의 PK와 SK, 그리고 별도의 WCU 한도를 갖는, 사실상 또 하나의 테이블 같은 구조입니다.

이 글에서 꼭 짚고 싶은 건 GSI의 쓰기가 메인 테이블 쓰기와 엮여 있다는 점입니다.

GSI는 따로 쓰기 요청을 받지 않고, 메인 테이블에 변경이 생기면 그 사항이 GSI로 비동기로 전파되는 구조입니다. 따라서 애플리케이션이 메인 테이블에 아이템을 쓸 때마다, 내부적으로는 메인 테이블에서 1 WCU + 각 GSI에서 1 WCU씩 따로 소모하게 됩니다.

이제 managed GSI가 어떤 구조였는지 보겠습니다.

managed GSI 구조 설명

managed GSI는 각 채널에서 관리 대상(managed)이 되는 사용자를 최근 접속순으로 조회하기 위한 인덱스입니다. 여기서 '관리 대상'이란 단순히 웹사이트를 스쳐 지나간 익명 방문자가 아니라, 연락처를 남겼거나 한 번이라도 상담을 통해 고객사와 관계가 생긴, 이후에도 고객사가 찾아 소통할 수 있는 사용자를 가리킵니다.

PK는 channelId, SK는 해당 사용자의 최근 접속 시각(lastSeenAt)이었습니다.

즉, channelId로 특정 채널의 사용자들만 골라낸 뒤, lastSeenAt순으로 정렬해 꺼낼 수 있는 구조인 것이죠.

이 GSI는 특정 채널의 managed 사용자 목록이 필요한 여러 기능에서 광범위하게 쓰이고 있습니다.

  • 마케팅 캠페인: 최근 접속한 사용자에게 프로모션을 발송하거나, 오래 접속이 없는 사용자에게 재방문 메시지를 보낼 때

  • 고객 연락처 페이지: 최근 활동 사용자를 시간순으로 훑어볼 때

  • 비활성 사용자 정리: 일정 기간 접속이 없는 사용자를 일괄 삭제 대상으로 뽑을 때

사실상 특정 채널의 managed 사용자 목록이 필요한 기능 대부분이 이 GSI 하나에 의존하고 있는 형태였습니다.

쓰기가 몰린 두 가지 이유

이 GSI가 장애의 출발점이 된 이유는 두 가지 성질이 겹쳐 있었기 때문입니다.

첫째, 한 번의 사용자 접속이 managed GSI에서는 두 번의 쓰기가 발생합니다.

managed GSI의 SK가 lastSeenAt이라, Boot 등 다양한 경로로 사용자의 접속 시각이 갱신될 때마다 SK 값이 바뀝니다. 그런데 DynamoDB는 GSI의 SK 값이 바뀌면 기존 위치의 아이템을 삭제하고 새 위치에 다시 기록하는 방식으로 동작합니다.

일반 속성 변경이라면 GSI에서도 1 WCU면 끝나지만, SK가 바뀌는 갱신은 매번 두 배의 쓰기 비용이 듭니다. managed GSI는 SK 변경이 빈번해서, 실질적으로 항상 2배 비용이 드는 구조였던 거죠.

둘째, PK가 channelId라 같은 채널의 모든 쓰기가 managed GSI의 같은 파티션으로 몰립니다.

평소에는 여러 고객사의 트래픽이 고루 퍼져 있어 문제가 없지만, 특정 고객사 사이트에 접속이 한꺼번에 몰리는 순간 그 채널의 managed GSI 파티션 한 곳으로 수만 건의 쓰기가 집중됩니다. 예를 들어 대형 프로모션을 열거나 대량으로 앱 푸시를 발송한 직후가 그런 상황이죠.

Hot Partition과 Back-Pressure

문제는 DynamoDB의 파티션 하나가 처리할 수 있는 쓰기 용량에 한계가 있다는 점입니다. 하나의 파티션은 초당 1,000 WCU까지만 쓰기를 받아 낼 수 있어서, 한 파티션으로 쓰기가 몰리면 금세 그 한계에 부딪힙니다. 이렇게 특정 파티션에 트래픽이 쏠려 병목이 생기는 현상을 핫 파티션(Hot Partition)이라고 부릅니다.

여기까지는 흔히 알려진 이야기지만, GSI의 경우 한 단계 더 있습니다. GSI 파티션의 쓰기가 밀리기 시작하면, DynamoDB는 메인 테이블 쓰기까지 거부하기 시작합니다. 이 현상을 Back-Pressure라고 부르는데요, GSI가 쓰기를 더 이상 받아 낼 수 없으니 원천적으로 메인 테이블 쓰기를 막아 버리는 방식으로 동작합니다. 이때 막히는 건 해당 GSI와 관련된 쓰기뿐 아니라 메인 테이블의 모든 쓰기입니다.

그리고 메인 테이블의 WCU를 아무리 높여도 이 문제는 해결되지 않습니다. 파티션당 1,000 WCU는 테이블 전체 용량 설정과 무관하게 적용되는 물리 한계이기 때문이죠. 즉, managed GSI의 한 파티션이 핫 파티션 상태인 한, Back-Pressure가 그대로 작동해서 User 테이블 전체의 쓰기가 거부됩니다.

위 그림처럼 채널 A의 사용자 5만 명에게 OTM(One Time Message)을 발송하면, 5만 건의 유저 업데이트가 동시에 발생합니다. User 테이블의 GSI는 모두 channelId를 파티션 키로 사용하고 있으므로, 이 업데이트들은 GSI의 "채널 A" 파티션 하나에 집중됩니다. 결국 GSI Back-Pressure에 의해 메인 테이블 쓰기가 막힙니다.

채널톡이 멈추는 순간

이 구조가 심각한 이유는, 한 채널의 managed GSI 파티션이 포화되면 대부분의 고객사 사이트에서 Boot가 실패하여 채널톡이 동작하지 않게 된다는 점입니다.

문제는 이 GSI 파티션을 포화시키는 경로가 여러 개였습니다.

  • 한 고객사 사이트에 방문이 몰리는 경우

    • 그 채널의 Boot 자체가 managed GSI를 핫 파티션 상태로 만들게 됩니다.

    • 이를 방지하기 위해 Boot 전용 Hybrid Rate Limit를 구축하였는데, 자세한 내용은 추후 저희 팀의 이노께서 테크 블로그 작성해주실 예정입니다.

  • Boot가 잠잠한 시간대라도, 대량의 외부 매체 소통이 일어나는 경우

    • 이메일이나 카카오톡 같은 외부 매체 방식으로 한 채널에서 대량 소통이 발생하면 다량의 lastSeenAt 갱신이 필요하고, 결과적으로 managed GSI를 핫 파티션 상태로 만들게 됩니다.

  • 그외 다양한 케이스들...

어느 쪽이든 결과는 같습니다. Boot가 막히면 채널톡은 사용자 세션을 발급하지 못하고, 그 고객사 페이지에 방문한 누구에게도 채널톡 위젯이 뜨지 않습니다.

저희 팀은 이 구조 자체를 바꿀 수밖에 없다고 판단했습니다.

메인 테이블 용량을 아무리 늘려도 해결되지 않고, 특정 경로 하나의 요청 수를 제한해 봐도 다른 경로가 같은 파티션을 포화시키면 같은 장애가 재현되거든요. 게다가 managed GSI 조회 기능은 앞서 본 것처럼 서비스 여러 곳에서 필수적이라 단순히 떼어 낼 수도 없었습니다.


해결 방향 검토

저희가 검토한 방향은 크게 세 가지였습니다.

  1. 키 샤딩: GSI의 파티션 키를 여러 값으로 쪼개 쓰기를 분산시키는 방식

  2. 도메인 테이블 분리: managed 대상 사용자를 managed_user 테이블로 떼어내, 비즈니스 로직이 직접 그 새 테이블에 쓰는 방식

  3. 인덱스 테이블 분리: GSI만 따로 떼어내 별도 인덱스 테이블로 두되, 비즈니스 로직 대신 파이프라인이 자동 동기화하는 방식

후보 1. 키 샤딩 방식

첫 번째 후보는 GSI 파티션 키를 여러 값으로 쪼개서 쓰기를 분산시키는 방법이었습니다.

예를 들어 channelId 대신 channelId#0, channelId#1, …, channelId#N 같은 키를 써서 한 채널의 쓰기를 여러 파티션으로 흩뜨리는 방식이죠.

이 방식의 장점은 단순함입니다. GSI의 파티션 키만 바꾸면 되고, 쓰기는 자연스럽게 여러 파티션에 분산되어 핫 파티션 자체가 잘 생기지 않습니다.

하지만 단점도 있었습니다.

  • 스캐터 게더(Scatter-Gather)로 인한 조회 비용.

    • managed GSI의 유즈 케이스는 전부 한 채널의 사용자를 최근 접속순으로 나열하는 조회였습니다.

    • 샤딩을 하면 샤드 수만큼 쿼리를 나눠 날린 뒤 결과를 합치고 다시 정렬해야 합니다.

      • 예컨대 채널 하나를 16개 샤드로 나누면, 한 페이지를 가져오기 위해 16번의 Query API가 동시에 나가고, 응답을 모두 받은 뒤 애플리케이션에서 다시 정렬 및 페이지네이션해야 합니다.

      • 평소에도 기존 대비 16배의 RCU가 소모되고, 트래픽이 몰리는 채널일수록 정렬된 목록 조회 비용이 빠르게 비싸지죠.

  • 기존 데이터 백필.

    • 이미 GSI에 들어 있는 수십억 건의 아이템을 새 샤딩 키로 다시 써야 하는 작업이 필요하고, 이 과정에서 또다시 같은 쓰기 집중이 일어날 수 있습니다.

다만 키 샤딩이 언제나 틀린 선택이라는 뜻은 아닙니다.

단건 조회가 주가 되는 GSI라면 스캐터 게더 부담이 크지 않아 오히려 깔끔한 선택이 되기도 하는데요, 실제로 저희도 managed가 아닌 다른 GSI에는 이 방식을 적용하였습니다. 그 이야기는 이 연재의 뒤쪽 편에서 따로 풀어 보겠습니다.

후보 2. 도메인 테이블 분리 : managed_user 테이블

두 번째 후보는 지난 '메시지 트래픽 100배에도 끄떡 없게 고객 테이블 뜯어고치기 (1)에서 적용한 Badge 분리 패턴을 그대로 가져오는 방향입니다.

Badge 데이터를 UserBadge 테이블로 떼어내 GSI 병목을 풀었듯, managed 대상 사용자도 managed_user라는 별도 테이블로 떼어내 보면 어떨까 하는 발상이죠. 데이터 자체를 도메인 단위로 쪼개고, 비즈니스 로직이 직접 그 새 테이블에 쓰기를 보내도록 바꾸는 방식입니다.

다만 Badge 테이블 분리가 깔끔하게 돌아간 이유는 쓰기 경로가 제한적이기 때문입니다. Badge 값을 바꾸는 비즈니스 로직이 몇 군데 정해져 있었고, 그 경로만 User 대신 UserBadge를 수정하도록 바꿔 주면 끝이었습니다. 조회도 Badge가 필요한 곳에서 UserBadge를 쓰도록 고치면 되었죠.

반면 managed쓰기 경로가 훨씬 넓습니다. 앞에서 봤듯이 Boot와 외부 매체 소통 등 여러 경로에서 User 테이블이 갱신되고, 그때마다 lastSeenAt이 바뀌면서 managed GSI에 쓰기가 전파됩니다. 이 모든 경로에서 "이번 쓰기는 managed_user에도 반영해 주세요"라고 비즈니스 로직을 일일이 바꿔 주는 건 현실적으로 불가능합니다. 쓰기 경로 하나만 놓쳐도 데이터가 어긋나기 시작합니다.

물론 managed_user를 프로젝션처럼 userId, channelId, lastSeenAt 같은 최소 정보만 담는 테이블로 설계해서 쓰기 부담을 줄여 볼 수도 있습니다. 하지만 이 방향에도 한계가 있었습니다. managed GSI로 사용자 목록을 꺼낼 때는 대부분 사용자 이름, 프로필, 태그 같은 정보가 같이 필요한데, 프로젝션 테이블만으로는 그걸 채울 수 없어서 매번 User 테이블로 2차 조회가 따라붙습니다.

Badge 분리와 결정적으로 다른 지점이 여기입니다. Badge는 딱 Badge 값만 필요한 단건 조회였지만, managed는 사용자 전체 정보가 같이 필요한 목록 조회라 접근 패턴 자체가 다릅니다.

후보 3. 인덱스 테이블 분리 : user_managed_index 테이블

그래서 방향을 틀었습니다.

데이터 자체를 도메인 단위로 쪼개는 대신, GSI만 따로 떼어내 자동 동기화하기로 했습니다.

비즈니스 로직은 지금처럼 User 테이블만 수정하고, User의 변경을 저희가 감지해서 user_managed_index라는 별도 인덱스 테이블로 흘려 보내는 파이프라인을 직접 만드는 거죠. 사실상 채널톡만의 GSI를 직접 만드는 셈입니다.

도메인 분리(managed_user)와 결정적으로 다른 지점은 쓰는 주체가 누구냐입니다. 도메인 분리는 비즈니스 로직이 직접 새 테이블에 써야 하지만, 인덱스 분리는 비즈니스 로직이 손대지 않아도 파이프라인이 알아서 따라옵니다.

  • User 테이블 쓰기는 그대로 두되, DynamoDB Streams로 변경 이벤트를 받아 별도 인덱스 테이블에 반영합니다.

  • 기존 쓰기 로직은 건드릴 필요가 없습니다. 모든 갱신 경로가 여전히 User 테이블만 바라보면, 인덱스 테이블은 저희가 만든 파이프라인이 알아서 따라갑니다.

  • 조회하는 곳은 한정적이라 조회 경로 변경 범위는 적습니다.

무엇보다 쓰기 전파를 저희가 직접 통제할 수 있게 됩니다.

메인 테이블 쓰기와 인덱스 테이블 쓰기가 비동기로 분리되어 Back-Pressure 자체가 사라지고, 파이프라인 중간에 저희가 필요한 제어 지점을 둘 수 있습니다.

덤으로 모니터링도 챙길 수 있습니다. AWS는 메인 테이블에서 GSI로 쓰기가 전파되는 시간 같은 지표를 별도로 제공하지 않는데, 파이프라인을 우리가 만들어 두면 전파 지연과 적체, 재시도 상태를 직접 확인할 수 있습니다.

왜 인덱스 테이블 분리를 택했나

세 후보를 비교하면,

  • 키 샤딩은 정렬된 목록 조회의 스캐터 게더 부담이 너무 컸습니다.

  • 도메인 기준의 테이블 분리는 쓰기 경로가 워낙 많아 비즈니스 로직 수정 비용이 감당이 안 됐습니다.

  • 결국 비즈니스 로직은 그대로 두고 인덱스만 우리가 통제할 수 있게 떼어내는 인덱스 테이블 분리가 가장 현실적인 해결책이었습니다.


채널톡만의 GSI 파이프라인 설계

전체 그림부터 한 번 보겠습니다.

DynamoDB Streams

DynamoDB Streams는 DynamoDB 테이블의 변경 이벤트를 순차 스트림으로 내보내는 기능입니다.

Streams에는 Native 모드와 Kinesis Data Streams for DynamoDB 모드가 있는데, 저희는 Kinesis Data Streams for DynamoDB 모드를 선택했습니다.

Native 모드 대신 Kinesis 모드를 고른 이유는 크게 두 가지입니다.

  • 여러 컨슈머를 자유롭게 붙일 수 있습니다.

    • Native 모드는 동일 샤드를 동시에 읽는 프로세스가 2개를 넘지 않을 때 안정적으로 동작하도록 권장됩니다. 그 이상이 되면 GetRecords 한도에 부딪혀 컨슈머끼리 처리량을 소모하기 시작합니다.

    • 반면 Kinesis 모드는 Enhanced Fan-Out을 지원해 컨슈머마다 전용 처리량(샤드당 2 MB/s)을 가집니다. 컨슈머를 추가해도 서로 영향을 주지 않죠.

    • User 테이블에는 이미 다른 목적의 컨슈머가 붙어 있었고, 이번 파이프라인까지 얹으면 Native 모드의 권장 한도에 닿을 가능성이 컸습니다. 그래서 Kinesis 모드를 택했습니다.

  • 이벤트 보관 기간이 훨씬 깁니다.

    • Native 모드는 24시간 고정이지만, Kinesis 모드는 최대 365일까지 설정할 수 있습니다.

    • 장애나 배포로 컨슈머가 잠시 멈추더라도 밀린 이벤트를 여유 있게 되짚어 처리할 수 있다는 뜻입니다.

ch-flow-shard와 ch-rate-limiter

DynamoDB Streams(Kinesis)에서 이벤트를 꺼내 인덱스 테이블로 흘려보내는 역할은 저희가 새로 만들 마이크로서비스 ch-flow-shard가 맡게 됩니다. 꺼낸 이벤트를 바로 인덱스 테이블에 쓰지 않고, 쓰기가 지금 안전한지 한 번 더 확인하는 단계를 거칩니다.

확인 기준은 해당 인덱스 파티션이 지금 핫파티션인지입니다. 앞에서 본 대로 DynamoDB 파티션은 초당 1,000 WCU가 한계라, 이 한계를 넘는 채널의 쓰기를 그대로 밀어 넣으면 같은 핫파티션 문제가 다시 발생합니다. 이 판정을 맡는 것이 저희가 사내에서 운영 중인 Redis 기반 Rate Limiter 마이크로서비스 ch-rate-limiter입니다.

ch-flow-shard는 이벤트를 받을 때마다 ch-rate-limiter에 "이 파티션이 지금 핫파티션인가요?"를 물어봅니다. ch-rate-limiter은 Redis로 파티션별 쓰기 부하를 계속 집계하다가 핫파티션인지 아닌지를 응답해 주고, 응답에 따라 이후 흐름이 갈라집니다.

  • 정상 파티션이면 ch-flow-shard가 변경 이벤트를 user_managed_index 테이블에 곧바로 반영합니다.

  • 핫파티션이면 이벤트를 내부 SQS 버퍼로 밀어 넣고, 이후에 천천히 다시 꺼내서 핫파티션 판정과 인덱스 테이블 쓰기를 처음부터 다시 거칩니다.

SQS 버퍼를 두는 이유는 두 가지입니다. 우선 핫파티션이 발생한 채널만 따로 격리해 다른 채널의 쓰기가 함께 밀리지 않습니다. 그리고 파티션 한계를 넘는 쓰기를 DynamoDB에 그대로 밀어 넣지 않기 때문에 DynamoDB 쓰로틀링 자체가 발생하지 않죠.

그 덕분에 User 테이블 쓰기는 인덱스 테이블 상태에 영향을 받지 않고, 특정 채널이 핫파티션이 되더라도 메인 테이블에는 Back-Pressure가 발생하지 않습니다.


22억 건의 무중단 마이그레이션 전략

앞서 설명한 실시간 파이프라인은 새로 들어오는 쓰기를 user_managed_index로 흘려보내 줍니다.

하지만 User 테이블에는 이미 쌓여 있는 기존 데이터가 있고, 이 데이터는 아직 새 인덱스 테이블에 존재하지 않습니다. 운영 트래픽을 끊지 않고 기존 데이터까지 함께 마이그레이션해야 하는 단계가 남아 있는 셈이죠.

큰 뼈대는 지난 '메시지 트래픽 100배에도 끄떡 없게 고객 테이블 뜯어고치기 (1)'에서 쓴 DynamoDB Export + Glue 패턴을 이어가되, 크게 두 가지 지점이 달라졌습니다.

마이그레이션 파이프라인 3단계

파이프라인은 세 단계로 진행됩니다.

  1. DDB Export + Glue ETL

    1. User 테이블의 특정 시점 스냅샷을 S3로 내보냅니다. (PITR 옵션이 켜져 있어야 함)

    2. Glue ETL 통해 스냅샷을 순회하면서 managed 대상 아이템만 골라 user_managed_index 테이블이 쓰는 형태로 변환합니다. 상세 스키마는 2편에서 따로 다룹니다.

  2. DDB Import: 변환된 데이터를 user_managed_index 테이블 생성하면서 적재합니다.

  3. Kinesis Catch-Up: Export 시점 이후의 변경분은 이미 DynamoDB Streams(Kinesis)에 쌓여 있습니다. ch-flow-shard가 Export 시각을 시작점으로 잡아 스트림을 재생하면서 그 변경분을 모두 인덱스 테이블에 반영합니다. Catch-Up이 끝나면 이후부터는 평소 파이프라인이 그대로 이어받습니다.

Badge 분리 때와 달라진 점

Badge 분리 때와 결정적으로 달라진 점은 두 가지입니다.

  • 임시 테이블을 따로 두지 않았습니다.

    • Badge 분리 때는 Export 도중 발생하는 변경분을 담아둘 TmpUserBadge 테이블을 별도로 만들었어야 했습니다.

    • 이번에는 User 테이블 위에 이미 DynamoDB Streams(Kinesis)가 달려 있고, 스트림이 변경 이벤트를 오랫동안 쌓아 두기 때문에 임시 테이블 없이도 똑같이 처리할 수 있었습니다.

    • Kinesis 보관 기간만 충분히 설정해 두면, Export 시각 이후의 모든 변경분이 스트림에 그대로 남아 있으니까요.

  • Dual Write가 필요 없습니다.

    • Badge 때는 비즈니스 로직 곳곳에서 UserBadge 테이블에도 같이 쓰도록 애플리케이션 코드를 고쳐야 했습니다.

    • 반면 이번에는 ch-flow-shard가 DynamoDB Streams(Kinesis)을 통해 쓰기를 자동 전파하는 구조라, 기존 비즈니스 코드를 전혀 건드리지 않습니다.

    • 앞에서 짚었던 "쓰기 경로가 많아도 한 번에 커버된다"는 장점이 마이그레이션 단계에도 똑같이 적용되는 구조죠.

정합성 체크

이 단계까지 마치면 조회는 여전히 기존 managed GSI를 바라보는 상태입니다. 조회 경로가 전환되기 전까지는 GSI와 user_managed_index 두 벌로 쓰기를 유지하는 상태로 운영합니다.

이 상태에서 ch-flow-shard 파이프라인에 문제가 생겨도 프로덕션 서비스에는 영향이 가지 않습니다. 조회는 여전히 GSI를 보고 있고, GSI는 DynamoDB가 알아서 유지해 주니까요. 인덱스 테이블 쪽 동기화만 잠시 밀릴 뿐입니다.

대신 저희가 이 단계에서 지켜봐야 하는 건 두 테이블의 정합성입니다. 인덱스 테이블이 GSI와 같은 내용을 갖고 있는지 일정 주기로 샘플링해 비교하고, 어긋나는 아이템이 있으면 원인을 추적합니다. 이 정합성 확보가 추후 조회 경로를 인덱스 테이블로 옮길 때의 안전 장치가 됩니다.

마무리

1편에서는 User 테이블의 managed GSI가 왜 Boot 트래픽까지 막는 장애로 번지는지 그 구조를 분석하고, 테이블 분리 + 자동 동기화 파이프라인이라는 해결책을 선택하기까지의 과정을 살펴보았습니다.

정리하면 다음과 같습니다.

  • 한 채널의 Boot 트래픽이 managed GSI 파티션을 포화시키면 DynamoDB Back-Pressure로 User 테이블 전체 쓰기가 막히고, Boot 자체가 거부되는 구조였습니다.

  • 키 샤딩은 정렬된 목록 조회가 핵심인 managed에 맞지 않았고, 도메인 테이블 분리도 쓰기 경로가 너무 많아 비즈니스 로직 수정으로 감당할 수 없었습니다.

  • 최종 해법은 채널톡만의 GSI를 직접 만드는 방식으로, DynamoDB Streams(Kinesis) + ch-flow-shard + ch-rate-limiter + 내부 SQS 버퍼로 쓰기 전파 파이프라인을 구성했습니다.

  • DDB Export + Glue + DDB Import + Kinesis Catch-Up으로 무중단 마이그레이션을 설계하되, 임시 테이블과 Dual Write 없이 진행할 수 있도록 구조를 잡았습니다.

다음 편에서는 이 설계를 실제로 구현하고 운영 환경에 올리면서 마주한 기술적 디테일들을 공유합니다. 인덱스 테이블의 상세 스키마, ch-flow-shard 내부 로직, 파이프라인 운영 과정에서 겪은 시행착오를 구체적인 숫자와 함께 다뤄보겠습니다!


채널톡에서는 수십억 건 규모의 테이블을 직접 운영하며, 대규모 분산 시스템을 설계하고 개선하는 경험을 쌓을 수 있습니다. 또한 감이나 관행이 아닌 데이터와 기술적 근거를 바탕으로 의사 결정을 내리고, AWS의 관리형 서비스를 적극 활용하되 한계가 보이는 지점에서는 직접 구조를 확장해 나가는 방식으로 비용 최적화와 시스템 안정성을 함께 달성하는 것을 중요하게 생각합니다.

대규모 트래픽과 복잡한 문제를 함께 고민하고 해결해 나가고 싶다면, 언제든 채널톡 엔지니어링 팀의 문을 두드려 주세요! https://channel.io/ko/careers

We Make a Future Classic Product