다이나모DB 트랜잭션: 유즈 케이스와 예시

김현수·2022년 8월 24일
0

다이나모DB 트랜잭션: 유즈 케이스와 예시

아마존의 다이나모DB는 2012년에 발표된 이래로 새 기능들을 계속 추가해왔습니다. 지금은 상상할 수 없지만, 발표되었을 때 다이나모DB엔 다이나모DB 스트림, 병렬 스캔, 심지어는 세컨더리 인덱스 조차 없었습니다.

다이나모DB에 추가된 기능 중 가장 기대되는 기능은 re:Invent 2018에서 발표된 다이나모DB 트랜잭션입니다. 다이나모DB 트랜잭션으로 다이나모DB에서 아이템들을 배치로 쓰거나 읽을 수 있으며, 모든 요청은 함께 성공하거나 실패할 것입니다.

이 기능 발표는 복잡한 버전관리나 여러 아이템들에 걸쳐 정확하게 작동해야 하는 여러 요청들이 포함된 워크 플로우들을 단순화할 것입니다. 이 글에선 다이나모DB 트랜잭션을 어떻게, 그리고 왜 사용하는지를 살펴볼 것입니다.

다음과 같은 것들을 다룹니다:

  • 다이나모DB 트랜잭션의 배경 지식. 이미 존재했던 배치 작업과의 차이와 트랜잭션의 멱등성
  • 다이나모DB 트랜잭션의 세 가지 일반적인 유즈 케이스

이 글은 다이나모DB 트랜잭션을 다룬 2부작 중 첫번째 글입니다. 이 시리즈의 두번째 글은 다이나모DB 트랜잭션의 성능 테스트를 다루고 있으니 확인해보세요.

시작합시다!

다이나모DB 트랜잭션의 배경 지식

시작하기 위해, 다이나모DB의 몇가지 디테일을 살펴봅시다. 두 가지 영역을 다룹니다.

  1. 트랜잭션을 다루는 API는 무엇이고 배치 API와는 무엇이 다른가?
  2. 트랜잭션 요청과 함께 멱등성 다루기

트랜잭션을 다루는 API는 무엇인가?

트랜잭션을 다루는 API 호출은 TransactWriteItemsTransactGetItems 두 개가 있습니다. 이름에서 추측할 수 있듯, 전자는 한 트랜잭션에서 여러 개의 아이템을 쓰기 작업할 때 사용하고, 후자는 한 트랜잭션에서 여러 개의 아이템을 읽기 작업할 때 사용합니다. 두 트랜잭션 API 모두 한 요청에서 25개의 아이템까지 작업할 수 있습니다.

다이나모DB에는 오랫동안 한 번에 여러 아이템을 작업하는 API로 배치 기반 API가 있었습니다. BatchGetItem을 이용해 한 번에 100개까지의 아이템을 읽어올 수 있고, BatchWriteItem을 이용해 한 번에 25개까지의 아이템을 쓸 수 있습니다.

Batch* API와 Transact* API에는 크게 두 가지의 다른 점이 있습니다. 첫째는 용량 소비에 관한 것입니다. 트랜잭션 API를 사용할 때엔 트랜잭션이 없는 작업을 수행할 때 보다 두 배의 비용을 청구받습니다. 따라서, 1KB를 넘지 않는 두 아이템을 삽입하는 TransactWriteItem 요청을 한다면, 1KB 아이템 2개 * 2개의 트랜잭션으로 4 쓰기 용량 유닛을 청구받게됩니다.

두번째 다른점은 실패했을 경우입니다. 트랜잭션 API는 모든 읽기 혹은 쓰기 작업이 동시에 성공하거나 실패합니다. 배치 API에선 어떤 요청은 성공하고 어떤 요청은 실패할 수 있으며, 에러를 핸들링하는 것은 개발자에 달렸습니다.

트랜잭션 요청이 실패하는 데에는 몇 가지 이유가 있을 수 있습니다. 먼저, 요청의 조건 때문에 요청 중 하나가 실패하는 경우가 있습니다. 쓰기 기반 작업이라면 요청에 조건 표현식을 포함할 수 있습니다. 만일 조건이 만족되지 않는다면 쓰기 작업은 실패하고 모든 작업 역시 실패할 것입니다.

