소트-키를 활용한 다이나모-디비 병렬-쿼리(DynamoDB Parallel-Query using Sort-Key)

검색창에 AWS 서버-리스 플랫폼을 검색해보면 많은 장점을 나열한 소개글을 쉽게 찾아볼 수 있다. 그래서 좋은 건 알겠는데, 내가 사용하려는 목적에 맞을까?. “세상에 공짜는 없다”라는 말이 있듯이 모든 기술에는 트레이드-오프(Trade-Off)가 존재한다. 따라서 기술 스택을 선택할 때, 사용하려는 스택이 나의 목적과 잘 부합하는지 잘 따져보아야 한다.

최근 진행하는 프로젝트에서 서비스 일부를 AWS 람다(Lambda)와 다이나모-디비(DynamoDB)를 활용하여 마이크로-서비스(Microservice)화 하려는 작업을 진행하고 있다. 분리되는 서비스의 도미넌드(Dominant)한 작업은 대용량 읽기 작업으로, 데이터베이스 읽기 성능이 서비스 성능을 크게 좌우한다.

이 포스팅은 다이나모-디비의 대용량 데이터 읽기 성능이라는 측면에서 다이나모-디비가 서비스 목적에 알맞은 기술 스택인가를 고민하고, 설계하는 과정을 다룬다. 기본 예제코드는 파이썬을 사용하며, 기본적인 다이나모-디비와 람다의 내용을 이해한다고 가정한다.


서비스의 목적

서비스에는 토픽(Topic)이라고 불리는 정보의 주제가 있고, 사용자는 관심사에 따라 토픽을 구독할 수 있다. 구독자들은, 해당 토픽에 새로운 정보가 발행되면, 푸시(Push)알림을 받을 수 있다. 다이나모-디비는 구독자들에게 푸시알림을 전송하기 위한 데이터 저장소로 사용된다.

예를 들어 topic_a를 사용자 user_auser_b가 구독하고, topic_b를 사용자 user_auser_c가 구독하고 있다면 다음과 같은 데이터가 저장될 것이다.

# Table-Name: topic-data-table: 
# Parition-Key: topic_id(S)
# Sort-Key: device_id(N)
topic_id | device_id | user_id | os_type | push_token
------------------------------------------------------
topic_a | 31232 | user_a | IOS | ... ...
topic_a | 2221 | user_b | Android | ... ...
topic_b | 31232 | user_a | IOS | ... ...
topic_b | 123 | user_c | IOS | ... ...
  • topic_id: 주제 정보 ID
  • device_id: 구독자가 푸시 알림을 받을 디바이스 ID
  • user_id: 토픽을 구독하고 있는 사용자 ID
  • os_type: 구독자가 푸시 알림을 받을 디바이스 OS 종류
  • push_token: 구독자가 푸시 알림을 받기 위해 디바이스에 부여된 토큰

특정 토픽에 새로운 정보가 발행되면, 어플리케이션은 해당 토픽 구독자의 모든 디바이스 정보를 읽어 푸시는 전송한다. 이러한 푸시 전송은 최대 1분을 넘기지 않는 것을 목표로 한다. 구독자의 수는 계속해서 증가하는 추세를 보인다.

topic_a에 새로운 정보 발행 시, 다음 예제코드와 같이, 파티션-키인 topic_id를 조건으로 한 query 명령어를 통해 정보를 읽고, 푸시를 전송한다.

import boto3
dynamodb = boto3.client('dynamodb')
rsp = dynamodb.query(
TableName='topic-table',
ExpressionAttributeValues={'topic_id': {'S': 'topic_a'}},
KeyConditionExpression='topic_id=:topic_id'
)
# Send Push
.
.
.

기대보다 느린 읽기 속도

AWS는 다이나모-디비의 스케일(Scale)에 상관없는 한 자릿수 밀리 초(Millisecond)읽기 속도를 자랑한다.

Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale.
Reference:

당연하게도, 이러한 성능은 파티션-키(Partition-Key)를 통해 하나의 아이템을 읽을 때이며, 네트워크-왕복 시간(Network-Round-Trip)은 포함하지 않은 값이다. topic_a를 구독하고 있는 충분한 수의 사용자 아이템을 생성하고, 읽기 성능 테스트를 진행해보자. 여기서 충분한 수란 query 명령어의 최대 응답 크기인 1 MB 이상의 아이템 개수이다.

A single Query operation can retrieve a maximum of 1 MB of data.
Reference

테스트는 다이나모-디비의 query명령어를 실행하고, 응답시간을 측정하였으며, 네트워크 왕복 시간을 최소화하기 위해서, 다이나모-디비와 같은 지역(AWS Region)에 위치한 람다에서 진행한다. 람다 인스턴스 메모리는 1024MB를 사용한다.

