메시지 트래픽 100배에도 끄떡 없게 고객 테이블 뜯어고치기 (1)

DynamoDB 쓰로틀링 원인 분석과 테이블 마이그레이션 전략 설계

Jayon • Jinyoung Park, Backend Enginner

  • 엔지니어링

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

채널톡에서 뱃지(Badge)는 사용자에게 읽지 않은 메시지나 새로운 알림이 있다는 걸 알려주는 시각적 표시입니다. 아래 앱 아이콘 우측 상단에 빨간 숫자로 표시되는 4처럼요. 작지만 사용자 경험에서 꽤 중요한 역할을 하죠.

채널톡에는 세 가지 유형의 사용자가 있습니다.

  • User: 채널톡을 통해 문의를 남기는 일반 고객. (= 고객사의 고객)

  • Manager: 고객사에서 상담을 담당하는 직원

  • Account: 채널톡을 구독하는 고객사 계정 (Account : Manager = 1 : N)

이 중 User 테이블16억 8천만 개의 레코드, 총 용량 1.8TB를 담고 있었습니다. 그런데 하루에도 여러 번 스파이크성으로 1500 TPS 이상의 Badge 업데이트 요청이 몰리면서 User 테이블 전체가 느려지기 시작했습니다.

User 테이블이 느려지면 채널톡의 핵심 기능인 “부트” 기능이 마비됩니다. 부트란 고객이 채널톡이 설치된 웹사이트나 앱에 방문했을 때 고객 정보를 채널톡에 연동하는 과정인데요, 이게 동작하지 않으면 고객사 홈페이지에서 채널톡 자체를 쓸 수 없게 됩니다.

“Badge 업데이트만 했는데 왜 User 테이블 전체가 느려지는 걸까요?”

결론부터 말씀드리면, 저희는 User 테이블에서 Badge 데이터를 별도 테이블로 분리하는 방식으로 이 문제를 해결했습니다. 이 글에서는 왜 분리가 필요했는지, 그리고 16억 건 레코드를 어떻게 안전하게 마이그레이션했는지 그 설계 과정을 공유합니다.


User 테이블 쓰로틀링, 두 가지 원인

너무 많은 책임을 지고 있는 User 테이블

User 테이블은 원래 다양한 역할을 담당하고 있었습니다.

이 중에서 특히 Badge 필드가 문제가 되었습니다.

다른 필드들은 비교적 고르게 업데이트되는 반면, Badge는 메시지를 전송할 때마다 업데이트가 발생하고, OTM(One-Time-Message)처럼 한 번에 여러 사용자에게 일회성 메시지를 보내는 경우 트래픽이 한꺼번에 몰립니다.

예를 들어, 한 고객사가 전체 고객 1만 명에게 OTM을 보내면 1만 건의 Badge 업데이트가 동시에 발생합니다.

두 가지 쓰로틀링 원인

문제를 더 복잡하게 만든 건 DynamoDB의 구조적 특성이었습니다.

DynamoDB는 테이블마다 초당 처리 가능한 쓰기 용량(WCU, Write Capacity Unit)읽기 용량(RCU, Read Capacity Unit)을 설정할 수 있는 프로비저닝 모드 옵션을 제공합니다.

설정된 용량을 초과하면 요청이 거부되는데, 이것을 쓰로틀링(throttling)이라고 부릅니다. Auto Scaling을 켜두면 트래픽에 맞춰 용량이 자동으로 늘어나긴 하지만, 급격한 스파이크에는 대응이 늦어져 쓰로틀링이 여전히 발생할 수 있습니다.

또 하나 알아야 할 개념이 GSI(Global Secondary Index)입니다. DynamoDB는 기본적으로 Primary Key로만 데이터를 조회할 수 있습니다. 이때 Primary Key는 Hash Key(Partition Key)를 반드시 포함하며 필요에 따라 Sort Key를 함께 사용하는 구조입니다. Sort Key가 없는 경우에는 Hash Key 하나만으로 아이템이 구분됩니다.

