채널톡의 DynamoDB 핫 파티션 연재 1편으로, GSI 하나에 쓰기가 몰려 User 테이블 전체의 쓰기까지 막혀 버린 장애를 분석하고 GSI를 별도 인덱스 테이블로 떼어내는 설계를 세운다. 더 큰 문제는 그 GSI와 아무 관련이 없는 Boot 요청까지 함께 거부됐다는 점이었다. Boot는 고객사 사이트 방문자를 채널톡에 연결하는 과정이라, 막히면 그 사이트에서 채널톡 위젯 자체가 뜨지 않는 장애로 번졌다.
핵심 포인트- GSI는 따로 쓰기 요청을 받지 않고 메인 테이블 변경이 비동기 전파되는 구조라, 아이템 하나를 쓸 때마다 메인 테이블 1 WCU와 각 GSI 1 WCU가 따로 소모된다.
- 문제의 GSI는 sort key가 접속 시각이라 값이 바뀔 때마다 기존 위치를 지우고 새 위치에 다시 기록해, 실질적으로 항상 2배 비용이 드는 구조였다.
- DynamoDB 파티션 하나는 초당 1,000 WCU가 물리 한계이고, 이는 테이블 전체 용량 설정과 무관하다. 메인 테이블 WCU를 아무리 높여도 해결되지 않는다.
- GSI 파티션이 밀리면 DynamoDB가 원천적으로 메인 테이블 쓰기를 막는 Back-Pressure가 작동하고, 이때 막히는 것은 그 GSI 관련 쓰기만이 아니다.
- 키 샤딩·도메인 테이블 분리·인덱스 테이블 분리 세 후보 중, 비즈니스 로직을 그대로 두고 인덱스만 통제할 수 있게 떼어내는 세 번째를 골랐다.
상세 정리- 문제의 GSI 용도: 특정 채널의 관리 대상 사용자를 최근 접속순으로 조회하는 인덱스로, 프로모션 발송이나 재방문 메시지, 최근 활동 사용자 조회, 일정 기간 미접속 사용자 일괄 삭제 등 여러 기능이 이 하나에 의존하고 있었다.
- 여기서 관리 대상은 웹사이트를 스쳐 간 익명 방문자가 아니라 연락처를 남겼거나 상담으로 관계가 생긴 사용자다.
- 포화 경로 1: 한 고객사 사이트에 방문이 몰리면 Boot가 대량으로 발생해 그 채널의 GSI 파티션이 핫 파티션이 된다. 이를 막기 위한 Boot 전용 Hybrid Rate Limit은 별도 글로 예고했다.
- 포화 경로 2: Boot가 잠잠한 시간대라도 이메일이나 카카오톡 같은 외부 매체로 한 채널에서 대량 소통이 발생하면 같은 파티션이 포화된다.
- 즉 특정 경로 하나의 요청 수를 제한해도 다른 경로가 같은 파티션을 포화시키면 장애가 재현되고, 조회 기능은 서비스 여러 곳에서 필수라 단순히 떼어낼 수도 없었다.
- 예시로 채널 A의 사용자 5만 명에게 One Time Message를 발송하면 5만 건의 유저 업데이트가 동시에 발생하고, User 테이블의 GSI들이 모두 채널 id를 파티션 키로 써서 그 한 파티션에 집중된다.
- 후보 1 키 샤딩: 파티션 키를 여러 값으로 쪼개 쓰기를 분산하는 방식이다. 파티션 키만 바꾸면 되고 핫 파티션 자체가 잘 생기지 않는다는 단순함이 장점이다.
- 기각 이유: 이 GSI의 유즈 케이스가 전부 한 채널의 사용자를 최근 접속순으로 나열하는 조회였다. 채널 하나를 16개 샤드로 나누면 한 페이지를 위해 16번의 Query가 동시에 나가고 응답을 모두 받아 다시 정렬·페이지네이션해야 해, 평소에도 16배의 RCU가 든다.
- 게다가 이미 GSI에 들어 있는 수십억 건 아이템을 새 샤딩 키로 다시 써야 하고 그 과정에서 또 같은 쓰기 집중이 일어날 수 있다.
- 다만 키 샤딩이 언제나 틀린 것은 아니다. 단건 조회가 주가 되는 GSI라면 스캐터 게더 부담이 크지 않아 오히려 깔끔한 선택이고, 실제로 다른 GSI에는 이 방식을 적용했다고 밝힌다.
- 후보 2 도메인 테이블 분리: 이전에 Badge를 별도 테이블로 떼어낸 패턴을 그대로 가져오는 방향이다. 데이터를 도메인 단위로 쪼개고 비즈니스 로직이 직접 새 테이블에 쓰게 한다.
- 기각 이유: Badge 분리가 깔끔했던 이유는 쓰기 경로가 몇 군데로 정해져 있었기 때문이다. 반면 이 경우 Boot와 외부 매체 소통 등 여러 경로에서 User 테이블이 갱신되므로, 모든 경로의 비즈니스 로직을 일일이 바꾸는 것은 현실적으로 불가능하고 경로 하나만 놓쳐도 데이터가 어긋난다.
- 최소 정보만 담는 프로젝션 테이블도 검토했지만, 목록을 꺼낼 때 사용자 이름·프로필·태그가 같이 필요해 매번 User 테이블로 2차 조회가 따라붙는다. Badge는 단건 조회였고 이쪽은 사용자 전체 정보가 필요한 목록 조회라 접근 패턴 자체가 다르다.
- 후보 3 채택: 비즈니스 로직은 지금처럼 User 테이블만 수정하고, User의 변경을 감지해 별도 인덱스 테이블로 흘려보내는 파이프라인을 직접 만든다. 사실상 자체 GSI를 만드는 셈이다.
- 이점 셋: 기존 쓰기 로직을 건드릴 필요가 없고, 조회하는 곳이 한정적이라 변경 범위가 적으며, 메인 테이블 쓰기와 인덱스 쓰기가 비동기로 분리돼 Back-Pressure 자체가 사라지고 파이프라인 중간에 제어 지점을 둘 수 있다.
- 덤으로 모니터링도 얻는다. AWS는 메인 테이블에서 GSI로 전파되는 시간 같은 지표를 제공하지 않는데, 직접 만들면 전파 지연과 적체, 재시도 상태를 확인할 수 있다.
- Streams 모드 선택: Native 모드는 동일 샤드를 동시에 읽는 프로세스가 2개를 넘지 않을 때 안정적이라고 권장되며 그 이상이면 처리량 한도에 부딪힌다. User 테이블에는 이미 다른 컨슈머가 붙어 있어 이번 파이프라인까지 얹으면 한도에 닿을 가능성이 컸다.
- Kinesis 모드는 enhanced fan-out으로 컨슈머마다 샤드당 2MB/s 전용 처리량을 가져 서로 영향을 주지 않고, 보관 기간도 Native의 24시간 고정 대신 최대 365일까지 설정할 수 있어 장애나 배포로 컨슈머가 멈춰도 여유 있게 되짚을 수 있다.
- 파이프라인 구조: 새 마이크로서비스가 Kinesis에서 이벤트를 꺼내되 바로 쓰지 않고, 사내 Redis 기반 Rate Limiter에게 해당 인덱스 파티션이 지금 핫 파티션인지 물어본다.
- 핫 파티션이면 SQS로 밀어 넣고 나중에 다시 꺼내 판정과 쓰기를 처음부터 거친다. SQS 버퍼의 이유는 핫 파티션이 발생한 채널만 격리해 다른 채널 쓰기가 함께 밀리지 않게 하고, 파티션 한계를 넘는 쓰기를 DynamoDB에 그대로 밀어 넣지 않아 쓰로틀링 자체를 막기 위해서다.
- 기존 데이터 마이그레이션: PITR 스냅샷을 S3로 Export하고 Glue로 인덱스 테이블 형태로 변환한 뒤, Export 시각을 시작점으로 스트림을 재생해 그 사이 변경분을 따라잡는다.
- Badge 분리 때와 달라진 점: 그때는 Export 도중 변경분을 담아둘 임시 테이블을 따로 만들어야 했지만, 이번에는 User 테이블에 이미 Kinesis가 달려 있고 스트림이 이벤트를 오래 쌓아 둬 임시 테이블 없이 처리할 수 있었다.
- 서두에서 핫 파티션이 DynamoDB만의 문제가 아니라 Cassandra·HBase 같은 분산 NoSQL이나 샤딩한 MySQL/PostgreSQL도 형태만 달리해 겪는 문제라고 짚는다.
왜 읽나GSI 쓰기가 메인 테이블까지 막는 Back-Pressure를 겪는 팀에게 키 샤딩과 도메인 분리, 인덱스 분리의 트레이드오프를 접근 패턴 기준으로 가르는 판단 근거를 준다.