두번째로, 개별 트랜잭션이나 요청에서 아이템이 대체되었다면 트랜잭션이 실패할 수 있습니다. 예를 들어, TransactWriteItems 요청을 통해 작업 중인 아이템에 동시에 TransactGetItems 요청을 보냈을 경우, TransactGetItems은 실패할 것입니다. 이런 종류의 실패는 트랜잭션 충돌(transaction conflict)이라고 알려져 있으며, CloudWatch에서 테이블에서의 트랜잭션 충돌 수 지표를 볼 수 있습니다.

마지막으로 테이블이 충분한 용량을 가지지 못했거나 다이나모DB 서비스가 다운되었을 경우 등의 이유로 트랜잭션이 실패할 수 있습니다.

트랜잭션 요청의 멱등성

TransactWriteItem API를 요청할 때 ClientRequestToken 파라미터를 요청과 함께 보낼 수 있습니다. 이 파라미터를 포함하는 것은 여러 번 요청해도 요청의 멱등성을 보장합니다.

이 것이 어떻게 유용한지를 알아보기 위해 아이템의 속성을 증가시키는 쓰기 요청들을 포함한 TransactWriteItem 요청이 있다고 가정합시다. 네트워크 이슈가 있어 이 작업의 성공 여부를 알 수 없다면, 이는 나쁜 상황입니다. 만일 작업이 성공했다고 가정했지만 실제로는 그렇지 않을 때, 속성의 값은 속성의 값이어야 할 값보다 낮을 것입니다. 만일 작업이 실패했다고 가정했지만 실제로는 그렇지 않을 때, 요청을 다시 보내면 속성의 값은 속성의 값이어야 할 값보다 높아지게 됩니다.

ClientRequestToken이 이를 해결할 수 있습니다. 10분 이내에 요청에 동일한 토큰을 동일한 파라미터에 넣어 보내면, 다이나모DB는 요청의 멱등성을 보장합니다. 요청이 처음 보내졌을 때 성공했다면 다이나모DB는 다시 온 요청을 실행하지 않습니다. 만일 처음 보내졌을 때 요청이 실패했다면, 다이나모DB는 다시 온 요청의 쓰기 작업을 실행합니다.

TransactWriteItems API는 멱등성을 보장하는 유일한 다이나모DB API이기 때문에, 멱등성을 꼭 필요로 할 때라면 단일 아이템에 대해서라도 TransactWriteItems API를 사용할 수 있습니다.

다이나모DB 트랜잭션의 일반적인 유즈 케이스

다이나모DB 트랜잭션의 기본에 대해 알았으니 실제로 어떻게 쓰이는지 살펴봅시다. 다이나모DB 트랜잭션은 비슷한 트랜잭션이 아닌 요청에 비해 두 배의 비용을 청구한다는 점을 기억하세요. 따라서 트랜잭션은 신중히, 반드시 필요할 때만 사용해야 합니다.

트랜잭션을 사용해야할 때는 언제일까요? 제가 좋아하는 아래의 세 가지 예시들을 설명하겠습니다:

  • 여러 속성에서 고유성 유지
  • 카운트를 다루고 복제되는 것을 막기
  • 사용자가 특정 액션을 하는 것을 인가하기

이전 단락에서 다뤘던 멱등성을 필요로 하는 예시는 포함하지 않았습니다. 하지만 멱등성을 필요로 하는 예시는 트랜잭션 API의 좋은 유즈 케이스입니다.

순서대로 각 예시를 살펴봅시다.

여러 속성에서 고유성 유지

다이나모DB에서, 특정 속성이 고유하기를 확인하려면 그 속성을 기본 키 구조에 직접 포함되도록 설계해야 합니다.

쉬운 예시로 애플리케이션에 사용자가 가입을 하는 경우가 있습니다. 애플리케이션에서 유저네임은 고유해야 하며, 따라서 유저네임을 기본 키로 설정했습니다.

Users table with username in primary key

