DynamoDB Internals (2) - DynamoDB

DynamoDB Internals (2) - DynamoDB

지난 글에서 DynamoDB를 지탱하는 큰 축인 Dynamo 시스템에 대해서 알아봤다. Dynamo 시스템은 DynamoDB가 등장하기 한참 전에 설계되었지만 이름에서 쉽게 알 수 있듯 굉장히 깊은 부분을 공유하고 있다. 그러나 DynamoDB는 관리형 인프라 서비스로 제공되는 만큼 사람들이 더 쉽고 범용적으로 사용할 수 있게 설계되었다. 구체적으로 어떻게 어떤 점이 다른지 알아보자.

특정 영상에서 “Dynamo와 다르게 ~ “라고 하면서 특정 컴포넌트가 어떻게 다른지 설명하는 부분은 있지만, 공식적인 텍스트로 차이점에 대해 명시한 문서는 찾지 못했다. 다만 공부하면서 ‘이 부분은 이런 방법으로 변경했구나’ 같은 느낌을 많이 받을 수 있었는데, 이런 포인트에 집중해서 글을 쓰려 노력했다. 본인의 의견에 해당하는 경우 의견임을 명시했고 그 외는 Reference의 도큐먼트 또는 AWS re:Invent 영상을 참조했다.

글에서 DynamoDB와 Dynamo를 명확히 구분한다. Dynamo는 DynamoDB의 기반이 되는 분산 스토리지 시스템을 의미한다. 이전 글을 참고하자.

기본적인 이야기

이 글을 읽고 있다면 DynamoDB의 기본적인 이야기에 대해서는 이미 알고 있을 확률이 높으므로, 다음 섹션부터 읽어도 좋다.

Item, Attribute, Primary Key

DynamoDB는 Key Value Storage NoSQL이다. Redis처럼 키값에 따라 데이터가 매칭이 되고, 해당 데이터의 스키마가 정해지지 않고 자유롭다. 하나의 데이터, 즉, RDB에서 하나의 로우라고 부를 수 있는 것을 DynamoDB에서는 아이템(Item)이라고 부른다. 이 아이템은 속성(Attribute)값으로 이루어져 있다.

Attribute가 가질 수 있는 값은 아래와 같다.

  • Number (N)
  • String (S)
  • Boolean (BOOL)
  • Binary (B)
  • List (L)
  • Map (M)
  • String Set (SS)
  • Number Set (NS)
  • Binary Set (BS)

Number, String, Boolean, Binary 같은 단순한 타입도 있지만, List, Set, Map과 같은 복합 타입도 존재한다. 이 값들을 읽고 쓰는 동작들은 모두 Atomic 하게 전달된 순서대로 동작한다.

위 리스트의 괄호로 쳐진 값은 Attribute를 지정할 때 사용하는 코드이다. 직접 사용하다 보면 이해하기 쉽다.

아이템의 스키마가 정해지지 않았다는 것은 아이템마다 자유롭게 속성값을 바꿀 수 있다는 것을 의미하는데, 예외가 존재한다. DynamoDB에서 테이블을 구성할 때 설정하는 Primary Key가 바로 그 예외이다. 이 값은 아이템을 식별하는 키 역할을 한다. 따라서 모든 아이템에서 Non-Nullable한 값이다.

이 키를 구성하는 방법은 기본적으로 2가지가 있다. 하나는 간단하게 Partition Key(파티션 키, PK, Hash Key)만 사용하는 것과 다른 하나는 PK와 함께 Sort Key(소트 키, SK, Range Key)를 함께 사용하는 것이다.

이 글에서 PK는 모두 Partition Key를 의미하고, Primary Key는 줄여 쓰지 않았다.

위 두 키를 제외하고는 쿼리를 할 수 있는 방법이 기본적으로는 없다. 만약 일반 Attribute를 가지고 필터링하려면 Scan을 사용해야 한다. 따라서 굉장히 디테일하게 애플리케이션에서 사용하는 쿼리 패턴에 대해 이해하고 키값을 설계해야 한다. DynamoDB를 설계하는 방법으로 가장 유명한 방법은 Single Table Design이 있다. 이는 과거에 한 번 소개한 바 있다.

RDB에서도 키를 사용하지 않으면 Scan을 하는 것은 마찬가지이다. 하지만 RDB는 필요에 따라 인덱스를 쉽게 추가할 수 있지만 DynamoDB의 경우엔 제약 조건도 있고, 고민해야 할 포인트가 몇 가지 있다.

