DynamoDB는 기존의 RDB와 달리 비정형화된 데이터를 담을 수 있는 noSQL 데이터베이스이며, 아마존에서 서비스하고 있따. 여기서는 DynamoDB를 python에서 다루는 방법에 대해 다룬다. 물론 AWS 공식가이드가 있긴 한데, 자세한 설명보다는 말 그대로 "따라해 보세요" 수준이라 감 잡는 용도 이상으로는 쓰기가 어려워서 따로 사용법을 정리해 본다.그래도 아래 공식 튜토리얼을 훝어보고 오면 도움이 될 것이다. https://docs.aws.amazon.com/ko_kr/amazondynamodb/latest/developerguide/GettingStarted.Python.01.html

 

1단계: Python으로 테이블 생성 - Amazon DynamoDB

이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.

docs.aws.amazon.com

 

 

0. 기본 세팅

 DynamoDB는 개발용으로 로컬 서버를 지원한다. 그래서 https://docs.aws.amazon.com/ko_kr/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html

이 링크대로 로컬 서버를 세팅하면 된다. 실행 인자에서 -port 8888을 통해 사용 포트를 8888로 바꾼 채로 사용했다.

 

 

 이 글은 다음 링크들을 많이 참고했다.

https://docs.aws.amazon.com/ko_kr/amazondynamodb/latest/developerguide/GettingStarted.Python.05.html

 https://www.dynamodbguide.com/what-is-dynamo-db

 자세한 정보, 특히 복잡한 쿼리문에 대한 정보를 원한다면 이 사이트들을 방문하면 편할 것이다. 아래의 내용 역시 위 사이트를 대부분 참고하였다.

 

1. DynamoDB 기본 

DynamoDB가 왜 필요하게 되었는가

 

 간단하게, DynamoDB가 왜 세상에 나타났는지부터 살펴보자. noSQL류 데이터베이스를 사용하지 않고 RDB를 사용하던 시절, 아마존의 엔지니어들이 자사에서 데이터베이스를 활용하는 패턴을 분석했다. 놀랍게도 무려 90%의 연산이 JOIN을 사용하지 않았다. 또, 약 70%의  연산이 단순히 기본 키를 이용하여 하나의 행을 가져오는 key-value 형식의 연산이었다. JOIN은 매우 코스트가 큰 연산이라 JOIN을 피하기 위해, 한 마디로 성능을 위해 정규화를 포기하는 경우도 있었다. 이렇게  엔지니어들은 관계형 데이터베이스 모델의 필요성에 대해 의문을 가지게 되었다.

 

 

 

  이와 더불어, 대부분의 RDB는 강한 일관성을 주요 특징으로 내세운다. 이 말은, 특정 연산이 실행된 이후, 모든 유저들이 해당 부분에 대해서 동일한 결과를 받아야 한다는 뜻이다. 이 점이 현실의 상황에선 어떤 문제를 갖는지 살펴보자. A는 서울에 살고, B는 브라질에 살고 있다. A가 트위터에 'hello'라는 게시물을 올렸다. RDB를 사용하는 상황이라면,  A가 게시글을 올린 순간 트위터 본사가 있는 미국 서버 내 데이터베이스에 'hello'가 커밋되고, 모든 트위터 유저들은 A의 타임라인에 들어가면 'hello'가 보여야 한다. 하지만, 물리적인 거리를 고려하면 A가 글을 작성한 이후, 서울에서 미국까지 갔다가 브라질까지 가는 데 필연적으로 (컴퓨터의 세계에선) 적지 않은 시간이 소요된다. A는 글을 썼지만, B는 해당 글을 조회할 수 없어 일관성에 어긋나게 된다.

 

 

 

  물론, 이를 해결하기 위한 방법이 있다. 서울과 브라질 각각에 데이터베이스 인스탄스를 두고, 서로 완전히 복제하도록 하는 것이다. 이 경우, 일관성을 유지하기 위해선 A가 작성한 글은 데이터베이스에 바로 커밋되지 않고, 우선 A의 글을 전 세계의 데이터베이스로 퍼뜨린 후, 동시에 커밋해야 한다. 'hello' 라는 글이 업로드됨과 동시에, 전 세계의 유저들이 저 글을 볼 수 있다. 하지만, 이 시나리오를 실현하려면 우선 여러 대의 데이터베이스를 한꺼번에 관리할 수 있는 복잡한 시스템이 필요하고, 무엇보다 A의 요청엔 불필요한 지연 시간이 생긴다. 일관성은 지켰지만, 이번엔 가용성에 문제가 생기게 된다.

 

 

 

  이러한 문제를 해결하기 위해 역시 아마존의 엔지니어들이 고민한 결과, 강한 일관성은 지키지 않아도 된다는 결론이 나왔다. 정확히는, 은행 거래와 같이 신뢰성이 생명인 부분에선 강한 일관성이 중요하지만, 위의 트위터의 사례와 같은 대부분의 일상적 상황에선 일관성보단 가용성과 속도가 더 중요하다는 것이다. 즉, 트위터에 올린 글이 다른 사람들에게 몇 초 늦게 보여져도 크게 문제가 생기지 않는다는 점이다. 이러한 점들에서, 기본의 관계형 DB보다 일관성, 관계성을 약화하고 대신 가용성 및 확장성에 초점을 둔 DynamoDB가 탄생하게 되었다.

 

 

