DynamoDB 에 대한 이론과 키 디자인 연습
2004년에 Amazon.com은 급속도로 확장하고 있었는데, 자사에서 사용하던 Oracle 데이터베이스의 용량 한계에 부딪혔다. 이 문제를 해결하기 위해, 다소 위험할 수도 있지만 자체적으로 데이터베이스를 만들기로 결정했다. 이 과정에서 개발된 것이 바로 Amazon DynamoDB이다. Amazon DynamoDB는 Amazon.com의 대부분의 트래픽을 감당할 정도로 견고한 구조를 가지도록 설계되었고, 2007년 10월 Amazon.com에서 DynamoDB 논문을 발표했다. 이후 2012년 AWS에서 DynamoDB를 정식 출시했다.
Amazon DynamoDB의 스케일을 보고 싶다면, Amazon.com에서 Prime Day 이후에 발표하는 블로그를 보면 얼핏 확인할 수 있다. Amazon Prime Day에서 DynamoDB는 최대 초당 1억 2천 6백만 건의 요청을 처리했다. 더 놀라운 건, 대부분 요청의 응답 속도가 밀리초 단위 안으로 떨어졌다는 것이다. 현재는 대부분의 Amazon.com 서비스가 DynamoDB를 사용하고 있고, Disney+와 Capital One과 같은 굵직한 글로벌 서비스에서 DynamoDB를 사용하고 있다.
DynamoDB에 대해서 공부하다 보면, DynamoDB가 정말로 많은 기능을 제공해 준다는 사실을 알 수 있다. Amazon.com 이 이미 성능에 대해서는 증명해 주었지만, 성능을 제외하고라도 정말로 어렵고 복잡한 기능들을 관리형으로 제공해주고 있다. 이번에는 DynamoDB가 왜 강력한지에 대해서 알아보자.
1. 무한 수평 확장이 가능한 데이터베이스.
DynamoDB는 AWS Managed NoSQL 서비스로, No Operation을 지향한다. 고객은 아무런 인프라 관리 없이 쓰는 만큼만 비용을 지불하면 된다. DynamoDB가 놀라운 이유는 바로 수평확장이 자유롭다는 것이다. 기존 RDBMS를 사용해 본 사용자라면 수평확장을 했을 때 고통받았던 경험이 있을 것이다. Amazon Aurora를 사용하더라도, 현재는 32xlarge가 최대 사이즈이기 때문에 결국은 한계에 부딪힌다. 이럴 때는 최적화를 통해 시간을 벌고, 그 시간 동안 샤딩을 준비하여 데이터베이스 병목을 해결해야 한다.
샤딩은 절대 단순한 문제가 아니다. 샤딩을 하는 작업 자체도 매우 어려운 일이지만, 그 이후에 샤딩된 클러스터를 운영하는 것도 여간 쉬운 일이 아니다. 샤딩을 했을 때의 가장 큰 단점은 샤드를 늘리는 순간부터 고정비용이 증가한다는 점이다. 왜냐하면 샤드를 늘린 후에 필요 없다고 줄이기가 쉽지 않기 때문이고, 샤딩 작업 자체가 난이도가 높기 때문에 미래를 생각해서 여유롭게 샤딩하는 경우가 보통이기 때문이다. 뿐만 아니라, 클러스터에서 장애가 발생하여 데이터 정합성이 깨지거나 꼬인다면 복구하는데 더 오랜 시간이 걸릴 수 있다. 기술력이 된다면, Slack과 같은 해외 기업처럼 Vitess와 같은 솔루션을 도입하여 수평 확장을 할 수 있는 RDBMS 클러스터를 구성할 수도 있다.
하지만 DynamoDB는 이러한 샤딩 걱정을 사용자가 하지 않아도 된다. 실제 샤딩 작업이 뒷단에서 발생하지만, 모두 다 AWS가 관리해 주기 때문이다. 더 중요한 건 샤드가 늘어나더라도, 데이터를 쓰거나 읽는데 시간 지연이 거의 발생하지 않는다. 아래 예시를 보면, 쓰기 요청(왼쪽 그래프, 주황색)이 급격하게 늘어났음에도, latency는 0.5밀리 초밖에 차이 나지 않음을 알 수 있다.
DynamoDB 테이블은 내부적으로 파티션으로 구성되어 있다. 파티션 하나당 3000 RCU/1000 WCU 만큼의 요청을 처리할 수 있다. 파티션 하나가 실제 물리적 서버 단위이다. 하나의 파티션에 데이터가 쌓여서 일정 수준이 넘어가면 내부적으로 DynamoDB가 샤딩을 진행한다. 그러면 일부 데이터가 다른 파티션으로 넘어간다. 즉, 데이터 사이즈가 커지면 커질수록 파티션은 늘어나게 된다. 그러면 자연스럽게 파티션에서 소비할 수 있는 RCU/WCU의 양이 많아지고, 자연스럽게 더 많은 요청을 처리할 수 있는 구조가 된다. 이것이 바로 데이터가 많아서 수평으로 확장해도 데이터 조회에 걸리는 시간이 거의 일정한 이유이다.
2. 글로벌 Active Write 지원
서비스가 커졌을 때 많은 개발자들이 겪는 문제 중 하나가 데이터베이스 복제이다. 애플리케이션 서버를 해외 리전에 올리는 건 그리 어렵지 않다. 애플리케이션 서버의 경우 stateless 하기 때문에 어느 리전에 띄워도 문제가 되지 않는다. 문제는 Stateful 한 데이터베이스이다. 예컨대, 미국 리전에 애플리케이션 서버를 위치시켜도 데이터베이스가 한국에 있다면, 어쩔 수 없이 리전 간 통신이 발생한다. 아무리 네트워크가 빠르다고 하더라도, 대규모 트래픽을 받는 서비스에서 미국 <->한국 간 네트워크 지연 시간은 고객 경험에 큰 손해를 발생시킬 수 있다. 따라서 글로벌 서비스 대부분이 해외 리전에 데이터베이스나 캐시 서버를 두고 정합성을 맞추는 작업을 수행한다.
DynamoDB는 Global Tables라는 이름으로 이 문제에 대한 해결책을 제공하고 있다. AWS Console에서 버튼만 누르면 Global Tables를 운영할 수 있다. Global Tables가 강력한 이유는 Multi Region Write가 가능하다는 점이다. 어떠한 지역에서 요청을 하건, 가장 가까운 리전에 쓰기 작업을 수행할 수 있다. 해당 리전에 장애가 발생하더라도, 다른 리전의 데이터를 조회해서 사용할 수 있기 때문에 안전하게 데이터를 보관하고 조회할 수 있다.
3. No management
AWS에는 많은 관리형 데이터베이스 서비스가 존재한다. 대표적으로 Amazon Aurora가 그렇다. 하지만 Amazon Aurora를 사용하더라도 아예 관리를 안 해도 되는 건 아니다. 예컨대, 엔진 버전을 올려야 하거나, 보안 업데이트가 필요한 경우 설정을 바꿔주어야 한다. 물론 직접 엔진 업그레이드를 수행하지 않고 버튼만 누르면 되지만, 그래도 관리가 필요한 건 사실이다.
하지만 DynamoDB는 그럴 필요가 없다. 일단 DynamoDB는 버전이 없다. 올리고 내릴 버전도 없고, 엔진도 선택한 적이 없다. 보안 그룹, 인증서, 서버 타입 등 아무것도 선택하는 것이 없다. 즉, AWS가 모든 것을 알아서 해줄 뿐, 사용자는 관리할게 아무것도 없다는 것이다. 유일하게 신경 쓸 부분이라면, 비용과 직결되는 부분인 RCU/WCU 사용량뿐이다.
이번에는 DynamoDB가 내부적으로 어떻게 동작하는지에 대해서 알아보고자 한다. 또한 어떤 유용한 기능들을 제공하고, 어떻게 사용하는지에 대해서도 알아보고자 한다.
>> DynamoDB는 어떻게 데이터를 저장할까?
DynamoDB는 테이블이라는 논리적 Keyspace에 구역별로 파티션을 구분한다. 하나의 테이블은 여러 파티션으로 구분되어 있으며, 각 파티션마다 담당하는 Key 구역이 정해져 있다. 새로운 아이템이 들어오면 해당 아이템의 파티션 키(Partition Key)를 hash함수로 돌려서 어느 파티션에 넣을지 결정한다. 데이터를 조회할 때도 파티션 키를 hash함수로 돌린 결과를 통해 어느 파티션에서 꺼낼지 결정한다. 그래서 DynamoDB에서 Read/Write를 할 때는 반드시 파티션 키를 전달해야 한다.
>> Eventually Consistent Read 기본, Strongly Consistent Read 지원
애플리케이션 로직을 개발하다 보면 쓰기와 동시에 읽기를 해야 하는 경우가 발생한다. 이런 경우는 가장 최신 데이터를 반드시 사용해야 하는 경우가 보통이다. 예컨대, 은행 거래 내역을 조회하거나, 주식 거래 로직 등이 대표적인 경우이다. 이렇게 가장 최신 데이터를 조회하는 것을 보장하는 방식을 Strongly Consistent Read라고 한다.
DynamoDB는 기본적으로 Eventually Consistent Read를 보장한다. Eventually Consistent Read란 최종적으로 일관성을 보장하는 읽기 방식으로, 분산시스템에서 복제가 일어나는 중간에 예전 데이터와 최신 데이터가 일시적으로 혼재되는 방식을 의미한다. DynamoDB는 내부적으로 3개의 AZ에 분산하여 데이터를 저장한다. 쓰기가 진행되면 2개의 AZ에 저장이 완료되는 시점에 200 OK를 요청자에게 전달한다. 즉, 개발자가 쓰기 완료가 되었다고 응답을 받은 시점에는 2개의 AZ에만 저장되었음을 보장한다. 따라서 쓰기 이후에 바로 읽기를 시도하면 2/3 확률로 최신 데이터를 가져올 수 있다. 재수 없으면 1/3 확률로 예전 데이터를 가져오는 것이다. 하지만, 시간이 지나서 나머지 1개의 AZ에도 복제가 완료되면, 그때는 100%의 확률로 최신 데이터를 가져올 수 있다. 이것이 Eventually Consistent Read이다.
하지만, DynamoDB에서도 Strongly Consistent Read를 지원한다. 별도로 Endpoint를 지정할 필요도 없이, 읽기 작업 요청에 ConsistentRead=True 파라미터만 넘겨주면 그만이다. 커넥션 기반 서비스가 아니기 때문에 별도로 커넥션을 유지하거나 선택해서 사용할 필요도 없다. ConsistentRead=True로 파라미터를 전달하면 내부적으로 DynamoDB는 리더 스토리지 노드로 요청을 전달한다. 리더 스토리지 노드는 항상 최신 데이터를 유지하기 때문에 최신 데이터를 받아올 수 있다. 만약 해당 파라미터가 없다면, 리더나 팔로워 스토리지 노드 중 하나에서 랜덤으로 읽어오기 때문에 복제 중에는 2/3의 확률로 최신 데이터를 읽는 것이다.
>> Global Secondary Index(GSI) & Local Secondary Index(LSI)
기본적으로 DynamoDB는 파티션 키를 통해 분리된 테이블에서 데이터를 쿼리 한다. 하지만 필요에 따라 별도의 파티션 키를 구성해서 데이터를 조회하고 싶을 수 있다. RDBMS에서도 속도를 높이기 위해서 인덱스를 추가로 만들 수 있다. DynamoDB에는 GSI와 LSI라고 하는 방식을 통해서 비슷한 기능을 제공한다.
GSI(Global Secondary Index)는 별도의 파티션을 생성해서 관리하는 인덱스이다. 기준 테이블과 별개의 테이블을 내부적으로 유지하기 때문에 기준 테이블 성능을 저하시키지 않는다. 또한 별도 파티션을 사용하는 만큼 별도 RCU/WCU를 소모한다. 즉, GSI를 더 많이 사용하는 애플리케이션이라면, GSI에 할당한 RCU/WCU만 증가시키면 된다. 다만 기준 테이블과 물리적으로 다르기 때문에, Strongly Consistent Read 기능을 지원하지는 않는다. 왜냐하면 GSI에 데이터가 저장되는 구조가 기준 테이블에 데이터가 저장되고 Log Propagator를 통해 테이터가 GSI로 전송되기 때문이다.
반면, LSI(Local Secondary Index)는 기준 파티션에 별도의 정렬 키(Sort Key)를 지정할 수 있는 인덱스이다. 기준 테이블에 만들기 때문에 생성 시점에만 생성할 수 있고, 중간에 지울 수 없다. 또한 물리적으로 기본 파티션과 구분되지 않기 때문에, 별도의 RCU/WCU를 지정할 수 없다. 다만, 기준 파티션과 함께 사용되므로, Strongly Consistent Read를 지원한다.
상황에 따라 유연하게 선택하는 것이 중요하지만, 개인적으로 LSI 보다는 GSI 사용을 권장하고 싶다. LSI의 경우 생성 이후에 삭제가 불가할 뿐 아니라, 스토리지 제한도 존재한다. 비록 Strongly Consistent Read를 지원하지 않지만, GSI를 사용한 후에 애플리케이션 수준에서 구현하는 것이 더 효율적으로 DynamoDB를 사용할 수 있는 방식이라고 생각한다.
>> DynamoDB에서 데이터를 읽는 방법
DynamoDB에서는 GetItem, Query, Scan 이렇게 3가지 읽기 방식을 지원한다. GetItem은 하나의 아이템을 조회하는 API로 파티션 키, 정렬 키를 사용해서 정확하게 데이터를 지정해서 가져오는 방식이다. Query는 파티션 키를 사용하고, 정렬 키를 조건으로 사용하여 여러 데이터를 가져오는 방식이다. Query를 할 때는 특정 속성이 없는 아이템은 제거하는 필터 기능을 사용할 수 있다. 마지막으로 Scan은 키 지정 없이 모든 아이템을 가져오는 방식이다.
DynamoDB는 경우에 따라 유용하게 활용할 수 있는 Batch와 Transcation 기반 API도 지원한다. BatchGetItem은 여러 아이템은 한 번에 조회해서 가져오는 방식이다. GetItem요청을 한 번에 여러 개 묶어서 보내는 방식이다. 만약 응답 중에 UnprocessedKeys가 명시되어 있다면, 해당 키로 조회한 GetItem요청은 실패했다는 의미이다. 개발자는 해당 키를 통해 이후 재시도 여부를 결정할 수 있다. Batch 요청에 GetItem을 여러 번 보내는 것보다 좋은 점은 네트워크 시간을 절약할 수 있다는 점이다.
또한, 여러 데이터를 동시에 Transactional 하게 모두 한 번에 업데이트해야 하는 경우, Transact가 붙은 API를 사용할 수 있다. TransactWriteItems/TransactReadItems는 Transactional 하게 데이터를 저장하고, 실패 시 전체 요청을 다시 원복 해줘야 하는 경우 유용하게 사용할 수 있다. 다만, Transact API의 경우 일반 API에 비해서 더 많은 RCU/WCU를 소비한다.
>> Throughput Management
DynamoDB는 RCU/WCU를 기준으로 Throughput을 관리한다. RCU(Read Capacity Unit)은 4KB, WCU(Write Capacity Unit)을 1KB 데이터를 처리한다는 기준으로 계산된다. 사용자는 미리 RCU/WCU를 설정해서 고정 사용량을 할당할 수도 있고, 아니면 필요한 만큼 사용되도록 설정할 수도 있다.
각 파티션에는 최대 초당 1000 WCU/3000 RCU를 제공한다. 해당 요청량을 초과하면, AutoAdmin이 내부적으로 파티션을 분리한다. 사용자는 모르는 사이에 알아서 분리해 주는 것이다. 그러면 추가적으로 1000 WCU/3000 RCU를 사용할 수 있게 된다. 또한 파티션 사이즈가 10GB가 넘어가면 파티션을 분리한다. 그래서 요청량이 많건, 데이터를 많이 넣건 일정한 응답시간을 보장해 줄 수 있는 것이다.
DynamoDB는 트래픽이 유난히 높은 아이템 관리도 알아서 해준다. 특정 아이템에 요청이 몰리는 경우를 추적하여 해당 아이템을 별도의 파티션으로 분리해 준다. 왜냐하면 해당 아이템에서 사용하는 RCU/WCU 때문에 같은 파티션의 다른 아이템이 손해를 볼 수 있기 때문이다.
DynamoDB는 생성하여 사용하기는 편리하지만, 그전에 사용 사례에 따른 키 디자인이 상당히 중요하다. 먼저 사용 사례에 대한 정의가 필요하다. 왜냐하면 DynamoDB에서 지원하지 않는 기능이 필요한 경우라면, DynamoDB를 사용해서는 안된다. 만들고자 하는 애플리케이션이 write-heavy 한 작업이 많은지 read-heavy 한 작업이 많은지, OLAP 작업이 있는지 없는지, TTL이 필요한지 등과 같은 부분에 대한 상세한 분석이 그 예시이다. 아무리 DynamoDB라고 해도, 테이블을 한번 설계하게 되면 중간에 갑자기 바꾸기는 어렵다. 따라서 반복적으로 설계->리뷰->재설계 과정을 거치면서 최적이 키 디자인을 만드는 것이 중요하다.
사용 사례를 분석한 후에는 Entity에 대한 정의가 필요하다. Entity란 DynamoDB 여러 속성을 가지고 있는 독립적인 객체를 의미한다. 예컨대, SNS 서비스를 만든다고 하면, 사용자(User), 게시글(Post) 등이 Entity이다. Entity를 정의하는 이유는 Entity끼리 관계성을 파악하고, 관계를 토대로 키 설계를 해야 효율적으로 데이터를 조회할 수 있기 때문이다.
그다음, 각 Entity 간의 관계를 정의한다. 예컨대, SNS 서비스에서 User/Post/Follower/Following과 같이 Entity를 정의했다면, 다음과 같이 관계를 정의할 수 있다.(정답이 아니라 예시이므로 참고만 하기 바란다.)
Entity 정의가 완료되었다면 접근 패턴(Access Pattern)을 분석한다. 접근 패턴이란 서비스를 개발할 때 필요한 데이터 접근 패턴이다. 예컨대, "사용자 ID를 사용하여 사용자 정보를 불러온다", "사용자 ID를 사용하여 게시글 리스트를 불러온다" 등이 있다. 서비스에 필요한 모든 접근 패턴을 최대한 정확하게 분석해야 어떠한 키 조합을 사용할지 결정할 수 있다. 만약 중간에 요구사항이 변경되면 상당히 골치 아픈 상황이 연출될 수도 있다.
다음은 SNS 서비스를 만들기 위한 기본적인 접근 패턴이다. 예시로, 조회하는 경우만 구성했지만, 실제로는 이보다 더 복잡한 접근 패턴이 나올 것이다.
이제는 각 접근 패턴에 부합하게 쿼리 할 수 있도록 DynamoDB 키 디자인을 하면 된다. 키 디자인을 할 때는 반드시 효율적으로 한 번에 쿼리 할 수 있는 구조를 만들면 좋다. 예컨대, 한 번의 쿼리로 데이터를 조회하지 않고, 하나 쿼리해서 정보 가져오고, 해당 정보를 토대로 또 다른 정보를 가져오는 방식을 많이 사용하는 건 좋지 않다. 가능하다면 한 번에 조회할 수 있도록 만드는 것이 DynamoDB 키 디자인의 핵심이다.
SNS 서비스의 경우 다음과 같이 키 디자인을 할 수 있다.(이 또한 예시이므로 참고만 하기 바란다.)
참고로, DynamoDB에서 TTL을 사용하려면 epoch timestamp 타입 데이터가 필요하다. 나중에 TTL을 설정할 수 있어서, 그때부터 속성을 추가해도 되지만, 추후 사용할 확률이 높다면 미리 넣어놓는 편이 좋다. 나중에 추가하려면 이전 데이터에 전부 넣어주어야 하는 불편함이 발생할 수 있다.
- AWS Event 유튜브 영상 Part1 & Part2