AWS 기술 블로그

채널코퍼레이션의 Amazon DynamoDB와 함께한 아키텍처 현대화 여정 – 3부

이 블로그는 Channel Corporation의 이해빈님, 박진영님과 함께 작성되었습니다.

채널코퍼레이션은 올인원 AI 메신저 ‘채널톡’을 운영하는 B2B SaaS 스타트업으로 Amazon DynamoDB의 수평 확장성, ACID 트랜잭션과 같은 특징을 활용해 빠르게 성장하는 비즈니스를 문제없이 수행하고 있습니다. 이 시리즈의 1부에서는 채널코퍼레이션이 Amazon DynamoDB를 선택한 이유와 Amazon DynamoDB에서 트랜잭션을 처리한 방법을 살펴보았습니다. 2부에서는 채널코퍼레이션이 두 가지 유형의 DynamoDB 스트림을 사용하여 이벤트 기반 아키텍처를 구현한 방법을 소개하였습니다.

이번 3부에서는 채널톡의 Badge 사례를 통해, 범용 DynamoDB User 테이블을 역할별로 분리하여 문제를 해결한 경험을 공유합니다.

배경: Badge

채널톡에서 뱃지(Badge)는 사용자에게 읽지 않은 메시지나 새로운 알림이 있다는 걸 알려주는 시각적 표시입니다.

그림 1: 읽지 않은 메시지를 나타내는 채널톡의 badge

그림 1: 읽지 않은 메시지를 나타내는 채널톡의 badge

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

  • User: 채널톡을 통해 문의를 남기는 일반 고객. (고객사의 고객)
  • Team Member: 고객사에서 상담을 담당하는 직원
  • Account: 채널톡을 구독하는 고객사 계정 (1 Account : N Team Members)

이 중 DynamoDB의 User 테이블은 총 용량 1.8TB에 달하는 약 16억 8천만 개의 레코드를 담고 있었습니다. 하루동안 이 Badge의 업데이트 요청이 여러 차례 급증하게 되면서, 전체 User 서비스가 악영향을 받기 시작했습니다. User 테이블이 느려지면, 채널톡의 핵심 기능인 “부트” 기능이 마비됩니다. 부트란 고객이 채널톡이 설치된 웹사이트나 앱에 방문했을 때 고객 정보를 채널톡에 연동하는 과정으로, 이 기능이 동작하지 않으면 고객사 홈페이지에서 채널톡 자체를 쓸 수 없게 됩니다.

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

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

첫번째로 User 테이블은 프로필, 태그, 뱃지, 상태 등 많은 역할을 담당하고 있었습니다. 그런데 Badge 업데이트는 급격하게 치솟는 특성이 있습니다. 일회성 메시지(OTM)를 많은 사용자에게 한꺼번에 보낼 때 트래픽이 폭증합니다. 예를 들어, 10,000명의 사용자에게 단일 OTM을 보내면 10,000건의 동시 쓰기가 발생합니다. Badge 업데이트는 단일 항목의 Badge 관련 속성(alert, unread)만 수정합니다. DynamoDB에서 Write Capacity Unit(WCU)은 항목 단위로 소비되므로, 일부 속성만 수정하더라도 전체 항목 크기를 기준으로 WCU가 소비됩니다.

1부에서 소개한 바와 같이, 채널톡에서 메시지를 보낼 때 채팅 세션 정보(ChatSession)와 Badge는 TransactWriteItems API를 통해 트랜잭션으로 처리됩니다. 단일 Badge 업데이트는 2개의 항목을 갱신하며, 이는 4 WCU(2개 항목 × 트랜잭션 쓰기당 2 WCU)를 소비합니다. 그러나 진짜 문제는 트랜잭션 충돌입니다. DynamoDB 트랜잭션에서 커밋 시점에 충돌이 감지되면 전체 트랜잭션이 롤백되고 클라이언트가 재시도합니다. 메시지 전송이 동시에 발생하면 충돌이 빈번하게 일어나고, 각 재시도마다 추가 WCU가 소비됩니다. User 테이블은 프로비저닝된 용량 모드를 사용하고 있었기 때문에, 이 재시도 증폭이 테이블의 프로비저닝된 WCU 예산을 빠르게 소진시켰습니다. 프로비저닝된 처리량을 초과하면 DynamoDB는 해당 테이블에 대한 모든 쓰기 요청을 스로틀링합니다. Badge 업데이트뿐만 아니라 일반적인 User 업데이트(프로필 변경, 태그 변경 등)도 ProvisionedThroughputExceededException으로 거부되었습니다.

