AWS DynamoDB 사용기_보조 인덱스 사용 위주로

han·2021년 1월 31일
6

Back Office 구축일지

목록 보기
1/3
post-thumbnail
  1. 이 글은 DELRYN'S BLOG 님의 글을 참고하였습니다.
    기본적인 문법을 정말 잘 설명해주셨어요
  2. 예시는 모두 typescript로 작성하였습니다. 타입 설정 부분만 지우면 javascript와 동일합니다.


DynamoDB란?

AWS에서 제공하는 Key-value 형태의 NoSql 데이터베이스

1. Dynamo DB의 구현 방식

  1. 기존의 데이터 베이스와 유사하게 테이블이 존재합니다.

  2. 테이블과 해당 테이블의 각각의 row는 partition이라는 공간에서 구성됩니다.

    이 공간은 가시적으로 볼 수 없고, 특별히 정렬된 공간도 아니며, javascript의 객체처럼 key-value의 연결로만 구성된 데이터상으로만 존재하는 공간입니다. 이 공간을 검색할 수 있도록 파티션들의 가상의 이름, 키가 파티션 키(partition key)입니다.
    AWS에서는 이 공간의 크기를 사전에 정의하여 관리할 수 있습니다. 온디맨드*하면 상관없지만 Capacity Units** 이 있어서 해당 테이블의 예상 트랙픽을 기반으로 파티션 크기를 설정하면 조금이나마(?) 요금절약을 할 수 있다고 합니다. 이렇게 용량을 조절하는 것을 프로비저닝이라고 합니다.

    조절이 필요한 이유는, 한 곳에만 요청이 몰리면 당연히 퍼포먼스가 떨어지기 때문이다. 이를 hot partition 이라고 합니다.

    *용량 설정의 기본 설정
    ** 단위용량 : DynamoDB에서는 읽기(RCU), 쓰기(WCU) 용량이 있습니다.

  3. Hash 구조로 저장이 됩니다.
    이에 대한 정확한 이해는 아직 하지 못했습니다ㅠ 좀 더 정리가 되면 추가 글을 작성해보도록 하겠습니다.

  4. NoSql 기반입니다.



2. 사용되는 기본 개념

AWS를 사용 해본 사람이라면 알겠지만 대부분의 정보는 공식문서를 통해서만 알 수 있습니다😂😂 특히나 DynamoDB의 경우는 사용자가 적은 편인지 공식문서에서도 한글 변역이 되지 않은 경우가 많았습니다. 개념어가 영어와 한글이 혼용되면서 헷갈렸던 경험이 있어 중요 개념 위주로 정리해보았습니다.

1) 테이블(Table)📚

AWS 콘솔을 이용해서 이름, 기본키partition key, 정렬키sort key 혹은 LSI, 글로벌 보조 인덱스GSI, 용량 등을 설정해준다. 용량을 변경할 수 있지만 이름 및 기본키 등은 변경할 수 없기 때문에 이를 고려해서 만들어야 합니다.

2) 💡기본키(partition key)

partition key라고도 하며, SQL의 primary key과 유사한 개념입니다. 이 키는 고유해야하며, 업데이트를 하거나 쿼리를 할 때 필요합니다. 고유한 키여야 하며, string, number, binary(암호화된 에디터나 복잡한 문자열 등) 타입만 가능합니다.

3) 🏷로컬 보조 인덱스(LSI)

정렬키 혹은 로컬 인덱스는?

파티션 키 및 정렬 키sort key를 합친 복합 인덱스입니다. 정확한 명칭은Local Secondary Index(LSI, 로컬 보조키)입니다. 파티션키와 정렬키를 합친 것이긴 하지만 개인적으로는 sort key와 동일하게 보아도 무방할 것 같습니다.

말그대로 보조키로서 동일한 파티션 키 내에서 정보들을 분리하고 싶을 때 사용합니다. 로컬 인덱스의 경우, 테이블을 만들 때 미리 설정해두어야 합니다. 한번 설정한 후에는 삭제, 추가, 수정이 불가능합니다.

자세한 내용을 아래 더 자세히 풀어서 설명해 보겠습니다.

