작성 배경

DynamoDB는 기존 RDBMS에 비해 아래와 같은 장점과 단점이 있는 DBMS로
사용처가 명확하기 때문에 서비스와 DynamoDB가 어울릴지 충분히 검토 후 사용해야 합니다

장점

  • 확장성이 훨씬 뛰어나서 트래픽 급증에도 유연하게 대응할 수 있습니다. 트래픽 예측이 안되는 서비스에 강합니다.
  • 네트워크 이슈만 없다면 항상 낮은 latency를 보장하기 때문에 OLTP를 안정적으로 잘 처리합니다.
  • Key - Value 스토어로 스키마가 유연하고 계층 구조의 데이터를 잘 처리할 수 있습니다.

단점

  • 다른 DynamoDB 간 JOIN이 불가능합니다.
  • 때문에 하나의 Single Table Design 설계가 굉장히 중요하며 설계가 어렵거나 불가능한 서비스라면 DynamoDB가 적합하지 않습니다.
  • OLAP에는 적합하지 않습니다.
    • DynamoDB의 데이터를 OLAP에 사용하려면 DynamoDB Streams 같은 CDC기능을 통해 OLAP용도의 다른 DB(ES,Redshift..)에 저장해서 사용해야 합니다.

정리하면

  • 트래픽 변동에 따라 자동으로 유연하게 대응이 필요하고
  • Key-Value 포맷의 schema-less 데이터를 Single Table 모델링으로 잘 다룰 수 있는 OLTP 서비스

라면 DynamoDB를 효율적으로 사용하실 수 있습니다

Partition Key, Sort Key, Primary Key

Dynamodb에는 세가지 Key 개념이 있습니다

Partition key(필수)

  • ddb의 item이 물리적으로 저장되는 partition을 결정하는 Key.
  • 하나의 partition에 몰리지 않도록 분산이 잘되는 partition key를 설정하는 것이 중요 합니다.
  • Partition Key로 조회할 땐 equal('=') 조건으로만 조회가 가능하며 < >등 범위 조건 조회는 지원하지 않습니다.

sort key(optional)

  • sort key를 통해 특정 파티션 키에 대해서 데이터를 정렬 순서로 유지할 수 있습니다.
  • 범위를 기반으로 파티션에서 데이터의 일부만 읽어야 하는 액세스 패턴이 있는 경우 정렬 키를 정의하는 것이 좋습니다.
  • dynamodb에서 1:n, m:n 관계로 모델링을 확장할 수 있고 <, > 같은 범위 조회와 SQL의 % 조회 (begins_with, contains 등) 를 할 수 있는 유일한 방법이지만, sort key 조건 단독으로 조회는 불가하기 때문에 partition key와 같이 사용되어야합니다.

Primary Key

  • DDB 내에서 unique 함을 보장하는 Key로 RDBMS의 PRIMARY KEY, UNIQUE KEY와 동일한 개념입니다.
  • Partition Key 단독으로 있을 땐 Partition Key가 Primary Key 역할을 하게 되고
  • Partition Key + Sort Key 일 땐 두 Key가 composite Primary Key 역할을 하게 됩니다.

