[NestJS][DynamoDB][typeDorm] 종합 안내서: 설계, 구현 및 모범 사례

이준규·2023년 4월 9일
0

백엔드

목록 보기
12/13
post-thumbnail

Best Practices가 되고 싶은 포스팅. (수정 중)

NestJs에서 dynamoDB로 서비스 구축하기

목차

  1. DynamoDB 및 파티셔닝 소개
  2. 도커를 사용한 로컬 DynamoDB 설정
  3. DynamoDB 테이블 및 인덱스 디자인
  4. TypeDORM을 사용한 DynamoDB 테이블 및 인덱스 구현
  5. DynamoDB에서 데이터 쿼리 및 조작 구현

1. DynamoDB 및 파티셔닝 소개

먼저 설계를 위한 개념을 잡기 위해 아래의 유튜브 영상을 시청했다.

AWS korea 키 디자인 패턴

  • 테이블: RDB의 테이블 개념과 같다.
  • 아이템: RDB의 row(record, tuple)
  • 어트리뷰트(속성): column
  • pk: 파티션 키. 이퀄연산만 가능
  • sk: 소트 키. 등호, 범위 연산 가능
  • primary key: 유니크해야하는 기본키로 pk와 sk를 묶어서 사용할 수 있고 pk만 사용할 수 있다. 쿼리명령 가능 (조회에 필요하다)
  • dynamoDB는 테이블을 만들면 파티셔닝을 알아서 처리해준다.
  • 파티션은 1000 WCU, 3000 RCU, 10GB 용량 제한
  • pk를 해싱해서 파티션에 나눠 저장한다.
  • 아이템 사이즈는 400kb 제한이지만 최대한 작게 가져가는 것이 좋다.
  • 조회 명령 scan, query
  • scan은 메가바이트 단위인데 OLTP에서는 거의 안쓰고 데이터웨어하우스에서 고려할 수 있을듯.
  • query 1메가바이트 단위로 페이지네이션이 가능.
  • gsi: 일반적으로 RDB에서 사용하는 세컨더리 인덱스 역할로 언제든 추가할 수 있다. pk만 사용할 수 있고, pk + sk 로 사용할 수 있다.
  • lsi: 테이블 생성시에만 생성할 수 있는 인덱스로 sk 만으로 사용하거나 pk+sk로 사용할 수 있다.
  • ttl: 데이터 만료정책, 백업정책 생각해야한다. 기본적으로 1년 보관한다.
  • 복합키, 인덱스 사용시 보통 # 으로 이어 붙인다 레디스의 콜론(:) 같은 역할 (ex. user#1)

인덱스 디자인을 위해서 GSI, LSI 의 동작 방식, 파티셔닝에 대해 공부해야할 것 같아서
데이터 중심 애플리케이션 설계 책의 6. 파티셔닝 을 공부했다.
정리한 github 링크

  • GSI
    글로벌 세컨더리 인덱스.
    쓰기 갱신이 어렵고 읽기에 효율적이다. 1개의 테이블 단위로 전역에서 관리되며 인덱스에 걸리는 파티션에만 요청을 보내 읽기가 가능하다.

  • LSI
    로컬 세컨더리 인덱스.
    쓰기 갱신이 용이하다. 1개의 파티션 단위로 인덱스가 각 파티션 자체에 저장된다.
    읽기 요청시 모든 파티션에 (스캐터/개더 방식)으로 요청을 보내야 한다.

2. 도커를 사용한 로컬 DynamoDB 설정

매번 AWS 콘솔에서 dynamoDB를 세팅할 수 없기에 로컬에서 개발하기 위한 환경구축이 필요하다.

https://jungmina.com/889

위의 블로그의 방법대로 하면 간편하다.
중간에 AWS NoSQL Workbench에서 확인하는 Credential은 코드에서 그대로 사용하게 된다.

  • 요약

    docker pull amazon/dynamodb-local
    docker run -d -p 8000:8000 amazon/dynamodb-local
    AWS NoSQL Workbench 다운받고 실행
    로컬 커넥션 생성
    Data Modeler에서 테이블 설계
    로컬 커넥션에 데이터 모델 커밋
    테이블 생성 완료
    로컬 커넥션의 View Credentials 에 나오는 정보 복사해두기.