두번째로 Channel Talk에는 “특정 채널에 속한 모든 사용자 조회”와 같이 기본 테이블의 파티션 키와 다른 읽기 패턴이 있습니다. 이러한 경우를 위해 Channel Talk은 Global Secondary Index(GSI)를 사용하며, User 테이블의 3개 GSI 모두 channelId를 파티션 키로 사용합니다. DynamoDB 테이블에 쓰기를 수행하면 해당 테이블의 모든 GSI가 비동기적으로 업데이트됩니다. 예를 들어, Channel A의 50,000명 사용자에게 OTM을 보내면 50,000건의 Badge 업데이트가 동시에 발생합니다. 이 업데이트들이 3개 GSI 모두에서 단일 “Channel A” 파티션에 집중됩니다. 기본 테이블 접근이 잘 분산되어 있더라도, 이러한 GSI 핫스팟은 GSI back pressure를 유발할 수 있으며, 이는 다시 User 테이블에 대한 쓰기를 스로틀링합니다.

다음 표는 User 테이블과 GSI 구조를 샘플 데이터로 보여줍니다.

Partition Key (userId) profile badge (alert/unread) tags status ChannelId
user-001 {name: Alice, email: …} {alert: 3, unread: 5} [vip, new] active channel-A
user-002 {name: Bob, email: …} {alert: 0, unread: 12} [returning] active channel-A
user-003 {name: Carol, email: …} {alert: 1, unread: 2} [vip] inactive channel-A

표1: User 테이블

Partition Key (ChannelId) Sort Key (userId) status
channel-A user-001 active
channel-A user-002 active
channel-A user-003 inactive

표2: GSI 구조

항목 분리 vs 테이블 분리

이 문제를 해결하기 위해 항목 분리와 테이블 분리, 두 가지 접근 방식을 검토했습니다. 항목 분리의 경우, User 테이블을 다음과 같이 복합 키로 구성합니다.

  • Partition Key: userId
  • Sort Key
    • profile: 사용자 프로필 정보
    • badge: 뱃지 관련 정보

이 접근 방식은 단일 파티션 키 아래에 모든 사용자 관련 데이터를 함께 배치하여 사용자별 쿼리를 단순화하고 데이터 모델을 컴팩트하게 유지할 수 있다는 장점이 있습니다. 그러나 항목 분리로는 두 가지 근본 원인을 본질적으로 해결할 수 없었습니다. 모든 항목이 여전히 동일한 기본 테이블에 존재하므로, GSI는 Sort Key와 관계없이 모든 쓰기에 대해 업데이트를 전파합니다. 또한 TransactWriteItems가 여전히 동일한 테이블 내에서 동작하고 동일한 파티션 키에 대한 경합이 남아 있기 때문에 트랜잭션 충돌도 지속됩니다. 따라서 기존 User 테이블에서 Badge 기능을 추출하여 새로운 UserBadge 테이블을 생성하기로 결정했습니다.

Partition Key (userId) profile tags status ChannelId
user-001 {name: Alice, email: …} [vip, new] active channel-A
user-002 {name: Bob, email: …} [returning] active channel-A
user-003 {name: Carol, email: …} [vip] inactive channel-A

표3: User 테이블 (분리 후)

Partition Key (userId) alert unread version
user-001 3 5 12
user-002 0 12 8
user-003 1 2 5

표4: UserBadge 테이블 (분리 후)