Secondary Index

기본 키만으로 필요한 쿼리를 모두 할 수 없는 경우가 많기 때문에, 추가로 두 종류의 보조 인덱스를 지원한다.

  • Local Secondary Index (LSI)
  • Global Secondary Index (GSI)

LSI의 경우 테이블을 생성할 때 만드는 기본 인덱스를 가진 테이블(이하 베이스)과 동일한 PK를 사용해야 한다는 제약 조건이 있다. SK만을 설정할 수 있으며, 베이스가 만들어질 때만 설정할 수 있다. 테이블이 이미 운영 중인 경우엔 LSI를 추가할 수 없다. 장점으로는 GSI와 다르게 강력한 읽기 일관성을 제공한다는 점이다. 읽기 일관성에 대해 자세히 모른다면, 이하 글 내용에서 대략 알 수 있다.

LSI를 굳이 사용해야 하는 케이스는 별로 없다. 강력한 읽기 일관성이 제공되고, GSI보다 비교적 싸게 운영할 수 있다는 장점이 있지만, 파티셔닝에 제약 사항(PK값이 같은 아이템들의 크기의 합이 파티션의 최대 사이즈보다 커질 수 없다는 점)이 생기기도 하고, 생성 시점이 정해져 있다는 점 역시 큰 장애물이다. 공식 문서에서도 GSI 유즈 케이스가 더 많다고 설명하고 있다.

강력한 읽기 일관성이 필요한 경우엔 DynamoDB 자체가 그다지 좋은 선택지가 아닐 수 있다. 일관성이 중요한 애플리케이션은 RDB를 사용하는 것이 일반적으로 더 좋은 선택일 것 같다.

GSI의 경우 LSI와 다르게 파티셔닝의 제약도 붙지 않고, PK를 아예 별도로 설정할 수 있다. 또한 테이블이 생성된 이후에도 자유롭게 GSI를 설정할 수 있다. 다만 강력한 읽기 일관성을 제공하지 않고, 쓰기 읽기 용량을 새로 생성해야 하므로 추가적인 비용이 든다.

LSI는 공짜라는 건 아니다. 베이스의 읽기 쓰기 용량을 공유하게 된다.

Key & Partition

파티션 키는 테이블 안에서 아이템이 어떤 파티션에 속하는지를 결정하는 키다. 해시 함수를 가지고 키를 해시한 다음 해시값을 이용해 각 파티션으로 분할한다. SK는 파티션 안에서 아이템을 정렬하는 용도로 사용된다. 각 파티션의 최대 사이즈는 10GB로, 만약 파티션 키에 속하는 아이템이 10GB가 넘는다면 SK를 기준으로 파티션을 분할한다. 아이템은 400KB가 최대 사이즈이기 때문에 각 파티션마다 최소 25,000개 정도의 아이템을 담을 수 있다.

해시 함수를 통해 파티션을 선택
이미지 출처

SK가 없는 경우 PK가 같은 아이템이 테이블마다 하나씩 있을 수 있고, 아이템은 400KB 사이즈 제한이 있기 때문에 파티션 안에서 아이템 컬렉션(PK가 같은 아이템의 모음)이 10GB가 넘을 수 없다.

LSI가 설정된 테이블은 위에서 말한 것처럼 10GB 이상의 아이템 컬렉션을 유지할 수 없다. 이 값은 꽤 큰 값이고 PK를 애플리케이션에서 잘 나누면 해결할 수 있는 문제이지만, 아무튼 이런 찜찜한 제약이 생긴다.

각 파티션은 읽기 용량과 쓰기 용량을 설정할 수 있다. On Demand 방식으로 설정한다면 들어오는 처리량을 적절히 받아주겠지만 가격이 비싸진다. 예측 불가능할 정도로 성장하는 서비스가 아니라면 보통 예측치를 기반으로 Read Capacity Unit(RCU), Write Capacity Unit(WCU)를 설정한다. 보내는 요청이 어떤 것인지(일관적인 읽기, 트랜잭션 쓰기 등)에 따라 다르지만, RCU 한 개는 기본 읽기의 경우 4KB 아이템 사이즈 기준 초당 2개의 아이템을 읽을 수 있고 WCU 한 개는 1KB 아이템을 초당 한 개 쓸 수 있다.