큰 단점: LSI 생성 불가.

3. DynamoDB 테이블 및 인덱스 디자인

Primary key

pk+sk 조합으로 설계할 거고 속성 이름도 pk, sk로 사용한다.
여기에 어떤 값이 들어갈 것인가가 설계의 핵심히고 typeDorm에서 필수적이다.

우선, typeDORM에서 쓰일 entity라는 개념이 있다.
아이템의 스키마를 지정하는 것이라고 생각하면 될 것 같다.
하나의 테이블에는 여러 엔티티가 들어갈 수 있는데, 그러면 엔티티별로 파티셔닝이 이루어지도록 유도해야 애플리케이션 레벨에서 CRUD에 효율적일 것이다.

그래서 pk는 entity 이름이 디폴트로 들어가도록 설계했다. (ex. pk: 'entity_a')

sk는 uuid로 이루어진 id 값을 붙인다. (ex. sk: 'entity_a#1604b772-adc0-4212-8a90-81186c57f598')

이유는 두 가지 이다.
1. (pk+sk) 조합은 유일성이 보장되어야한다. 복합 기본키이기 때문,
2. update, delete 요청을 entity의 id 값으로 할 것이기 때문.

GSI & LSI

보조 인덱스는 기존 속성을 그대로 사용할 수도 있고 새로운 속성으로 해시값을 할당할 수 있다.

GSI의 pk는 검색에 필수적으로 사용하게될 속성을 조합한 해시값으로 지정했다.
ex) 속성: 'gsi1pk', 값: 'userId#123#type#100'
애플리케이션 단에서는 userId와 type 속성을 사용하지만 실제 dynamoDB에 물리적으로는 새로운 속성으로 해싱된 값이 저장되고, 인덱스 역할을 하게 된다.

GSI의 sk: createdAt을 사용 'createdAt#1234567890'.
typeDORM에서는 Date.now()를 1000으로 나눈 값을 사용한다.

LSI 는 sk 만 사용할 수 있다.
새로운 속성을 커스텀할 필요 없이 기존 속성을 그대로 사용할 수 있다.
createdAt 속성을 그대로 사용했다.
정렬, 범위검색 등이 자주 필요할 것 같아 createdAt을 그대로 sort key로 사용했다.

이유는
1. 모든 파티션에 요청이 가게될 경우에 효율적인 인덱스이기 때문이다. (스캐터/개더) 전체 조회를 하더라도 시간에 대한 질의는 늘 필요하다. (정렬, 기간 조회)
2. 모든 엔티티에 공통적인 속성이기 때문이다. 엔티티 구분없이 모든 데이터를 범위 질의, 정렬 등이 필요할 때를 고려한다.

4. TypedORM을 사용한 DynamoDB 테이블 및 인덱스 구현

공식문서 의 내용을 꼼꼼히 읽고 따라했다.

Table 객체에 value 들은 실제 속성(컬럼) 이름을 지정하는 것이다.

Entity의 options 에 있는 value 들은 index로 지정한 속성의 값으로 들어갈 포맷이다.

테이블 선언

import { INDEX_TYPE, Table } from '@typedorm/common'

export const isntkyuTable = new Table({
  name: 'isntkyu',
  partitionKey: 'pk',
  sortKey: 'sk',
  indexes: {
    gsi1: {
      partitionKey: 'gsi1pk',
      sortKey: 'gsi1sk',
      type: INDEX_TYPE.GSI
    },
    lsi1: {
      sortKey: 'createdAt',
      type: INDEX_TYPE.LSI
    }
  }
})

엔티티

import {
  Attribute,
  AutoGenerateAttribute,
  AUTO_GENERATE_ATTRIBUTE_STRATEGY,
  Entity,
  INDEX_TYPE
} from '@typedorm/common'

@Entity<TEST>({
  name: 'TEST',
  primaryKey: {
    partitionKey: 'TEST',
    sortKey: 'TEST#{{testId}}'
  },
  indexes: {
    gsi1: {
      partitionKey: 'userId#{{userId}}#type#{{type}}',
      sortKey: 'createdAt#{{createdAt}}',
      type: INDEX_TYPE.GSI
    },
    lsi1: {
      sortKey: {
        alias: 'createdAt'
      },
      type: INDEX_TYPE.LSI
    }
  }
})
export class TEST {
  @Attribute()
  pk = 'TEST'

