

# DynamoDB에서 동시 업데이트를 처리하는 모범 사례
<a name="BestPractices_ImplementingVersionControl"></a>

분산 시스템에서는 여러 프로세스 또는 사용자가 동시에 동일한 데이터를 수정하려고 시도할 수 있습니다. 동시성 제어가 없으면 이러한 동시 쓰기로 인해 업데이트 손실, 일관되지 않은 데이터 또는 레이스 조건이 발생할 수 있습니다. DynamoDB는 동시 액세스를 관리하고 데이터 무결성을 유지하는 데 도움이 되는 여러 메커니즘을 제공합니다.

**참고**  
`UpdateItem`과 같은 개별 쓰기 작업은 원자성이며 동시성에 관계없이 항상 항목의 최신 버전에서 작동합니다. 다른 프로세스에서 읽기와 쓰기 간에 항목을 수정할 수 있으므로 애플리케이션이 항목을 읽고 읽기 값(read-modify-write 주기)을 기반으로 다시 기록해야 하는 경우 잠금 전략이 필요합니다.

동시 업데이트를 처리하기 위한 두 가지 기본 전략이 있습니다.
+ **낙관적 잠금** - 충돌이 드물다고 가정합니다. 동시 액세스를 허용하고 조건부 쓰기를 사용하여 쓰기 시 충돌을 감지합니다. 충돌이 감지되면 쓰기가 실패하고 애플리케이션이 다시 시도할 수 있습니다.
+ **비관적 잠금** - 충돌이 발생할 가능성이 있다고 가정합니다. 리소스를 수정하기 전에 리소스에 대한 독점 액세스 권한을 획득하여 동시 액세스를 방지합니다. 다른 프로세스는 잠금이 해제될 때까지 기다려야 합니다.

다음 표에는 DynamoDB에서 사용할 수 있는 접근 방식이 요약되어 있습니다.


| 접근 방식 | 메커니즘 | 최적의 용도 | 
| --- | --- | --- | 
| 낙관적 잠금 | 버전 속성 \$1 조건부 쓰기 | 낮은 경합, 저렴한 재시도 | 
| 비관적 잠금(트랜잭션) | TransactWriteItems | 다중 항목 원자성, 중간 경합 | 
| 비관적 잠금(클라이언트 잠금) | 리스 및 하트비트가 있는 전용 잠금 테이블 | 장기 실행 워크플로, 분산 조정 | 

# 버전 번호를 이용한 낙관적 잠금
<a name="BestPractices_OptimisticLocking"></a>

낙관적 잠금은 쓰기 시 충돌을 방지하는 대신 감지하는 전략입니다. 각 항목에는 업데이트마다 증가하는 버전 속성이 포함되어 있습니다. 항목을 업데이트할 때 버전 번호가 애플리케이션이 마지막으로 읽은 값과 일치하는지 확인하는 [조건 표현식](Expressions.ConditionExpressions.md)을 포함합니다. 다른 프로세스가 그 동안 항목을 수정한 경우 조건이 실패하고 DynamoDB가 `ConditionalCheckFailedException`을 반환합니다.

## 낙관적 잠금을 사용해야 하는 경우
<a name="BestPractices_OptimisticLocking_WhenToUse"></a>

다음과 같은 경우 낙관적 잠금이 적합합니다.
+ 여러 사용자 또는 프로세스가 동일한 항목을 동시에 업데이트하려고 시도할 수 있지만 충돌이 드물게 발생하는 경우.
+ 실패한 쓰기를 재시도해도 애플리케이션에 비용 부담이 적은 경우.
+ 분산 잠금 관리의 오버헤드와 복잡성을 피하고 싶은 경우.

일반적인 예로는 전자 상거래 인벤토리 업데이트, 협업 편집 플랫폼, 금융 거래 레코드 등이 있습니다.

## 단점
<a name="BestPractices_OptimisticLocking_Tradeoffs"></a>

**높은 경합으로 오버헤드 재시도**  
동시성이 높은 환경에서는 충돌 가능성이 증가하여 재시도 횟수와 쓰기 비용이 증가할 수 있습니다.

