DynamoDB TTL 설정 TIL — 100만 건 자동 삭제, 비용 0원의 이면

목차

DynamoDB에서 100만 건의 만료된 세션을 BatchWriteItem으로 지우면 약 $1.25가 청구된다. 같은 양을 DynamoDB TTL 설정으로 처리하면 비용이 0원이다. 운영 중인 세션 테이블의 정리 스크립트를 뜯어보다가 이 차이를 다시 확인했고, 그동안 왜 TTL을 본격적으로 안 썼나 싶었다.

결국, 이 글은 TIL 노트다. 오늘 한 작업, 새로 알게 된 것, 그리고 나중에 똑같은 길을 가는 사람이 헷갈리지 않게 적어두는 메모.

오늘 한 것 — cron 삭제 스크립트를 TTL로 교체

운영 중인 서비스에 세션을 저장하는 DynamoDB 테이블이 하나 있다. 항목 수는 평소 1,500만 건 안팎. 하루 한 번 EventBridge 스케줄로 Lambda가 깨어나서 만료된 항목을 Scan + BatchWriteItem 콤보로 지운다. 한 번 돌면 70만~90만 건이 정리된다. 정리 비용도 비용이지만, 이 Lambda가 가끔 timeout으로 죽으면 다음 날 알람이 울리고 누군가 호출 알람을 받는다.

반면, 오늘 한 작업 자체는 단순했다. 테이블에 TTL 속성을 활성화하고, 백엔드에서 세션을 쓸 때 expires_at이라는 Unix timestamp 필드를 추가하도록 코드를 바꿨다. 기존 cron Lambda는 일단 살려두고, TTL이 실제로 잘 도는지 확인할 때까지 모니터링 모드로만 돌리는 중이다.

TTL 활성화 자체는 30초

예를 들어, 콘솔에서는 정말 간결하다. 테이블 페이지 → 추가 설정 → TTL → 활성화 → TTL 속성명을 입력하면 끝. AWS CLI로 하면 한 줄이다.

aws dynamodb update-time-to-live \
  --table-name user-sessions \
  --time-to-live-specification "Enabled=true, AttributeName=expires_at"

한편, 설정 자체는 30초 안에 끝났다. 그 다음부터 진짜 일이 시작된다. 이미 들어가 있는 1,500만 건에는 expires_at이 없다. 신규 쓰기에만 속성을 박는다고 가정하면, 기존 항목은 영영 안 지워진다. 백필을 어떻게 할 것인지가 본 게임이었다.

백필 전략 세 갈래

선택지는 세 가지였다. 첫째, Scan을 한 번 돌면서 UpdateItem으로 모든 항목에 expires_at을 추가한다. 단순하지만 RCU와 WCU를 한꺼번에 다 쓴다. 둘째, 점진적 백필 — 사용자가 다음에 세션을 갱신할 때 자연스럽게 expires_at이 박히도록 두기. 시간은 걸리지만 운영 비용이 0에 가깝다. 셋째, S3로 export한 뒤 Glue로 변환해서 다시 import. 항목 수가 정말 많을 때만 가성비가 나온다.

또한, 이번에는 둘째 방식으로 갔다. 세션은 어차피 만료가 짧아서 한 달이면 거의 다 회전된다. 한 달 뒤에도 expires_at이 없는 항목이 남아있으면 그건 진짜 좀비 데이터다. 그때 Scan으로 한 번 청소하면 된다.

새로 알게 된 것 — TTL의 함정 네 가지

그런데, 공식 문서에는 분명히 적혀 있는데, 실제로 부딪히기 전에는 잘 안 읽힌다. 운영 시뮬레이션 돌리면서 다시 마주친 함정 네 가지를 정리한다.

1. TTL은 즉시 삭제가 아니다

그러나, "만료 시점이 지나면 바로 삭제된다"고 생각했다면 틀렸다. AWS 공식 문서에는 TTL이 만료 후 보통 며칠 이내에 삭제된다고 명시돼 있다(출처: AWS DynamoDB Developer Guide, "Time to Live (TTL)" 항목, 2026년 5월 기준). 보장된 SLA는 없다. 부하가 큰 테이블이거나 파티션이 많이 분산돼 있으면 더 늦어질 수도 있다.

운영 환경에서 "이 데이터는 X시각 이후 절대 보이면 안 된다"는 요구사항이 있으면 TTL만으로는 부족하다. 쿼리 시점에 expires_at을 비교하는 필터를 같이 거는 게 정석이다. TTL은 스토리지 정리 도구이지 가시성 통제 수단이 아니다.

2. 만료된 항목은 여전히 읽기에 잡힌다

즉, 1번의 결과로 따라오는 두 번째 함정. 삭제 전까지 GetItem이나 Query 결과에 그대로 나온다. 클라이언트 입장에서는 "만료된 데이터가 왜 아직 살아있지?"라고 보인다. 그래서 애플리케이션 레벨에서 한 번 더 거르는 게 안전하다.

여담이지만, 이 점이 의외로 보안 리뷰에서 자주 지적받는다. 비밀번호 리셋 토큰을 TTL로 관리한다고 하면 보안팀이 정색한다. 토큰이 만료된 뒤에도 며칠 동안 테이블에 남아있을 수 있고, 어떤 경로로든 노출되면 그게 사고다.

3. TTL 속성은 Number 타입의 Unix epoch 초여야 한다

밀리초가 아니라 초 단위. 자바스크립트에서 Date.now()를 그냥 박으면 밀리초라서 TTL이 동작하지 않는다. Python의 time.time()은 초 단위라 그대로 써도 되는데, 정수로 캐스팅하지 않으면 또 안 먹는다. 속성 타입도 String이 아니라 Number여야 한다. 이걸로 "분명 TTL 켰는데 왜 안 지워지지?"로 한나절 날린 케이스를 봤다.