분산 시스템의 관심사 분리 원칙에 따라, 다음 세 가지 기술적 이유로 테이블 분리를 선택했습니다.

  1. 쓰기 접근 패턴이 완전히 다름
    • User 테이블의 일반적인 쓰기 트래픽은 비교적 균등하게 분산되는 반면, Badge 업데이트는 메시지 전송이나 OTM 발생 시점에 급증합니다. 즉, 대부분의 시간에는 트래픽이 거의 없다가 특정 이벤트 순간에 대량의 쓰기 요청이 짧은 시간 내에 몰리는 스파이크 형태의 패턴입니다. 본질적으로 다른 성격의 쓰기 패턴을 하나의 테이블에서 처리할 이유가 없다고 판단했습니다.
  2. GSI 요구사항이 다름
    • User 테이블은 channelId 기반 조회가 빈번하여 3개의 GSI를 사용하고 있었습니다. 반면 Badge 데이터는 항상 userId로만 조회되며, 다른 쿼리 패턴은 존재하지 않았습니다. Badge 데이터를 UserBadge 테이블로 분리하면 GSI가 전혀 필요 없는 구조를 만들 수 있어, GSI back pressure 문제를 근본적으로 제거할 수 있다고 판단했습니다.
  3. 독립적인 용량 관리
    • Badge 트래픽은 예측 가능성이 상대적으로 낮았습니다. OTM 발송 시 WCU가 평상시 대비 10배 이상 급증하는 경우도 있었습니다. 테이블을 분리함으로써 각 접근 패턴에 맞는 방식으로 비용을 최적화할 수 있다고 판단했습니다. UserBadge 테이블에는 온디맨드 용량 모드를, User 테이블에는 Auto Scaling이 적용된 프로비저닝 용량 모드를 적용할 수 있습니다.

이 접근 방식은 분산 시스템에서 확립된 원칙, 즉 기능별로 책임을 분리하여 복잡성과 장애 전파를 줄이는 원칙에 부합합니다. 예를 들어, Amazon Simple Storage Service(Amazon S3)는 각각 집중된 책임을 가진 300개 이상의 마이크로서비스로 구성되어 있습니다. 채널코퍼레이션의 DynamoDB 테이블 분리도 같은 철학을 따릅니다. Badge에 전용 테이블을 부여함으로써 고유한 트래픽 패턴을 격리하고, User 테이블 운영과의 상호 오염을 제거합니다.

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

기존에는 애플리케이션이 두 테이블에 이중 쓰기(dual write)를 수행하면서, 별도의 애플리케이션이 기존 테이블을 Scan하여 데이터를 새 테이블로 변환하는 방식을 주로 사용했습니다. 채널코퍼레이션에서는 이를 java-migration이라고 부릅니다.

그림2: 애플리케이션 이중 쓰기와 전체 테이블 Scan을 사용하는 java-migration 방식

그림2: 애플리케이션 이중 쓰기와 전체 테이블 Scan을 사용하는 java-migration 방식

User 테이블을 Scan하면 대량의 RCU를 소비하므로, 빠르게 스캔하면 운영 중인 User 테이블에 읽기 스로틀링을 유발할 수 있습니다. 따라서 안전하게 약 500 RCU 수준으로 스캔하는 것을 고려했습니다. 1.8TB의 데이터를 4KB 단위의 Eventually Consistent Read로 읽으려면 약 2억 3,000만 RCU가 필요합니다. 500 RCU 기준으로 계산하면 128시간, 약 5.3일이 소요됩니다. 게다가 중간에 문제가 발생하면 한 번의 반복 주기가 5.3일이라는 것은 너무 긴 시간이었습니다.

따라서 채널코퍼레이션은 DynamoDB Export to S3Import from S3 기능과 AWS Glue를 마이그레이션에 활용하였습니다. DynamoDB Export to S3와 Import from S3는 전체 또는 증분 스냅샷을 S3로 내보내고, S3 데이터로부터 새 테이블을 생성하는 기능입니다. 핵심은 테이블의 RCU/WCU를 전혀 소비하지 않으므로 프로덕션 테이블의 성능에 영향을 주지 않는다는 점입니다. 또한 다양한 AWS ETL 옵션 중에서 서버리스이며 운영 비용이 가장 낮은 AWS Glue를 선택하게 되었습니다.

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

그림3: Export to S3 → Glue ETL → Import from S3로 이어지는 온라인 마이그레이션 파이프라인