4) 📌글로벌 보조 인덱스(GSI)

기본키(파티션 키)나 정렬키와는 별개로 특정 키를 인덱스 키로 활용할 수 있는 개념입니다. 영어로는 Global Secondary Index(GSI)라고 하기 때문에 LSI와 헷갈리지 않도록 주의해야 합니다.

글로벌 인덱스의 경우, 콘솔에서 해당 테이블을 클릭하면 글로벌 인덱스를 만들 수 있는 탭이 있습니다. 기존의 attribute 중 하나를 선택하여 자유롭게 추가, 수정, 삭제할 수 있습니다.




3. 보조 인덱스의 개념과 역할

생각보다 이 보조 인덱스(GSI, LSI)의 역할이 중요하였습니다.
왜냐하면 파티션 키 외의 내용으로 쿼리해야하는 경우가 생각보다 많기 때문입니다. 이를 sql에서는 foriegn key와 join table로 해결했지만, 여기서는 인덱스라고 이야기하는 보조키로 이 역할을 합니다.

구체적인 예시에 들어가기 전에 보조 인덱스의 개념에 대해 먼저 정리해보겠습니다.


1) 보조 인덱스의 개념

보조 인덱스는 sql의 인덱스와 비슷한 역할을 합니다.
하지만 좀 더 개념적인 것들을 덧붙여보자면 기본 테이블에 대해서 설명해보아야 할 것 같습니다.
모든 보조 인덱스은 정확하게 하나의 테이블과 연결되어 데이터를 얻습니다. 이를 인덱스의 기본 테이블이라고 합니다. 출처: 공식문서

로컬 보조 인덱스

예를 들어보겠습니다. 아래 그림은 AWS 공식문서에서 캡처한 것입니다.

위의 그림은 Thread라는 테이블에

  1. ForumName이 파티션 키

  2. Subject가 정렬키입니다.

  3. ForumName과 Subject를 이용해서 새로운 가상의 테이블을 만들 때, 해당 테이블을 구성하는 기준이 되는 것이 로컬 보조 인덱스입니다. 만들어진 가상 테이블의 파티션 키처럼 활용되겠네요.

    사실 위에서 언급한 것처럼 3번이 이해가 안된다면 Subject가 로컬 보조 인덱스라고 이해해도 무방할 것 같습니다.

  4. LastPostDateTime과 Replies 등이 속성attribute으로 사용되고 있구요.


1~4번째 열을 보면 S3이라는 동일한 파티션키이지만 Subject는 다름을 볼 수 있습니다. 이렇게 local secondary index는 **모든 파티션이 동일한 파티션 키 값을 가진** 기본 테이블 파티션으로 한정된다는 의미에서 "로컬" 보조 인덱스라고 합니다.


글로벌 보조 인덱스

반면 글로벌 보조 인덱스는 파티션 키와 별개입니다.
기존에 있던 필드를 글로벌 보조 인덱스를 만들면, 해당 인덱스를 기준으로 가상의 새로운 테이블이 형성되는 것입니다.

이것도 예를 들어보겠습니다.


1. GameScores 라는 테이블
2. UserId라는 파티션 키
3. GameTitle이라는 정렬키

이 상황에서 각 게임의 최고 점수를 표시하는 순위표를 만들기 위해서는 어떻게 해야할 까요?
GameTitle만을 토대로 GameScores에서 데이터를 검색해야 할 경우에는 Scan작업*을 해야 하기 때문에 효율이 떨어집니다. 반면 UserId로 쿼리하면 Galaxy Invaders의 최고 점수 기록 보유자를 찾을 수 없습니다. 경기별로 볼 수 없으니까요.

가장 최적의 상황은 TopScore를 인덱스로 만들어서 해당 인덱스를 기준으로 쿼리하는 것이겠지요. 그럴 때 TopScore가 글로벌 보조 인덱스가 됩니다.

* Scan 작업이란
조건을 설정하여, 해당된 row만 불러오는 쿼리와 달리 모든 row를 불러오는 것입니다. 비용도 더 많이 들고, 데이터 양이 많아지면 느려지기 때문에 최대한 지양해야 하는 작업입니다.