import datetime
import boto3
def handler(event, context):
dynamodb = boto3.client('dynamodb')
start_time = datetime.datetime.now()
rsp = dynamodb.query(
TableName='topic-data-table',
ExpressionAttributeValues={'topic_id': {'S': 'topic_a'}},
KeyConditionExpression='topic_id=:topic_id'
)
end_time = datetime.datetime.now()
    print('consumed time: %s' % (end_time - start_time))
print('retruend item num: %s' % rsp['Count'])
print('consumed capacity: %s' rsp['ConsumedCapacity'])

# Output------------------------------------------------------------
consumed time: 0.535461
returned item num: 5405
consumed capacity: 119.5

query 명령은 한 번에 최대 5000여 개의 아이템을 반환하며, 0.5초 이상의 시간을 소요하였다. 테스트 결과에 따르면, 읽기를 수행할 아이템 개수에 따라 아래와 같은 시간이 소요됨을 예측될 수 있다.

When reading 5000 items: 0.5sec = 0.5sec
When reading 10000 items: 0.5sec * 2 = 1secc
When reading 100000 items: 0.5sec * 20 = 10sec
When reading 1000000 items: 0.5sec * 200 = 100sec

데이터의 크기가 커질수록, 비례하여 응답 시간은 계속해서 증가한다. 이러한 성능은 서비스의 목적을 달성하기에는 적합하지 않는 성능을 보여준다.

클라우드-와치의 Query.SuccessfulRequestLatency을 통하여, 순수 query명령어에서 소요된 지연시간(Latency)을 확인할 수 있다. 예제의 경우 100–150ms 정도의 시간으로 확인되며, 500ms중 나머지 시간은 네트워크-왕복 시간으로 추산된다. 람다의 성능을 최대치로 설정하여 테스트를 진행해보았지만 큰 차이는 없었다.

병렬-퀴리(Parallel-Query) 구조

오늘날의 대용량 시스템은 하나의 거대한 컴퓨팅 파워가 많은 양의 데이터를 처리하기 보다는, 작은 여러개의 자원들이, 나누어 병렬 처리하는 방향으로 발전해 왔다. 그리고 이러한 방향성에 맞게 서버-리스 플랫폼 역시 병렬처리에 강한 모습을 보여준다.

  • 람다는 최대 수 천개의 동시-실행(Concurrent Execution)을 가지고 있다.
  • 다이나모-디비는 병렬 수행되는 명령어 개수와 상관없이 일정한 성능을 반환한다.

이러한 특성을 고려하여, 읽기를 수행할 데이터를 여러 개의 조각으로 나누고, 나누어진 데이터를 람다를 통해 병렬-쿼리를 할 수 있다면, 큰 읽기 성능 향상을 기대할 수 있다.

Expected read-performance depending on the number of parallel-query

소트-키(Sort-Key) 조건 병렬-쿼리

병렬-쿼리를 수행하기 위한 방법 중 하나는 소트-키 조건을 이용한 방법이다. 예를들어, 1~100000범위의 device_idtopic_a를 구독하고 있을때 1~50000 그리고 50001~100000, 2개의 범위로 나누어 병렬-쿼리를 수행하는 방법이다.

# Query device_id 1~50000
dynamodb.query(
TableName='topic-data-table',
ExpressionAttributeValues={
'topic_id': {'S': 'topic_a'},
'start_id': {'N': '1'},
'end_id': {'N': '50000'}

},
KeyConditionExpression='topic_id=:topic_id AND device_id BETWEEN :start_id AND:end_id'
)
--------------------------------------------------------------------
# Query device_id 50001~100000
dynamodb.query(
TableName='topic-data-table',
ExpressionAttributeValues={
'topic_id': {'S': 'topic_a'},
'start_id': {'N': '50001'},
'end_id': {'N': '100000'}

},
KeyConditionExpression='topic_id=:topic_id AND device_id BETWEEN :start_id AND :end_id'
)

간단해 보이는 이 방법은, 몇 가지 요구사항이 존재한다.

device_id의 최대, 최소 값은 계속해서 추적해야 한다. 올바른 데이터 범위를 나누기 위해서는, 데이터의 최대, 최소 값을 알고 있어야한다. device_id는 RDBMS에서 생성되는 자동-증가(Auto-Increment) ID로써, 새로운 디바이스가 생성됨에 따라 device_id의 최대 값은 계속해서 증가할 것이다.

각각의 토픽 구독자수를 계속해서 추적해야 한다. 구독자수와 상관없이, 모두 같은 개수의 병렬-쿼리를 수행하는 것은 비효율인 행위이다. 적은수의 구독자를 가지고 있는 토픽은 병렬-쿼리가 필요없을 수도 있으며, 많은 구독자를 가지고 있는 토픽은 수 백개의 병렬-쿼리가 필요할 수 도있다. 토픽의 구독자수는 사용자의 설정에 따라 동적으로 증가하거나 감소할 수 있다.