4. 삭제 자체는 무료지만 Streams를 거치면 비용이 붙는다

즉, TTL 삭제 자체는 WCU를 소비하지 않는다. 무료다. 다만 DynamoDB Streams를 켜둔 상태에서 TTL 삭제가 일어나면, 그 삭제 이벤트는 Stream에 흘러간다. Streams 읽기 비용, 그리고 Stream을 트리거로 하는 Lambda 비용은 별도로 청구된다. "TTL이 무료인 줄 알았는데 청구서가 나왔다"의 출처는 보통 여기다.

항목 BatchWriteItem 수동 삭제 TTL 자동 삭제
100만 건당 비용 약 $1.25 (1 WCU × 1M, on-demand) $0
삭제 정확도 즉시 만료 후 며칠 이내
운영 부담 Lambda + 스케줄러 + 알람 속성 하나 추가
쿼리 시 필터 불필요 (이미 삭제됨) 필요 (잠깐 남음)
Streams 연동 비용 수동 호출이라 제어 가능 자동으로 발생 가능

즉, 표만 보면 TTL이 압도적이지만, "삭제 정확도"와 "쿼리 시 필터" 줄이 의외로 무겁다. 컴플라이언스 요구가 강한 데이터라면 TTL은 보조 수단이지 단독 해법이 아니다.

실제 설정과 코드 메모

작성 시점(2026년 5월) 기준 Boto3 1.34에서 동작 확인한 패턴. 세션을 쓸 때 expires_at만 챙기면 된다. 만료까지 30일을 주는 형태.

import time
import boto3

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("user-sessions")

# 만료 시각: 30일 후 (Unix epoch 초)
ttl_seconds = int(time.time()) + 30 * 24 * 60 * 60

table.put_item(Item={
    "session_id": "sess_abc123",
    "user_id": "u_42",
    "data": {"...": "..."},
    "expires_at": ttl_seconds,  # Number 타입, 반드시 초 단위
})

그러나, 쿼리 시점에 만료 항목을 거르고 싶으면 FilterExpression을 같이 건다. FilterExpression은 RCU 절감 효과가 없다는 점은 헷갈리지 말 것. 어차피 읽고 나서 거르는 구조라, 만료된 항목이 많으면 RCU는 그대로 청구된다. 그래서 TTL의 "며칠 이내 삭제"가 결국 운영상 의미가 있다 — 며칠 지나면 진짜로 사라지니까 RCU도 같이 줄어든다.

CloudWatch에서 켜야 할 지표는 TimeToLiveDeletedItemCount다. 활성화하고 일자별로 확인할 수 있다. 일주일이 지나도 0이면 뭔가 잘못된 거다. 보통은 속성 타입이 String으로 박혔거나, 단위가 밀리초로 박힌 경우다.

이처럼, 테이블 사이즈도 즉시 줄어들지 않는다. 며칠 텀을 두고 천천히 떨어지는 그래프를 보게 된다. 처음 켰을 때 "안 지워지는데?" 하고 당황하지 말 것.

운영하면서 메모해둘 것

그러나, 기존 데이터 백필을 안 하면 영영 안 지워지는 좀비 항목이 남는다. 위에서 언급한 세 가지 백필 전략 중 어느 쪽이든 하나는 골라야 한다. 점진적 백필을 택했다면 N일 뒤에 잔여량을 한 번 측정해보고, 그 시점에 일회성 정리를 추가로 돌리는 패턴이 무난하다.

그런데, Streams를 쓰는 테이블이라면 TTL 삭제 이벤트를 Lambda에서 어떻게 처리할지를 미리 정해두는 게 좋다. 이벤트 레코드의 userIdentity.principalIddynamodb.amazonaws.com이면 TTL에 의한 시스템 삭제다. 일반 삭제와 분리해서 처리하지 않으면, 사용자 액션과 시스템 만료가 한 큐에 섞인다.

GSI(Global Secondary Index)에도 TTL 삭제는 그대로 전파된다. GSI에서 따로 정리할 일은 없다. GSI 쪽이 약간 늦게 동기화되는 케이스가 보고된 적 있어서, GSI 기반 쿼리에서도 만료 필터를 동시에 거는 게 안전한 듯하다.

그런데, :::tip TTL을 본격적으로 켜기 전에 expires_at을 모든 신규 쓰기에 먼저 박아두고, 일주일쯤 데이터가 쌓인 뒤 TTL을 활성화하는 게 안전하다. 그래야 켜자마자 "왜 아무것도 안 지워지지?" 하는 상황이 줄어든다. 첫 며칠은 TimeToLiveDeletedItemCount만 보지 말고, 실제 테이블 사이즈와 RCU 추이도 같이 봐야 한다. :::

게다가, 당장 시도해볼 만한 것 세 가지를 적어둔다. 첫째, 테이블 하나를 골라서 콘솔에서 TTL 활성화만 켜본다. 데이터 변경 없이 활성화만 해도 시스템에는 영향이 없다. 둘째, 백엔드 코드 한 군데에서 신규 쓰기에 expires_at을 추가한다. 단위는 반드시 Unix epoch . 셋째, CloudWatch에 TimeToLiveDeletedItemCount 알람을 하나 만든다. 일주일 뒤에 0이 아니어야 정상이다.

(개인적으로는 RDS 쪽이라면 partitioning + DROP PARTITION이 정석이라고 보지만, DynamoDB처럼 스키마리스 키-밸류 스토어에서는 TTL이 가장 깔끔한 답에 가까운 것 같다.)

관련 글