실제 서비스에서는 “특정 채널에 속한 모든 사용자 조회” 같은 다른 읽기 패턴이 필요할 때가 있습니다. 이런 경우에 GSI를 만들어서 channelIdGSI Hash Key로 사용할 수 있습니다. GSI는 별도의 테이블처럼 동작하며, 마찬가지로 WCU/RCU 설정이 필요합니다.

RDB를 사용해 본 독자라면 PK가 아닌 컬럼에 세컨더리 인덱스를 설정해 조회 성능을 높이는 방식에 익숙할 것입니다. GSI는 이러한 RDB의 세컨더리 인덱스와 유사한 개념으로, Primary Key가 아닌 속성을 기준으로 효율적인 조회를 가능하게 합니다.

User 테이블에는 3개의 GSI가 있었고, 3가지 GSI 모두 channelId를 GSI Hash Key로 사용하고 있었습니다.

첫 번째: 메인 테이블 WCU 소모

Badge 업데이트는 User 아이템의 Badge 관련 필드(alert, unread)만 수정하는 작업입니다. 그러나 DynamoDB에서는 아이템 단위로 WCU가 소모되기 때문에, 일부 필드만 수정하더라도 아이템 전체 크기를 기준으로 WCU가 계산됩니다.

즉, 1KB 크기의 아이템을 수정하면 1 WCU를, 2KB 크기의 아이템을 수정하면 2 WCU를 소모합니다. WCU는 KB 단위로 올림 계산되므로, 아이템 크기가 0.3KB이더라도 1 WCU가 소모됩니다.

여기에 트랜잭션 구조가 문제를 더 키웠습니다. 채널톡에서는 메시지를 전송할 때 ChatSession(채팅방 참여 정보)과 User#Badge를 TransactWriteItems로 묶어서 원자성을 보장하고 있었습니다. 이 구조에 대한 자세한 내용은 채널코퍼레이션의 Amazon DynamoDB와 함께한 아키텍처 현대화 여정 – 1부에서 확인하실 수 있습니다.

DynamoDB TransactWriteItems는 일반 쓰기의 2배 WCU를 소모하므로, Badge 업데이트 한 번에 실제로는 4 WCU가 소모되는 셈입니다.

문제는 트랜잭션 충돌 상황에서 더 심각해집니다.

DynamoDB 트랜잭션은 낙관적 동시성 제어 방식으로 동작합니다. 커밋 시점에 충돌이 감지되면 트랜잭션 전체가 롤백되고 클라이언트가 재시도를 수행하는데, 메시지 전송이 동시다발적으로 발생하면 충돌이 빈번해지고 재시도할 때마다 WCU가 누적됩니다. 최악의 경우 한 번의 Badge 업데이트에 4, 8, 16, … 그 이상의 WCU가 소모될 수 있는 구조였습니다.

스파이크성으로 Badge 업데이트가 발생하면 User 테이블의 한정된 WCU가 빠르게 고갈되고, 다른 정상적인 User 업데이트(프로필 수정, 태그 수정 등)까지 쓰로틀링에 걸리게 됩니다.

물론 이 문제는 User 테이블의 프로비저닝 쓰기 용량을 높이면 해결되지만, 들쑥날쑥한 Badge 트래픽만을 위해 User 테이블에 추가 요금을 지불하는 것은 바람직하지 않다고 판단했습니다.

두 번째: GSI 쓰로틀링 전파 (Back-Pressure)

먼저 DynamoDB에서 메인 테이블과 GSI의 관계를 짚고 넘어가야 합니다.

DynamoDB에서 아이템을 쓰면 메인 테이블에 먼저 기록되고, GSI는 비동기적으로 업데이트됩니다. 평소에는 문제가 없지만, 특정 채널에서 대량의 Badge 업데이트가 동시에 발생하면 상황이 달라집니다.

예를 들어 채널 A의 사용자 5만 명에게 OTM을 발송하면, 5만 건의 Badge 업데이트가 동시에 발생합니다. User 테이블의 GSI는 모두 channelId를 파티션 키로 사용하고 있으므로, 이 업데이트들은 GSI의 "채널 A" 파티션 하나에 집중됩니다.