파티션 키는 반드시 분산이 가능한 키로 설계해야 합니다

  • user_id,email id , invoice number 등 서비스에서 유일한 값이거나, 균일한 비율로 무작위로 요청되는 속성, 중복값이 적은 성격으로 설계해야합니다.

    • customerid#productid#countrycode 처럼 여러 attribute를 #로 하나의 Key 값으로 연결하여 partition key로 설정하는 것도 가능합니다
  • Partition Key로 date 같은 값을 설정하는 것 만큼은 반드시 피해주세요

    • ex) ‘20231004’ 같은 date값을 partition Key로 설정하면 많은 데이터가 하나의 파티션으로 들어가기 때문에 RCU, WCU limit에 따른 hot partition 이슈에 취약하며 많은 장애 사례가 있습니다.
    • user_id, email_id 같은 값을 partition key로 설정하고 date 같은 값은 Sort Key로 설정할 수 있습니다 . 확장성과 시간으로 범위 조회 모두 문제가 없는 설계입니다
    • 반드시 위와 같은 Key를 잡아야한다면 20231004#random_key[0-5] 처럼 뒤에 random key, hash key 를 붙여서 분산할 수 있습니다. (쓰기 샤딩 이라고도 함 )
    • Key#RandomKey 예시

      • 몇개의 난수로 나눌지 검토한 뒤에 (트래픽 * 아이템사이즈 ) / 파티션 당 최대 WCU

      • PartitionKey#rand(0,N) 으로 분할하여 저장

      • 분할한 데이터를 읽어와 통합해주거나 뒷단에서 DynamoDB Streams 같은 CDC를 통해 데이터를 처리하여 통합된 결과를 보여준다
  • RDBMS처럼 auto increment, uuid를 사용할 순 있지만 ddb에선 안티패턴입니다.

    • 이 값들은 보통 서비스에선 의미 없는 값이기 때문에 scan 이나 GSI 등으로 값을 얻은 뒤 재검색하게 되는 비효율이 있습니다.
    • 즉, 파티션 키를 통해서 빠르게 데이터를 조회할 수 있는 기회를 날리게 되고, 이를 해결하기 위해 GSI를 추가로 생성하게 되는 낭비가 발생하게 됩니다.
  • DynamoDB 모델링의 핵심은 Single Table Design 입니다

    • 사용전 DynamoDB의 데이터 접근 패턴을 정의하고 위 개념을 바탕으로 Partition Key, Sort Key를 설계해주세요

Query / Scan

두 기능 모두 DDB의 item을 가져오기 위한 API지만 사용방법과 그 목적이 다릅니다.
중요한 것은 Admin이 아닌 User에 의해서 호출되는 API가 Scan 을 사용하는 것 만큼은 반드시 피해주셔야 합니다!

Query

  • Partition Key 혹은 Partition Key + Sort Key(Primary key) 를 기반으로 Item을 조회할 때 사용합니다
  • GetItem, BatchGetItem 이 해당되며
  • RDB 등에서 인덱스를 잘 타는 쿼리처럼 필요한만큼만 데이터를 읽기 때문에 안정적인 성능을 제공합니다.

Scan

  • Parition Key가 없는 쿼리로 전체 DDB를 풀스캔 하기 때문에 주로 배치작업에서 많이 사용됩니다.

  • FilterExpression 같은 조건이나 Sort Key를 사용해도 해당 옵션은 전체 풀스캔한 결과에 대해 필터링하거나 정렬을 할 뿐 Scan은 내부적으로 DDB의 모든 Item을 찾습니다

  • RDB 등에서 인덱스가 없는 풀스캔 쿼리처럼 모든 Item을 찾기 때문에 RCU 소모가 심하고 굉장히 오래걸릴 수 있습니다.

  • 아래처럼 배치작업에서 주로 사용되며 RDB에서 limit offset, PK 페이징 처럼 DDB에서도 페이징을 사용할 수 있습니다 가져와야하는 데이터가 1MB이상으로 크다면 필수입니다

  • Scan 배치 예시

var request = new ScanRequest
    {
        TableName = "tb_test",
        Limit = pageSize
    };
 
    var items = new List<Document>();
 
    do
    {
        var response = await client.ScanAsync(request);
 
        /// 현재 페이지를 처리합니다
        items.AddRange(response.Items);
 
        /// 검색할 item이 더 있는지 확인
        if (response.LastEvaluatedKey != null && response.LastEvaluatedKey.Count > 0)
        {
            /// 다음 페이지의 전용 시작 키 설정, limit offset 혹은 PK 페이징과 같은 부분
            request.ExclusiveStartKey = response.LastEvaluatedKey;
        }
        else
        {
            /// 더 이상 item이 없으면 루프 종료
            request.ExclusiveStartKey = null;
        }
 
    } while (request.ExclusiveStartKey != null);