**구현 복잡성**  
항목에 버전 관리를 추가하고 조건부 확인을 처리하면 애플리케이션 로직이 더 복잡해집니다. AWS SDK for Java v2 Enhanced Client는 [https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/ddb-en-client-extensions.html#ddb-en-client-extensions-VRE](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/ddb-en-client-extensions.html#ddb-en-client-extensions-VRE) 주석을 통해 기본 지원을 제공하며, 주석은 자동으로 버전 번호를 관리합니다.

## 패턴 설계
<a name="BestPractices_OptimisticLocking_PatternDesign"></a>

각 항목에 버전 속성을 포함합니다. 다음은 간단한 스키마 설계입니다.
+ 파티션 키 - 각 항목의 고유 식별자입니다(예: `ItemId`).
+ 속성:
  + `ItemId` – 항목의 고유 식별자입니다.
  + `Version` - 항목의 버전 번호를 나타내는 정수입니다.
  + `QuantityLeft` - 항목의 남은 재고입니다.

항목이 처음 생성되면 `Version` 속성이 1로 설정됩니다. 매번 업데이트 시 버전 번호가 1씩 증가합니다.


| ItemID(파티션 키) | 버전 | QuantityLeft | 
| --- | --- | --- | 
| 바나나 | 1 | 10 | 
| 사과 | 1 | 5 | 
| 오렌지 | 1 | 7 | 

## 구현
<a name="BestPractices_OptimisticLocking_Implementation"></a>

낙관적 잠금을 구현하려면 다음 단계를 따르세요.

1. 항목의 현재 버전을 읽습니다.

   ```
   def get_item(item_id):
       response = table.get_item(Key={'ItemID': item_id})
       return response['Item']
   
   item = get_item('Bananas')
   current_version = item['Version']
   ```

1. 버전 번호를 확인하는 조건 표현식을 사용하여 항목을 업데이트합니다.

   ```
   def update_item(item_id, qty_bought, current_version):
       try:
           response = table.update_item(
               Key={'ItemID': item_id},
               UpdateExpression="SET QuantityLeft = QuantityLeft - :qty, Version = :new_v",
               ConditionExpression="Version = :expected_v",
               ExpressionAttributeValues={
                   ':qty': qty_bought,
                   ':new_v': current_version + 1,
                   ':expected_v': current_version
               },
               ReturnValues="UPDATED_NEW"
           )
           return response
       except ClientError as e:
           if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
               print("Version conflict: another process updated this item.")
           raise
   ```

1. 새 읽기로 다시 시도하여 충돌을 처리합니다.

   각 재시도에는 추가 읽기가 필요하므로 총 재시도 횟수를 제한합니다.

   ```
   def update_with_retry(item_id, qty_bought, max_retries=3):
       for attempt in range(max_retries):
           item = get_item(item_id)
           try:
               return update_item(item_id, qty_bought, item['Version'])
           except ClientError as e:
               if e.response['Error']['Code'] != 'ConditionalCheckFailedException':
                   raise
               print(f"Retry {attempt + 1}/{max_retries}")
       raise Exception("Update failed after maximum retries.")
   ```

Java 애플리케이션의 경우 AWS SDK for Java v2 Enhanced Client는 [https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/ddb-en-client-extensions.html#ddb-en-client-extensions-VRE](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/ddb-en-client-extensions.html#ddb-en-client-extensions-VRE) 주석을 통해 내장된 낙관적 잠금 지원을 제공하여 버전 번호를 자동으로 관리합니다.

조건 표현식에 대한 자세한 내용은 [DynamoDB 조건 표현식 CLI 예제](Expressions.ConditionExpressions.md) 섹션을 참조하세요.

# DynamoDB 트랜잭션을 사용한 비관적 잠금
<a name="BestPractices_PessimisticLocking"></a>

DynamoDB [트랜잭션](transactions.md)은 그룹화된 작업에 대한 양자택일 접근 방식을 제공합니다. `TransactWriteItems`를 사용하면 DynamoDB가 트랜잭션의 모든 항목을 모니터링합니다. 트랜잭션 중에 다른 작업에 의해 항목이 수정되면 전체 트랜잭션이 취소되고 DynamoDB가 `TransactionCanceledException`을 반환합니다. 이 동작은 충돌하는 동시 수정이 사후에 감지되지 않기 때문에 비관적 동시성 제어의 한 형태를 제공합니다.

## 잠금에 트랜잭션을 사용해야 하는 경우
<a name="BestPractices_PessimisticLocking_WhenToUse"></a>

트랜잭션은 다음과 같은 경우에 적합합니다.
+ 동일한 테이블 내에서 또는 여러 테이블 간에 여러 항목을 원자적으로 업데이트해야 합니다.
+ 비즈니스 로직에는 모든 변경 사항이 성공하거나 적용되지 않는 모든 또는 양자택일 의미 체계가 필요합니다.

일반적인 예로는 계정 간 자금 이체, 인벤토리 및 주문 테이블을 모두 업데이트하는 주문, 게임 내 플레이어 간 항목 교환 등이 있습니다.

## 단점
<a name="BestPractices_PessimisticLocking_Tradeoffs"></a>

**더 높은 쓰기 비용**  
최대 1KB 항목의 경우 트랜잭션은 항목당 2WCU(준비할 항목 하나, 커밋할 항목 하나)를 소비하는 반면 표준 쓰기의 경우 1WCU를 소비합니다.

**항목 제한**  
단일 트랜잭션에는 하나 이상의 테이블에 걸쳐 최대 100개의 작업이 포함될 수 있습니다.

**충돌 민감도**  
트랜잭션의 항목이 다른 작업에 의해 수정되면 전체 트랜잭션이 실패합니다. 경쟁이 많은 시나리오에서는 취소가 자주 발생할 수 있습니다.

## 구현
<a name="BestPractices_PessimisticLocking_Implementation"></a>

다음 예제에서는 `TransactWriteItems`를 사용하여 두 항목 간에 인벤토리를 원자적으로 전송합니다. 다른 프로세스가 트랜잭션 중에 두 항목을 수정하면 전체 작업이 롤백됩니다.

```
import boto3

client = boto3.client('dynamodb')

def transfer_inventory(source_id, target_id, quantity):
    try:
        client.transact_write_items(
            TransactItems=[
                {
                    'Update': {
                        'TableName': 'Inventory',
                        'Key': {'ItemID': {'S': source_id}},
                        'UpdateExpression': 'SET QuantityLeft = QuantityLeft - :qty',
                        'ConditionExpression': 'QuantityLeft >= :qty',
                        'ExpressionAttributeValues': {
                            ':qty': {'N': str(quantity)}
                        }
                    }
                },
                {
                    'Update': {
                        'TableName': 'Inventory',
                        'Key': {'ItemID': {'S': target_id}},
                        'UpdateExpression': 'SET QuantityLeft = QuantityLeft + :qty',
                        'ExpressionAttributeValues': {
                            ':qty': {'N': str(quantity)}
                        }
                    }
                }
            ]
        )
        return True
    except client.exceptions.TransactionCanceledException as e:
        print(f"Transaction canceled: {e}")
        return False
```

이 예제에서 조건 표현식은 인벤토리가 충분한지 확인하지만 버전 속성은 필요하지 않습니다. DynamoDB는 준비 단계와 커밋 단계 사이의 다른 작업에 의해 트랜잭션의 항목이 수정된 경우 트랜잭션을 자동으로 취소합니다. 이는 비관적 동시성 제어를 제공합니다. 트랜잭션 자체가 충돌하는 동시 수정을 방지합니다.

**참고**  
버전 확인을 조건 표현식으로 추가하여 트랜잭션을 낙관적 잠금과 결합할 수 있습니다. 이는 추가 보호 계층을 제공하지만 트랜잭션이 충돌을 감지하는 데 필요하지 않습니다.

자세한 내용은 [DynamoDB Transactions를 사용하여 복잡한 워크플로 관리](transactions.md) 섹션을 참조하세요.

# DynamoDB 잠금 클라이언트를 사용한 분산 잠금
<a name="BestPractices_DistributedLocking"></a>

기존 잠금-획득-릴리스 의미 체계가 필요한 애플리케이션의 경우 DynamoDB 잠금 클라이언트는 DynamoDB 테이블을 잠금 저장소로 사용하여 분산 잠금을 구현하는 오픈 소스 라이브러리입니다. 이 접근 방식은 여러 애플리케이션 인스턴스에서 외부 리소스(예: S3 객체 또는 공유 구성)에 대한 액세스를 조정해야 할 때 유용합니다.

잠금 클라이언트는 오픈 소스 [Java 라이브러리](https://github.com/awslabs/amazon-dynamodb-lock-client)로 사용할 수 있습니다.

## 작동 방식
<a name="BestPractices_DistributedLocking_HowItWorks"></a>

잠금 클라이언트는 전용 DynamoDB 테이블을 사용하여 잠금을 추적합니다. 각 잠금은 다음과 같은 키 속성을 가진 항목으로 표시됩니다.
+ 잠기는 리소스를 식별하는 파티션 키입니다.
+ 잠금의 유효 기간을 지정하는 리스 기간입니다. 잠금 홀더가 충돌하거나 응답하지 않을 경우 리스 기간이 지나면 잠금이 자동으로 만료됩니다.
+ 잠금 소유자가 리스를 연장하기 위해 주기적으로 보내는 하트비트입니다. 이렇게 하면 소유자가 아직 능동적으로 처리 중인 동안 잠금이 만료되지 않습니다.

잠금 클라이언트는 조건부 쓰기를 사용하여 한 번에 하나의 프로세스만 잠금을 획득할 수 있도록 합니다. 잠금이 이미 보류된 경우 호출자는 대기했다가 다시 시도하거나 즉시 실패하도록 선택할 수 있습니다.

## 잠금 클라이언트를 사용해야 하는 경우
<a name="BestPractices_DistributedLocking_WhenToUse"></a>

잠금 클라이언트는 다음과 같은 경우에 적합합니다.
+ 여러 애플리케이션 인스턴스 또는 마이크로서비스에서 공유 리소스에 대한 액세스를 조정해야 합니다.
+ 중요한 섹션은 장기 실행(초\$1분)이며 충돌 시 전체 작업을 재시도하는 데 비용이 많이 듭니다.
+ 프로세스 실패를 정상적으로 처리하려면 자동 잠금 만료가 필요합니다.

일반적인 예로는 분산 워크플로 오케스트레이션, 여러 인스턴스에서 CRON 작업 조정, 공유 외부 리소스에 대한 액세스 관리 등이 있습니다.

## 단점
<a name="BestPractices_DistributedLocking_Tradeoffs"></a>

**추가 인프라**  
잠금 작업을 위한 추가 읽기 및 쓰기 용량과 함께 잠금 관리를 위한 전용 DynamoDB 테이블이 필요합니다.

**클럭 종속성**  
잠금 만료는 타임스탬프에 의존합니다. 클라이언트 간의 상당한 클럭 스큐로 인해 특히 짧은 리스 기간 동안 예기치 않은 동작이 발생할 수 있습니다.

**교착 상태 위험**  
애플리케이션이 여러 리소스에 대한 잠금을 획득하는 경우 교착 상태를 방지하려면 일관된 순서로 획득해야 합니다. 리스 기간은 응답하지 않는 소유자로부터 잠금을 자동으로 해제하여 안전망을 제공합니다.

## 구현
<a name="BestPractices_DistributedLocking_Implementation"></a>

다음 예제에서는 DynamoDB 잠금 클라이언트를 사용하여 잠금을 획득하고 해제하는 방법을 보여줍니다.

```
import java.io.IOException;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;

final DynamoDbClient dynamoDB = DynamoDbClient.builder()
    .region(Region.US_WEST_2)
    .build();

final AmazonDynamoDBLockClient lockClient = new AmazonDynamoDBLockClient(
    AmazonDynamoDBLockClientOptions.builder(dynamoDB, "Locks")
        .withTimeUnit(TimeUnit.SECONDS)
        .withLeaseDuration(10L)
        .withHeartbeatPeriod(3L)
        .withCreateHeartbeatBackgroundThread(true)
        .build());

// Try to acquire a lock on a resource
final Optional<LockItem> lock =
    lockClient.tryAcquireLock(AcquireLockOptions.builder("my-shared-resource").build());

if (lock.isPresent()) {
    try {
        // Perform operations that require exclusive access
        processSharedResource();
    } finally {
        // Always release the lock when done
        lockClient.releaseLock(lock.get());
    }
} else {
    System.out.println("Failed to acquire lock.");
}

lockClient.close();
```

**중요**  
처리 로직에서 예외가 발생하더라도 잠금이 해제되도록 항상 `finally` 블록에서 잠금을 해제합니다. 릴리스되지 않은 잠금은 리스가 만료될 때까지 다른 프로세스를 차단합니다.

조건부 쓰기를 직접 사용하여 잠금 클라이언트 라이브러리 없이 간단한 잠금 메커니즘을 구현할 수도 있습니다. 다음 예제에서는 `UpdateItem`을 조건 표현식과 함께 사용하여 잠금을 획득하고 `DeleteItem`으로 해제합니다.

```
from datetime import datetime, timedelta
from boto3.dynamodb.conditions import Attr

def acquire_lock(table, resource_name, owner_id, ttl_seconds):
    """Attempt to acquire a lock. Returns True if successful."""
    expiry = (datetime.now() + timedelta(seconds=ttl_seconds)).isoformat()
    now = datetime.now().isoformat()
    try:
        table.update_item(
            Key={'LockID': resource_name},
            UpdateExpression='SET #owner = :owner, #expiry = :expiry',
            ConditionExpression=Attr('LockID').not_exists() | Attr('ExpiresAt').lt(now),
            ExpressionAttributeNames={'#owner': 'OwnerID', '#expiry': 'ExpiresAt'},
            ExpressionAttributeValues={':owner': owner_id, ':expiry': expiry}
        )
        return True
    except table.meta.client.exceptions.ConditionalCheckFailedException:
        return False

def release_lock(table, resource_name, owner_id):
    """Release a lock. Only succeeds if the caller is the lock owner."""
    try:
        table.delete_item(
            Key={'LockID': resource_name},
            ConditionExpression=Attr('OwnerID').eq(owner_id)
        )
        return True
    except table.meta.client.exceptions.ConditionalCheckFailedException:
        return False
```

이 접근 방식은 조건 표현식을 사용하여 잠금이 존재하지 않거나 만료된 경우에만 잠금을 획득할 수 있고, 잠금을 획득한 프로세스에서만 잠금을 해제할 수 있도록 합니다. 잠금 테이블에서 [TTL(Time to Live)](TTL.md)을 활성화하여 만료된 잠금 항목을 자동으로 정리하는 것이 좋습니다.

## 동시성 제어 전략 선택
<a name="BestPractices_ChoosingLockingStrategy"></a>

다음 지침에 따라 워크로드에 적합한 접근 방식을 선택합니다.

다음과 같은 경우 **낙관적 잠금을 사용합니다**.  
+ 충돌이 자주 발생하지 않습니다.
+ 실패한 쓰기를 재시도해도 비용 부담이 적습니다.
+ 한 번에 하나의 항목을 업데이트합니다.

다음과 같은 경우 **트랜잭션을 사용합니다**.  
+ 여러 항목을 원자적으로 업데이트해야 합니다.
+ 항목 또는 테이블에 모두 또는 양자택일 의미 체계가 필요하지 않습니다.
+ 단일 작업에서 조건 확인을 쓰기와 결합해야 합니다.

다음과 같은 경우 **잠금 클라이언트를 사용합니다**.  
+ 분산 프로세스 전반에 걸쳐 외부 리소스에 대한 액세스를 조정해야 합니다.
+ 중요한 섹션은 장기 실행이며 충돌에 대한 재시도 비용이 많이 듭니다.
+ 프로세스 실패를 처리하려면 자동 잠금 만료가 필요합니다.

**참고**  
[DynamoDB 글로벌 테이블](GlobalTables.md)을 사용하는 경우 글로벌 테이블은 동시 업데이트에 ‘마지막 작성자 성공’ 조정 전략을 사용한다는 점에 유의하세요. 한 리전의 쓰기가 버전 확인 없이 다른 리전의 동시 쓰기를 덮어쓸 수 있으므로 버전 번호를 사용한 낙관적 잠금은 리전 전체에서 예상대로 작동하지 않습니다. 글로벌 테이블을 사용할 때 애플리케이션 수준에서 충돌을 처리하도록 애플리케이션을 설계합니다.