2) 로컬 보조 인덱스 vs 글로벌 보조 인덱스 차이

공식 문서 내용 중, 반드시 이해가 필요한 내용만 추려보았습니다.
아래 내용을 정리해보면, 글로벌 보조 인덱스가 생성, 삭제도 자유롭고 쿼리 용량에도 제한이 없는 등 로컬 인덱스보다 더 장점이 많은 것처럼 보입니다.

그래서 저 또한, 로컬 보조 인덱스 사용은 지양하고 글로벌 보조 인덱스로만 사용해보려고 하였습니다. 그러면
1) 실질적으로 보조 인덱스를 사용하는 경우는 언제이고, 어떻게 사용할까요?
2) 로컬 인덱스는 사용할 일이 없는 것일까요?

특성글로벌 보조 인덱스로컬 보조 인덱스
키 생성 기준단순 기본 키(파티션 키)이거나 복합 기본 키(파티션 키 및 정렬 키)일 수 있습니다.기본 키는 반드시 복합 기본 키(파티션 키 및 정렬 키)여야 합니다.
타입인덱스 파티션 키 및 정렬 키(있을 경우)는 문자열, 숫자 또는 이진수 형식의 기본 테이블 속성일 수 있습니다.인덱스의 파티션 키는 기본 테이블의 파티션 키와 동일한 속성입니다. 정렬 키는 문자열, 숫자 또는 이진수 형식의 기본 테이블 속성일 수 있습니다.
파티션 키크기 제한이 없습니다.10GB 이하여야 합니다.
온라인 인덱스 작업1. 테이블을 생성할 때 동시에 생성 가능합니다 2.기존 테이블에 추가, 삭제 모두 가능합니다.테이블을 생성할 때만 생성 가능. 그 후에는 추가, 삭제 할 수 없습니다.
프로비저닝된 처리량 소비쿼리 및 스캔은 기본 테이블이 아닌 인덱스의 용량 단위를 소비합니다.기본 테이블의 읽기 용량 단위를 소비합니다. 테이블에 쓸 때 해당 local secondary indexes도 업데이트됩니다.




3) 보조인덱스를 이용한 쿼리

입고 혹은 출고 내역건 하나를 row로 하는 테이블이 있다고 해보겠습니다. 이 테이블의 이름은 stock_by_case 입니다. 파티션 키는 uuid를 이용해 고유하고 랜덤한 id(case_id)를 가지고 있습니다.

입력한 시점의 date 값을 id로 넣어줄 수도 있지만 초 단위까지 정확하게 파악해서 검색하는 것이 불가능하기 때문에 랜덤한 값으로 넣어주었습니다. 그렇다면 특정 일자, 21년 1월 21일에 입고된 row는 어떻게 검색할 수 있을까요?


case_idtitlegsi_datepricenumber
랜덤한 고유 id(uuid)'gs_12 외 2건'2021-01-21120003

저의 경우는 글로벌 보조인덱스를 사용하였습니다.
gsi_date를 글로벌 인덱스로 설정하였습니다. 해당 row가 입력되었을 때의 날짜를 string으로 변환해서 넣었습니다. gsi_date를 글로벌 인덱스로 설정해두면 파티션키와 동일하게 쿼리할 수 있습니다. 글로벌 보조 인덱스를 사용하여 쿼리하는 방법은 아래와 같습니다.


// 건별재고 내역 GET 
// 무엇을? 건별 재고 내역 전체(요청된 일자의 데이터만/기본은 현재 일자)

export const getMaterialByCase = async (date: string): Promise<DocumentClient.ItemList | undefined> => {
  try {
    console.log(date)
    const params: DocumentClient.QueryInput = {
      TableName: 'stock_by_case',
      IndexName: 'gsi_date',
      KeyConditionExpression: 'gsi_date= :x',
      ExpressionAttributeValues: {
        ':x': date,
      },
    }
    const { Items } = await dynamo.query(params).promise()
    console.log('Items :>>', Items)
    return Items
  } catch (e) {
    console.log('err', e)
    throw e
  }
}

글로벌 보조인덱스 뿐 아니라 로컬 보조인덱스의 경우에도 IndexName에 해당 키 이름을 입력하면 됩니다.