Table의 구조

  각 테이블 안에는 item이라고 불리는 항목들이 있고, 각 항목 안에는 RDB의 column 같은 개념의 attribute(속성)들이 들어 있다. 위 그림에선 People이라는 테이블 안에 item 3개가 있고, 각 아이템 안에는 여러 가지 속성을 가지고 있다. noSQL이라는 특성상 정형화된 구조가 없기에, 각각의 아이템은 저마다 다른 구조를 가질 수 있다.

 

 

  각 아이템들을 구분하는 용도로, partition key가 있다. 필수적으로 테이블에 존재해야 하며 흔히 말하는 기본 키에 해당하며, 해시 함수를 통해 각 아이템의 저장공간이 지정되며 서로 다른 값을 가져야 한다. 위의 예제에선 PersonID가 파티션 키이다. 또, 필수는 아니지만 sort key라는 테이블 내 아이템의 정렬에 사용되는 키가 있다. 이 파티션 키와 정렬 키가 합쳐서 복합 기본 키(composite primary key)로, 두 개의 속성을 합쳐서 기본 키처럼 사용할 수 있다. 이 경우, 기본 키와 정렬 키 중 하나만 달라도 서로 다른 아이템으로 인식이 될 수 있다.

 

 

  또 키와 별개로 인덱스를 생성할 수 있는데, 두 가지의 인덱스가 있다. local secondary index(로컬 보조 인덱스)는 기존의 파티션 키와 함께, 지정했던 정렬 키 이외에 다른 속성을 정렬 키처럼 이용하여 테이블에 접근하고 싶을 때 사용할 수 있다.global secondary index(전역 보조 인덱스)는 아예 기존의 키와 관련없는, 새로운 구조의 키(단순 파티션 키or복합 키)를 사용하고 싶을 때 쓸 수 있다. 전역 보조 인덱스가 더 넓은 개념이라고 보면 된다.

 

 

2. DynamoDB 테이블 다루기

 

테이블 불러오기

import boto3

ENDPOINT_URL = "http://localhost:8888"


resource = boto3.resource('dynamodb', endpoint_url=ENDPOINT_URL)
client = boto3.client('dynamodb', endpoint_url=ENDPOINT_URL)

table = resource.Table("TestTable")

  우선, boto3.resource와 boto3.client를 불러왔다. 둘 다 AWS의 서비스를 이용하기 위한 SDK이다. 차이점이라면 resource는 client를 래핑한 고수준의 서비스이다. 따라서, client는 비교적 로우 레벨에 접근이 가능하며, resource를 이용하여 다룰 수 없는 부분을 위해 사용한다. 테이블을 부르는 건 resource.Table(테이블명)으로 간단하게 불러올 수 있지만, 처음 실행한 상태라면 아직 아무 테이블도 존재하지 않을 것이고, 따라서 맨 밑의 부분에선 오류가 뜰 것이다. 우선 테이블을 만들어 보자.

 

 