효율적인 병렬-퀴리 범위를 정하기 위한 알고리즘이 필요한다. 구독하고 있는 유저의 device_id는 일정한 분포 값을 가지고 있지 않으며, 상대적으로 낮은 번호의 device_id일수록 구독자 분포율이 낮은 경향이 있다. 이런한 경우 동일한 크기로 데이터 범위를 나누어 병렬-쿼리를 실행한다면 효율적인 성능을 얻을 수 없다. 아래는 그 예제로 2개의 균등한 데이터 범위적용 시, 불균등한 2개와 4개의 구독자 device_id가 분포됨을 보여준다.

예제에서는 2개와 4개의 적은 차이로, 큰 성능 차이가 없어 보이지만, 데이터 범위가 넓어지고 구독자 device_id가 많아질수록 확연한 성능 차이를 보여 줄 것이다.
Evenly separate ranges

device_id의 특성에 따라 범위를 차등 분배해보자. 차등분배는 낮은 번호의 범위일수록 상대적으로 넓은 범위를 지정한다.

Differently separate ranges

device_id의 속성을 고려하여 차등 분배함으로써, 두 개의 범위가 동일한 양의 구독 device_id를 보유하고 있음을 확인할 수 있다. 소트-키로 사용되는 데이터의 특성에 따라서 병렬-퀴리 범위를 정하는 알고리즘은 성능에 큰 영향을 줄 수 있다.

예제에서는 동일한 개수의 device_id를 분배하는 것을 예제로 언급하였지만, 실제는 랜덤하게 설정되는 device_id를 동일하게 분배하는 것은 불가능하며, 분배 알고리즘은 격차를 최소한으로 줄이는 것을 목적으로 한다.

위에선 언급한 추가적인 정보(최대, 최소 device_id, 토픽 구독자수)를 추적하기 위해서, 토픽의 메타정보를 저장하고 있는 별도의 테이블이 필요한다.

# topic-meta-data-table
# partition-key: attr_name(S)
attr_name     | attr_value |
----------------------------
max_device_id | 1000000 |
min_device_id | 100 |
topic_a_num | 50000 |
topic_b_num | 28 |
topic_c_num | 605645 |
.
.
.

서버-리스 병렬-쿼리 구조

앞서 언급한 내용을 고려하여 실제 서비-리스 플랫폼을 활용하여 소트-키 조건 병렬-퀴리를 수행할 서비스를 구성해보자.

토픽정보 푸시전송 절차

  1. master-lambdaapi-gateway로부터 토픽정보에 대한 푸시 알림 전송을 요청 받는다.
  2. 실행된 master-lambdatopic-meta-data-table에서 최대, 최소device_id와 해당 토픽의 구독자 수를 확인한다.
  3. master-lambda는 구독자수에 따라서, 데이터 범위의 개수를 정하고, 데이터 범위를 정하기 위한 알고리즘에의해 각각의 데이터 범위를 나눈다.
  4. master-lambda는 나누어진 범위 값들을 push-sqs에 넣는다.
  5. push-sqs에서 worker-lambda는 데이터 범위 값을 하나씩 꺼내여 topic-data-table을 데이터를 읽고, 푸시를 전송한다.

메타-데이터 업데이트 절차

  1. master-lambdaapi-gateway로부터 새로운 디바이스 정보 및 구독자 생성 요청을 받는다.
  2. 실행된 master-lambdatopic-meta-data-table에 새로운 정보를 생성 및 업데이트 하고, 그로인해 업데이트될 정보를 meta-sqs에 넣는다.
  3. meta-event-role은 일정 간격으로 스케줄링하여 meta-update-lambda를 실행시킨다.
  4. 실행된 meta-update-lambdameta-sqs에서 업데이트된 메터정보를 모두 읽어 meta-data-table을 업데이트 한다.

조금의 메타-정보 오차는 서비스 성능에 영향을 주지 않는다. 따라서 meta-event-role의 스캐줄링 간격은 데이터의 변화속도와 요구사항에 의해 결정된다.


마치며…

서비스를 구성하는데 있어, 다이나모-디비의 읽기 성능은 중요한 요소였고, 이러한 읽기 성능을 끌어 올리기 위해서 람다와 다이나모-디비의 소트-키를 활용하여 병럴-쿼리를 수행하도록 하였다. 그리고 읽기를 수행할 데이터의 큰 스케일 변화에 대응하여 병렬-쿼리의 개수를 자동으로 조정함으로써 일정한 성능을 유지할 수 있도록 하였다.

그러나 사실, 소트-키 조건의 병렬-쿼리는 사용하려는 소트-키의 성격에 따라 분명한 한계를 가지고 있다. 포스팅에서 사용한 device_id의 경우에도 어느정도 일정한 규칙을 갖는 분포도를 가지고 있다고 언급하였지만, 랜덤으로 값을 갖는 device_id에게 “일정한 규칙"이란 결국 확률적인 문제이다.

그럼에도 불구하고, 사용하려는 소트-키의 성질에 따른, 잘 정제된 분배 알고리즘을 사용하여 이러한 확률을 최대로 끌어 올린다면, 여전히 큰 성능 향상을 기대할 수 있을 것이다.