📌 주의할 점
보조인덱스로 '쿼리' 즉 검색은 할 수 있지만 보조인덱스를 가지고 바로 row를 업데이트 하는 것은 불가능합니다. 그 경우는
1) 보조인덱스로 쿼리를 한번 한 뒤,
2)해당 결과에서 파티션키를 찾아 업데이트를 해야만 합니다.

예를 들면 다음과 같습니다.

클라이언트 측에서 특정 주문번호의 상태를 결제완료에서 배송중으로 바꿨습니다.
이를 DB에 반영하기 위해 req.body에는 주문번호를 배열형태로 담아 서버측에 보냈습니다. 주문이력이 담긴 테이블은 paid입니다.

paidId(파티션 키)itemspriceuserIdpur_code
임의의 IdItem112000123@gmail.com10395830
임의의 IdItem2,Item410000456@gmail.com32809350

pur_code가 글로벌 보조 인덱스로 설정되어 있더라도 해당 인덱스로 업데이트를 하려고 하면 설정된 스키마와 구조가 다르다는 에러가 나옵니다.
강제로 아래와 같이 작성한다고 해도 안된다는 거죠.

//무엇을? 선택된 주문번호의 row들의 pur_status를 변경하기
export const updatePaidStatus = async (code: string) => {
  try {
    const params: DocumentClient.UpdateItemInput = {
      TableName: 'paid',
      IndexName: pur_code,
      Key: { pur_code: code },
      UpdateExpression: 'set pur_status= :status',
      ExpressionAttributeValues: {
        ':status': 200, //제작중
      },
      ReturnValues: 'UPDATED_NEW',
    }
    dynamo
      .update(params)
      .promise()
      .then(async result => {
        console.log('updatePaidStatus :>>', result)
      })
  } catch (err) {
    console.log('err', err)
    throw err
  }
}

그리고 애초에 update 작업에 IndexName이라는 attribute는 없습니다.😂
대신 아래와 같이 할 수 있습니다.



Controller : route/material/ouput.ts

//controller : status가 변경된 row의 주문번호(pur_code)가 body로 들어옴.
router.post(
  '/add-output',
  async (req: Request, res: Response) => {
    //req.body['10395830','32809350']
    req.body?.map(async (el: number) => {
      //하나의 주문건
      const paid = await getPaiedItem(el) //paid 테이블에서 가져오기
      await updatePaidStatus(paid.paid_key) //업데이트
    }
  })


Model : service/material/output.ts

//주문번호를 기준으로 paid테이블에서 쿼리
export const getPaiedItem = async (code: number) => {
  try {
    const params: DocumentClient.QueryInput = {
      TableName: 'bummy_paid',
      IndexName: 'pur_code',
      KeyConditionExpression: 'pur_code = :code',
      ExpressionAttributeValues: {
        ':code': code,
      },
    }
    const { Items } = await dynamo.query(params).promise()
    console.log('getPaiedItem :>>', Items)
    let result: any
    Items?.forEach(el => {
      result = el
    })
    return result
  } catch (err) {
    console.log('err', err)
    throw err
  }
}

//무엇을? 선택된 주문번호의 row들의 pur_status를 변경하기
export const updatePaidStatus = async (code: string) => {
  try {
    const params: DocumentClient.UpdateItemInput = {
      TableName: 'paid',
      Key: { paid_key: code },
      UpdateExpression: 'set pur_status= :status',
      ExpressionAttributeValues: {
        ':status': 200, //제작중
      },
      ReturnValues: 'UPDATED_NEW',
    }
    dynamo
      .update(params)
      .promise()
      .then(async result => {
        console.log('updatePaidStatus :>>', result)
      })
  } catch (err) {
    console.log('err', err)
    throw err
  }
}




4) 로컬 보조 인덱스의 활용

로컬 인덱스를 사용하는 경우는 hot key 이슈를 막기 위함입니다.
hot key 이슈는 특정 파티션 키만 유독 많이 사용되어 용량이 몰리는 것을 의미합니다.