위의 테이블에서, PK와 SK 값은 모두 유저네임을 포함하고 있으며 따라서 고유할 것입니다.

하지만 다른 사람들이 같은 이메일 주소를 가입하는 것을 막기 위해 이메일 주소 역시 고유해야한다면 어떻게 해야 할까요?

이메일을 기본 키 구조에 추가할 수도 있을 것입니다:

Users table with username and email in primary key

이제 유저네임은 PK에 있고, 이메일이 SK에 있습니다.

하지만 이는 올바르게 작동하지 않습니다. 테이블에서 아이템을 고유하게 만드는 것은 파티션 키와 정렬 키의 조합입니다. 이 키 구조를 사용하면, 이메일 주소가 그 유저네임에서 한 번 이상 쓰였는지를 확인하게 됩니다. 이는 유저네임 속성에 대한 고유성을 잃어버리게 된 것이며, 다른 사람이 같은 유저네임에 다른 이메일로 가입할 수 있게 된 것입니다!

만일 유저네임과 이메일 주소 모두 고유한 것을 확인하려면, 각 아이템을 생성해 트랜잭션으로 추가해야 합니다.

트랜잭션 코드는 다음과 같이 쓸 수 있습니다:

const response = client.transact_write_items([
  {
    Put: {
      TableName: "UsersTable",
      Item: {
        PK: { S: "USER#alexdebrie" },
        SK: { S: "USER#alexdebrie" },
        Username: { S: "alexdebrie" },
        FirstName: { S: "Alex" },
        // ...
      },
      ConditionExpression: "attribute_not_exists(PK)",
    },
  },
  {
    Put: {
      TableName: "UsersTable",
      Item: {
        PK: { S: "USEREMAIL#alex@debrie.com" },
        SK: { S: "USEREMAIL#alex@debrie.com" },
      },
      ConditionExpression: "attribute_not_exists(PK)",
    },
  },
]);

그러면 테이블이 다음과 같이 될 것입니다:

Users table with email tracking item

이메일 주소를 저장하는 아이템은 사용자의 속성 중 어떤 것도 가지고 있지 않다는 점을 주목하세요. 이러한 구조는 사용자가 유저네임으로만 접근하고 이메일 주소로는 접근할 수 없을 때에만 할 수 있습니다. 이메일 주소 아이템은 근본적으로는 이메일이 사용되었는지를 기록하는 마커입니다.

만일 사용자가 이메일 주소로도 접근할 수 있게 하고 싶다면, 모든 정보를 두 개의 아이템에 복제해야 합니다. 그러면 테이블이 다음과 같이 됩니다:

Users table with duplicated data

저는 가능하다면 이런 구조를 사용하지 않을 것입니다. 유저 아이템을 업데이트할 때마다 두 아이템 모두를 업데이트하기 위해 트랜잭션을 사용해야 하기 때문이죠. 이는 쓰기 비용을 증가시키고 요청에 대한 지연 속도 역시 높아질 것입니다.

카운트를 다루고 복제되는 것을 막기

트랜잭션이 유용한 두번째 경우는 관련된 아이템들의 카운트를 저장할 때입니다. 어떻게 작동하는지 살펴보겠습니다.

'좋아요'를 한 아이템의 시스템이 있는 소셜 어플리케이션이 있다고 가정합니다. 트위터에서 다른 사람의 트윗을 좋아요 하거나, 레딧에서 다른 사람의 글이나 댓글을 좋아요하거나 추천하는 등의 경우입니다. 깃허브에서 사용자가 이슈에 리액션을 추가할 수 있는 경우도 해당합니다.

이러한 상황에서, 한 사용자가 여러번 추천을 누르는 것을 막기 위해 유저가 추천한 아이템을 저장해야 합니다.

추가로, 아이템을 추천할 수 있을 때, 총 추천 수를 화면에 표시하고 싶다고 합시다. 매번 추천한 아이템을 fetch하는 쿼리 작업을 하는 것보다 아이템 안에 카운터 속성을 저장하는 방식으로 비정규화하는 게 더 효율적입니다.

테이블은 다음과 같을 것입니다:

Reddit posts and likes