=> 다음 페이지 탐색의 시작점으로 LastEvaluatedKey를 return 하고 ExclusiveStartKey 로 설정하는 구조

정리하면

  • User에 의해서 호출되는 API가 Scan을 사용하는 것은 반드시 피해야 합니다.
  • Scan은 되도록 배치작업에서만 사용하고 RDB와 마찬가지로 페이징으로 가져올 수 있습니다.
  • 가능하면 Scan보다는 Query를 사용해야하며, GSI (원본 테이블과 다른 Partition Key로 재설계) 를 만들어서 해결할 순 없을지 검토해보는 것이 좋습니다

Partition

DynamoDB의 item(데이터)은 결국 모두 Partition에 물리적으로 분산되어 저장됩니다
Hash Sharding 처럼 Partition Key에 해시 함수를 적용하고 그 값에 따라 여러 Partition에 분산 저장 되는 구조입니다.

파티션 수를 계산하는 방법

  • 용량 기준일 때 = (RCUs Total/3000) + (WCUs Total/1000)
  • 데이터 사이즈 기준일 때 = (총 크기/10GB)
  • 파티션 개수 = ceil(max(파티션 수 (용량 기준) , 파티션 단위 (크기별) ))
    • 즉, 용량 기준, 데이터 사이즈 기준 중 더 큰 값으로 파티션 개수가 정해집니다.

그리고 WCU와 RCU는 파티션 전체에 고르게 분포되게 됩니다.
ex) WCU , RCU가 각각 1000개 파티션은 10개라면 각각의 파티션에 WCU , RCU가 100개씩 할당되는 구조

Adaptive Capacity라는,
트래픽에 따라 다른 파티션에 할당된 자원을 유동적으로 땡겨오는 내부 기능이 있어서 상황에 따라 조금씩 다를 순 있으나 기본 원리는 위와 같습니다.

중요한 건 DDB는 할당된 WCU, RCU보다 더 많은 트래픽을 받을 수 없는 구조이기 때문에
그 이상의 트래픽이 들어오면 모두 throttling이 걸리게되고 재시도하게 되어 어플리케이션의 latency가 급증하여 장애로 이어질 수 있습니다.
(버스팅 개념이 있지만 버스팅을 염두하고 설계하는 것은 위험한 패턴입니다)

WCU / RCU

WCU

  • WCU (쓰기 용량 단위) 하나는 최대 1KB 항목의 초당 쓰기 한번을 의미 합니다.
  • item 1KB보다 크면 더 많은 WCU가 사용됩니다 (1KB 씩 올림 처리)
  • 예시
    • item 크기가 2KB, 초당 12개씩 Write 하면

      • => 12 * (2KB/1KB) = 24WCUs
    • item 크기가 4.5KB인 초당 10개씩 write 하면

      • => 10 * (5KB/1KB) = 50WCUs
    • item 크기가 2KB인 분당 120개씩 write 하면

      • => (120/60) * (2KB/1KB) = 4WCUs

RCU

  • RCU(읽기 용량 단위) 하나는
    • 4KB 크기의 항목에 대해서 초당 강력한 일관된 읽기 1개 (strong consistency)
    • 4KB 크기의 항목에 대해서 초당 최종적 일관된 읽기 2개 (Eventually Consistent Read)
  • item이 4KB보다 크면 더 많은 RCU가 사용됩니다 (4KB 씩 올림 처리)

읽기 일관성

최종적 일관된 읽기 (Eventually Consistent Read)

  • DDB의 default 읽기 설정입니다
  • DDB 테이블에서 데이터를 읽을 때, 응답에 최근 완료된 쓰기 작업의 결과가 반영되지 않을 수 있습니다
  • 쓰기 작업을 하고 잠시 후 읽기 요청을 반복하면 응답이 최신 데이터를 반환합니다
  • RDBMS에서 어느정도 replica lag에 유연하여 Replica로 읽기 분산할 수 있는 유형의 쿼리가 해당합니다.