그림3: Export to S3 → Glue ETL → Import from S3로 이어지는 온라인 마이그레이션 파이프라인

  1. (1차 애플리케이션 배포) User#Badge 필드가 변경될 때, TmpUserBadge에 동시에 쓰는 로직을 추가합니다.
  2. (1차 마이그레이션) DynamoDB Export로 기존 User 테이블을 S3로 내보내고, AWS Glue ETL을 사용하여 UserBadge 테이블에 로드할 데이터 형태로 변환합니다.
  3. (2차 마이그레이션) S3에 업로드된 User#Badge 데이터를 기반으로, S3에서 DynamoDB Import 기능을 사용하여 UserBadge 테이블을 생성합니다.
  4. (2차 애플리케이션 배포) User#Badge 필드가 변경될 때, UserBadge에 동시에 쓰는 로직을 추가합니다.
  5. (3차 마이그레이션) TmpUserBadge에 있던 레코드를 전체 Scan하여 변경사항을 UserBadge에 반영합니다.
  6. (3차 애플리케이션 배포) User 테이블을 바라보던 로직을 UserBadge 테이블을 바라보도록 변경합니다 (읽기 및 쓰기).

Export → ETL → Import가 진행되는 동안에도 프로덕션 환경에서는 Badge 변경 트래픽이 계속 발생하며, 이러한 변경을 손실 없이 흡수할 수 있는 버퍼가 필요했습니다. 이러한 이유로, 마이그레이션 중 발생하는 실시간 Badge 변경을 임시로 저장하는 별도의 임시 테이블 TmpUserBadge를 생성하는 구조를 선택했습니다.

트랜젝션 이중 쓰기를 활용한 기존/신규 테이블 동기화

마이그레이션 중에도 서비스가 계속 운영되므로, User 테이블의 Badge 데이터는 실시간으로 변경됩니다. 이중 쓰기 없이 마이그레이션하면, 마이그레이션 시작 이후의 변경사항이 UserBadge 테이블에 반영되지 않아 데이터 불일치가 발생합니다. 결국 기존 테이블과 새 테이블 간의 트랜잭션 이중 쓰기를 선택했습니다. 마이그레이션이 완료되면, 애플리케이션의 Badge 쓰기 로직을 User 테이블이 아닌 UserBadge 테이블을 대상으로 동작하도록 변경해야 합니다. 동기식 이중 쓰기를 적용함으로써, 마이그레이션 단계에서 새 테이블 쓰기 로직을 애플리케이션 코드에 미리 포함시켜 검증할 수 있습니다. 마이그레이션 완료 후에는 기존 테이블 쓰기만 제거하면 전환이 가능합니다. 즉, 결정적인 이유는 이중 쓰기 단계를 단순한 임시 동기화 메커니즘이 아니라, 최종 구조로의 전환을 위한 리허설 단계로 활용할 수 있다는 점이었습니다.

새 마이그레이션 파이프라인 결과

항목 기존 방식 (java-migration) 새 방식 (export/glue/import) 차이
DDB scan/write $336.71 0 -$336.71
EC2 $27.42 0 -$27.42
DDB Export 0 $183.55 +$183.55
Glue ETL 0 $19.93 +$19.93
DDB Import 0 $41.93 +$41.93
TmpUserBadge 테이블 병합 0 $8.33 +$8.33
합계 $394.13 $253.74 -$140.40 (36%)

표5: 인프라 비용 (USD)

항목 기존 방식 (java-migration) 새 방식 (export/glue/import)
기본 테이블 scan/write 5.3일 (128시간)
DDB Export 30분
Glue ETL 변환 30분
DDB Import 4시간 30분
TmpUserBadge 테이블 병합 ~ 30분
총 시간 5.3일 (128시간) 5.5 ~ 6시간
단축률 96%

새 파이프라인은 프로덕션 테이블의 WCU/RCU를 전혀 소비하지 않으므로 스로틀링 위험이 없으며, 수동 개입이 최소화되어 휴먼 에러 가능성도 크게 줄었습니다.

