🔥 DynamoDB Streams: 변경 이벤트 스트림
강의 목차

처음 DynamoDB 표에 Streams를 켰을 때, 한 시간쯤 지나서 Lambda 로그에 모르는 이벤트가 들어와 있었다. 사용자가 직접 삭제한 적이 없는 항목인데도 eventName: REMOVE가 찍혀 있었다. 한참을 살피다가 그 항목들에 TTL 속성이 있었고 만료 시각이 그 시간 즈음이었다는 사실을 확인했다. AWS가 TTL 만료 삭제도 똑같이 stream record로 보내고 있었다. 그제야 Streams가 단순한 "사용자 변경 알림"이 아니라 표에 일어나는 모든 변경의 단일 채널이라는 점을 다시 봤다.
처리량 단위와 partition 모델은 DynamoDB란 무엇인가: 키-값 스토어의 관점 편에서 정리했다. 그 표에서 나온 변경 이벤트를 다른 시스템이 어떻게 받는가. StreamViewType, Lambda 통합, 청구와 한도까지가 그 답을 만든다.
Streams는 24시간짜리 변경 캡처 스트림이다
DynamoDB Streams는 표 단위로 켜는 시간 순서 stream이다. AWS Developer Guide "Change data capture for DynamoDB Streams"는 DynamoDB Streams가 표의 항목 단위 변경을 시간 순서대로 캡처해 최대 24시간 동안 로그에 보관한다고 설명한다. 24시간이라는 retention 한도는 옵션이 아니다. 줄이는 것도, 늘리는 것도 안 된다.
stream record는 여러 shard로 나뉜다. AWS Developer Guide "Change data capture for DynamoDB Streams"는 같은 항목의 변경이 같은 shard 안에서 SequenceNumber 순서를 따른다고 적는다. 다른 항목 사이의 절대 순서는 공식 문서가 약속하지 않는다. shard는 시간이 지나면서 자동으로 split될 수 있고, 컨슈머는 부모 shard를 자식 shard보다 먼저 끝까지 읽어야 다음으로 넘어간다. partition key 설계 자체의 영향은 Partition Key와 Sort Key: 데이터 분산의 원리 편에서 정리한 그대로 stream에도 작용한다.
stream 활성화 자체는 콘솔에서 토글 한 번이거나, UpdateTable의 StreamSpecification에 StreamEnabled: true와 StreamViewType 한 줄을 적는 일이다. CloudFormation의 AWS::DynamoDB::Table에서는 StreamSpecification 블록을, Terraform의 aws_dynamodb_table에서는 stream_enabled = true와 stream_view_type 인자를 같이 적는다. 켜는 데 필요한 코드는 짧지만, 실제 운영 비용은 청구·한도·실패 처리에서 나온다.

StreamViewType이 record 구성을 정한다
StreamSpecification의 StreamViewType은 record에 어떤 image가 들어가는지 정하는 네 가지 값 중 하나다. AWS Developer Guide는 KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES 네 값을 바로 나열한다. KEYS_ONLY는 변경된 항목의 키만 넣는다. NEW_IMAGE는 변경 직후의 전체 항목을 넣는다. OLD_IMAGE는 변경 직전의 전체 항목을 넣는다. NEW_AND_OLD_IMAGES는 직전과 직후 두 image를 모두 넣는다.
이 네 가지 차이는 record 크기와 디버깅 편의성의 맞바꿈으로 보면 된다. KEYS_ONLY는 record가 가장 작아 stream read 비용도 가장 적게 들지만, 컨슈머가 변경 내용을 알려면 base 표에 다시 GetItem 호출을 하고 그쪽 RCU를 추가로 쓴다. NEW_AND_OLD_IMAGES는 record가 가장 크지만 변경 전후를 바로 비교할 수 있어 audit log·CDC pipeline·검색 인덱스 동기화에 잘 맞는다. AWS는 base item 자체에 400 KB 한도를 두므로, image를 담는 stream record도 그 근처 크기까지 자란다. 큰 항목을 자주 갱신할수록 record 한 건의 부피가 늘어난다.
StreamViewType을 바꾸려면 stream을 한 번 끄고 다시 켜야 한다. AWS Developer Guide "Enabling a stream"도 그렇게 적는다. 이때 기존 stream record는 새 컨슈머에 더 이상 보이지 않는다. 그래서 활성화 시점에 NEW_AND_OLD_IMAGES를 기본값으로 두고, 컨슈머가 필요한 image만 골라 쓰는 패턴이 흔하다.
GSI나 LSI의 변경은 stream record에 들어오지 않는다. DynamoDB Streams는 base 표의 항목 단위 변경만 캡처한다. GSI와 LSI: 다른 키로 조회하기 편에서 본 GSI의 비동기 propagation도 별도 record로 내보내지 않는다. base 변경 한 번에 GSI 여러 개가 같이 갱신돼도 stream에는 base 변경 record 하나만 들어온다.