문제는 DynamoDB GSI 파티션의 처리 한계입니다. 하나의 파티션은 초당 최대 1,000 WCU까지만 처리할 수 있습니다. 이 한계를 초과하면 해당 파티션에서 쓰로틀링이 발생합니다. 이렇게 특정 파티션에 트래픽이 몰려 병목이 생기는 현상을 핫 파티션(Hot Partition)이라고 부릅니다.

GSI에서 핫 파티션 쓰로틀링이 지속되면, DynamoDB는 메인 테이블 쓰기까지 거부하기 시작합니다. 이를 Back-Pressure라고 부릅니다. GSI 업데이트가 너무 밀려서 더 이상 받아줄 수 없으니, 원천적으로 메인 테이블 쓰기를 막아버리는 것입니다.

Back-Pressure가 발생하면 해당 쓰기가 GSI에 영향을 주는지 여부와 관계없이 메인 테이블의 모든 쓰기가 거부됩니다. 결국 Badge 업데이트 때문에 생긴 GSI 병목이 프로필 수정, 태그 수정 같은 일반적인 User 업데이트까지 차단하게 됩니다.

GSI Back-Pressure는 메인 테이블이나 GSI의 WCU를 늘리는 것으로 해결되지 않습니다. 파티션당 1,000 WCU 제한은 테이블의 전체 용량 설정과 무관하게 적용되기 때문입니다.

아이템 분리 vs 테이블 분리

결국 User 테이블에서 Badge를 분리해야겠다고 생각했습니다. 분리 방법으로는 두 가지 선택지가 있었습니다.

같은 테이블 내에서 아이템 분리

DynamoDB에서는 하나의 테이블에 여러 종류의 아이템을 함께 저장할 수 있습니다. 보통 Hash Key는 동일하게 유지하고, Sort Key를 통해 아이템의 역할을 구분하는 방식입니다.

예를 들어 User 테이블을 다음과 같이 구성할 수 있습니다.

  • Hash Key: userId

  • Sort Key

    • PROFILE → 사용자 프로필 정보

    • BADGE → Badge 관련 정보

이 구조에서는 한 사용자의 Profile과 Badge가 서로 다른 아이템으로 저장됩니다.

이렇게 하면 Badge 업데이트 시 SK = BADGE 아이템만 수정하므로, Profile 아이템 자체에는 영향을 주지 않습니다. 결과적으로 아이템 크기 역시 작아져 WCU 소모를 줄일 수 있습니다.

하지만 이 방식으로는 두 가지 문제가 해결되지 않았습니다.

첫째, 트랜잭션 충돌 문제가 여전합니다. 앞서 언급했듯이 채널톡에서는 ChatSession과 Badge를 TransactWriteItems로 묶어 원자성을 보장하고 있었습니다. 아이템을 분리하더라도 Badge 업데이트는 여전히 트랜잭션으로 처리되므로, 메시지 전송이 동시다발적으로 발생하면 충돌과 재시도로 인한 WCU 낭비는 그대로입니다.

둘째, GSI Back-Pressure 문제가 그대로입니다. User 테이블의 GSI들은 여전히 channelId를 파티션 키로 사용하고 있고, Badge 아이템도 해당 GSI에 포함됩니다. Badge 업데이트가 발생하면 동일한 GSI 파티션으로 쓰기가 집중되어 Back-Pressure 문제는 해결되지 않습니다.

테이블 분리

Badge 정보를 아예 별도 테이블(UserBadge)로 완전히 분리하는 방법입니다. User 테이블과 UserBadge 테이블이 물리적으로 분리되므로, WCU/RCU도 독립적으로 관리됩니다.

저희는 테이블 분리를 선택하였고, 이유는 다음과 같았습니다.

(1) 쓰기 패턴이 완전히 다름

User 테이블의 일반적인 쓰기 트래픽은 비교적 고르게 분포되어 있는 반면, Badge 업데이트는 메시지 발송이나 OTM 발생 시점에 집중적으로 발생합니다.