강력한 일관된 읽기 (Strongly Consistent Read)

  • DDB는 항상 최신 버전의 데이터를 읽습니다

  • 당연히 최종적 일관된 읽기 보다 latency가 길고 네트워크 이슈에 취약합니다.

  • RDBMS에서 Replica로 읽기 분산을 하지 못하는 쿼리들, Primary에서 쓰기 후 바로 읽어가는 경우와 동일합니다.

  • GSI에서는 지원되지 않습니다

  • 최종적 일관된 읽기 보다 2배의 RCU를 소모합니다

  • 예시

    • item 크기가 4KB인 12개의 강력한 일관된 읽기
      • 12 * (4KB/4KB) = 12RCUs
    • item 크기가 8KB인 16개의 최종적 일관된 읽기
      • (16/2) * (8KB/4KB) = 16RCUs
    • item 크기가 6KB인 12개의 강력한 일관된 읽기
      • 12 * (8KB/4KB) = 24RCUs

Capacity mode

provisioned는 트래픽이 예측가능하고 안정화된 서비스에서 auto-scale 설정과 함께 사용 가능합니다
최초 생성시엔 on-demand로 생성하지만, 일정기간 개발팀과 트래픽 모니터링을 거친 뒤 안정화가 되었다고 판단되면 Provisioned+autoscailing 모드로 전환하는 것도 좋은 방법입니다

on-demand는 provisioned와 달리 min / max 리밋의 설정은 없이 요청하는 대로 트래픽을 모두 받아줍니다
단, on-demand mode여도 Throttling, Hot Partition 이슈로 자유로운 것은 아닙니다.
전체 테이블이 받을 수 있는 트래픽은 제한이 없지만
한 파티션당 최대 RCU / WCU 는 3,000, 1,000개의 조합으로 이루어지기 때문에
파티션키가 잘못 설계된 경우 마찬가지로 hot partition 이슈가 발생할 수 있습니다

on-demand

  • 워크로드에 따라 자동으로 읽기/쓰기 확장/축소. 단, 이전 트래픽의 2배이상이 갑자기 들어오는 경우 Throttling이 발생할 수 있기 때문에 Pre warming 작업이 필요할 수 있습니다.
  • 최초 생성 시엔 4,000 WCU / 12,000 RCU를 수용할 수 있습니다
  • 사용량에 대한 비용 지불, provisioned mode 보다 2.5배 더 비싸기 때문에 트래픽이 안정화된 서비스에서는 provisioned 모드로 전환하여 비용을 절감할 수 있습니다
  • on-demand mode가 무제한으로 트래픽을 받는다는 의미는 아닙니다 하나의 파티션이 최대 3,000 / 1,000 만큼의 RCU,WCU를 사용할 수 있는 Limit은 동일하기 떄문에 hot partition 이슈를 염두해야 합니다

provisioned

  • 사전에 용량 (초당 읽기/쓰기 수 RCU, WCU)를 운영자가 직접 설정합니다.
  • 프로비저닝된 읽기 및 쓰기 용량 단위에 대해 비용 지불합니다
  • provisioned mode여도 Auto Scalining 설정이 가능하여 할당된 WCU & RCU의 일정 수치를 사용하면 자동으로 확장되게 할 수 있습니다. (threshold 및 확장 정도도 설정 가능합니다)

Pre warming

  • 워크로드에 따라 자동으로 읽기/쓰기 확장/축소. 단, 이전 트래픽의 2배이상이 갑자기 들어오는 경우 Throttling이 발생할 수 있습니다.
  • 최초 생성 시엔 4,000 WCU / 12,000 RCU를 수용할 수 있습니다