Lambda EventSourceMapping이 가장 흔한 컨슈머다
DynamoDB Streams의 직접 호출 API는 DescribeStream → GetShardIterator → GetRecords 세 호출의 반복이다. Shard iterator에서 TRIM_HORIZON, LATEST, AT_SEQUENCE_NUMBER, AFTER_SEQUENCE_NUMBER 중 시작 위치를 고르고, 그 iterator로 GetRecords를 호출해 한 번에 최대 1,000개 record 또는 1 MB까지 받아 온다. 이 API를 직접 다루는 경우는 KCL(Kinesis Client Library) 위에 워커를 얹는 정도다.
실제 운영에서는 보통 Lambda EventSourceMapping(ESM)을 붙인다. AWS Developer Guide "Using AWS Lambda with Amazon DynamoDB"는 ESM이 shard를 폴링해 record를 모아 Lambda를 동기 호출하고, Lambda가 정상 응답하면 다음 위치로 넘어간다고 설명한다. Lambda 함수가 stream을 직접 읽는 구조가 아니다. Lambda 서비스 쪽 poller가 읽고 함수를 호출한다.
운영에서 먼저 확인할 설정은 네 가지다. BatchSize는 한 번 호출에 묶어 보낼 최대 record 수로 default 100, 최대 10,000이다. ParallelizationFactor는 한 shard에서 동시에 호출할 Lambda 인스턴스 수로 1부터 10까지(default 1) 잡는다. 한 shard에 트래픽이 몰려 병목이 되면 이 값을 키운다. BisectBatchOnFunctionError를 true로 두면 Lambda가 실패한 배치를 ESM이 절반으로 나눠 다시 호출한다. 그 절반에서도 실패가 나오면 다시 절반으로 나눈다. DestinationConfig.OnFailure는 retry 한도를 다 쓴 배치를 SQS, SNS, 또는 S3 destination으로 보낸다. SQS dead-letter queue에 해당하는 stream 쪽 출구다.
MaximumRetryAttempts의 default는 -1, 즉 retry 무한이다. 운영 정책에 맞는 한도를 적어 두지 않으면 한 record가 24시간 retention이 끝날 때까지 같은 Lambda를 계속 깨울 수 있다. MaximumRecordAgeInSeconds는 -1 또는 60~604,800 범위에서 잡는다(default -1은 retention 끝까지 무한 보존). retry 횟수와 record 나이 두 축으로 막아 둔다.
한 stream에 ESM을 여러 개 붙일 수도 있다. 그 경우 같은 record가 각 ESM에 묶인 Lambda 함수로 모두 간다. fan-out이 필요한 흔한 패턴이지만, 같은 record를 여러 번 처리하는 cost와 idempotency를 같이 책임져야 한다. 컨슈머가 enhanced fan-out 같은 더 강한 격리가 필요해지면 Kinesis Data Streams for DynamoDB로 갈아탄다.

record에는 변경 시각·키·image·식별자가 함께 들어온다
stream record는 정해진 필드 묶음으로 온다. eventID는 record의 고유 ID, eventName은 INSERT·MODIFY·REMOVE 세 종류 중 하나, eventSource는 aws:dynamodb 고정값이다. eventVersion은 AWS 공식 예제에 1.0과 1.1이 같이 등장하므로, 컨슈머는 특정 값에 의존하지 않는 편이 안전하다. awsRegion은 region 코드를 적고, dynamodb.ApproximateCreationDateTime은 변경 시각의 근사치(초 단위)를 적는다.
dynamodb 객체가 핵심 payload를 담는다. Keys는 항상 partition key와 sort key를 적고, NewImage/OldImage는 StreamViewType이 정한 대로 들어오거나 들어오지 않는다. SequenceNumber는 같은 shard 안 순서를 정하고, SizeBytes는 그 record 자체의 크기다. StreamViewType도 record 안에 다시 들어 있어 컨슈머 쪽 디버깅에 쓴다.
userIdentity 필드는 평소에는 없고 AWS가 자동 처리한 변경에서만 들어온다. TTL 만료 삭제가 대표적이다. 처음 Lambda 로그에서 봤던 모르는 REMOVE 이벤트도 바로 거기 해당한다. AWS Developer Guide "DynamoDB Streams and Time to Live"는 TTL 만료로 들어온 REMOVE record의 userIdentity를 { "type": "Service", "principalId": "dynamodb.amazonaws.com" }라고 적는다. 이 두 필드를 보면 사용자 직접 삭제와 TTL 만료 삭제를 구분할 수 있다. 만료된 항목을 S3나 다른 store로 옮기는 archival 패턴에서는 이 값이 기준점이 된다.
ESM은 stream record를 at-least-once 방식으로 보낸다. AWS Developer Guide도 ESM이 같은 record를 두 번 보낼 수 있다고 적어 둔다. 컨슈머는 같은 record를 다시 받아도 결과가 달라지지 않게 만든다. eventID를 dedup 키로 쓰는 패턴이 표준이다.

