🔥 DynamoDB를 쓰지 말아야 할 때

처음 한 회사에서 사용자 활동 로그를 어디에 적을지 결정해야 했던 일이 있었다. 회의 끝에 "그냥 Dynamo로 가자" 가 결론이었다. 사용자 ID 를 partition key 로 잡고 timestamp 를 sort key 로 두면 단일 사용자 조회가 빠르고 운영 부담이 적게 든다는 흐름이었다. 그렇게 두 분기를 별다른 사고 없이 보냈다.
세 번째 분기에 마케팅팀이 "지난 30 일 매출을 카테고리·지역·디바이스별로 묶어 달라" 고 요청했다. 그 요청 앞에서 한 번 멈췄다. partition key 가 사용자 ID 라서 카테고리 단위로 모으는 길이 없었다. GSI 를 새로 만들면 카테고리로 좁힐 수는 있어도 지역·디바이스 조합은 단일 GSI 로 다 풀리지 않아 인덱스 설계 자체를 다시 잡아야 했다. 결국 별도 분석 파이프라인을 옆에 두기로 했고, 처음의 "Dynamo 로 가자" 결정이 분석 파이프라인 비용까지 합치고 보면 다른 답이 더 나았다는 사실을 그때 알았다.
DynamoDB 가 답이 아닌 세 가지 경우를 짚는다. 복잡한 조인 쿼리, ad-hoc 분석, 예측 불가능한 쓰기 패턴. 셋이 각각 왜 DynamoDB 의 설계와 어긋나는지, 그리고 그 곳에서 무엇으로 가야 하는지를 차례로 본다. DynamoDB란 무엇인가: 키-값 스토어의 관점에서 한 줄로 적어 둔 "ad-hoc 쿼리·임의 join·분석 워크로드는 DynamoDB 의 답이 아니다" 라는 한 줄을, 한 단계 더 깊게 짚는다.
첫째, 복잡한 조인 쿼리
DynamoDB 의 1차 연산 단위는 단일 항목 또는 단일 partition 안의 Query 다. 두 표를 키로 묶어 한 번에 가져오는 SQL JOIN 같은 연산은 API 자체에 없다. 두 표의 데이터를 합쳐야 하면 애플리케이션 쪽이 첫 표에서 키를 받아 두 번째 표를 다시 호출하는 N+1 형태가 된다. 호출 한 건마다 RRU 또는 RCU 가 따로 청구되고, 100 건 묶인 한 번의 분석이 100 번 호출 비용을 그대로 받는다.
비용 단가는 On-Demand vs Provisioned: 가격 모델에서 적은 그대로다. On-Demand 의 RRU 100 만 건당 약 0.125 USD 는 단일 lookup 단위로 보면 작아 보인다. 다만 한 분석 요청이 수만 건의 lookup 을 만드는 패턴이 잦아지면 시간 단가의 합이 다른 차원에 도달한다. 같은 분석을 RDBMS 에서 한 번의 JOIN 으로 끝내는 비용과 비교하면, RDBMS 의 cost-based optimizer 가 한 번에 끝내는 일이 DynamoDB 에서는 N+1 호출 비용으로 청구서에 돌아온다.
트랜잭션과 조건부 쓰기: ACID의 한계의 TransactWriteItems 를 분석 join 대용으로 쓰는 길이 있을지 묻는 의문도 있다. 한 호출 100 액션·합계 4 MB 한도와 쓰기 위주 API 라는 한계 때문에 분석 join 에는 안 맞는다. 트랜잭션은 같은 운명으로 묶어야 할 항목들을 함께 쓰는 도구이지, 여러 표를 묶어 읽는 데 쓰는 도구가 아니다.
이 곳의 답은 RDBMS 다. AWS 에서는 RDS란 무엇인가: 관리형 DB의 의미 의 RDS 가 그 역할을 맡는다. 두 표의 키를 SQL JOIN 한 줄로 묶고, 옵티마이저가 인덱스 조합과 조인 순서를 한 번에 결정한다. RDS 의 청구가 분석 워크로드에서 DynamoDB 의 N+1 호출 비용보다 낮은 이유는 한 번의 plan 으로 끝난다는 점에 있다.