즉 평소에는 트래픽이 거의 없다가, 특정 이벤트 시점에 짧은 시간 동안 대량의 쓰기 요청이 몰리는 스파이크성 패턴을 보였습니다.

이처럼 성격이 전혀 다른 쓰기 패턴을 하나의 테이블에서 함께 처리하는 것은 구조적으로 무리가 있었습니다.

(2) GSI 요구 사항이 다름

User 테이블은 channelId 기준 조회가 빈번하여 3개의 GSI를 사용하고 있었습니다. 반면 Badge 데이터는 항상 userId 기준으로만 조회되며, 다른 조회 패턴이 존재하지 않았습니다.

UserBadge 테이블을 분리함으로써 GSI 자체가 필요 없는 구조를 만들 수 있고, 이는 GSI 파티션 병목과 Back-Pressure 문제를 근본적으로 제거하는 효과를 가져올 수 있겠다고 판단하였습니다.

(3) 독립적인 용량 관리

Badge 트래픽은 예측 가능성이 낮은 편이었습니다. OTM 발송 시에는 WCU가 평소 대비 10배 이상 급증하는 경우도 있었습니다.

테이블을 분리하면 UserBadge 테이블의 용량을 독립적으로 관리할 수 있게 됩니다.

예를 들어 스파이크성 트래픽에는 온디맨드 모드를 적용하거나, OTM 발송 전 사전 단계로 프로비저닝 용량을 미리 높여두는 전략을 사용할 수 있습니다.

반면 User 테이블은 상대적으로 안정적인 프로비저닝 설정을 유지할 수 있으므로 비용과 안정성 측면에서 모두 이점이 있었습니다.

(4) 시스템 설계의 일관성

과거에 AccountBadge, ManagerBadge는 이미 Account, Manager에서 분리한 상태였습니다. UserBadge 역시 동일한 설계 패턴을 따르는 것이 시스템 전반의 구조적 일관성을 유지하는 방향이었습니다.

(5) 산업적 근거: Amazon S3 사례

대규모 분산 시스템에서는 모든 기능을 하나로 묶는 방식이 오히려 복잡성과 장애 전파를 키우는 요인이 됩니다.

AWS 역시 이러한 문제를 해결하기 위해, 기능 단위로 책임을 분리하는 아키텍처를 선택해 왔습니다.

대표적인 사례가 Amazon S3입니다.

S3는 하나의 스토리지 서비스처럼 보이지만, AWS 공식 블로그에 따르면 내부적으로는 300개 이상의 소프트웨어 마이크로서비스로 구성된 분산 시스템으로 운영됩니다.

각 서비스는 서로 다른 역할과 트래픽 특성을 기준으로 분리되어 있으며, 독립적으로 확장되고 개선되면서 전체 S3 서비스를 구성합니다. 이러한 구조 덕분에 S3는 수백조 개의 객체를 처리하면서도 높은 확장성과 신뢰성을 유지할 수 있습니다. Amazon Web Services, Inc.

User와 UserBadge 역시 데이터 특성, 트래픽 패턴, 확장 요구가 명확히 달랐고, 이를 별도의 테이블로 분리하는 결정은 이러한 산업적 설계 원칙과도 잘 맞아떨어진다고 판단했습니다.

결정은 내렸습니다. 이제 문제는 “16억 레코드를 어떻게 안전하게 옮기느냐”였습니다.


어떻게 마이그레이션할 것인가?

Full Scan/Full Write 스크립트 기반 마이그레이션

User 테이블에서 Badge 필드를 분리하기로 결정하고 나서, 가장 먼저 떠올린 방법은 별도 Java 애플리케이션으로 직접 마이그레이션하는 것이었습니다.

채널톡에서 그동안 테이블 분리 작업을 할 때 주로 사용하던 방식이었습니다. 저희는 이 방식을 java-migration 이라고 부릅니다.