  @AutoGenerateAttribute({
    strategy: AUTO_GENERATE_ATTRIBUTE_STRATEGY.UUID4
  })
  testId: string

  @Attribute()
  userId: number
  
  @Attribute({
    default: () => false
  })
  isDeleted: boolean

  @Attribute()
  deletedAt: number

  @AutoGenerateAttribute({
    strategy: AUTO_GENERATE_ATTRIBUTE_STRATEGY.EPOCH_DATE,
    autoUpdate: false
  })
  createdAt: number

  @AutoGenerateAttribute({
    strategy: AUTO_GENERATE_ATTRIBUTE_STRATEGY.EPOCH_DATE,
    autoUpdate: true
  })
  updatedAt: number
}

*주의할 점

  • 속성을 그대로 인덱스로 사용하려면 alias 옵션에 속성명을 넣어줘야 한다.

  • primary key, gsi, lsi 각 인덱스에서 같은 속성(createdAt)을 그대로 인덱스를 사용하려고 하면 안된다.
    여러 인덱스가 같은 속성을 사용하도록 하면 put Item 요청시 Duplicate 에러가 난다. (typedorm 단)
    나는 lsi 는 createdAt를 그대로 사용하는 인덱스로 지정했다.

  • @Attribute() 데코레이터 옵션중에 default 속성이 있는데 string, number, boolean이 들어갈 수 있다.
    string, number는 그냥 리터럴 값을 넣어도 되는데, boolean값은 콜백함수를 넣어야 동작함.

  • soft delete를 위한 속성을 추가했고, hard delete는 dynamoDB의 ttl 을 설정할 것이다.

typeDOrm의 entitymanager를 의존성 주입하기 위해
DynamicModule과 Custom Decorator를 구현해서 사용했다.

5. DynamoDB에서 데이터 쿼리 및 조작 구현

공식문서의 내용을 잘 따라하면 될 것 같고
find, count (many select API)와
페이징기능 사용법에 대해서만 설명하겠다.

  • find, count 사용법
    사용법은 같습니다.

  • 첫 번쨰 파라미터는 엔티티 클래스를 넣는다.

  • 두 번째 파라미터는 세 번쨰 파라미터의 queryIndex로 사용할 인덱스에 맞는 속성 조건을 넣는다.

  • primary key나 다른 속성으로 조회하고자 하면 세 번쨰 파라미터에 queryIndex를 사용하지 않으면 된다.

const { items, cursor } = await this.testEntityMananer.find<Test>(
      Test,
      {
        userId: userId,
        type: type,
        isDeleted: false
      },
      {
        queryIndex: 'gsi1',
        cursor: dto.cursor,
        keyCondition: {
          GT: timestampOneMonthAgo
        },
        limit: limit,
        orderBy: QUERY_ORDER.DESC
      }
    )

일반적인 페이지네이션 조회 요청이다.

const { items, cursor } = await this.testEntityMananer.find<Test>(
      Test,
      {
        userSeq: dto.userId,
        type: dto.type
      },
      {
        queryIndex: 'gsi1',
        cursor: dto.cursor,
        keyCondition: {
          GT: dto.timestamp
        },
        limit: dto.limit,
        orderBy: QUERY_ORDER.DESC
      }
    )
  • 쿼리옵션의 cursor 값으로 undefined을 넘겨주면 첫페이지의 데이터가 나온다

  • 마지막 페이지의 응답에는 cursor 속성이 없다.(undefined)

  • 다음페이지를 가르킬 cursor 객체는 primary key와 사용한 인덱스(gsi1) 의 속성들이 들어간다.


필터 옵션

queryIndex를 사용하지 않던 사용하던 간에 dynamoDB의 query 질의에는 필터 옵션이 사용 가능하다.

AWS 콘솔에는 필터라고 써있고 조건을 걸 수 있다.
typeDORM에서 필터를 사용하려면 where 절을 사용하면 된다.

index 조건은 keyCondition
filter는 where

const { items, cursor } = await this.testEntityMananer.find<Test>(
      Test,
      {
        //...
      },
      {
        //..., 
        where: {
        	
        }
      }
    )

...

profile
백엔드

0개의 댓글