Python(boto3)의 DynamoDB 조건부 쓰기

boto3는 조건부 쓰기에 잡을 수 있는 이름 붙은 예외 클래스가 있는 유일한 SDK이자, 반환된 항목이 짐작하기 어려운 곳에 숨어 있는 유일한 SDK이기도 합니다. 표현식 자체는 어디서나 똑같이 동작합니다. 함수와 낙관적 잠금 패턴은 DynamoDB 조건 표현식에서 다룹니다.

코드

import boto3

client = boto3.client("dynamodb")

# Update the item only if nobody changed it since we read version 7.
try:
    client.update_item(
        TableName="Music",
        Key={"Artist": {"S": "Arturo Sandoval"}, "SongTitle": {"S": "Cubano Chant"}},
        UpdateExpression="SET #upd0 = :updValue0, #version = :newVersion",
        ConditionExpression="attribute_exists(#cond0) AND #version = :expectedVersion",
        ExpressionAttributeNames={"#upd0": "Genre", "#version": "Version", "#cond0": "Artist"},
        ExpressionAttributeValues={
            ":updValue0": {"S": "Latin Jazz"},
            ":expectedVersion": {"N": "7"},
            ":newVersion": {"N": "8"},
        },
        ReturnValuesOnConditionCheckFailure="ALL_OLD",
    )
    print("Updated to version 8")
except client.exceptions.ConditionalCheckFailedException as e:
    # With ReturnValuesOnConditionCheckFailure="ALL_OLD", the current item
    # rides back on the exception — no extra read to see what beat you.
    print("Lost the race — item is now:", e.response.get("Item"))

설명

  • ConditionalCheckFailedException은 모델링된 클래스이므로 except client.exceptions.…가 동작합니다. 대부분의 DynamoDB 오류는 그렇지 않습니다. ValidationException은 클래스가 아예 없어서 e.response["Error"]["Code"]로 맞춰야 합니다. 모델링된 클래스도 여전히 ClientError를 상속하므로, 위쪽에 있는 광범위한 except ClientError가 핸들러 순서를 부주의하게 두면 이 예외를 삼켜 버립니다.
  • 반환된 항목은 e.response["Error"]가 아니라 e.response의 최상위 키입니다. 그래서 코드가 e.response.get("Item")을 읽는 것입니다. CodeMessage["Error"] 아래를 뒤지다가 아무것도 못 찾고 이 파라미터가 동작하지 않는다고 결론 내리기 쉽습니다.
  • 항목은 DynamoDB JSON으로 돌아옵니다. 네이티브 값에 익숙하더라도 이것이 저수준 클라이언트이기 때문입니다. 평범한 Python 값을 원한다면 boto3.dynamodb.types.TypeDeserializer가 변환해 줍니다.
  • 리소스 API는 같은 가드를 객체로 표현합니다. ConditionExpression=Attr("Version").eq(7) & Attr("Artist").exists()처럼 네이티브 값을 쓰고 플레이스홀더 맵이 없습니다. 동일한 예외를 발생시키므로 아래의 처리 방식은 그대로입니다.
  • 실패한 검사에도 쓰기 요금이 부과됩니다. 개발자 안내서는 false 조건이 이전 항목과 새 항목 중 더 큰 쪽을 기준으로 쓰기 용량을 소비한다고 분명히 밝히므로, 경합하는 키에 무제한 재시도를 걸면 아무 진전 없이 실제 비용이 나갑니다.

boto3가 반환된 항목을 두는 곳

저장된 Version이 9인 상태로 위 코드를 실행하고 예외 응답의 키를 출력해 보세요. DynamoDB Local 3.3.0, boto3 1.43.58 기준입니다:

sorted(e.response.keys())  ->  ['Error', 'Item', 'ResponseMetadata']

e.response["Item"]  ->  {'Artist': {'S': 'Arturo Sandoval'},
                         'Year': {'N': '1994'},
                         'Version': {'N': '9'},
                         'SongTitle': {'S': 'Cubano Chant'},
                         'AlbumTitle': {'S': 'Danzon'}}

ReturnValuesOnConditionCheckFailure를 빼면 같은 실패가 ['Error', 'ResponseMetadata']를 줍니다. Item 키가 없으며 e.response.get("Item")은 예외 대신 None을 반환합니다. 이것이 코드 리뷰를 통과해 살아남아 프로덕션에서 None을 기록하기 시작하는 버전의 버그입니다.

표현식의 모든 이름을 별칭 처리하는 이유

위 코드는 VersionArtist 대신 #version#cond0을 씁니다. 평범한 단어 두 개에 과한 조치처럼 보입니다. 이 둘에 한해서는 실제로 그렇습니다. Version은 DynamoDB 예약어가 아니며, 그대로 써도 이름 검증을 통과합니다.

Year는 예약어이고, 같은 테이블에 그 속성이 있습니다. 이것을 직접 가드에 쓰면 다음을 받습니다:

ValidationException: Invalid ConditionExpression: Attribute name is a reserved keyword;
reserved keyword: Year

그 목록에는 Name, Status, Size, Count, Data, Owner, Timestamp, Items를 포함해 573개 단어가 있습니다. 모든 이름에 별칭을 붙이는 것은 생성된 코드가 어느 것이 어느 쪽인지 알 필요 자체를 없애는 방법입니다. 예약어 검사기에 속성 이름을 붙여 넣으면 별칭이 필요한 것들에 대한 ExpressionAttributeNames 맵을 돌려줍니다.

별칭 처리를 대신 맡기고 여러분의 테이블에 이런 가드를 작성하려면, DynoTable을 다운로드하세요.

관련 예제

참고 자료

위에 링크된 공식 AWS 문서를 기준으로 2026-07-28에 마지막으로 검증했습니다.

Console 없이 DynamoDB 작업하기

DynamoDB로는 실행할 수 없는 진짜 SQL(JOINs, GROUP BY, 집계)을 실행하는 빠른 DynamoDB 데스크톱 클라이언트. 시각적 편집과 여러분 자신의 Bedrock 키로 동작하는 AI 에이전트를 제공합니다.

30일 무료 체험, 신용카드 불필요 — 이후 기간 제한 없는 무료 요금제.