절차는 간단했습니다.

  1. 미리 UserBadge 테이블을 생성해 둔다.

  2. (1차 애플리케이션 배포) User#Badge 필드가 변경되면 UserBadge 테이블에 동시 쓰기 로직을 작성한다.

    1. Dual-Write 방식: 동기적으로 동시 쓰기 로직 작성 or onStream 통해 비동기로 동시 쓰기 로직 작성

  3. (마이그레이션) User#Badge 필드를 UserBadge 테이블로 java-migration을 수행한다.

    1. User 테이블을 Full Scan하면서 Badge에 해당하는 필드만 조합하여 UserBadge 테이블에 Full Write를 수행.

    2. 이때 위 마이그레이션과 DW로 인한 변경 과정에서 충돌이 발생하면 최신 변경 사항만 받아들이도록 ConditionExpression을 작성해야 함.

  4. (2차 애플리케이션 배포) User 테이블을 바라보던 로직을 UserBadge 테이블을 바라보도록 수정한다. (읽기 & 쓰기)

이 방법의 장점은 명확했습니다.

파이프라인이 직관적이고, 팀 내에서 이미 여러 번 검증된 방식이었습니다. 별도로 AWS 서비스를 공부하거나 인프라를 세팅할 필요도 없었습니다.

하지만 User 테이블의 16억 레코드라는 규모 앞에서 현실적인 문제에 부딪혔습니다.

비용과 시간이 기하급수적으로 늘어났습니다.

User 테이블을 Full Scan하는 행위는 대량의 RCU를 소모하는 것이므로 빠르게 스캔을 진행하면 운영 중인 User 테이블에 읽기 쓰로틀링이 발생할 수 있습니다. 따라서 안전하게 500 RCU/초로 스캔하자고 생각했습니다.

1.8TB 데이터를 4KB 단위로 Eventually Consistent Read로 읽으면 약 2억 3천만 RCU가 필요했고, 500 RCU/초로 스캔하면 168시간, 즉 7일이 걸리는 계산이 나왔습니다. 물론 시간을 더 줄일 수는 있지만 그만큼 비용은 더욱 증가합니다.

여기에 UserBadge 테이블로 쓰는 시간까지 더하면 총 비용은 $366.71, 총 시간은 최소 7일이었습니다.

Plaintext
[User 테이블 풀스캔]
- 1.8TB = 1,887,436,800 KB
- 4KB 청크: 471,859,200개
- Eventually Consistent Read: 235,929,600 RCU 필요
- 500 RCU/초로 스캔: 7일 소요
- 비용: $11.84

[UserBadge 테이블 쓰기]
- 16억 8천만 레코드
- 아이템 크기: 76.9바이트 (1KB 미만) → 1 WCU/건
- 3,000 WCU/초로 쓰기
- 비용: $354.87

→ 총 $366.71 + EC2(java-migration 수행할 서버) 비용, 7일 소요

비용도 문제였지만, 더 큰 문제는 휴먼 에러였습니다.

7일 동안 java-migration 스크립트가 돌아가는 동안 예상치 못한 이슈가 발생하면 어떻게 될까요?

EC2에서 OOM이 발생하거나, 코드 결함이 발견되거나, 네트워크가 불안정해지면 처음부터 다시 시작해야 할 것입니다. 그러면 비용과 시간은 더 늘어날 수밖에 없습니다.

DynamoDB Import/Export + AWS Glue 기반 마이그레이션

Amazon DynamoDB - The Definitive Guide 책을 통해 DynamoDB Import/Export + AWS Glue 기반 마이그레이션 기법을 알게 되었습니다.

DynamoDB Export/Import란?

DynamoDB Export/Import는 AWS에서 제공하는 대용량 테이블 데이터 마이그레이션 서비스입니다.

DynamoDB의 PITR(Point-In-Time Recovery) 스냅샷을 S3로 내보내고, S3 데이터로 새 테이블을 만드는 기능입니다. 핵심은 RCU/WCU를 전혀 소모하지 않는다는 점이었습니다. 백그라운드 프로세스로 동작하기 때문에 프로덕션 테이블에 영향을 주지 않았습니다.