On Demand 방식과 Auto Scaling에 대해서는 이 글에서 다루고 있지 않다. 간단히 설명하자면 Auto Scaling은 배달앱처럼 “점심시간에 높은 트래픽을 찍고 평시에는 낮다” 처럼 범위에 대한 이해가 있는 애플리케이션에 대해 해당 범위에 맞춰서 처리 용량을 늘이고 줄이는 설정이다. On Demand는 서비스가 예측 불가능한 속도로 성장하는데, 이를 문제 없이 받아주기 위해 넣는 설정이다. 둘의 차이가 명확히 있다.

테이블마다 최대 RCU, WCU가 정해져 있어서 프로비저닝 당시 설정한 값에 따라 초기 파티셔닝이 결정된다. 읽기 용량은 파티션마다 최대 3,000 그리고 쓰기 용량은 파티션마다 1,000이 최대이다. 따라서 다음 식으로 초기 파티셔닝이 결정된다.

1
initial partition = (RCU / 3000) + (WCU / 1000)

합친 값을 정수로 올림 한 값을 기본 파티션 수로 설정하고, 2개 이상의 파티션이 생성되면 Capacity Unit은 공평하게 분배된다. 예를 들어 RCU 3000, WCU 1000으로 설정하면 초기 파티션은 2개로 나눠지고, 각각 RCU 1500, WCU 500을 나눠 갖게 된다.

Request Router

Dynamo 논문에서는 클라이언트에서 직접 파티션을 선택해 요청을 보낼 수 있는 방법과 그 앞단에 로드밸런서를 통해 파티션을 찾아가는 방법에 관해 얘기한다. 특별히 어떻게 사용 중이라는 말은 없었지만, Client 쪽에서 어떤 스토리지 노드로 요청을 보내야 하는지 알고 있는 “Partition-Aware”를 사용하는 것으로 보였다. 하지만 DynamoDB에서는 모든 요청을 Request Router라고 불리는 중간자에 보낸다.

Request Router

Request Router는 두 가지 컴포넌트와 상호작용 후 실제 데이터가 있는 스토리지 노드에 접근하게 된다.

  • Authentication System: AWS 플랫폼에서 공통으로 사용되는 권한 확인 컴포넌트
  • Partition Metadata System: 파티션의 리더 스토리지 노드를 관리해, Request Router가 요청을 보낼 노드를 선정

Partition Metadata System 내부적으로 Auto Admin이라는 시스템이 동작하고 있다. 이는 관리형 서비스를 만들어주는 핵심적인 컴포넌트로, AWS에서는 이를 DynamoDB의 DBA라고 부른다고 한다. 구체적인 관리에 대해서는 조금 있다가 후술한다.

Partition Replication

해시 함수에 의해 정의되는 파티션은 각각 레플리카 셋(Replica Set)을 갖고 있다. 리더 노드와 두 개의 추가적인 복제 노드를 갖게 되는데, 이는 AWS의 가용 영역에 골고루 나눠서 운용된다. Dynamo 시스템에서는 Sloppy Quorum을 사용하고 어떤 스토리지 노드든 요청을 처음 받게 되는 coordinator로서 동작할 수 있다. DynamoDB에서는 이와 다르게 리더 노드를 선출한다. 이 과정은 Paxos 알고리즘으로 구성되어있다고 하는데, Paxos를 이번 글에서 다루고 있지는 않다.

Paxos는 분산 시스템에서 특정 값이 합의되도록 하는 합의 알고리즘이다. DynamoDB에서는 파티션을 구성하는 세 개의 스토리지 노드들 사이에 “리더”가 어떤 노드인지를 합의하는 과정에 Paxos를 사용한다. Paxos는 스탠포드 대학생을 가르쳐도 이해하기 위해 1년이 걸렸다는 “이해 불가능한” 알고리즘이라는 슬픈 이야기가 있다. 이에 따라 “이해 가능한 합의 알고리즘”이라는 느낌으로 Raft가 나왔다고 한다.

대략 설명하자면, 집단의 리더는 현재 정상 상태임을 다른 스토리지 노드에 하트 비트를 통해 알린다. 영상에서는 1.5초? 2초쯤 한 번씩 하트 비트를 보내고 있다고 하는데, 만약 다른 스토리지 노드가 이를 받지 못하면 새로운 리더를 선출하기 위해 다른 노드에 본인을 리더로 주장하는 정보를 보낸다. 이 요청을 다른 스토리지 노드가 동의하면 새로운 리더가 선출된다.