이 테이블은 레딧을 단순화한 것입니다. 사용자는 글을 작성할 수 있고, 다른 유저는 글들에 '좋아요'를 할 수 있습니다. 이 테이블에는 5개의 아이템이 있습니다. 2개는 PK와 SK 모두 Post<PostId> 패턴인 글 아이템이고, 세개는 PK는 글과 같은 POST#<PostId> 패턴, SK는 USER#<Username>인 좋아요를 누른 사용자 아이템입니다. 각 글 아이템이 총 추천 수를 저장하고 있는 UpvotesCount 속성을 가지고 있다는 점을 주목하세요.

사용자가 아이템을 추천할 때, 가장 먼저 이전에 추천을 눌렀는지를 확인하고, 아이템의 UpvotesCount를 증가시켜야 합니다. 트랜잭션이 없다면, 이 과정은 두 단계로 진행될 것입니다.

트랜잭션을 사용하면 이를 한 단계만에 할 수 있습니다. 이 트랜잭션을 실행하는 코드는 다음과 같습니다:

const response = client.transact_write_items([
  {
    Put: {
      TableName: "RedditTable",
      Item: {
        PK: { S: "POST#1caa5be06389" },
        SK: { S: "USER#alexdebrie" },
        Username: { S: "alexdebrie" },
      },
      ConditionExpression: "attribute_not_exists(PK)",
    },
  },
  {
    Update: {
      TableName: "UsersTable",
      Key: {
        PK: { S: "POST#1caa5be06389" },
        SK: { S: "POST#1caa5be06389" },
      },
      UpdateExpression: "SET UpvotesCount = UpvotesCount + :incr",
      ExpressionAttributeValues: {
        ":incr": { N: "1" },
      },
    },
  },
]);

위의 트랜잭션은 두개의 쓰기 요청을 포함합니다. 첫째 요청은 주어진 레딧 글에 좋아요를 누른 유저인 alexdebrie를 포함하는 아이템을 삽입하는 것입니다. 이 쓰기 요청은 동일한 키를 가진 아이템이 존재하지 않는 것을 확인한는 조건 표현식을 가지고 있어, alexdebrie가 이미 이 아이템(글)을 추천했는지를 확인할 수 있습니다.

두번째 쓰기 요청은 추천한 글의 UpvotesCount 속성을 증가시키는 업데이트 표현식입니다.

만일 alexdebire가 이미 추천을 한 글이어서 첫째 쓰기 요청이 실패한다면, 모든 트랜잭션은 실패하므로 UpvotesCount 역시 업데이트되지 않습니다.

참고: 이 패턴을 트랜잭션 없이 다른 방법으로 처리할 수도 있습니다. 특정 아이템을 추천한 유저를 포함하는 아이템을 위에 사용한 조건 표현식을 사용해 PutItem 요청을 보낼 수 있습니다. 그리고, 다이나모DB 스트림을 사용해 새로 추가된 아이템을 집계해 부모 아이템의 UpvotesCount를 배치로 증가시킬 수 있습니다.

이러한 접근 방식은 꽤 비슷하므로, 둘 중 어느 것이든 택할 수 있습니다. 다음과 같은 상황이라면 스트림 기반 접근 방법을 추천합니다:

  1. 가능한 한 빠르게 추천 경로를 원한다면 TransactWriteItems보다 빠른 PutItem 요청을 선택할 수 있습니다.
  2. 추천할 수 있는 아이템이 적어서 UpvotesCount를 증가시킬 때 집계하는 여러 개별 추천의 수가 적은 상황

두번째 경우를 두개의 다른 예시를 들어 생각해 봅시다. 전국적인 투표 애플리케이션과 트위터같은 소셜 미디어 사이트가 있습니다. 투표 애플리케이션에는 사용자가 선택할 수 있는 선택지가 적습니다. 이때는 스트림 기반의 방법인 배치로 여러 투표를 집계하고 UpvotesCount를 증가시키는 방법으로 상당한 테이블의 쓰기 용량을 절약할 수 있습니다.

