BUZZVIL 블로그에 소개된 글을 편집한 뒤 모비인사이드에서 한 번 더 소개합니다.
버즈빌의 대표 프로덕트는 잠금화면 리워드 앱인 허니스크린입니다. 유저는 잠금화면 상에서 혹은 허니스크린 오퍼월에서 광고에 참여하거나 혹은 단순히 잠금화면을 해제하는 것만으로도 포인트를 획득할 수 있으며, 이 포인트를 이용하여 다양한 상품을 구매할 수 있습니다. 허니스크린에서 포인트는 서비스의 핵심 요소인만큼, 유저가 획득한 혹은 사용한 포인트를 데이터베이스에 정확하게 기록하는 것은 매우 중요한 일입니다.
기존에는 MySQL RDS를 활용하여 포인트 시스템을 운영하였습니다. 그러나 서비스의 규모가 커짐에 따라 포인트 관련 요청이 기하급수적으로 증가하게 되어 DB에 상당한 부담을 주게 되었습니다.
그러한 부담은 DB에 여러 가지 문제를 야기할 수 있는 만큼, 대안을 찾던 중 AWS의 NoSQL 데이터베이스인 DynamoDB를 발견하게 되었습니다. DynamoDB의 큰 강점은 스케일링이 매우 쉽다는 것입니다.
따라서 기존DB의 부담을 줄이고, 보다 유연하게 포인트시스템을 운영하고자 버즈빌에서는 포인트시스템을 기존 관계형 데이터베이스에서 DynamoDB로 옮겨서 운영하고 있습니다. 이번 포스팅에서는 허니스크린의 포인트시스템에 맞게 어떻게 DynamoDB table 구조와 관련 function들을 만들었는지 다루고자 합니다.
기존 데이터베이스에서는 기본적으로 테이블을 두 종류로 나누어서 운영을 하고 있었습니다. 각 테이블의 단순화한 구조는 다음과 같습니다.
유저의 포인트가 변동된 기록을 저장하는 테이블입니다. 즉 누가 언제 어떠한 방식으로 얼마나 포인트를 쌓았는지 기록하는 곳입니다. 예를 들어 앱 상에서 유저가 “적립 내역”을 조회하면 이 테이블에 요청을 보내게 됩니다.
한 유저가 현재 시점에서 보유한 포인트의 합을 저장하는 곳입니다. 예를 들어 앱의 메인 화면에서 현재 포인트의 총합을 보여줄 시 이 테이블에 요청을 보내게 됩니다.
테이블이 두 종류인데 각 테이블의 데이터가 서로 일관성을 유지해야 하기 때문에 두 테이블에 대한 요청은 atomic하게 이루어져야 합니다. 즉 포인트 적립시 amount 테이블에 대한 insert와 sum 테이블에 대한 update가 하나의 transaction으로 묶여서 요청이 들어가게 됩니다.
이렇게 하면 요청이 중간에 실패하더라도 두 테이블 모두 transaction이 시작하기 전의 기존 상태로 돌아가게 됩니다. 따라서 예를 들어 1번 테이블에 적립 내역은 생겼는데 2번 테이블에서 sum이 변화하지 않거나, 2번 테이블에서 sum이 바뀌었는데 1번 테이블에 적립금 내역이 생기지 않는 등의 문제를 방지할 수 있습니다. 처음에는 기존 테이블들의 구조를 그대로 따라가기 위해 DynamoDB에도 다음과 같이 테이블을 만들고자 하였습니다.
하지만 곧 테이블을 이렇게 생성할시 여러가지 문제점이 발생한다는 사실을 알 수 있었습니다. 가장 큰 문제점은 DynamoDB의 transaction은 atomicity를 보증하지 않는다는 점입니다.
즉 amount 테이블과 sum 테이블에 대한 요청이 하나의 transaction으로 묶일 수 없기 때문에 두 테이블 간의 일관성을 보증할 수 없습니다. 예를 들어 amount 테이블에 아이템을 생성하고 나서 sum 테이블의 아이템에 대한 update가 실패하면 적립 내역은 생겼는데 총 적립금은 바뀌지 않는 문제가 생깁니다.
여러 transaction을 atomic하게 묶을 수 없는 DynamoDB의 성격 때문에, 데이터의 일관성을 보증하기 위해서는 테이블을 하나로 합쳐야 한다는 사실이 분명해졌습니다. 따라서 두 테이블을 다음과 같이 합하기로 하였습니다.
두 테이블에 amount와 sum이 각각 나뉘어 있었던 것에 비해 이 테이블에서는 한 아이템이 해당 유저가 적립한 amount와 그 시점의 sum을 모두 갖고 있게 됩니다. 포인트 적립 요청이 들어왔을 시에는 다음과 같은 과정을 거치게 됩니다. 먼저 해당 user_id의 가장 최신값을 읽어서 sum을 가져옵니다.
최신값 하나을 가져오기 위해서는 query에 Limit 옵션에 1을, ScanIndexForward에 false를 설정하면 됩니다. 그러면 query는 파티션 내에서 Sort Key의 크기를 기준으로 정렬된 아이템을 역순으로 한 개 가져오게 됩니다. 이렇게 최신값을 읽고 나서는 그 sum에 현재 적립하려는 amount을 더해 새로운 sum 값을 구한 뒤, 다른 정보들과 함께 새로운 아이템을 생성하게 됩니다.
즉 해당 유저의 가장 최신값 읽기, 새 아이템 생성 순입니다. 유저의 최신값을 읽는 것은 테이블을 변화시키지 않으므로, 테이블의 state를 바꾸는 transaction이 기존 구조에서는 (amount에 대한) insert와 (sum에 대한) update 두 가지였던 것이 여기서는 insert 한가지가 된 것입니다. 따라서 애초에 데이터를 변경하는 transaction이 한가지이므로 이 테이블에 대한 transaction은 atomic하게 이루어지게 됩니다.
그러나 여전히 몇가지 문제가 남아있습니다. 첫번째는 덮어쓰기 문제입니다. 테이블의 partition key와 sort key가 각각 user_id와 date이기 때문에 한 유저에 대해 동시에 입력이 들어올 경우 한 입력이 다른 입력을 덮어쓰게 됩니다.
하지만 이 문제는 DynamoDB의 conditional write를 활용하면 간단하게 해결할 수 있습니다. 같은 user_id와 date의 조합이 존재하지 않을 시에만 새 아이템을 생성하도록 condition을 설정하고, 만약 존재한다면 date를 1초 정도 더해가면서 user_id와 date의 조합이 고유할 때까지 재시도를 하게 하면 date에 몇 초 정도 오차가 생길 수는 있지만 적어도 한 아이템을 다른 데이터가 덮어쓰는 일은 막을 수 있습니다.
(conditional write 관련 참고: http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.SpecifyingConditions.html, https://java.awsblog.com/post/Tx3RRJX73ZNOVL/Using-Improved-Conditional-Writes-in-DynamoDB)
두번째는 테이블 내에서의 데이터 일관성 문제입니다. 예를 들어 한 유저에 대해 각각 100포인트를 적립하라는 요청 두 개가 동시에 들어왔다고 합시다. 그 유저의 최신 sum값은 1000이라고 가정합니다. 위에서 설명한 과정을 따른다면 먼저 두 요청은 거의 동시에 최신값을 읽어들입니다.
따라서 두 요청 모두 테이블로부터 읽은 유저의 최신 sum은 1000입니다. 두 요청 모두 100포인트를 더하려고 하므로 읽어들인 값에 100을 더해 sum이 1100인 새 아이템을 각각 생성합니다. 결과적으로 적립금이 1000포인트 있었던 유저가 100포인트 적립을 두 번 했는데도 불구하고 그 유저의 적립금 총합은 1200이 아닌 1100이 됩니다.
이는 각각의 요청이 순서대로 들어왔을 시에는 발생하지 않지만 거의 동시에 들어왔을시에는 이루어지는 문제로서, transaction의 isolation과 관련된 문제입니다. 이 문제는 다음과 같이 각 아이템에 대해 version값을 부여함으로써 해결할 수 있습니다.
이 테이블에서는 date 대신 version이 Sort key가 됩니다. version은 같은 역할을 할 수 있다면 어떤 형태도 상관없으나 여기서는 0부터 시작하는 Number type으로 하겠습니다. 이 테이블은 다음과 같이 위의 문제를 해결할 수 있습니다. 테이블 상에 최신값 sum이 1000인 유저에게 각각 100포인트를 더해주라는 요청이 동시에 2개 들어옵니다.
두 요청은 각각 테이블로부터 최신 sum값 1000과 그 아이템의 version값인 21을 읽어옵니다. 그리고나서 각각 sum에 100을 더하고 version에 1을 더해 sum이 1100이고 version이 22인 새 아이템을 거의 동시에 생성하려 합니다. 하지만 이 경우 둘 중 하나는 실패하게 됩니다. 위에서 제시한 것처럼 conditional write를 활용하고 있기 때문에 중복된 user_id와 version 조합이 생성될 수 없기 때문입니다.
실패한 요청은 재시도를 하기위해 다시 테이블로부터 최신값을 읽어들이고, 이번에는 sum으로 1100을, version으로 22를 얻습니다. 그리고나서 그 요청은 sum이 1200이고 version이 23인 새 아이템을 생성하게 됩니다. 혹시 이 과정에서 또 실패하면 성공할 때까지(혹은 프로그래머가 정해놓은 횟수까지) 같은 과정을 재시도하게 됩니다.
이렇게 하면 위와 같은 isolation 문제도 해결할 수 있으며, 더 높은 버전은 낮은 버전보다 후에 생성되므로 한 user_id 파티션 내에 시간순 정렬도 유지할 수 있습니다.
이렇게 하면 테이블에 새로운 아이템을 생성하는 것에 관련된 문제들은 모두 해결이 되었습니다. 다만 아직 테이블의 데이터를 조회하는 문제는 완전히 해결되지 않았습니다.
위와 같은 테이블 구조 상에서는 한 유저의 데이터는 시간순으로 볼 수 있지만, 전체 데이터를 시간순으로 보기 위해서는 유저 각각에 대해 query하거나 테이블 전체를 scan하는 방법밖에는 없기 때문입니다. 이는 유저가 매우 소수일때는 활용할 수 있는 방안이지만 유저가 몇 천, 몇 만 이상으로 늘어가면 매우 비효율적인 방법이 될 것입니다.
보다 효율적으로 전체 데이터를 시간순으로 보기 위해서는 다음과 같이 하나의 Attribute와 Global Secondary Index를 추가해야 합니다.
간단하게 생각하면 모든 아이템에 같은 값을 준 뒤, 그 값을 Partition key로 하고 date를 Sort key로 하면 모든 값이 한 파티션 내에 시간순으로 정렬될 것입니다. 그러나 그렇게 하면 새로운 아이템이 생성시 항상 같은 파티션에 쓰이게 되는 hot partition 문제가 발생하여 쓰로틀링을 유발하게 됩니다. 이와 같은 문제를 방지하기 위해 새로운 아이템 생성시 scatter는 일정 범위 내의 랜덤한 값을 갖도록 합니다.
이렇게 하면 아이템들이 여러 파티션에 분배되어 쓰여지기 때문에 쓰로틀링이 발생할 가능성이 훨씬 줄어들게 됩니다. 이렇게 테이블을 세팅한 뒤에 데이터를 시간순으로 조회하기 위해서는 각 파티션을 돌며 Query를 시행하면 됩니다.
이 방법이 유저 각각에 대해 Query하는 것보다 효율적인 이유는 대부분의 경우 scatter 파티션의 수가 유저 수보다 훨씬 적고 (100 이하) scatter 파티션의 수는 철저히 프로그래머가 컨트롤할 수 있기 때문입니다.
지금까지 버즈빌에서 포인트시스템을 위해 어떻게 DynamoDB를 활용하는지 소개했습니다. DynamoDB에 대한 기본적인 정보에 대해서는 이미 잘 정리된 자료들이 있기 때문에 포인트시스템을 구현할 시에 마주한 문제들과 그 해결방법을 위주로 설명했습니다. DynamoDB는 여러가지 분명한 강점이 있는 데이터베이스이지만 여러가지 약점 또한 안고 있는 것이 사실입니다.
하지만 AWS측의 지속적인 개선과 DynamoDB를 사용하는 여러 개발자들의 노력으로 강점은 살리면서 약점들을 극복해 나가고 있습니다(본문에 소개된 conditional write의 경우 2014년에 추가된 기능입니다). AWS 공식 블로그를 보시면 새로 추가되고 업데이트되는 기능들을 실시간으로 체크하실 수 있습니다.
버즈빌에서는 DynamoDB 뿐만 아니라 AWS의 다양한 최신 서비스를 활용하고 있습니다. 앞으로도 다양한 소식 알려드리겠습니다.