위에서 살펴본 on-demand capacity mode의 제약사항 때문에
on-demand 모드로 DDB를 생성한 직후, 혹은 적은 트래픽으로 운영하다가 대량 트래픽을 받게 되면 아래처럼 허용된 수용치 이상의 트래픽은 모두 Throttle 걸립니다.

따라서 특별한 이벤트 (선착순,블랙프라이데이 등등) 를 앞두어 트래픽 급증이 예상된다면 반드시 사용중인 DDB의 이전 트래픽 (WCU,RCU)를 확인하여 pre warming을 해야 합니다.

pre warming 방법엔 크게 두가지가 있습니다.

트래픽을 받지 않는 상태에서 이벤트로 곧 20,000 WCU, RCU를 수용해야한다고 가정한다면

  • 최초 생성 시 (이벤트를 위해 신규 DynamoDB를 생성한 경우)
    • on-demand 의 최초 capacity는 4,000 WCU / 12,000 RCU 이기 때문에 바로 트래픽을 받으면 Throtlling이 발생합니다.
    • Provisioned 모드로 10,000 WCU, RCU 를 미리 할당하여 생성한 뒤, on-demand 모드로 바로 변경해줍니다.
    • on-demand에서는 직전 트래픽 (할당된 WCU,RCU) 의 2배 까지는 바로 수용할 수 있기 때문에 20,000 WCU, RCU 를 수용할 수 있는 on-demand DDB로 세팅이 완료되었습니다.
  • 이미 운영 중인 상태일 때
    • 이벤트 전까지 더미 데이터로 목표 트래픽의 부하를 주어 천천히 WCU,RCU를 늘리는 방법도 있지만 시간이 오래걸립니다.
    • 따라서 예상되는 트래픽인 20,000 WCU, RCU 의 Provisioned 모드로 변경한 뒤 auto-scailing 모드를 설정해줍니다.
    • 그 후 다시 on-demand로의 변경은 24시간마다 한번만 허용되기 때문에, 24시간 이후에 on-demand로 변경해주면 최소 40,000 WCU, RCU를 즉시 받아줄 수 있는 capacity를 가지게 됩니다.

정리하면

  • 최초 생성시 : Provisioned 모드로 예상되는 트래픽만큼의 WCU/RCU를 할당하여 생성한 뒤 바로 on-demand로 변경해줌
  • 이미 운영중인 상태 : on-demand → Provisioned 모드+auto-scailing 변경 → 24시간 이후 ondemand로 변경 (시간 여유가 없을땐 Provisioned 모드로 유지해도 됩니다)

GSI / LSI

GSI (Global Secondary Index)

  • 기존 원본 DDB와 다른 별도의 attribute로 Partition Key, Sort Key를 재설계할 수 있습니다.
  • 운영중에 기존 원본 DDB 테이블을 대상으로 추가/삭제가 가능하지만 write가 많은 ddb의 경우엔 피크시간을 피해야합니다.
    • 인덱스 생성시 원본 DDB → GSI로 write가 발생하며, 원본 DDB의 write가 너무 많아 GSI의 WCU가 부족하면 원본 DDB의 write throttling을 통해 조절하게 됩니다. (OnlineIndexThrottleEvents 발생)
    • 따라서 피크시간을 피하거나, GSI의 write capacity 를 미리 높게 할당해두는 것이 좋습니다.
  • Eventual consistent read 만 가능하며 strong consistent read는 불가합니다
  • 기존 원본 DDB를 복사한 뒤 GSI를 재설정하는 개념이기 때문에 원본 DDB와 별도의 읽기/쓰기 용량(RCU/WCU) 할당됩니다
    • 원본 DDB 테이블의 capacity는 충분한데 GSI capacity 가 부족하여 throttling이 걸릴 수 있습니다

LSI (Local Secondary Index)

  • DDB 테이블을 생성할 때만 설정할 수 있으며 운영 중 추가/삭제가 불가능합니다. (테이블당 5개)
  • 원본 테이블과 동일한 Partition Key를 사용하고, 테이블에 할당된 WCU / RCU를 사용합니다.
  • Eventual, strong consistency 모두 사용이 가능합니다.