예를 들어보겠습니다.
쇼핑몰 서비스 중 유저 데이터를 모든 user 테이블과 구매이력을 담은 paid 테이블이 있다고 해봅시다. 특정 유저가 수년 간 해당 쇼핑몰을 이용하면서 구매한 한 이력이 1000건이 넘었다고 해봅시다.(정기결재를 했나봐요ㅋㅋㅋ)

<User Table>

userIdnameaddresstype
123@gmail.com홍길동서울시 동대문구user
456@gmail.com박철수서울시 서대문구user
dsjl@f%DS(세션키)김코딩-session
1111@gmail.com이인나서울시 동대문구user

<Paid Table>

paidIditemspriceuserId
임의의 IdItem112000123@gmail.com
임의의 IdItem2,Item410000456@gmail.com
임의의 IdItem310500dsjl@f%DS(세션키)
임의의 IdItem4200001111@gmail.com
임의의 IdItem1,Item2100001111@gmail.com
임의의 IdItem1,Item2,Item450001111@gmail.com
임의의 IdItem3,Item4120001111@gmail.com
임의의 IdItem5150001111@gmail.com

(다양한 제품을 구매하신 이메일 1111@gmail.com 유저님)

그렇다면 구매이력을 관리하기 위해서 paid 테이블에 userId를 보조 인덱스로 1000번을 쿼리하게 됩니다. 단 하나의 유저를 위해서 말이죠!!
이런 경우를 hot key 이슈 라고 합니다.

위 테이블의 경우, paid 테이블에서 userId가 글로벌 보조 인덱스라고 한다면, 1111@gmail.com만 5번 호출되겠네요.

  1. 이런 경우에는 1111@gmail.com 님만을 위한 테이블을 따로 만들거나(DynamoDB에서 테이블 생성 개수 제한은 들어본 적이 없습니다)
  2. 혹은 user 테이블에 month 등으로 sort key를 만들어서 활용할 수 있을 것 같습니다.

사수님께서도 서비스 확장을 경우에 두고 계획하시다본 위의 경우가 생겨서 로컬 보조 인덱스의 활용도 다음에는 고려해보자고 하였습니다. 아쉽게도 이미 만들어진 테이블에 로컬 보조 인덱스 생성은 불가하니까요ㅠ






DynamoDB를 사용했을 때 장점

사실 DynamoDB 자체의 장점도 있지만 SQL만 사용했던 저에게는 NoSql 방식이 가진 장점이 더 와닿았습니다.

  1. join table 따위 없어도 됨😍
    테이블을 자유롭게 연동 할 수 있습니다. 이전에는 연결되지 않은 테이블들을 연결해주기 위해서는 조인테이블을 새로 만들어줘야 해서 구현하다가 스키마 구성부터 다시하고, 마이그레이션하고 그과정의 반복이었는데 이제 그렇지 않아도 됩니다!!


    이정도면 정말 귀여운 수준..

  2. 필드 구성이 자유롭다❤️
    물론 DynamoDB도 초기 셋팅을 잘하는 것이 중요하다는 의견이 많습니다.
    로컬 보조 인덱스나 파티션키의 경우는 테이블 생성 때만 가능하기 때문이지요. DB 특성상 데이터들이 어느정도 쌓이고 나면 변경하기 어렵습니다. 하지만 Sql에 비해서는 고려해야한 것이 상대적으로 적다고 느꼈습니다.


    이전에는 스키마가 한번 셋팅되면 필드 추가하는 것이 매우 까다로웠는데 1) dynamoDB에서는 업데이트 할 때 새로운 필드를 추가해서 업데이트 하면 필드가 추가됩니다. 2) 보조 인덱스도 GSI의 경우, 언제든 콘솔에서 설정할 수 있습니다. 3) 위에서 말한 것처럼 조인 테이블을 고려하지 않아도 되구요!

    NoSql의 특징이라고 할 수 있습니다!