테이블 생성하기

 테이블을 생성하는 문법은 다음과 같다. 상세한 문법은 다음 사이트를 참고하면 된다.

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Client.create_table

import boto3

ENDPOINT_URL = "http://localhost:8888"

resource = boto3.resource('dynamodb', endpoint_url=ENDPOINT_URL)
client = boto3.client('dynamodb', endpoint_url=ENDPOINT_URL)

schema = {
    'TableName': 'TestTable',
    'KeySchema': [
        {
            'AttributeName': 'name',
            'KeyType': 'HASH'  # Partition key
        },
        {
            'AttributeName': 'key1',
            'KeyType': 'RANGE'  # Sort Key
        }
    ],
    'AttributeDefinitions': [
        {
            'AttributeName': 'name',
            'AttributeType': 'S'
        },
        {
            'AttributeName': 'key1',
            'AttributeType': 'S'
        },
        {
            'AttributeName': 'key2',
            'AttributeType': 'S'
        }
    ],
    'LocalSecondaryIndexes': [
        {
            'IndexName': 'idx',
            'KeySchema': [
                {
                    'AttributeName': 'name',
                    'KeyType': 'HASH'
                },
                {
                    'AttributeName': 'key2',
                    'KeyType': 'RANGE'
                }
            ],
            'Projection': {
                'ProjectionType': 'INCLUDE',
                'NonKeyAttributes': [
                    'key2',
                ]

            }
        },
    ],
    'ProvisionedThroughput': {
        'ReadCapacityUnits': 10,
        'WriteCapacityUnits': 10
    }
}

retu = client.create_table(**schema)
print(retu)

 문법이 굉장히 복잡하다. 이걸 create_table에 파라미터로 그냥 박으면 가독성이 답이 없으므로 위처럼 일단 딕셔너리 형태의 변수로 뺀 후에 넣는게 제일 편할 것이다. 

 

간단하게 스키마에 대해 설명하자면,

- TableName : 말 그대로 테이블 이름이다.

 

- KeySchema : 테이블에서 key로 사용할 속성들에 대해 정의해야 한다. KeyType은 'HASH'는 파티션 키, 'RANGE'는 소트 키를 나타낸다.

 

- AttributeDefinitions : 쉽게 말해 여기 스키마에서 언급된 키와 인덱스에 대해선 전부 여기 명시되어 있어야 한다. 키뿐만 아니라 인덱스를 쓴다면 인덱스에 명시된 속성들도 있어야 한다는 점에 유의해야 한다.

여기 쓰이는 type엔 S, N ,B 세가지가 있는데 각각 String, Number, Binary 이다. 각각의 속성에 맞춰서 넣으면 된다.

 

- LocalSecondaryIndexes : 위에서 언급한 로컬 보조 인덱스이다. 인덱스 이름과 키 schema를 입력한다. 위에서 봤듯이 로컬 보조 인덱스는 파티션 키를 그대로 사용하기에, 우선 기존 파티션 키를 그대로 쓰고 다음에 인덱스로 사용할 키를 정의한다. Projection은 인덱스로 복사할 속성 및 옵션을 정하는 부분이다.

GlobalSecondaryIndexes는 위의 로컬 보조 인덱스와 거의 유사하게 사용할수 있기에, 따로 언급하지 않았다.

 

- ProvisionedThroughput : 각각 읽기, 쓰기에 사용할 유닛(=동시에 처리 가능한 트랜잭션의 수)의 수를 정하는 옵션으로, 데이터베이스의 구조와는 관련이 없지만 트래픽에 따라 유연하게 조정할 수 있다.

 

 

 

 이렇게 열심히 언급했는데, 솔직히 쓰면서도 좀 많이 복잡하다.

그냥 키-값 store로 사용하고자 한다면 간단하게 아래의 코드를 통해 테이블을 생성할 수 있다.

 

schema = {
    'TableName': 'TestTable',
    'KeySchema': [
        {
            'AttributeName': 'name',
            'KeyType': 'HASH'  # Partition key
        },
    ],
    'AttributeDefinitions': [
        {
            'AttributeName': 'name',
            'AttributeType': 'S'
        },
    ],
    'ProvisionedThroughput': {
        'ReadCapacityUnits': 10,
        'WriteCapacityUnits': 10
    }
}