켜는 순간 청구서 줄이 늘어난다
Streams를 켜면 청구 항목이 늘어난다. AWS DynamoDB pricing 페이지의 "DynamoDB Streams" 섹션은 us-east-1 기준 stream read request unit 100,000개당 $0.02라고 적는다. stream read request unit 하나는 GetRecords 호출 한 번이고, 한 호출은 최대 1,000개 record 또는 1 MB까지 받는다. 같은 shard에 컨슈머 두 개를 붙이면 호출 수도 청구도 두 배가 된다.
일반 Lambda ESM 경로로 붙이면 그 stream read는 사실상 무료다. AWS pricing 페이지가 account마다 region별 매월 무료 read request 2.5백만 개를 준다고 적기 때문이고, 일반적인 Lambda 트리거 워크로드는 그 안에 들어간다. 대신 Lambda 쪽 청구는 따로 나간다. invoke 수, duration, memory 가격이 그대로 붙는다. Lambda 자동 로그도 CloudWatch 로그 수집 구조 편에서 본 대로 ingest와 storage 두 줄로 남는다.
24시간 retention을 위한 storage는 별도 항목이 없다. AWS는 stream record 보관에 추가 청구를 하지 않고 read request만 청구한다. 다만 NEW_AND_OLD_IMAGES로 큰 항목을 자주 갱신하면 한 호출에 담기는 record 수가 줄어 호출 수가 늘 수 있다. 큰 객체는 DynamoDB란 무엇인가: 키-값 스토어의 관점 편에서 정리한 대로 S3에 두고 stream에는 식별자만 넣는 편이 청구와 한도 양쪽에서 안전하다.
합치면 보통 네 줄이 따라온다. DynamoDB Streams read request, Lambda invoke, Lambda duration, CloudWatch Logs ingest다. OnFailure 대상으로 SQS를 붙였다면 SQS receive 호출이 한 줄 더 붙는다. 관리형이라는 말은 인스턴스 운영을 없애 준다는 뜻이지, 관련 청구 항목까지 사라진다는 뜻은 아니다.
Streams가 맞지 않은 경우도 분명하다
첫째, 24시간 보존이 짧으면 Streams만으로는 부족하다. 컨슈머가 멈춘 사이 24시간이 지나면 그 동안의 record는 다시 받지 못한다. AWS Developer Guide "Kinesis Data Streams for DynamoDB"는 이 경계를 분명하게 적는다. KDS for DynamoDB로 옮기면 보존을 default 24시간에서 최대 1년(8,760시간)까지 늘릴 수 있고, 컨슈머도 enhanced fan-out으로 여러 개 붙일 수 있다. 24시간을 넘기면 그만큼의 별도 storage 청구가 따라온다.
둘째, 분석이 목적이면 stream 자체에서 누적을 만들지 않는다. AWS Database Blog "Stream Amazon DynamoDB table data to Amazon S3 Tables for analytics"는 두 가지 길을 같이 적는다. 대부분의 경우 권장은 Streams + Lambda + Firehose + S3 패턴으로 이벤트를 분석 store에 옮겨 두는 쪽이다. 24시간보다 긴 보존이나 enhanced fan-out 컨슈머가 필요하면 Kinesis Data Streams for DynamoDB → Firehose → S3 흐름으로 옮긴다. 어느 쪽이든 누적 집계는 별도 store에서 하는 편이 안전하다. Streams 컨슈머 안에서 매 record마다 집계를 쌓으면 record 수만큼 비용이 늘고, hot partition은 그 shard 처리량 한도까지만 처리한다.
셋째, 쓰기 직후 다른 시스템이 반드시 같은 상태를 봐야 하는 흐름에도 Streams는 맞지 않다. 결제 완료 직후 사용 가능 잔액 화면을 바로 갱신해야 하는 경우가 그렇다. Streams의 비동기 propagation으로는 이 요구를 만족시키기 어렵다. 이런 경우에는 transaction API(TransactWriteItems)나 표 쓰기와 같은 트랜잭션 안에서 후속 처리를 같이 묶는 설계를 쓴다.

다음에 새 표에 Streams를 켜면 나는 먼저 두 가지를 확인한다. 변경 이벤트를 누가 소비하는가. 얼마나 오래 보관해 다시 처리해야 하는가. 답이 24시간 안이고 컨슈머가 Lambda 하나뿐이면 Streams로 충분하다. 그렇지 않으면 KDS for DynamoDB로 시작한다. 그다음 StreamViewType은 NEW_AND_OLD_IMAGES로 두고, ESM에는 MaximumRetryAttempts와 OnFailure를 명시적으로 적는다. 두 값을 default(retry 무한, OnFailure 미지정)로 두면 record 하나가 종일 같은 Lambda를 깨우는 상황이 바로 나온다.