GSI 사용 예시


GameScores 테이블에서 Partition Key는 UserId, Sort Key는 GameTitle입니다.
각 user를 기준으로 데이터를 보기는 쉽지만 각 게임에서 TopScore ranker를 찾고싶다면
모든 데이터에 대해서 scan해야 하기 때문에 시간도 오래 걸리고 RCU 소모가 심할 수 있습니다


GSI 는 원본 DDB와는 다른 attribute로 PartitionKey, SortKey를 설정할 수 있기 때문에
GameTitle, TopScore로 Partition Key, Sort Key로 GSI를 생성하면

각 게임 별 탑 랭커와 점수를 쉽게 확인할 수 있습니다.
DDB도 ScanIndexForward 기능 등을 통해 SortKey로 정렬 방향을 설정할 수 있습니다

TTL

  • Redis, MongoDB의 TTL과 동일한 개념으로 아이템 별 데이터의 유효기간 설정이 가능하며, 유효기간이 지난 데이터는 자동 삭제됩니다.
  • DDB에 TTL로 사용할 attribute를 지정하고 item을 write할 때 해당 attribute에 이 item이 만료될 시간을 unix epoch time으로 기록합니다.
    • ex) TTL attribute 에 1697180094 을 설정하면 이 item은 2023년 10월 13일 금요일 오후 3:54:54 GMT+09:00에 삭제됩니다
  • DDB 테이블 생성시 TTL을 설정하지 않는 경우 무한정 수십TB 까지도 커지는 경우가 있어서 데이터가 계속 증가하는 성격의 DDB라면 TTL을 반드시 설정해주어야 합니다.

작동 방식

  • DeleteItem 작업과 동일한 방식으로 GSI, LSI 에서 제거 (추가 비용없이 처리)
  • 100% 백그라운드에서 진행되며 성능에 전혀 영향이 없음
  • Dyanmo Streams 을 통해 삭제시 별도 저장 및 처리 가능

GZIP 등 압축으로 성능 개선하기

DDB는 item의 크기에 굉장히 민감한 서비스입니다.
하나의 Item은 최대 400KB라는 제약 뿐만 아니라 WCU, RCU 등 많은 것들이 item의 크기에 영향을 받습니다.
때문에 대용량의 attribute를 저장할 땐 gzip 같은 압축을 통해 데이터의 사이즈를 줄이는 것도 성능 개선에 큰 효과가 있습니다.

기존에 Redis 등에서 적용한 것과 같이 gzip을 사용했을 때의 예시입니다

const { loremIpsum } = require('lorem-ipsum');
const { gzipSync } = require('zlib');
const content = loremIpsum({
    count: 20,
    units: "paragraph",
    format: "plain",
    paragraphLowerBound: 5,
    paragraphUpperBound: 15,
    sentenceLowerBound: 5,
    sentenceUpperBound: 15,
    suffix: "\n\n\n",
});
 
const compressed = gzipSync(content);
console.log(`total size (uncompressed): ~${Math.round(content.length/1024)} KB`);
console.log(`total size (compressed): ~${Math.round(compressed.length/1024)} KB`);
Generated a text with 12973 characters and 1943 words
total size (uncompressed): ~13 KB
total size (compressed): ~4 KB
 
Write capacity for compressed post { TableName: 'tb_test', CapacityUnits: 4 }
Write capacity for raw post { TableName: 'tb_test', CapacityUnits: 14 }
 
Read capacity for compressed post { TableName: 'tb_test', CapacityUnits: 0.5 }
Read capacity for raw post { TableName: 'tb_test', CapacityUnits: 2 }

=> 압축 전후 데이터 사이즈뿐만 아니라 저장할 때의 WCU,RCU도 크게 차이나는 것을 확인할 수 있습니다