하지만 한 가지 문제가 있었습니다. Export는 테이블 전체를 내보내기 때문에 Badge 필드만 따로 추출할 수 없었습니다. User 테이블의 모든 필드를 S3로 Export한 뒤, Badge 관련 필드만 골라내는 변환 작업이 필요했습니다.

그래서 AWS Glue를 함께 사용하기로 했습니다.

AWS Glue란?

AWS Glue는 서버리스 ETL(Extract, Transform, Load) 서비스입니다.

S3에 Export된 User 데이터에서 Badge 필드만 골라내고, DynamoDB Import에 필요한 JSON 형식으로 변환하는 작업을 Glue로 수행할 수 있습니다.

AWS에는 Kinesis Data Firehose, EMR, Data Pipeline 등 다양한 ETL 옵션이 있습니다. 그중 Glue를 선택한 이유는 간단합니다. 사람 손이 가장 적게 가기 때문입니다.

EMR을 쓰면 Spark 클러스터를 직접 띄우고, 애플리케이션 코드를 작성하고, 클러스터 설정을 관리해야 합니다. Kinesis Data Firehose는 실시간 스트리밍에 특화되어 있어 일회성 배치 변환에는 맞지 않았습니다.

반면 Glue는 Visual 에디터에서 소스, 변환, 타겟을 드래그 앤 드롭으로 연결하면 파이프라인이 완성됩니다. 복잡한 로직이 필요하면 스크립트로 직접 작성할 수도 있습니다. 클러스터 프로비저닝이나 스케일링은 Glue가 알아서 처리하고, 작업이 끝나면 리소스도 자동으로 정리됩니다.

일회성 마이그레이션에 인프라 구성까지 신경 쓰고 싶지 않았습니다. Glue는 그 요구에 딱 맞았습니다.

온라인 마이그레이션 파이프라인 설계

DynamoDB Export의 전체 테이블 내보내기로 인한 ETL 작업으로 인해 파이프라인은 이전에 비해 다소 복잡합니다.

  1. 미리 TmpUserBadge 테이블을 생성해 둔다.

  2. (1차 애플리케이션 배포) User#Badge 필드가 변경되면 TmpUserBadge에 동시에 쓰기 로직을 작성한다.

  3. (1차 마이그레이션) 기존 User 테이블을 S3로 DynamoDB Export하고 AWS Glue ETL 통해 UserBadge 테이블에 넣을 데이터로 변환한다.

  4. (2차 마이그레이션) S3 올라간 User#Badge 데이터를 기반으로 S3에서 DynamoDB Import 기능을 사용하여 UserBadge 테이블을 생성한다. (DynamoDB Import 하면 곧바로 테이블이 만들어짐)

  5. (2차 애플리케이션 배포) User#Badge 필드가 변경되면 UserBadge에 동시에 쓰기 로직을 작성한다.

  6. (3차 마이그레이션) TmpUserBadge에 있던 레코드를 Full Scan하면서 UserBadge에 변경 사항을 반영한다.

  7. (3차 애플리케이션 배포) User 테이블을 바라보던 로직을 UserBadge 테이블을 바라보도록 수정한다. (읽기 & 쓰기)

이렇게 설계한 이유를 자세히 설명해 보겠습니다.

왜 TmpUserBadge 테이블이 필요한가?

DynamoDB S3 Import는 새 테이블 생성만 지원하며, 이미 존재하는 테이블에 데이터를 추가하는 방식은 26년 1월 기준으로 제공하지 않습니다.

즉, Export → ETL → Import 과정이 진행되는 동안에도 운영 환경에서는 계속해서 Badge 변경 트래픽이 발생하는데, 이 변경분을 그대로 유실 없이 흡수할 수 있는 Buffer가 필요했습니다.

이를 위해 저희는 TmpUserBadge라는 임시 테이블을 별도로 생성하여, 마이그레이션 진행 중 발생하는 실시간 Badge 변경 사항을 임시로 저장하는 구조를 선택했습니다.

대안으로 DynamoDB Streams를 활용하는 방법도 고려할 수 있습니다.