둘째, ad-hoc 분석
DynamoDB 는 access-pattern-first 설계다. 표를 만들기 전에 어떤 키와 어떤 인덱스로 어떻게 읽을지를 먼저 정하고, 그 패턴에 맞게 partition key·sort key·GSI·LSI 를 설계한다. 사전에 정의되지 않은 임의 조합 (분석가가 "이번 분기에는 디바이스별로 잘라 보고 싶어요" 라고 한 줄을 꺼내는 경우) 은 받아낼 수 없다. GSI vs LSI: 보조 인덱스의 두 갈래에서 적은 그대로 GSI 와 LSI 도 사전에 정의된 패턴 위에서 동작한다. ad-hoc 결합은 인덱스 추가로도 답이 안 나온다.
남은 길은 Scan 이다. Scan 은 표 전체를 partition 마다 순회한다. 표가 작을 때는 한 번 돌아 답을 만들지만, 운영 표가 수십 GB 를 넘기 시작하면 Scan 한 번이 분 단위까지 시간을 가져갈 수 있고 그 동안 전체 표의 RCU 를 소진한다. Partition Key와 Sort Key: 데이터 분산의 원리에서 본 partition 한도 1,000 WCU·3,000 RCU per second 는 Scan 위에서도 그대로다. 분석 한 번이 운영 트래픽까지 같은 표에서 throttle 받는 사고를 만든다.
DynamoDB Streams 가 이 곳을 메우지 않는다. DynamoDB Streams: 변경 이벤트 스트림에서 적은 그대로 Streams 의 retention 은 24 시간이고 base 표 항목 변경만 캡처한다. 분석 워크로드처럼 이미 적힌 데이터의 임의 조합을 다시 묻는 경우에는 Streams 가 답이 아니다. AWS Database Blog 도 분석 패턴이 필요한 곳에서는 Streams + Lambda + Kinesis Data Firehose + S3 (또는 Kinesis Data Streams for DynamoDB → Firehose → S3) 으로 데이터를 옆 시스템에 적어 두고 그 옆 시스템에서 분석을 하라고 안내한다.
이 곳의 답은 컬럼 지향 / 인덱스 풍부한 시스템이다. Amazon Athena 는 S3 위에 적힌 Parquet·ORC 같은 컬럼 지향 데이터를 SQL 로 직접 묻고 스캔한 데이터 1 TB 당 약 5 USD 로 청구한다. Amazon Redshift 는 데이터 웨어하우스로서 분석 쿼리를 위해 만든 컬럼 저장과 분산 실행을 갖춘다. Amazon OpenSearch Service 는 full-text 검색과 aggregation 을 같이 다룬다. 어느 길이 맞는지는 데이터 구조와 쿼리 형태에 따라 다르지만, 공통점은 분명하다. ad-hoc 결합은 사전 정의된 인덱스가 아니라 컬럼 지향 저장과 쿼리 시점 인덱싱으로 답을 찾는 일이라는 것이다.

셋째, 쓰기 패턴이 예측 불가
DynamoDB 는 partition 단위로 1,000 WCU·3,000 RCU per second 한도를 둔다. Provisioned 와 On-Demand 모두 같다. 트래픽이 partition 들에 고르게 분산되면 표 전체 capacity 가 합쳐져 큰 숫자로 보이지만, 한 hot 항목 또는 한 hot 키 묶음에 트래픽이 몰리면 그 항목이 속한 partition 의 한도에서 throttle 받는다. 이 형태가 hot partition 사고다.
burst 흡수에 대한 첫 기대는 adaptive capacity 와 split for heat 이다. adaptive capacity 는 다른 partition 의 여유 capacity 를 hot partition 으로 재분배하는 동작이라서 partition 의 한도 자체를 올리지 못한다. split for heat 은 hot key 의 partition 을 분할하지만 즉시 끝내지 않고 분할이 안정화되기까지 어느 정도 시간이 든다. 둘 모두 지속되는 hot 패턴에 대한 답이지, 예측 불가능한 burst 에 대한 답이 아니다. viral content, 이벤트 burst, 한 사용자가 같은 PK 로 폭주하는 경우에는 partition 분할이 따라잡기 전에 throttle 응답 (HTTP 400 ProvisionedThroughputExceededException) 이 애플리케이션으로 돌아오고, 그 응답이 사용자 쪽에서 오류 화면으로 비친다.
이 곳의 답은 큐를 앞단에 두는 패턴이다. SQS란 무엇인가: 큐의 추상 같은 이웃 서비스가 burst 를 흡수하고, 소비자 쪽이 자기 속도로 DynamoDB 에 쓴다. 100 건이 들어오는데 DynamoDB 가 60 건만 받아도 차이 40 은 큐가 메시지로 보관한다. 소비자 속도와 DynamoDB partition 한도가 맞을 때까지 큐가 평탄화 역할을 한다. partition 한도가 원천적으로 부족하면 partition key 설계 자체를 다시 봐야 하지만, 일시적 burst 가 본질일 때는 큐 한 줄이 사고를 막는다.
쓰기 burst 가 본질이 아니라 읽기 burst 가 문제라면 DAX (DynamoDB Accelerator) 가 답으로 들어온다. DAX 는 DynamoDB 앞단의 in-memory 캐시 클러스터로, eventually consistent read 를 미리 캐시해 둔다. 다만 DAX 도 partition 한도 자체를 우회하지 않는다. 캐시 미스가 누적되면 결국 base 표 partition 으로 호출이 돌아간다.