100배 이상의 메시지 트래픽도 견딜 수 있게 된 이유

UserBadge 테이블을 분리한 후, User 테이블의 안정성이 크게 향상되었습니다. 메시지 전송 트래픽이 100배를 초과하더라도, UserBadge 테이블이 온디맨드 스케일링을 통해 트래픽 스파이크를 흡수할 수 있는 구조가 되었습니다.

기존 User 테이블에는 Badge 외에도 프로필, 타입, 태그 등 여러 속성에 대한 쓰기가 발생하고 있었습니다. Badge 업데이트 트랜잭션이 동일한 항목을 건드렸기 때문에, 다음 그래프에서 보는 것처럼 분당 최대 10,000회까지 충돌이 발생했습니다.

그림 4: 분리 전 User 테이블의 트랜잭션 충돌, 최대 분당 약 10,000회

그림 4: 분리 전 User 테이블의 트랜잭션 충돌, 최대 분당 약 10,000회

UserBadge로 분리한 후, Badge 관련 트랜잭션 로직이 User 테이블에서 사라졌고, 충돌에서 자유로워졌습니다. 다음 그래프는 UserBadge 테이블 내 Badge 업데이트 간 충돌이 여전히 존재하지만 분당 약 80회로 감소했음을 보여줍니다.

그림 5: 분리 후 UserBadge 테이블의 충돌, 분당 약 80회

그림 5: 분리 후 UserBadge 테이블의 충돌, 분당 약 80회

UserBadge 테이블이 새로 추가되었음에도 User 테이블의 WCU 사용량이 이전 대비 약 25% 감소하여 약 12~13%를 절감하게 되었습니다. 그러나 사용량 절감보다 더 중요한 것은 Badge 트래픽이 더 이상 User 테이블에 영향을 미치지 않는다는 사실입니다. 메시지 전송 트래픽으로 인해 Badge 업데이트가 100배 증가하더라도, UserBadge 테이블의 프로비저닝 용량을 올리는 것으로 즉시 대응할 수 있습니다.

또한 Badge 업데이트로 인한 스로틀링이 User 테이블과 UserBadge 테이블 모두에서 더 이상 발생하지 않습니다. UserBadge 테이블에는 GSI가 없기 때문에, Badge 업데이트가 단일 채널에 집중되더라도 GSI back pressure가 발생하지 않습니다.

이를 종합하면, User 테이블과 UserBadge 테이블의 분리를 통해 전체 WCU 사용량 감소, 스로틀링 문제 해결, 그리고 서로 다른 두 기능이 상호 부하 전파 없이 독립적으로 운영될 수 있게 되었습니다.

향후 개선 계획

UserProfile 분리

User 테이블에는 고객사가 직접 관리하는 Profile 속성이 있습니다. 유연성이 높은 속성이므로 항목 크기가 매우 다양합니다. 항목의 일부 속성만 업데이트하더라도 UpdateItem은 전체 프로비저닝된 처리량(변경 전/후 항목 크기 중 큰 값)을 소비합니다. UserBadge와 마찬가지로, 이를 별도 테이블로 분리할 계획입니다.

GSI back pressure의 근본 원인 해결

Badge로 인한 back pressure는 해결되었지만, User 테이블의 GSI는 여전히 channelId를 파티션 키로 사용하고 있습니다. 단일 채널에서 대량의 User 업데이트가 발생하면 동일한 문제가 재발할 수 있습니다.

GSI를 별도 테이블로 분리하고 Amazon Kinesis Data Streams를 통해 비동기적으로 복제하는 구조를 통해 GSI 역할을 하는 테이블에 지연이 발생하더라도 기본 테이블에는 영향을 주지 않도록 하고자 합니다.

배치 쿼리 기반 삭제에서 이벤트 기반 삭제로 전환

만료된 UserChat을 삭제하기 위해 매일 배치 작업이 GSI를 쿼리하고 있었습니다. GSI 유지 비용뿐만 아니라, 삭제가 배치 실행 시점에만 처리되므로 지연도 존재했습니다. DynamoDB TTL과 Amazon Kinesis Data Streams를 활용하여, TTL 만료 시점에 이벤트 기반으로 처리하는 방식으로 전환할 계획입니다. 이후에는 배치 쿼리용 GSI도 제거할 수 있습니다.

