다이나모DB의 조건 표현식 이해하기

김현수·2022년 8월 23일
0

다이나모DB의 조건 표현식 이해하기

https://www.alexdebrie.com/posts/dynamodb-condition-expressions/ 를 번역한 글입니다. 오역이 있을 수 있습니다.

다이나모DB로 작업을 하고 있다면 테이블의 아이템을 조작할 때 조건 표현식에 의존할 가능성이 큽니다. 조건 표현식은 이미 존재하는 유저를 덮어쓰거나, 은행 잔고 계좌가 $0 아래로 떨어지게 하거나, 애플리케이션의 모든 유저에게 어드민 권한을 주는 일을 막도록 보장해줍니다.

하지만 조건 표현식의 유용함에도 불구하고 사람들은 조건 표현식을 꽤 자주 오해합니다. 제 직감으로는 다이나모DB가 작동하는 방식과 내리는 선택에 대한 개발되지 않은 정신 모델 때문인 것 같습니다.

이 글에서는 다이나모DB의 조건 표현식에 대한 모든 것을 배웁니다. 먼저, 조건 표현식이 무엇인지에 대한 몇 가지 배경 지식에서 시작할 겁니다. 왜 조건 표현식이 유용한지, 적용되는 API 작업을 살펴봅니다.

두번째로, 어떻게 조건 표현식을 생각해야할 지 논의합니다. 이 단락에서 어떻게 조건 표현식이 알맞게 작동하는지 이해하기 위해 다이나모DB와 확장에 대한 적절한 정신 모델을 세울 것입니다.

마지막으로 조건 표현식을 사용할 때의 일반적인 패턴과 실수들을 살펴봅니다. 이를 통해 다음을 포함한 몇 가지 유용한 예시를 살필 것입니다:

  • 아이템의 존재 유무 확인
  • 여러 개의 고유한 속성 강제하기
  • 비즈니스 규칙 실행하기
  • 어그레게이트에 기반한 규칙 실행하기

시작해봅시다.

다이나모DB 조건 표현식이란 무엇인가?

다이나모DB 조건 표현식의 세부 사항을 배우기 전에, 조건 표현식이 무엇이고 왜 사용하는지 알아봅시다.

ConditionExpression은 쓰기 기반 작업에서 사용할 수 있는 옵션 파라미터입니다. 만일 쓰기 작업에 조건 표현식을 포함하면 쓰기 작업을 실행하기 전에 먼저 조건 표현식이 평가됩니다. 조건 표현식이 false로 평가되면 쓰기 작업은 중단됩니다.

이는 다음과 같은 몇가지 일반적인 패턴에서 유용합니다:

  • 고유성 확보
  • 비즈니스 규칙 검증
  • 존재 확인

조건 표현식을 사용함으로써 다이나모DB에 보내는 요청을 줄일 수 있고 여러 클라이언트가 동시에 다이나모DB에 쓰기 요청을 보낼 때 조건의 경쟁을 피할 수 있습니다.

조건 표현식은 다음의 쓰기 기반 API 요청에서 사용할 수 있습니다:

  • PutItem
  • UpdateItem
  • DeleteItem
  • TransactWriteItems