어찌 됐든 리더가 있다는 사실이 중요한데, Dynamo와 다르게 쓰기 요청이 리더에게만 보내지기 때문이다. 리더는 항상 최신 데이터를 갖게 된다. 쓰기 요청을 받은 리더는 로컬 데이터를 수정하고 다른 두 레플리카에게 이 요청을 전파한다. 그리고 둘 중 하나의 성공 응답을 받으면 이 쓰기 요청이 성공했다고 응답한다.

여러 AZ에 나눠서 레플리카를 운영

반면 읽기의 경우 세 개의 레플리카 중 하나에게 요청을 보낸다. 따라서 연속된 요청을 통해 값을 읽는다면 1/3 확률로 최신 데이터가 아닐 수도 있다. Dynamo와 마찬가지로 Eventual Consistency가 발생한다.

Dynamo의 Quorum이 아예 적용 안 된 건 아니라는 것을 알 수 있다. 쓰기 쿼럼이 3개 중 2개 성공으로 설정된 것이나 다름없다. 위 설명은 모두 기본 읽기와 쓰기에 대한 설명이고, 트랜잭션 또는 강력한 읽기 일관성에 대한 옵션은 다르게 동작한다.

Storage Node

위에서 언급했지만, 스토리지 노드는 파티션을 구성하는 레플리카 셋이다. 내부적으로는 크게 두 가지 컴포넌트로 구성되어있다.

  • B Tree: 쿼리와 뮤테이션이 발생할 때 사용되는 자료구조
  • Replication Log: 파티션에서 발생하는 모든 Mutation Log를 기록하는 시스템

B 트리의 경우 우리가 흔히 아는 그 자료구조가 맞다. RDB에서 인덱스로 사용되는 트리 자료구조가 똑같이 사용된다. Replication Log도 다른 DB에서 레플리카 셋을 유지할 때 복구를 위해 사용되는 컴포넌트와 똑같다. 위에서 잠깐 말했던 Auto Admin이 이 복구 과정에 개입한다. Auto Admin은 레플리카 셋의 리더와 그 위치를 관리하고 스토리지 노드를 모니터링하는데 스토리지 노드에 장애가 발생해서 다운되면 새로운 스토리지 노드를 생성하고 다른 스토리지 노드의 Replication Log를 가지고 자료구조를 복사해간다. 새로운 스토리지 노드의 Replication Log가 성공적으로 B 트리에 적용되면 파티션에 합류할 자격이 생긴 것으로 보고 레플리카 셋에 합류시킨다.

Secondary Index

위 기본 내용에 보조 인덱스에 대한 이야기를 짧게 했는데 이 구조가 어떻게 되어있는지 확인해보자. 프로세스는 일반적인 테이블과 비슷하다. PK를 해시 해서 각 파티션으로 나눠 보낸다. 다른 점은 보조 인덱스는 베이스 테이블과 독립적으로 파티션을 구성한다는 점이다. 그리고 테이블 내에서 보조 인덱스에 해당하는 Attribute이 수정되면 이 작업은 Log Propagator라고 하는 컴포넌트에 의해 보조 인덱스 파티션의 리더 스토리지 노드에 전파된다.

보조 인덱스와 Log Propagator

Log Propagator는 스토리지 노드의 Replication Log를 바라보고 있다가 보조 인덱스 수정이 발생하면 Request Router가 베이스 테이블에 요청하듯 보조 인덱스 파티션에게 변경을 요청하게 된다. 이렇게 비동기적으로 전파되는 구조이므로 보조 인덱스의 Eventual Consistency는 필수적이다.


해시 기반으로 샤딩 된 데이터를 수정할 때 원래 위치한 스토리지 노드에서 데이터를 삭제하고 해시 된 위치에 맞는 스토리지 노드에 새로 쓰는 작업을 해야 하기 때문에 쓰기 작업이 생각보다는 무겁다.

실제 업데이트는 두 개의 파티션을 수정한다

파티션에 뮤테이션을 만드는 작업은 레플리카 셋 3개에 동일한 작업을 하는 것과 같기 때문에 베이스 파티션 레플리카 셋, 보조 인덱스에서 삭제될 파티션 레플리카 셋, 보조 인덱스에 추가될 파티션 레플리카 셋에 뮤테이션이 발생한다. 즉 하나의 보조 인덱스를 수정하는 작업은 9개의 스토리지 노드의 수정을 가져온다.

따라서 Secondary Index의 수정은 자주 발생하지 않는 것이 좋다. 이는 샤딩을 할 때 기준이 되는 필드가 자주 수정이 발생하면 안 된다는 얘기와 같다.