Badge 트랜잭션 제거

ChatBadgeTeamMemberBadge 업데이트가 TransactWriteItems로 묶여 있습니다. 낙관적 동시성 제어(Optimistic Concurrency Control)는 충돌이 빈번한 상황에 적합하지 않으며, 충돌이 발생할 때마다 WCU가 낭비됩니다. DynamoDB Lock Client를 사용한 분산 잠금(distributed lock) 방식을 검토하고 있습니다.

결론

이 블로그 포스트를 마무리하며, 이번 마이그레이션을 수행하면서 깨달은 몇 가지를 정리합니다.

쓰기 패턴이 테이블 설계를 결정할 수 있다

지금까지 DynamoDB를 사용하면서, 테이블을 분리하기보다는 Sort Key를 활용하여 항목 분리로 조회하는 패턴을 주로 사용했습니다. 하지만 User 테이블의 문제는 읽기가 아니라 쓰기였습니다. 결국 DynamoDB 테이블 설계에서는 읽기 패턴뿐만 아니라 쓰기 패턴도 함께 분석해야 한다는 것을 이해하게 되었습니다.

익숙한 방법이 항상 최선은 아니다

처음에는 전체 Scan 마이그레이션을 수행하는 커스텀 Java 애플리케이션(내부적으로 java-migration이라 부르는)이 더 안전해 보였습니다. 팀이 여러 번 사용해 본 방법이었고, 예상치 못한 변수가 적을 것 같았습니다. 하지만 16억 건의 레코드 규모 앞에서, 익숙한 방법이 최선은 아니었습니다. AWS 관리형 서비스는 RCU/WCU를 소비하지 않고, 휴먼 에러를 최소화하며, 시간과 비용을 모두 절약했습니다. 물론 Glue ETL 스크립트를 작성하는 데 시간이 걸렸지만, 한 번 구축하면 앞으로 유사한 마이그레이션이 필요할 때 재사용할 수 있습니다. 팀 관점에서 보면 장기적인 승리였습니다.

숫자가 설득한다

기존 방식과 새 방식의 비용 및 시간을 구체적으로 계산한 것이 큰 도움이 되었습니다. “새 서비스를 배워보자”는 말보다 “128시간을 5.5시간으로 줄이고 $140를 절약할 수 있다”는 말이 팀 합의를 이끌어내는 데 훨씬 빨랐습니다. 정량적 근거가 의사결정의 속도와 질을 모두 높였습니다.


박진영

박진영

박진영님은 채널톡의 Core API 팀 백엔드 엔지니어입니다.

DynamoDB, Glue, Lambda 등 AWS 서비스를 기반으로 수십억 건 규모의 데이터를 운영하고 있으며, 현재는 메시징 플랫폼의 인프라 비용 최적화와 성능 개선에 집중하고 있습니다.

이해빈

이해빈

이해빈님은 채널톡의 리드 백엔드 엔지니어로, 채널톡의 핵심 플랫폼 시스템을 담당하는 Core API 팀을 이끌고 있습니다.

대규모 백엔드 아키텍처와 DynamoDB 운영에 집중하는 동시에, 팀이 명확하게 사고하고 소통하며 성과를 낼 수 있는 환경을 만들어가고 있습니다.

Kihoon Kwon

Kihoon Kwon

권기훈 스타트업 솔루션즈 아키텍트는 스타트업 고객들이 AWS에서 성공적인 비즈니스를 달성할 수 있도록 함께 고민하고 지원하는 역할을 하고 있습니다.

Eunwoo Park

Eunwoo Park

AWS 스타트업팀의 어카운트 매니저 박은우는 스타트업 고객이 클라우드를 활용하여 민첩하게 혁신하고 글로벌로 확장할 수 있도록 지원하고 있습니다.

Hyuk Lee

Hyuk Lee

Hyuk Lee is a DynamoDB Solutions Architect based in South Korea. He loves helping customer modernize their architecture using the capabilities of Amazon DynamoDB.