DynamoDB를 사용했을 때 단점

  1. 사용 예시를 찾기 어렵다.
    이건 대부분의 AWS 서비스를 이용하는 경우 많이 느끼는 것입니다. 구글링을 하면 AWS 공식문서 외에는 찾기 어렵다는 것. 공식문서가 이해가 안되서 찾는 것인데 구글도 답을 해주지 않습니다😭

    udemy에서도 겨우 들을만한 강의 하나를 찾았는데 그것도 공식문서의 내용을 정리하는 수준이었습니다.


  2. row를 쿼리할 때 특정 field만 불러오고 싶지만 전체를 불러오는 수 밖에 없다.

    dynamoDB는 사용양에 따라 요금이 부과됩니다. 그렇기에 쿼리 수를 최적화 하는데 집중하게 됩니다. 예를 들어 원자재 목록이 있는 material이라는 테이블에서 이름, 거래처, 재고수, 단가만 불러오고 싶을 때, MySql의 ORM인 squelize에서는 불러오고자 하는 필드만 선택해서 가져올 수 있지만 dynamoDB에서는 불가능하죠. 특정 field만 쿼리해서 요금을 절약하고 싶지만 row전체를 불러오게 됩니다.


아래 예시처럼ProjectionExpression 설정을 통해 해당 field만 불러올 수 있는 것처럼 보이지만 이것도 row을 전체를 불러와서 필터링하는 기능이기에 그리 차이가 없습니다.

//typescript
//입고내역 페이지 열 때 : 입력할 때 필요한 기본정보들
//이름, 거래처, 재고수, 정도만 불러오기

  export const input = async (): Promise<DocumentClient.ItemList | undefined> => {
    try {
      const params: DocumentClient.QueryInput = {
        TableName: "material",
        ProjectionExpression:
          "material_id, material_name, account, available_stock, unit_price",
      }
      const { Items } = await dynamo.scan(params).promise()
      console.log('input :>>', Items)
      return Items

    } catch (err) {
      console.log(err)
      throw err
    }
  }
  1. findOrCreate가 없다...
    Sequelize 사용할 때는 꽤 자주 사용하던 기능이었는데 여기서는 없습니다... 한번 쿼리하고, 해당 내용에서 있으면 업데이트, 없으면 생성하라고 조건문들 만들어 주어야 해요.



정리

작성하다보니 내용이 많이 길어졌습니다.
글 상단에 언급한 것처럼 DELRYN'S BLOG님 글에 기본적인 명령어들을 정말 잘 정리해주셔서 저는 실전에서 이해가 필요했던 개념들 위주로 정리해 보았습니다. 사실 이 글에서 실질적으로 필요했던 내용은

  1. 보조 인덱스를 이용해서 쿼리하려면 IndexName을 활용하면 된다.
  2. 보조 인덱스를 이용해서 바로 update하는 것은 불가능하다.
  3. 로컬 보조인덱스를 사용할 거면 테이블 생성시 만들어라
  4. 서비스에 따라 로컬 보조인덱스가 필요한 경우도 있겠지만 글로벌 보조 인덱스가 자유도가 높다.

정도가 될 것 같습니다.
이 후에도 AWS 공식문서로 괴로워하는 많은 분들을 위해 제가 경험한 선에서 내용들을 정리해보도록 하겠습니다. 아직은 주니어 개발자이다보니 내용 중 오류나 부족한 점이 있다면 언제든 댓글 남겨주시면 감사하겠습니다😊



참고

  1. AWS 공식문서 : 보조 인덱스 개념
  2. AWS summit 2016 유튜브 영상
profile
vue, Nuxt, typescript를 사용하는 주니어 개발자입니다. 데이터 분석에도 관심이 있어요 :)

4개의 댓글

comment-user-thumbnail
2021년 4월 7일

제가 찾은 자료 중 제일 도움이 되는 자료네요. 굿

답글 달기
comment-user-thumbnail
2021년 6월 7일

기본키의 개념이 이상한 것 같아요. partition key와 sort key를 함께 쓰는 composite key도 기본키가 될 수 있는 걸로 알고 있습니다. 작성해주신 본문의 내용에서는 composite key와 lsi의 개념이 섞인 것 같습니다.다. https://aws.amazon.com/ko/premiumsupport/knowledge-center/primary-key-dynamodb-table/

1개의 답글
comment-user-thumbnail
2021년 11월 9일

오 정말 좋은 글이에요! 잘 보고 갑니다

답글 달기