영상에서 별다른 설명은 없었지만, 위 설명은 GSI에 해당하는 설명일 것이라 생각한다. LSI는 테이블이 생성될 때 같이 생성되어야 한다는 점, PK는 베이스 테이블과 같아야 한다는 점, 강력한 읽기 일관성이 제공된다는 점, 베이스 테이블의 RCU와 WCU를 공유한다는 점, 그리고 이름에서 알 수 있듯, 베이스 테이블과 같은 파티션을 공유하는 것 같다.

Provisioning & Adaptive Capacity

처리 용량 프로비저닝에 대해서는 위에 파티셔닝에 대해 설명하면서 함께 얘기했다. 이 섹션에서는 조금 구체적인 동작 방식에 대해서 짧게 설명한다.

RCU, WCU는 쿼터를 정할 때 흔히 사용되는 Token Bucket 알고리즘으로 구성되어있다. 매초 RCU 수만큼 토큰이 버킷에 쌓이는데 버킷의 총용량은 설정한 RCU의 300배 정도이다. 따라서 아무것도 안 하면 5분 정도는 토큰이 쌓인다. 다만 버킷의 용량을 초과하면 토큰은 버려진다. 이런 구조로 트래픽이 치솟는 상황에서도 일시적으로나마 스로틀링이 생기는 것을 막아준다.

위에서 잠깐 언급한 적 있는데 RCU, WCU는 파티션에 골고루 분산된다. 만약 파티션이 3개이고 RCU가 300이라면 각 파티션은 100씩 RCU를 나눠 갖는다. 이렇게 나눠진 파티션에서 실제 운영할 때 아래 이미지처럼 25 RCU, 150 RCU, 50 RCU를 사용한다고 가정해보자.

Hot Partition이 발생한 상황

위와 같은 상황에서 두 번째 파티션의 RCU가 50만큼 부족하고 나머지는 남는 상황이다. 총합은 75 RCU 만큼 남기 때문에 위 요청량이 잘 처리되어야 하지만 데이터가 고르게 분산되지 않아 Hot Partition이 생길 수 있다.

AWS에서는 이 문제를 해결하고자 Adaptive Capacity를 도입했다. Adaptive Capacity는 Adaptive Multiplier라는 실숫값을 RCU, WCU에 곱해 일시적으로 처리 용량을 수정해주는 방식이다. Adaptive Multiplier는 피드백 루프를 돌며 지속해서 조절되는데, 이런 조절 루프를 돌리는 컴포넌트를 PID Controller라고 한다. DynamoDB에서 PID Controller는 인풋으로 소비된 용량, 프로비전된 용량, 스로틀링 속도, 현재 Multiplier 값을 받아서 결과로 새로운 Multiplier 값을 넘겨준다.

Adaptive Capacity 적용 후

위 예시에서는 1.5 정도 값이 Multiplier로 설정되면 스로틀링이 해결된다. 하지만 처리 용량은 테이블에 적용되는 개념이기 때문에 파티션마다 부족한 값에 대해 Multiplier를 곱하는 구조가 아니고, 테이블의 전체 처리 용량에 곱해진다. 따라서 위 예시에서는 총 150만큼 처리 용량 초과가 발생한다. 그러나 이 상태는 잠깐 지속되고 PID Controller에 의해 정상화된다. 일시적으로 스로틀링을 해결해주는 장치라고 볼 수 있다.

즉, Adaptive Capacity는 Hot Partition을 완전히 해결해주는 장치는 아니다. 애초에 Hot Partition이 나오지 않도록 데이터 분산을 잘 만들어야 하고 특별히 많은 요청을 처리해야 하는 파티션이 있다면 아예 별도로 테이블을 관리하는 것도 좋은 방법이다.


이번 글은 DynamoDB를 가장 기본적으로 사용하는 상황에서 거치게 되는 컴포넌트 구성에 대해 작성했다. 특히 파티셔닝 얘기와 프로비저닝 얘기는 우리가 어떻게 키를 설계해야 할지 대략적인 감을 잡을 수 있게 해준다. 이 외에도 설명하지 않은 Auto Scaling, Stream, 강력한 읽기 일관성, 트랜잭션 등 여러 기능이 DynamoDB에 있다. 특수한 유즈 케이스가 있다면 따로 AWS 문서를 확인해보자.

Reference

Author

changhoi

Posted on

2022-04-19

Updated on

2022-04-19

Licensed under

댓글

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×