반면 트위터같은 소셜 미디어 사이트는 추천할 수 있는 아이템의 수가 매우 많습니다. 다이나모DB 스트림의 배치 사이즈가 1000개 레코드이더라도 같은 부모 아이템의 여러 레코드들을 가져오는 것은 힘들 것입니다. 이 경우 각 부모 아이템을 개별적으로 증가해야 하므로 쓰기 용량은 같을 것입니다.

권한 관리

트랜잭션의 세번째 유즈 케이스는 인가 혹은 권한 관리 세팅입니다.

거대한 기업에 SaaS 애플리케이션을 서비스하고 있다고 가정합시다. 기업이 애플리케이션을 설치할 때, 사용자의 레벨에는 운영자와 회원 두 가지가 있습니다. 운영자는 새 멤버의 설치를 허용할 수 있지만 일반 회원을 그렇지 못합니다.

트랜잭션 없이는 새 멤버를 추가하기 위해 읽기 과정과 쓰기 과정의 여러 단계를 거쳐야 합니다. 트랜잭션을 사용하면 한 번에 할 수 있습니다.

다음과 같은 테이블이 있다고 가정합시다:

Access control example with Orgs and Users

이 테이블은 기업과 사용자를 포함합니다. 기업은 PK와 SK로 ORG#<OrgName> 패턴을 사용하며, 사용자는 PK로 ORG#<OrgName>, SK로 USER#<Username>를 사용합니다.

기업 아이템이 Admins 속성을 문자열의 집합 타입으로 포함하고 있다는 점을 주목하세요. 이 속성은 어떤 사용자가 운영자이고, 따라서 그들이 새 유저를 만들 수 있다는 것을 저장합니다.

누군가가 기업에서 새 사용자를 만들려고 할 때, 트랜잭션을 이용해 요청한 사람이 운영자인지 안전하게 평가할 수 있습니다.

TransactWriteItems은 다음과 같을 것입니다:

response = client.transact_write_items([
  {
    ConditionCheck: {
      TableName: "AccessControl",
      Key: {
        PK: { S: "ORG#amazon" },
        SK: { S: "ORG#amazon" },
      },
      ConditionExpression: "contains(Admins, :user)",
      ExpressionAttributeValues: {
        ":user": { S: "Charlie Bell" },
      },
    },
  },
  {
    PutItem: {
      TableName: "AccessControl",
      Item: {
        PK: { S: "ORG#amazon" },
        SK: { S: "USER#jeffbarr" },
        Username: { S: "jeffbarr" },
      },
    },
  },
]);

이 예제는 이전 것들과는 조금 다릅니다. ConditionCheck 작업을 사용했음을 주목하세요. 존재하고 있는 기업 아이템을 실제로 수정하지 않고, 특정한 조건(Charlie Bell이 운영자인가)만을 평가하기를 원합니다. 만일 조건이 충족되지 않는다면 모든 트랜잭션은 실패해야 합니다.

애플리케이션에서 인가를 다루는 방법은 여러개가 있고, 이것은 한가지 방법일 뿐입니다. 좋은 점은 이 방식이 유연하다는 것입니다. 테이블의 싱글톤 아이템을 사용해 애플리케이션 전체 인가를 구현할 수도 있고, 테이블 전체 자원에 인가 정보를 추가해 세분화된 자원 기반 인가를 구현할 수도 있습니다.

결론

이 글에서는 다이나모DB 트랜잭션을 다뤘습니다. 첫째로 우리는 다이나모DB 트랜잭션의 기본을 배웠습니다. 어떻게 작동되는지, 보장하는 것은 뭔지, 얼마나 비용이 드는지.

둘째로 실제로 사용되는 다이나모DB 트랜잭션의 예시들을 봤습니다. 여러 속성에서 고유성 유지하기, 카운트를 다루고 복제 막기, 권한 관리 등의 세가지 예제를 살펴봤습니다.

다이나모DB는 유용한 도구입니다. 하지만 성능의 영향 역시 알고 계셔야 합니다. 다음에 작성될 다이나모DB 트랜잭션의 성능 영향 테스트를 다룬 글을 읽어보세요.

다이나모DB 트랜잭션에 대해 더 많은 정보를 얻고 싶다면 다음을 참고하세요:

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

0개의 댓글