client.create_table(**schema)

 

 

   현재 dynamoDB에 있는 테이블의 목록을 가져오는 방법은 아래와 같다.

# boto3.client 활용
client.list_tables()

이렇게 요청하면, 결과는 다음과 같이 HTTP Response와 형태로 온다. 처음 써보는 거라면 아마 TableName엔 아무것도 없을 것이다.

{'TableNames': ['Book', 'TestTable', 'name'], 
'ResponseMetadata': {'RequestId': 'a637b852-986f-4d41-9454-0e1d15241986', 
'HTTPStatusCode': 200, 
'HTTPHeaders': {'date': 'Tue, 13 Jul 2021 03:53:01 GMT', 
'content-type': 'application/x-amz-json-1.0', 
'x-amz-crc32': '3717359480', 
'x-amzn-requestid': 'a637b852-986f-4d41-9454-0e1d15241986', 
'content-length': '42', '
server': 'Jetty(9.4.18.v20190429)'}, 
'RetryAttempts': 0}}

 응답 내용이 많지만, TableNames와 처리 상태를 체크할 수 있는 HTTPStatusCode 정도만 사용해도 충분할 것이다.

 

 

 특정 테이블의 존재여부를 확인할 수 있는 다른 방법으론 describe_table()이 있다.

client.describe_table(TableName="TestTable")

 

 만약, 해당 이름의 테이블이 존재하지 않을 경우 botocore.errorfactory.ResourceNotFoundException이 나기 떄문에, 테이블의 존재 여부를 판단하고자 할 경우 try ~ catch로 처리하면 될 것이다. 응답은 아래와 같다.

{
    'Table': {
        'AttributeDefinitions': [
            {
                'AttributeName': 'name',
                'AttributeType': 'S'
            }
        ],
        'TableName': 'TestTable',
        'KeySchema': [
            {
                'AttributeName': 'name',
                'KeyType': 'HASH'
            }
        ],
        'TableStatus': 'ACTIVE',
        'CreationDateTime': datetime.datetime(2021,
                                              7, 6, 16, 53, 37, 417000, tzinfo=tzlocal()), 'ProvisionedThroughput': {
            'LastIncreaseDateTime': datetime.datetime(1970,
                                                      1, 1, 9, 0, tzinfo=tzlocal()),
            'LastDecreaseDateTime': datetime.datetime(1970,
                                                      1, 1, 9, 0, tzinfo=tzlocal()), 'NumberOfDecreasesToday': 0,
            'ReadCapacityUnits': 10,
            'WriteCapacityUnits': 10
        },
        'TableSizeBytes': 0,
        'ItemCount': 0,
        'TableArn': 'arn:aws:dynamodb:ddblocal:000000000000:table/TestTable'
    },
    'ResponseMetadata': {
        ...
}

 크게 테이블의 구조와 테이블 생성일자 등의 정보, 그리고 역시 해당 응답에 대한 HTTP 정보(위 응답에선 임의로 생략함)가 담겨 있다.

 

 

테이블 내 항목 읽기(Read)

 

table = resource.Table("TestTable")
resp = table.scan()


items = resp['Items']
count = resp['Count']

 table.scan()을 통해 전체 테이블의 항목 및 아이템의 수를 위와 같이 가져올 수 있다... 인줄 알았는데 안 된다. AWS 문서에 따르면, 1MB 크기 내에서만 결과를 반환하기 때문에,  큰 데이터를 가져오려면 쿼리를 계속 불러서 페이지네이션처럼 처리해야 한다. 이는 count도 마찬가지라 정확한 테이블 내 아이템 개수를 반환한다고 보장하지 않는다.

따라서, 테이블 내 전체 item 수를 얻는 바른 방법은 다음과 같다.

 

client.describe_table(TableName="TableName")['Table']['ItemCount']

 

 이제, DynamoDB에서 쿼리를 날리는 방법에 대해 알아보자.

from boto3.dynamodb.conditions import Key

table = resource.Table("TestTable")
query = {"KeyConditionExpression": Key("name").eq("키값")}

print(table.query(**query))

# 결과물은 위와 동일하게 table.query(**query)['Items']와 ['Count']로 각각 아이템과 아이템 수 조회 가능

 

 

위에서 사용한 scan을 이용해서도 쿼리를 조회할 수 있다.

from boto3.dynamodb.conditions import Attr

query = {"FilterExpression": Attr('att1').eq('원하는 값')}
response = table.scan(**query)
print(response['Items'])

Key나 Attr에 eq뿐만 아니라 lt, gt, between 등등 많은 조건을 붙일 수 있다.

 

scan과 query를 사용한 방법을 제시했는데, query는 key를 이용하여 결과를 조회하는 거라면, scan은 그냥 말 그대로 테이블 전체를 훑는 거라 큰 테이블에서 사용하면 심각한 성능 문제를 야기할 수 있다. 심지어 scan은 필터 조건을 걸어도 일단 싹 긁은 다음에 필터링 하는 방식이라, 큰 테이블에서 쓰기 힘들다. 그래서, 작은 테이블이 아닌 이상 가급적 query를 쓰는 게 좋다.

 

 

테이블의 아이템 수정하기

query = {'Key': {'name': 'name1111'},
         'UpdateExpression': 'set att1=:new_data',
         'ExpressionAttributeValues': {
             ':new_data': 'new_attttt'
         }
         }
resp = table.update_item(**query)
print(resp)

 아이템을 수정하는 방식은 특이하다. 수정 대상인 키를 지정하고, UpdateExpression을 통해  자체적인 문법을 통해 업데이트 명령을 지정한다. 여기서 new_data는 임의로 정한 값으로, 변수명 앞에 콜론(:)을 붙여 내부적으로 사용하는 변수임을 나타낸다. 마지막으로, ExpressionAttributeValues를 통해 내부 변수가 어떤 값을 가지고 있는지 표현한다. 나는 새로 업데이트될 값으로 썡 문자열 'new_attttt'를 지정했는데, 값을 바꿀 속성인 att1이 인덱스나 키에 속하지 않은 이상, 당연히 여기엔 어떤 파이썬의 기본 오브젝트(숫자, 문자, 리스트, 딕셔너리 등)이 와도 괜찮다.

 

추가적인 파라미터인 ConditionExpression를 통해 WHERE절처럼 조건부 삭제를 할 수도 있다.

 

 

테이블에 쓰기, 조회하기, 삭제하기

 

# 아이템 삽입
item = {'name': 'name1112', 'att1': 'attttt'}
resp = table.put_item(Item=item)
print(resp)

# 아이템 조회
resp = table.get_item(Key={'name': 'name1112'})
print(resp['Item'])

# 아이템 삭제
resp = table.delete_item(
    Key={
        'name': 'name1112'
    }
)
print(resp)

# 데이블 삭제
teble.delete()

 

 R과 U는 상세하게 설명했는데 나머지는 이렇게 한번에 설명해도 될 거 같다. 왜냐하면, 위에서 다룬 문법들을 그대로 활용하면 되기 때문이다. 예를 들어 조건부 삭제를 하고 싶으면 update의 조건부 업데이트와 똑같이 조건을 지정하면 된다. 나머지 명령들도 위에서 사용한 문법과 거의 유사하다.

 

get_item()은 단 하나의 아이템만 읽어 오는 함수로, 여러 개를 읽어 와야 할땐 위에서 본 scan()이나 query()를 사용하면 된다.

테이블 삭제는 워낙 간단해서 저 한줄이면 충분하다.

 

 

 

 전체적으로 파이썬 문법보다는 aws cli를 활용하는 능력이 더 중요한 것 같다. 사실 ConditionExpression, FilterExpression과 같이 파라미터들이 이름이나 문법이나 무지하게 복잡한 이유가, 그냥 boto3 자체가 aws cli를 파이썬에서 활용할 수 있게 만든 라이브러리라, 거기서 쓰던 변수명이나 방식을 그대로 가져와서 그런 것 같다. 그래서, 어느정도 깊게 다루려면 python, boto3보다는 오히려 aws cli에 더 익숙해져야 할 것 같다.

+ Recent posts