하지만 DynamoDB Streams는 변경 이벤트 보관 기간이 기본적으로 24시간으로 제한되어 있으며, 초기 마이그레이션 단계에서는 작업 일정이 예측하기 어렵고 재시도가 발생할 가능성도 높았기 때문에 24시간 보관 제한은 운영 리스크로 판단했습니다.

또한 DynamoDB Streams의 보관 기간을 늘리기 위해 Kinesis Data Streams for DynamoDB Streams 모드를 사용하는 방법도 있으나, 이는 추가 비용과 운영 복잡도를 수반하기 때문에 일회성 대규모 마이그레이션이라는 성격에 비해 과도하다고 판단했습니다.

Dual Write를 통한 source/target 테이블 동기화

왜 필요한가?

마이그레이션 중에도 서비스는 계속 운영되므로, User 테이블의 Badge 데이터가 실시간으로 변경됩니다. Dual-Write 없이 마이그레이션하면 마이그레이션 시작 이후의 변경사항이 UserBadge 테이블에 반영되지 않아 데이터 불일치가 발생합니다.

구현 방법 1) 동기 Dual Write

Java
  • 장점

    • 구현이 단순함

    • 두 테이블 간 데이터가 즉시 일관되게 유지됨

  • 단점

    • 쓰기 경로가 길어져 응답 시간이 증가할 수 있음

    • Target 테이블(UserBadge) 쓰기 실패 시, 원본(User) 업데이트까지 실패할 가능성 있음

구현 방법 2) 비동기 Dual Write

Java
  • 장점

    • 원본 테이블 쓰기 성능에 영향이 없음

    • 재시도 및 오류 처리에 유연함

  • 단점

    • 수 초 이내의 복제 지연(Eventual Consistency) 발생

    • 스트림 기반 파이프라인을 별도로 운영해야 함

최종 선택

최종적으로 저희는 동기 Dual Write 방식을 선택했습니다.

마이그레이션이 완료되면 애플리케이션의 Badge 쓰기 로직은 결국 User 테이블이 아닌 UserBadge 테이블을 기준으로 동작하도록 변경되어야 합니다.

동기 Dual Write를 적용하면, 마이그레이션 단계에서 미리 UserBadge 테이블 쓰기 로직을 애플리케이션 코드에 포함시키고 검증할 수 있으며, 마이그레이션 완료 후에는 User 테이블 쓰기만 제거하는 방식으로 전환이 가능합니다.

즉, Dual Write 단계를 단순한 임시 동기화 수단이 아니라 최종 구조로 전환하기 위한 사전 리허설 단계로 활용할 수 있다는 점이 결정적인 이유였습니다.


마무리

1편에서는 Badge 업데이트가 왜 User 테이블 전체를 느리게 만들었는지 그 원인을 분석하고, 테이블 분리라는 해결책을 선택하기까지의 과정을 살펴보았습니다.

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

  • User 테이블은 너무 많은 책임을 지고 있었고, 특히 Badge는 스파이크성 트래픽 패턴으로 인해 다른 필드들과 성격이 완전히 달랐습니다.

  • 메인 테이블 WCU 소모와 GSI Back-Pressure라는 두 가지 구조적 문제가 쓰로틀링의 원인이었습니다.

  • 같은 테이블 내 아이템 분리로는 문제가 해결되지 않았고, 결국 별도 테이블로 완전히 분리하는 방향을 선택했습니다.

  • 16억 건 규모의 데이터를 안전하게 옮기기 위해 DynamoDB Export/Import와 AWS Glue를 활용한 마이그레이션 파이프라인을 설계했습니다.

2편에서는 이 파이프라인을 실제로 수행하면서 마주한 기술적 디테일들을 공유합니다. Glue ETL 스크립트 작성 과정에서 겪은 시행착오, DynamoDB Import 시 주의할 점, 그리고 마이그레이션 전후의 비용과 성능 변화까지 구체적인 숫자와 함께 다룰 예정입니다.


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

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

We Make a Future Classic Product