사실 이 제목은 내가 지은 것은 아니고,
AWS re:invent에서 발표될때, "Data modeling with Amazon DynamoDB in 60 minutes" 라는 제목으로 소개가 되서 따라 적어보았다;
도입.
어쨌든 이번 포스트에서 소개하고자 하는 내용은 "어떻게 하면 더 효율적으로 DynamoDB를 모델링 할 수 있을까"에 관한 것이다.
RDBMS를 다루면서, 테이블들의 관계를 나타내고 모델링 하는 것은 많이들 경험을 해봤거나, 들어봤을 것이다.
그러나 NoSQL 기술에 해당하는 DynamoDB는 어떻게 모델링하는 것이 좋을까.
게다가 엄밀히는, (MongoDB 같은) 기존에 잘 알려진 NoSQL과는 좀 다른 특성을 가진 DynamoDB는 어떻게 모델링하는 것이 좋은지 살펴보자.
DynamoDB의 뿌리 NoSQL의 탄생.
우선 DynamoDB의 뿌리?에 해당하는 NoSQL이 왜 세상에 나오게 된 것인지 부터 알아보자.
NoSQL에 탄생하기 전까지, 데이터베이스 시스템은 RDBMS (즉, 관계형 데이터베이스) 세상이었다. 스토리지 비용이 비쌌던 시절, 정규화를 통해 중복 데이터를 최대한 줄이는 것이 중요했고, 관계형 데이터베이스는 그것을 수행하기에 최적의 도구였다.
즉, 관계형 데이터베이스의 목적은 효율적인 스토리지의 사용이었다.
그러나, 최근들어 스토리지의 비용이 점차 저렴해지고, 대규모의 연산을 빠르게 처리해내는게 중요해짐에 따라,
데이터가 중복되도 좋으니, 데이터베이스도 빠르게 처리하는 것이 중요한 시대가 되었다.
스토리지를 희생하더라도, 성능을 최대한 끌어올리자, 그래서 NoSQL이 나온 것이다.
몇 가지 DynamoDB의 기본적인 특징.
1. DynamoDB는 HTTP 통신을 한다. 해서, 다른 DB Resource들이 TCP Connection 기반인데 비해, Connectionless 하다.
2. AWS Lambda나 AppSync같은 것들이 DynamoDB와 궁합이 찰떡이다.
앞서 언급한 것과 같이 NoSQL의 탄생 목적 자체가 트래픽이 많아져 연산의 속도를 극대화 시켜야 하는 상황이다.
그러한 상황에선 scale-in/out 능력이 중요한데, Lambda나 AppSync도 그 목적에 부합하는 컴퓨팅 리소스이기 때문이다.
(반대로, scale-in/out 능력이 중요한 RDBMS와는 잘 맞지 않다.)
3. Key를 제외한 테이블의 Attribute들은 미리 정의 될 필요가 없다. 뭐든 들어갈 수 있다 Flexible하게.
이런 부분도 미리 Schema가 만들어져 있어야 하는 RDBMS와는 매우 다른 부분이다.
4. Key의 구성 방법은 크게 두 가지로 나누어진다.
(1) Simple Primary Key
- Partition Key 하나만을 Primary Key로 쓰는 것이다.
(2) Composite Primary Key
- Partition Key와 Sort Key의 조합으로 Primary Key를 쓰는 것이다.
이 때, Key를 어떻게 만드냐는(Simple Primary Key로 쓸지, Composite Primary Key로 쓸지),
우리가 데이터를 어떤 상황에서 액세스하는지, 그 유즈케이스를 잘 생각해보고 결정해야 한다.
DynamoDB에서 데이터를 핸들링 할 수 있는 3가지 방식.
우리가 DynamoDB를 통해 데이터를 처리할 수 있는 방식은 크게 세가지로 나눌 수 있다.
1. Item-based actions
DynamoDB에서 Item은 관계형 데이터베이스로 치면 Row에 해당하는 것이다.
Item-based actions에는 Write, Delete, Update이 가능하다.
그리고, 하나의 Item을 대상으로 처리를 하는 것이기 때문에, 해당 액션을 하기 위해서는 Primary Key를 제공해야한다.
이 말을 다르게 하면, 한번에 여러 개를 처리 할 수는 없다는 의미이기도 하다. (batch request가 안된다 뜻)
예를 들어, 아래와 같이 영화 배우 정보(배우, 영화 제목, 배우의 역할, 개봉 연도, 장르) 테이블이 있다고 해보자.
Partition Key는 톰 행크스, Sort Key는 톰 행크스가 출연한 영화라고 했을 때,
"톰행크스가 출연한 모든 영화 삭제해줘" 이런 요청은 불가능하다는 것이다. 아이템 하나하나 처리해줘야한다.
2. Query
쿼리는 이름에서 풍겨지듯이, 데이터를 읽기 위한 액션이며, Read-only action이다.
한번의 요청으로 여러 개의 아이템들을 처리(fetch) 할 수 있다.
위의 그림 처럼, "톰 행크스가 출연한 모든 영화 가져와줘" 라고 요청 하는게 가능하다는 것이다.
이 때, Partition Key를 제공하는 것은 기본인데. 필요에 따라, Sort Key에다가 세부 조건을 적용할 수도 있다.
그러니까, "톰 행크스의 모든 영화를 가져와줘" 라고 할 수도 있지만,
"톰 행크스의 영화 중에, 알파벳 a~c안에 드는 글자로 시작하는 영화만 가져와줘" 할 수도 있는 것이다.
3. Scan
관계형 데이터베이스의 스캔과 동일하며, 모든 아이템들을 다보는 기능이다.
Avoid! Expensive at scale!
DynamoDB의 활용을 극대화 할 수 있는 Secondary Indexes.
만약에 우리가 테이블에 액세스하는 다섯가지의 액세스 패턴이 있다고 했을 때,
기존의 디자인으로, Primary Key (Partition Key only 혹은 Partition Key + Sort Key)를 이용해서, 두 가지는 만족시켰으나
나머지 세 가지의 패턴에 대해서는 만족시키지 못했다면?
몽땅 스캔해서 필터링? 그러면, 성능이 처참해질 것이다. 테이블의 사이즈가 커질수록.
이럴 때 쓰라고 DynamoDB에는 Secondary Index가 있다.
영화 배우 예시를 다시 한번 보자.
기존과 같은 테이블 구조에서, "Toy Story에 출연한 배우들을 보여줘"라는 액세스 패턴이 추가 될 때,
Query를 통해 가져올 수 있는 방법은 없다.
이런 상황에서, 아래 처럼 Global Secondary Index(GSI)를 적용하여,
Sort Key 내용을 GSI의 Partition Key로 하고, Partition Key 내용을 GSI의 Sort Key로 한다면,
Query를 통해 Toy Story에 출연한 배우들이 누군지 알 수 있게 된다.
Secondary Index를 적용하면, 그 형태에 맞는 Primary Key의 새로운 테이블(replicated)이 내부적으로 생겼다고 보면 된다.
대신 우리가 직접 테이블을 만들고 관리해 줄 필요없이, 사용자가 데이터를 더하거나 빼거나 수정했을 때,
그게 내부적으로 Secondary Index 적용에 의해 만들어진 테이블에 같이 적용되는 것이다.
Modeling의 시작.
지금까지, 모델링을 위해 필요한 DynamoDB의 요소/특징들을 살펴봤으니, 모델링을 해보자.
크게는 아래의 세 가지를 순서대로 한다고 생각하면 된다.
1. Start with an ERD
구현하고자하는 애플리케이션에서 필요한 Entity와 그 Entity들 간의 관계가 있을 것이다.
1:1관계든, 1:N 관계든. 일단 표현해야한다. 이 과정은 관계형 데이터베이스를 모델링 할 때와 다르지 않다.
2. Define your access patterns
구현하고자하는 애플리케이션의 요구사항이 뭔지,
그에 따라, 데이터베이스에 어떤 데이터를 넣고, 데이터베이스로 부터 어떤 데이터를 읽을 것인지 등을 작성해야 한다.
앞서 살펴본 영화 배우 예시에서, "톰 행크스의 모든 영화를 가져와줘", "Toy Story에 출연한 배우들을 보여줘"와 같은 것이 액세스 패턴에 해당한다.
3. Design your primary keys & secondary indexes
1, 2에서 도출한 내용을 바탕으로 Write/Delete/Modify에 대한 처리가 가능하면서도, Scan이 아닌 Query로 데이터를 얻고, 필터링 할 수 있도록 Key를 디자인해야 한다.
관계형 데이터베이스는 잊어라.
DynamoDB 데이터를 모델링 함에 있어서, 관계형 데이터베이스를 모델링 하던 경험은 독이 될 수 있다.
아래의 3가지 특징은 관계형 데이터베이스에서 데이터 모델링을 할 때 고려하는 것들인데,
DynamoDB에서는 이것과 정반대로 간다고 보면 비슷하다.
1. Normalization
관계형 데이터베이스에서 중복 데이터를 없애주기 위해썼던 정규화(normalization)는 DynamoDB에 적용하는 것이 거의 불가능하다 (애초에 그럴려고 만든 DB가 아니다). 왜냐면, 정규화의 핵심은 Join인데, DynamoDB에 Join이라는 개념 자체가 없고, 애플리케이션 레벨에서 구현한다 하더라도 매우 비효율적이기 때문이다. 그래서, DynamoDB에서는 De-normalization을 한다.
2. Join
NoSQL 특징을 가진 데이터베이스에서는 불가능하다고 보면된다. 특히, 대용량 데이터의 상황에서 특히나 Expensive하다.
3. One entity type per table
관계형 데이터베이스에는 Entity의 종류나 특성에 따라 테이블을 모두 나누었다. 예를 들어, '영화' 테이블, '배우' 테이블 이런식으로..
그러나 DynamoDB에서는 그런 것들을 모두 하나의 테이블에 표현할 수 있다.
오히려 하나의 테이블에 표현했을 때, Query의 성능이 극대화 된다.
모델링 in practice.
모델링을 해보기 위한, 예시 상황을 하나 만들어보자.
(1) 애플리케이션은 E-commerce 서비스이다. (2) 사용자들이 주문을 할 수 있다. (3) 하나의 주문은 여러 개의 아이템을 가질 수 있다.
이제 앞에서 말한 것과 같이 모델링을 위한 세 단계를 순서대로 수행해보자.
1. Start with an ERD
구현하고자 하는 서비스에는 요구사항을 분석했을때, 4가지의 Entity가 있을 수 있고, 다음과 같은 관계여야 한다면,
- User : User Address = 1 : N
- User : Order = 1 : N
- Order : Item = 1 : N
아래 그림과 같이 그려질 수 있다.
2. Define your access patterns
가능한 액세스 패턴들을 나열해보자. (다음과 같은 액세스 패턴이 요구사항에 의해 존재한다고 가정)
- 사용자(user)의 프로파일을 가져온다.
- 사용자의 모든 주문(order)을 가져온다.
- 하나의 주문과 그 주문의 아이템들을 가져온다(item).
- 한 사용자의 특정 상태(status)의 주문을 가져온다.
- 사용자의 새로운 주문을 가져온다.
3. Design your primary keys & secondary indexes
1:1 관계와 1:N 관계를 나누어서 표현해보자.
[1:1 관계]
'사용자의 프로파일'은 '사용자'에 종속적인 개념으로, 사용자가 Partition Key(PK), 프로파일이 Sort Key(SK) 관계로 표현될 수 있다.
이때, 각 Key 앞에 "USER#", "PROFILE#"와 같은 식으로, 어떤 내용의 Key인지 Prefix를 붙이면,
디버깅하기 용이할 뿐만 아니라, 값이 중복될 가능성을 낮출 수 있다.
[1:N 관계]
1:N 관계를 표현하는 방법에는 3가지가 있다: (1) Attribute (list or map), (2) Primary key + Query, (3) Secondary index + Query
(1) Attribute (list or map)
'사용자'와 '사용자 주소' 관계에 대해서 생각해보자. 두 가지 흥미로운 포인트에 대해서 생각해볼 수 있다.
첫 번째는 우리가 주소를 직접 접근할 일이 있을까?하는 것이다. 주소 자체에 접근을 한다든가, 해당 주소의 사용자는 누굴까 이런식으로.
적어도 앞서 가정한 시나리오 상에서는 없다.
두번째는 애플리케이션의 정책적인 이유로 사용자 주소 개수에 제한을 둘 수 있다.
이런 것들을 고려했을때, '사용자'와 '사용자 주소' 관계의 표현은 list나 map을 활용한 de-normalization을 적용할 수 있을 것이다.
(2) Primary key + Query
1:N 관계를 표현하는 두 번째 방법은, DynamoDB에서 1:N 관계를 나타내는 가장 일반적인 방법인, Sort key를 이용하는 것이다.
위의 ERD와 같이, '사용자'와 사용자의 '주문' 관계가 1:N의 관계에 있고,
요구조건 상, 사용자의 '주문'의 개수가 제한적이지 않은 상황일 때는 아래의 테이블과 같이 Partition Key + Sort Key로 표현할 수 있다.
*이때 Order에 해당하는 Attribute들을 보면, Profile 때의 내용과는 다르다는 것을 확인할 수 있다.
이처럼, DynamoDB는 Attribute들을 Key의 내용에 따라 자유롭게 구성할 수 있다.
(3) Secondary index + Query
세번째로는 Secondary index를 사용하는 것이다.
기존의 PK, SK를 이용해서는 Query를 할 수 없을때 (액세스 패턴을 모두 만족시킬 수 없을때), Secondary index를 쓴다.
'주문'과 '아이템'의 1:N 관계를 표현하기 위해, Secondary index로, PK='아이템', SK='주문'을 적용할 수 있다.
일반적인 접근 방법이라면, '주문' 하나에, '아이템' 여러개이므로, PK='주문', SK='아이템'을 할 것 같지만,
반대로 뒤집어서 표현한 이유는, '주문'을 SK로 했을때, 아래의 테이블에서 확인할 수 있는 것과 같이, 서로 다른 Partition Key(User, Item)이 공통의 Sort Key(Order)를 가질 수 있는 형태가 되기 때문이다.
지금의 구조에서 GSI로 SK에 PK를 적용하고, PK에 SK를 적용을 하면 아래의 그림과 같이 될 것이다. (PK, SK가 뒤집힌 Inverted Index)
이 때, Secondary Index의 PK인 '주문'을 이용해서 Query를 하면, '사용자'와 '아이템'들이 함께 나올 것이다.
그런데 이 결과는 마치 두 개의 테이블이 Join된 결과와 같다. (User-Order를 나타내는 테이블과 Order-Item을 나타내는 테이블의 Join)
이렇게 DynamoDB에서는 하나의 테이블 안에서 PK, SK, Secondary index를 이용해서 Pre-aggregated된 내용을 Join 없이 고성능으로 얻을 수 있는 것이다.
Filtering.
만약에 우리가 특정 attribute에 대해서 필터링을 하고 싶은데,
필터링의 대상이 기존의 PK, SK, Secondary Index에 의해 Query로 뽑아낼 수 있는 데이터 안에서 가능하다면,
문제가 되지 않을 것이다.
그러나, 만약 찾고자 하는 대상의 Partition Key가 서로 다르다면(서로 다른 파티션에 데이터가 저장되어있다면) 어떻게 해야할까? Scan 후 Filtering을 적용하는 것도 하나의 방법일 수 있다. 그러나, 역시 Scan은 좋은 방법이 아니다.
DynamoDB의 Filter expression 동작 방식: Filter expression이 어떻게 동작하는지 보자.
1. 테이블에서 데이터(아이템 들)을 읽는다.
2. 해당 아이템들을 메모리에 로드하고 나면, DynamoDB는 사용자가 정의한 Filter expression이 있는지 확인한다.
3. 만약에 Filter expression이 있다면, 그 내용에 따라 아이템들을 필터링 한다.
4. Return 한다.
문제는 1번 단계이다. DynamoDB에서 데이터를 한번에 가져올 수 있는 최대 크기는 1MB이다.
우리가 1 Gigabyte 크기의 테이블을 가지고 있다고 상상해보자. (사실 1 Gigabyte 테이블은 그렇게 큰 테이블도 아니다)
그 테이블을 대상으로 Scan을 한다면, 요청을 처리하기 위해서 테이블에다가 요청을 1000번은 보낼 것이다.
그러니,Filter expression을 믿고 Scan을 날리는 행위는 하지말자.
대신 필터링 된 데이터를 Primary Key나 Secondary index를 통해 가져올 수 있도록 구성하는 것이 좋다.
필터링 액세스 패턴을 Key나 Index를 통해 가져올 수 있도록 구성하는 방법은 크게 세 가지로 나눌 수 있다.
- Primary Key
- Composite sort key
- Sparse index
앞서 나열했던 액세스 패턴 중, 필터링이 적용되는 것들을 Key와 Secondary index를 통해 어떻게 처리하면 좋은지 확인해 보자. 요구 사항에 따라, 필터링이 적용되야하는 액세스 패턴들은 다음과 같다.
- 사용자의 모든 주문(order)을 가져온다.
- 한 사용자의 특정 상태(status)의 주문을 가져온다.
- 사용자의 새로운 주문을 가져온다.
1. 사용자의 모든 주문(order)을 가져온다. -> Primary Key 방법 적용
해당 필터링 액세스 패턴을 SQL 쿼리로 나타내면, 의미가 좀 더 명확하게 와닿는다.
SELECT * FROM orders WHERE username = 'alexdebrie'
이 부분은 앞서 적용한 Primary Key의 구성에 따라, PK='사용자', SK='주문'으로 다음과 같이 필터링 할 수 있다.
"PK=USER#alexdebrie AND BEGINS_WITH(SK, 'ORDER#')"
2. 한 사용자의 특정 상태(status)의 주문을 가져온다. -> Composite Sort Key 방법 적용
SQL로 표현하면 다음과 같다.
SELECT * FROM orders WHERE username= 'alexdebrie' AND status='shipped'
현재 테이블의 형태를 보면, "status" attribute에 해당하는 내용은 Primary Key나 Secondary index를 통해 직접 접근할 수 없다.
뿐만 아니라, 아래 그림과 같이, 가져오고자 하는 내용이 서로 다른 Partition Key에 속해 있어서,
Primary Key나 Secondary index를 통해 가져온 데이터에 Filter expression을 적용하는 것도 불가능하다.
이럴 때, Composite sort key를 적용해볼 수 있다.
Composite sort key를 만드는 방법은, 기존의 두 개 (혹은 그 이상)의 attribute를 합치고, 그것을 Global Secondary Index(GSI)를 통해 Sort Key로 적용하면 된다.
(1) Status와 CreatedAt를 합쳐서 OrderStatusDate attribute를 추가
(2) GSI를 이용해, OrderStatusDate를 SK로 적용
그러면, GSI를 통해 PK, SK 구조가 만들어지기 때문에, 다음과 같은 Query가 가능해진다.
"PK=USER#alexdebrie AND BEGINS_WITH(OrderStatusDate, 'Shipped#')"
뿐만 아니라, CreatedAt 속성을 Composite Key로 만들어 줌으로 써, "날짜"까지 필터링의 조건으로 넣을 수 있게되었다.
예를 들어, AlexDebrie가 2019년 4월 중에 주문한거중에 상태가 Shipped 인거 달라. 이런 Query도 가능한 것이다.
3. 사용자의 새로운 주문을 가져온다. -> Sparse Key 방법 적용
SELECT * FROM orders WHERE status='placed'
이번 패턴은 기존의 PK, SK와 관계없이, 특정 attribute 값을 기준으로 아이템을 필터링 해서 얻고 싶은 경우이다.
이럴 때는 해당 attribute의 값에 대한 ID를 만들고, 그 ID에 대해서 GSI를 적용하면 된다.
이때, Status가 "PLACED"에 해당하는 아이템은 테이블 전체 중 일부이고, 이것에 대한 Key를 만든다해서 Sparse Key라고 하는 것이다.
주어진 상황과 같이 Status가 "PLACED"인 경우만 뽑고 싶다면, PlaceId라는 attribute를 생성하고,
그것이 GSI를 통해 PK로 적용될 수 있도록 Unique한 값을 넣어주자.
이렇게 만들어진 GSI 테이블은, 원본 테이블의 Subset이며, 특히나 "특정 attribute"에 대한 "특정 값"을 대상으로 만들어진 것이기 때문에, 그 사이즈 자체가 크지 않을 것으로 기대할 수 있다. 그래서, 해당 Index를 대상으로 Scan를 수행하는 것도 용인된다.
Reference.
AWS re:Invent 2019: Data modeling with Amazon DynamoDB (CMY304)
https://www.youtube.com/watch?v=DIQVJqiSUkE&feature=youtu.be