'관리형'이라는 단어가 감추는 비용
지금까지 이어 온 keyThread 가운데 하나는 "관리형이라는 단어가 감추는 비용" 이다. DynamoDB 는 그 단어가 잘 어울리는 서비스다. 인스턴스 패치도 없고, replica 셋업도 없고, partition 분할도 자동이다. 처음 도입할 때는 운영 부담이 없다는 점이 가장 큰 매력으로 보인다.
부적합한 곳에서 억지로 쓸 때 그 편리함의 청구서가 다른 줄로 돌아온다. 첫 번째 줄은 RRU·WRU 의 합이다. N+1 호출이 누적되면 단일 호출 단가는 작아도 합이 커진다. 두 번째 줄은 GSI 의 묶음 비용이다. 분석 패턴을 GSI 로 메우려고 인덱스를 늘리면 base 쓰기 한 번이 영향 받은 GSI 마다 추가 쓰기 비용을 더한다. GSI vs LSI: 보조 인덱스의 두 갈래에서 적은 "한 번의 base 쓰기 총 비용 = base + 영향 받은 GSI 수" 가 그 대목이다. 세 번째 줄은 데이터 이관 비용이다. 분석 워크로드를 옮기려고 Streams + Firehose + S3 파이프라인을 별도로 두면 Streams read request 와 Firehose 청구가 같이 붙는다. 네 번째 줄은 사고 비용이다. hot partition 으로 사용자에게 오류 화면이 비친 시점부터 백필·롤백·서비스 보상까지 들어간 시간을 그대로 비용으로 받는다.
이 비용들이 청구서의 DynamoDB 줄에는 안 보인다. 청구서는 그 비용을 CloudWatch 메트릭 줄과 옆 서비스 비용 줄로 나눠 적는다. "관리형이라 편하다" 라는 첫 인상은 적합한 경우에만 사실이고, 부적합한 경우에는 같은 비용이 다른 라인으로 돌아온다는 점을 같이 짚는다.

마무리: 언제 쓰고 언제 양보할까
새 도구를 처음 익힐 때는 적합한 경우만 보면 충분히 강해 보인다. 그 인상을 다른 곳에까지 확장하기 시작하는 시점부터 사고가 한 건씩 생긴다. 매 섹션에서 "언제 이 서비스를 쓰지 말아야 하는가" 를 한 번씩 되묻는 까닭이 거기에 있다. 부적합한 경우에 대한 명시적 정리가 다음에 도구를 고를 때 결정의 정밀도를 키운다.
DynamoDB 의 답은 분명하다. 키와 partition 이 명확한 단일 항목 액세스가 압도적으로 많고, 트래픽이 어떤 partition key 에 어떻게 분산되는지가 사전에 보이며, ms 단위 응답이 운영 SLA 의 본질일 때, 이 셋이 겹치는 곳이 DynamoDB 의 영역이다. 그 외에는 이웃 서비스에 양보한다. 복잡한 join 은 RDS·Aurora 로, ad-hoc 분석은 Athena·Redshift·OpenSearch 로, 예측 불가능한 burst 는 SQS 같은 큐로 흡수한 뒤 DynamoDB 에 쓴다.
다음 섹션부터는 그 이웃 서비스들을 하나씩 살핀다. SQS 는 큐의 추상, SNS 는 pub/sub, Kinesis 는 스트림 데이터, EventBridge 는 이벤트 버스 순으로 메시지·이벤트 계열의 도구들을 짚는다. DynamoDB 와 어디서 만나고 어디서 멀어지는지를 그 편들에서 다시 본다.