눈치 빠른 분이라면 한 가지 쓰기 기반 작업- BatchWriteItem -이 없다는 것을 알 수 있습니다. BatchWriteItem에는 조건 표현식을 사용할 수 없습니다. (BatchWriteItem은 여러 결함이 있으며, 그것들을 고치는 것은 제 #awswishlist 중 하나입니다.)

마지막 참고 사항으로, 조건 표현식은 TransactWriteItems 내에서 확장된 기능을 가집니다. ConditionCheck이라는 특별한 액션을 사용할 수 있으며, 이는 실제로 쓰기 작업을 실행하지 않고 조건만 평가할 수 있습니다. 다이나모DB 트랜잭션은 반드시 모두 성공하고나 실패되어야 하기에, ConditionCheck의 실패는 모든 트랜잭션의 실패를 의미합니다.

조건 표현식에 대해 생각하는 방법

조건 표현식의 기초를 알았으니, 어떻게 조건 표현식을 생각해야할 지 논해봅시다. 조건 표현식이 어떻게 평가되는지에 대해서는 약간의 미묘함이 있어서, 다이나모DB가 처음인 사람을 혼란스럽게 합니다.

조건 표현식을 사용하는데 주저하게 만든다면 그나마 다행이지만, 최악의 경우에는 잘못된 로직을 사용해 안 좋은 데이터를 테이블에 삽입하게 될 수도 있습니다.

다이나모DB에서 조건 표현식을 평가하는 과정을 살펴봅시다. 시작하기 전에, 다이나모DB에서의 모든 아이템은 기본 키(primary key)로 고유하게 식별된다는 점을 기억하세요. 동일한 기본 키를 가진 두 개의 아이템이 있을 수 없습니다. 추가로, 각 쓰기 작업은 반드시 기본 키를 포함해야 하며, 이를 통해 어떤 아이템을 조작할 것인지 알 수 있습니다.

조건 표현식을 평가할 때, 다이나모DB는 다음의 단계를 밟습니다:

먼저, 쓰기 작업에 주어진 기본 키를 사용해 (존재한다면) 존재하는 아이템을 식별합니다.

그 다음, 존재하는 아이템(존재하는 아이템이 없다면 null)에 대해 조건 표현식을 평가합니다.

마지막으로, 조건 표현식이 true로 평가되었다면 쓰기 작업을 진행합니다.

중요한 것은 다이나모DB가 조건 표현식을 평가할 때의 평가 대상은 최대 한 개의 아이템이라는 점입니다.

다음 섹션에서 이에 대한 실질적인 의미를 살펴보겠지만, 먼저 다이나모DB가 왜 이러한 방식으로 조건 표현식을 진행하는지 알아봅시다.

다이나모DB는 예측가능한 성능을 중시한다는 점을 기억하세요. 다이나모DB는 데이터베이스가 비었든 10 TB의 데이터를 가지고 있든 쓰기 작업에 동일한 시간이 소요되는 것을 원합니다.

예측 불가능한 성능의 큰 이유 중 하나는 무제한 쿼리입니다. 만일 데이터베이스가 데이터가 많아짐에 따라 증가하는 레코드의 숫자를 검증해야 한다면, 조건부 쓰기 작업은 점점 느려질 것입니다.

이를 피하기 위해 다이나모DB는 제한없는 아이템의 수에 대해 조건을 평가하는 것을 허용하지 않습니다. 사실은, 두개 이상의 아이템에 조건을 평가하는 것을 허용하지 않습니다. 조건은 단일 아이템에 대해서만 실행되고 평가됩니다. 이러한 작업에는 테이블의 크기와는 관계없이 10ms 미만이 소요됩니다.

실제 조건 표현식

이를 이해하는데 문제가 있다면 다음의 예시가 도움이 될 수 있습니다.

Goodreads 같은 책 리뷰 애플리케이션을 애플리케이션이 있다고 가정합시다. 유저는 가입할 수 있고 책에 리뷰를 남길 수 있습니다. 다른 유저들은 집계된 리뷰를 보기 위해 책을 탐색할 수 있습니다.

리뷰들을 테이블에 저장할 때 아마 다음과 같은 기본 키 패턴을 쓰기로 결정할 수 있습니다:

  • PK: user#${username}#book#${book}
  • SK: ${timestamp}

이는 제가 추천하는 패턴은 아닌데, 잠시 뒤에 그 이유를 말씀드리겠습니다.

이렇게 함으로써, 테이블에는 다음과 같은 데이터를 갖게 됩니다:

유저 alexdebrie가 Goldilocks, To Kill a Mockingbird, Dune을 리뷰한 것을 볼 수 있습니다.

alexdebrie가 Goldilocks의 새 리뷰를 추가하려 하면, 다음과 같은 PutItem 요청을 할 것입니다:

const response = await client
  .putItem({
    TableName: "BookReviews",
    Item: {
      PK: "user#alexdebrie#book#goldilocks",
      SK: "2021-01-21T14:59:21",
      //  ...additional properties ...
    },
    ConditionExpression: "attribute_not_exists(PK)",
  })
  .promise();

ConditionExpression 파라미터의 attribute_not_exists(PK) 값을 주목하십시오. 이것의 의미가 "오직 이 PK 값을 가진 아이템이 없을 때에만 쓰기 작업 실행하기"라고 생각하실 수도 있습니다. 하지만, 그것은 틀렸습니다! 다이나모DB가 먼저 기본 키의 값을 가진 단일 아이템을 먼저 찾고, 그 다음 비교를 한다는 것을 기억하십시오.

이 경우에, 우리의 새 아이템과 같은 기본 키(PK와 SK)를 가진 아이템은 존재하지 않습니다. 그렇기 때문에, 조건 표현식은 null 항목에 대해 평가됩니다. null 아이템은 PK 값을 가지고 있지 않기 때문에, 조건 표현식은 true로 평가되고 쓰기 작업이 실행됩니다.

여기서 가장 핵심적인 문제는 기본 키에 비결정적 요소(타임스탬프)를 추가했다는 것입니다. 타임스태프나 UUIDs같은 속성을 다이나모 기본 키에 사용하는 것은 유용할 수 있지만, 해당 항목의 다른 속성에 고유성을 원하지 않는 경우에만 사용해야 합니다.

예시를 고쳐봅시다. 기본 키 패턴을 다음과 같이 수정합니다:

  • PK: user#${username}
  • SK: book#${book}

기본 키에서 비결정적인 요소(타임스탬프)를 제거했고, 이를 통해 아이템을 찾고 고유하게 식별하기 쉬워졌다는 점을 주목하십시오.

수정된 PutItem 요청은 다음과 같습니다:

const response = await client
  .putItem({
    TableName: "BookReviews",
    Item: {
      PK: "user#alexdebrie",
      SK: "book#goldilocks",
      // ...additional properties ...
    },
    ConditionExpression: "attribute_not_exists(PK)",
  })
  .promise();

아래 표를 사용해 진행 단계를 생각해보겠습니다:

먼저 alexdebrie가 책 Goldilocks를 리뷰한 존재하는 아이템과 매칭됩니다. 그리고 조건 표현식을 평가합니다. 매칭된 아이템에 PK 속성이 존재하기 때문에, 조건 표현식은 false로 평가되고 쓰기 작업은 거부됩니다.

이 베이직 모델을 기억하면서 조건 표현식의 일반적인 패턴들을 살펴보겠습니다.

일반적인 패턴과 실수들

다이나모DB에서 조건 표현식을 효과적으로 사용할 수 있는 몇 가지 유용한 유즈 케이들을 살펴봅시다. 각 유즈 케이스는 가장 흔히 볼 수 있는 경우를 설명합니다. 주어진 패턴에서 흔하게 발생하는 실수들도 함께 설명하겠습니다.

1. 아이템의 존재 유무 확인

첫번째로, 가장 흔한 조건 표현식의 유즈 케이스는 특정한 아이템의 존재 유무를 확인하기 위해 사용하는 것입니다. 아이템을 생성할 때 데이터를 덮어 쓰는 것을 피하기 위해 동일한 기본 키가 이미 존재하지 않는다는 것을 확인하고 싶을 때가 종종 있습니다. 아이템을 업데이트하거나 삭제할 때, 애플리케이션에 예기치 않은 상태를 방지하기 위해 아이템이 먼저 존재하는지를 확인하고 싶을 수 있습니다.

이를 위해서 조건 표현식에 attribute_exists()attribute_not_exists() 함수를 사용할 수 있습니다.

존재하는 아이템을 덮어 쓰지 않기 위해 조건 표현식을 attribute_not_exists(pk)와 같이 쓸 수 있습니다.

아이템을 조작하기 전에 존재하는 것을 확인하기 위해 조건 표현식을 attribute_exists(pk)와 같이 쓸 수 있습니다.

여기서 발생하는 흔한 실수는 조건 표현식에 attribute_not_exists(pk) AND attribute_not_exists(sk) 과 같은 여러 개의 식을 사용하는 것입니다. 이 여러 개의 식이 나쁜 영향을 끼치지는 않지만 두번째 식은 관련이 없습니다. 다이나모DB는 비교하기 위한 아이템을 먼저 식별하고, 조건 표현식을 실행한다는 것을 기억하십시오. 다이나모DB가 아이템을 찾으면, 그것은 pk와 sk를 둘 다 가지고 있다는 것입니다(기본 키 구조에 따라 다를 수 있습니다).

다시 한 번, 이는 돌아가는 코드에 악영향을 끼치진 않습니다. 하지만, 저는 명확하게 하기 위해 관련 없는 식을 지우는 것을 선호합니다. 그렇게 하면 나중에 조건 표현식을 수정해야 할 때 신경을 덜 써도 됩니다.

2. 여러 고유 속성 적용

비슷하지만, 개발자가 단일 아이템의 두 개의 다른 속성에 고유성을 다루려 할 때 더 치명적인 문제입니다. 주의를 기울이지 않으면 두 속성의 고유성을 처리하지 않는 방식으로 구현하게 될 수도 있습니다.

여기에 사용하는 표준 예시는 유저 생성 워크플로우입니다. 유저네임이 고유해야 하고 이메일 주소가 다른 유저에 사용되지 않았다는 점을 확인해야 한다고 가정합시다.

저는 pk를 유저네임으로, sk를 이메일로 하는 기본 키 패턴을 사용할 것이며, 다음의 다이나모DB 워크벤치에서 모델을 확인할 수 있습니다:

alexdebriealexdebrie1@gmail.com(실제 이메일 주소입니다. 질문이 있다면 이메일 보내주세요!)로 가입했고, lukeskywalkerrebel@gmail.com를 이메일 주소로 가지고 있다는 점을 주목하십시오.

새 유저가 alexdebrie를 유저네임으로, 이메일 주소를 evilalex@hotmail.com로 가입하려 한다고 가정합시다. PutItem 요청은 다음과 같을 것입니다:

const response = await client
  .putItem({
    TableName: "MyTable",
    Item: {
      PK: "user#alexdebrie",
      SK: "email#evilalex@hotmail.com",
      // ...additional properties ...
    },
    ConditionExpression:
      "attribute_not_exists(PK) AND attribute_not_exists(SK)",
  })
  .promise();

조건 표현식이 마지막 예시(attribute_not_exists(PK) AND attribute_not_exists(SK))와 같다는 점을 주목하세요. 하지만 이번에는 훨씬 나쁜 결과를 가져올 것입니다!

이 쓰기 작업을 평가할 때, 다이나모DB는 주어진 기본 키를 가진 아이템을 찾을 것입니다. 같은 PK 값을 가진 아이템이 있기는 하지만, 같은 PK와 같은 SK를 가진 아이템은 없습니다. 따라서 어떤 아이템도 찾아지지 않을 것입니다. 조건 표현식을 평가할 때, 매칭된 아이템이 없기 때문에 attribute_not_exists() 식은 실패할 것입니다.

여러 개의 요소(이 예시에선 유저네임과 이메일 주소)를 기본 키에 결합할 때, 두 개의 요소의 조합이 고유하다는 점만을 확인할 수 있습니다.

만일 각 요소가 고유한 것을 확인하려면, 두 개의 개별 아이템을 만들어 다이나모DB 트랜잭션을 통해 둘 다 존재하지 않는다고 확인해야 합니다. 이 작업의 예시는 다이나모DB 트랜잭션을 다룬 제 글에서 확인할 수 있습니다.

3. 비즈니스 규칙 실행

기초적인 고유성에 더해, 다이나모DB에 쓰기 작업을 실행할 때 애플리케이션에 특정한 비즈니스 규칙을 실행하는 것을 원할 수 있습니다. 조건 표현식을 사용하는 것은 아이템을 검색하여 비교하고 애플리케이션 코드에 비즈니스 로직을 평가하는 것을 다루는 쉬운 방법입니다.

지금까지, 우리는 오직 존재 함수(attribute_exists() and attribute_not_exists())만을 사용했습니다. 하지만 조건문에는 다른 함수나 수학 식 또한 사용할 수 있습니다.

예시로, 사용자가 잔고가 있는 계좌를 가진 은행 애플리케이션이 있다고 가정합시다. 사용자가 트랜잭션을 시작하면, 그것을 받아들이기 전에 잔고가 $0 아래로 내려가진 않는지 확인해야 합니다. 이를 위해 다음과 같은 UpdateItem 작업을 실행할 수 있습니다.

const response = await client
  .updateItem({
    TableName: "MyTable",
    Key: {
      PK: "user#alexdebrie",
      SK: "account#0123456789",
    },
    ConditionExpression: "#balance > :amount",
    UpdateExpression: "SET #balance = #balance - :amount",
    ExpressionAttributeNames: {
      "#balance": "balance",
    },
    ExpressionAttributeValues: {
      ":amount": { N: "<amount of transaction>" },
      ":zero": { N: "0" },
    },
  })
  .promise();

이 API 호출에서, 트랜잭션의 양에 따른 계좌의 현재 잔고를 줄이려 합니다. 하지만, 그것을 하기 전에 현재 잔고가 트랜잭션 양보다 큰지를 ConditionExpression을 평가해 잔고가 $0 아래로 내려가지 않도록 합니다.

이를 다양한 유즈 케이스에서 사용할 수 있습니다:

  • 아이템이 만료되지 않았다는 것을 확인하기 위해 타임스탬프를 비교하기
  • 특정 사용자가 주어진 아이템에 대한 권한이 있는지를 확인하기
  • 아이템의 최신 버전을 작업하는 것을 확인하기

4. 집계에 기반한 규칙 실행

마지막 예시는 조금 까다롭습니다. 만약 여러 아이템의 집계에 기반한 조건을 실행해야 한다면 어떻게 해야 할까요?

가장 흔한 예시로, 특정한 관계에서 아이템의 수를 제한하는 경우가 있습니다. 당신의 SaaS 제품을 사용하는 기업은 오직 10명의 유저만을 초대할 수 있다거나, 깃허브 유저는 오직 5개의 프라이빗 저장소를 가질 수 있다는 등의 경우가 이에 해당합니다. 이는 레코드의 수의 최댓값 혹은 최솟값이 될 수 있습니다.

다이나모DB는 단일 아이템에 대해서만 비교를 실행하기 때문에, 이 유즈 케이스를 다룰 때는 조금 더 창의성을 발휘해야 합니다. 비즈니스 로직을 만족하기 위해 각 아이템에 대한 집계를 갖고 있어야 합니다.

위의 예시 중 하나를 들어봅시다. SaaS 제품에 가입한 기업이 있고, 그 기업이 고른 플랜에 따라 사용할 수 있는 사용자의 수가 제한된다고 가정합시다. 새 유저를 만들 때, 제한을 넘지 않았는지 확인해야 합니다.

이를 다루기 위해, 부모인 기업 아이템에 ActiveUsersAllowedUsers라는 두개의 속성을 포함시켜야 합니다. 기업은 가입을 하거나 플랜을 바꿀 수 있습니다. 개발자는 기업이 고른 플랜에 해당하는 허용된 값을 AllowedUsers 프로퍼티에 업데이트합니다.

테이블은 이렇게 설계될 것입니다:

이 테이블의 데이터 하위 집합으로, 몇 유저 아이템과 함께 BigCorp와 TinyInc 라는 두 개의 기업 아이템을 볼 수 있습니다. BigCorp와 TinyInc 모두 현재 값을 나타내는 ActiveUsersAllowedUsers 집계를 갖고 있다는 점을 주목하세요.

기업에 새 유저를 초대하려 시도할 때, ActiveUsers 카운트를 늘리는 다이나모DB 트랜잭션을 감싸 AllowedUsers 카운트를 넘지 않았는지 확인할 수 있습니다.

트랜잭션은 다음과 같을 것입니다:

const response = await client
  .transactWriteItems({
    TransactItems: [
      {
        Update: {
          TableName: "SaaSTable",
          Key: {
            PK: { S: "ORG#BigCorp" },
            SK: { S: "ORG#BigCorp" },
          },
          ConditionExpression: "#active < #allowed",
          UpdateExpression: "SET #active = #active + :inc",
          ExpressionAttributeNames: {
            "#active": "ActiveUsers",
            "#allowed": "AllowedUsers",
          },
          ExpressionAttributeValues: {
            ":inc": { N: "1" },
          },
        },
      },
      {
        Put: {
          TableName: "SaaSTable",
          Item: {
            PK: { S: "ORG#BigCorp" },
            SK: { S: "USER#newnick" },
            // ... additional attributes ...
          },
          ConditionExpression: "attribute_not_exists(PK)",
        },
      },
    ],
  })
  .promise();

첫 요청이 ActiveUsersAllowedUsers보다 적은지를 확인하는 ConditionExpression를 가지고 있다는 점을 주목하십시오. 만일 첫 요청의 조건 표현식이 통과되면, ActiveUsers의 카운트를 1씩 증가시킬 것입니다.

이는 다이나모DB의 모든 집계와 일치합니다. 개발자는 스스로 집계를 유지해야 합니다. 이는 특히 관계형 데이터베이스에서 집계를 사용했을 경우 번거롭게 느껴질 수 있습니다. 하지만 이 방법이 지속적인 성능과 확장 결정을 명시적으로 내리는 다이나모DB의 철학과 일치합니다. 데이터가 커질 수록, 집계는 점점 느려질 것입니다.

결론

조건 표현식은 다이나모DB의 강력한 부분이지만, 어떻게 작동하는지에 대한 확실한 정신 모델이 없다면 어려워보일 수 있습니다.

이 글에선 다이나모DB의 조건 표현식이 무엇이며 어떻게 사용해야하는지를 살펴보았습니다. 그리고 조건 표현식이 어떻게 그리고 왜 그렇게 작동하는지에 대해 이해하기 위한 모델을 세웠습니다. 조건 표현식이 다이나모DB의 철학에 부합하는 방법과 어떤 크기에서도 지속적인 성능을 제공하는 방법을 살펴보았습니다. 마지막으로, 애플리케이션에서 조건 표현식을 사용하는 일반적인 예시를 봤습니다.

만일 이 글에 질문이나 의견이 있다면, 아래에 메모를 남기시거나 직접 이메일을 보내주세요!

0개의 댓글