기준: DVA-C02 시험 가이드오류 제보
DynamoDB를 처음 배울 때 "파티션 키를 잘 골라야 한다"는 말을 듣지만, "잘 고른다"의 수학적 의미는 잘 가르쳐주지 않는다. 이 day에서는 세 가지를 다룬다. 파티션 키 설계가 왜 수학적 문제인지, LSI와 GSI가 내부적으로 어떻게 다르게 저장되는지, 그리고 여러 테이블을 하나로 합치는 Single-Table Design이 왜 DynamoDB의 표준 패턴인지를 짚는다.
파티션 키의 "좋은 선택"을 수학으로 정의하면 이렇다: 모든 파티션에 걸쳐 읽기/쓰기 부하가 균등하게 분산되도록 해야 한다.
이 균등 분산을 측정하는 지표가 카디널리티(Cardinality)와 편향도(Skewness)다.
카디널리티 계산:
userId(UUID): 수백만 고유 값 → 수백만 파티션 가능 → 좋음
status: 3~5개 값 → 최대 5개 파티션 → 핫 파티션 위험
날짜: 수천 개 값이지만 오늘 날짜에 쓰기 집중 → 시간적 편향
편향도가 중요한 이유:
파티션 A: 초당 쓰기 900건 (한도 1,000 WCU에 근접)
파티션 B: 초당 쓰기 10건
파티션 C: 초당 쓰기 5건
테이블 총 WCU = 915/s로 한도 여유가 있어도
파티션 A에서만 throttling 발생 → Adaptive Capacity가 완화
하지만 편향이 극심하면 AC도 한계
실무에서 핫 파티션을 해결하는 기법: 파티션 키에 샤드 번호 추가(Write Sharding)
import random
def get_sharded_pk(base_pk: str, shard_count: int = 10) -> str:
"""파티션 키에 랜덤 샤드 번호를 붙여 분산"""
shard = random.randint(0, shard_count - 1)
return f"{base_pk}#{shard}"
# 날짜 기반 PK 분산 예시
date_key = "2026-06-26"
sharded_key = get_sharded_pk(date_key, 10)
# → "2026-06-26#7" 또는 "2026-06-26#3" 등 10개 파티션에 분산
# 조회 시: 모든 샤드를 병렬로 쿼리 (Scatter-Gather)
def query_all_shards(date: str, shard_count: int = 10):
results = []
for shard in range(shard_count):
pk = f"{date}#{shard}"
results.extend(query_partition(pk))
return resultsWrite Sharding의 단점은 읽기 시 Scatter-Gather 패턴이 필요하다는 것이다 — 모든 샤드를 쿼리한 후 결과를 합쳐야 한다. 읽기 복잡도가 증가하므로 "핫 파티션이 심각한 쓰기 집중 시나리오"에서만 사용한다.
Write Sharding은 분산 데이터베이스 이론에서 "horizontal partitioning"의 응용이다. Cassandra의 "wide partitions" 문제를 해결하는 방법과 동일한 원리다. 샤드 수(Fan-out Factor)는 예상 피크 WCU / 파티션당 WCU 한도(1,000)로 계산한다. 피크 WCU가 8,000이면 최소 8개 샤드가 필요하다. 10개로 잡으면 여유분이 생긴다.
LSI와 GSI는 둘 다 "보조 인덱스"지만 내부 구현이 완전히 다르다.
LSI(Local Secondary Index): "로컬"이라는 이름이 의미하듯, 기본 테이블의 같은 파티션 안에 인덱스 데이터가 저장된다. 기본 테이블의 파티션 키를 그대로 사용하면서 정렬 키만 바꾸는 것이다.
기본 테이블 파티션:
userId=U001 파티션
├── Item(orderId=O001, orderDate=2026-01, status=PENDING, total=50000)
├── Item(orderId=O002, orderDate=2026-02, status=COMPLETED, total=30000)
└── Item(orderId=O003, orderDate=2026-01, status=COMPLETED, total=80000)
LSI(PK=userId, SK=orderDate 대신):
같은 파티션 내에 추가 인덱스 구조만 생성
├── Item(orderDate=2026-01, orderId=O001, ...) ← 정렬 키만 변경
├── Item(orderDate=2026-01, orderId=O003, ...)
└── Item(orderDate=2026-02, orderId=O002, ...)
LSI는 기본 테이블과 같은 파티션에 있으므로:
GSI(Global Secondary Index): "글로벌"이라는 이름이 의미하듯, 기본 테이블과 완전히 별도의 파티션에 인덱스 데이터가 저장된다. 기본 테이블의 파티션 키와 다른 속성을 GSI 파티션 키로 사용할 수 있다.
기본 테이블:
PK=userId, SK=orderId로 파티션됨
GSI(PK=status):
완전히 별도 파티션에 저장
status=PENDING 파티션: O001, O005, O008
status=COMPLETED 파티션: O002, O003, O007
status=FAILED 파티션: O004, O006
이 데이터는 기본 테이블과 비동기적으로 동기화됨
GSI는 별도 파티션이므로:
| 특성 | LSI | GSI |
|---|---|---|
| 파티션 키 | 기본 테이블과 동일 | 다른 속성 가능 |
| 정렬 키 | 다른 속성 | 다른 속성 |
| 저장 위치 | 기본 파티션 내부 | 별도 파티션 |
| 생성 시점 | 테이블 생성 시만 | 언제든 |
| 일관성 | 강력한 일관성 지원 | 최종 일관성만 |
| 용량 | 기본 테이블 공유 | 별도 설정 |
| 최대 개수 | 5개 | 20개 |
| 항목 컬렉션 한도 | 10GB (PK 기준) | 해당 없음 |
GSI의 비동기 복제 지연은 일반적으로 밀리초 수준이지만, 테이블에 쓰기 부하가 높거나 GSI WCU가 부족하면 수 초까지 늘어날 수 있다. 특히 GSI WCU가 기본 테이블 WCU보다 낮으면 GSI가 쓰기를 따라가지 못해 지연이 증가한다. 심각한 경우 기본 테이블 쓰기 자체가 throttling된다 — 이것이 "GSI WCU ≥ 기본 테이블 WCU"를 유지해야 하는 이유다.
GSI WCU가 기본 테이블 WCU보다 낮을 때 발생하는 연쇄 문제:
[시나리오]
기본 테이블 WCU: 1,000
GSI WCU: 100 (너무 낮음)
기본 테이블 쓰기 속도: 800 WCU/s
→ 기본 테이블: 정상 (800 < 1000)
→ GSI 복제: 800 WCU/s 필요하지만 100 WCU만 가능
→ GSI throttling 발생
→ 연쇄 효과: 기본 테이블 쓰기도 throttling!
해결책:
1. GSI WCU를 기본 테이블과 동일하게 설정
2. 온디맨드 모드로 변경 (자동 확장)
3. 불필요한 GSI 제거
온디맨드 모드로 테이블을 설정하면 GSI도 자동으로 온디맨드가 되므로 WCU 불일치 문제가 해결된다. 프로비저닝 모드에서는 GSI WCU를 항상 기본 테이블 WCU 이상으로 유지해야 한다.
⚠️ 함정: 기본 테이블 WCU는 충분히 잡지만 GSI WCU를 기본 테이블의 절반 정도로 설정한다. 이것이 "무작위로 발생하는" throttling의 원인인 경우가 많다.
GSI를 만들 때 기본 테이블의 어떤 속성을 인덱스에 포함할지(Projection) 결정해야 한다.
| Projection 유형 | 인덱스에 저장 | 스토리지 비용 | 추가 조회 필요 |
|---|---|---|---|
| KEYS_ONLY | 기본 테이블 PK + GSI PK/SK만 | 최저 | 모든 속성 필요 시 GetItem 필요 |
| INCLUDE | 지정한 속성 + 키 | 중간 | 미지정 속성 필요 시 GetItem 필요 |
| ALL | 모든 속성 | 최고 | 추가 조회 불필요 |
실무 패턴: KEYS_ONLY로 GSI를 만들고, GSI 쿼리 결과의 기본 키를 수집한 후 BatchGetItem으로 필요한 속성을 한 번에 가져오는 패턴. GSI 스토리지 비용이 최소화되고, BatchGetItem으로 100개까지 한 번에 가져올 수 있어 효율적이다.
# KEYS_ONLY GSI 패턴
def get_pending_orders(status: str) -> list:
# 1. GSI 쿼리 (키만 반환)
gsi_result = table.query(
IndexName='StatusIndex',
KeyConditionExpression=Key('status').eq(status)
)
# 2. 기본 키 목록 수집
keys = [{'PK': {'S': item['PK']}, 'SK': {'S': item['SK']}}
for item in gsi_result['Items']]
# 3. BatchGetItem으로 전체 속성 조회
response = dynamodb.batch_get_item(
RequestItems={
'OrdersTable': {
'Keys': keys,
'ProjectionExpression': 'orderId, userId, total, createdAt'
}
}
)
return response['Responses']['OrdersTable']관계형 DB에서 개발자는 보통 엔티티별로 테이블을 만든다 — Users 테이블, Orders 테이블, Products 테이블. DynamoDB에서 이 방식을 그대로 따르면 성능 문제가 생긴다.
예를 들어 "특정 사용자의 최근 주문 10개와 사용자 프로필"을 한 화면에 보여줄 때:
DynamoDB 내부의 Werner Vogels팀과 Rick Houlihan이 수년에 걸쳐 정립한 것이 Single-Table Design이다.
[Single-Table Design 예시: 전자상거래]
테이블 이름: ECommerceTable
PK | SK | 속성들
--------------|-----------------|---------------------------------
USER#U001 | PROFILE | name, email, phone, createdAt
USER#U001 | ORDER#2026-01#O001 | orderId, total, status, items
USER#U001 | ORDER#2026-02#O002 | orderId, total, status, items
USER#U001 | REVIEW#P001 | rating, text, createdAt
PRODUCT#P001 | DETAIL | name, price, category, stock
PRODUCT#P001 | REVIEW#U001 | rating, text
PRODUCT#P001 | REVIEW#U002 | rating, text
ORDER#O001 | STATUS | status, updatedAt
[자주 사용하는 쿼리 패턴]
1. 사용자 프로필 + 주문 내역:
Query(PK=USER#U001) → 프로필, 주문, 리뷰 모두 반환
2. 사용자의 최신 주문만:
Query(PK=USER#U001, SK begins_with("ORDER#"), ScanIndexForward=false, Limit=10)
3. 특정 주문의 상태:
GetItem(PK=ORDER#O001, SK=STATUS)
4. 상품별 리뷰 목록:
Query(PK=PRODUCT#P001, SK begins_with("REVIEW#"))
Single-Table Design은 RDBMS의 정규화(Normalization)와 반대되는 역정규화(Denormalization) 전략이다. 정규화는 데이터 중복을 줄이고 일관성을 높이지만 JOIN이 필요하다. 역정규화는 데이터 중복을 허용하지만 JOIN 없이 빠른 조회가 가능하다. DynamoDB는 JOIN 자체가 없으므로 역정규화가 필수다. 이 설계 철학은 Google의 Bigtable 논문(2006)에서도 동일하게 나타나며, "store data how you use it"이라는 원칙으로 요약된다.
Sparse Index는 특정 속성을 가진 항목만 GSI에 포함되게 하는 고급 패턴이다.
User 항목들:
User#001: {isAdmin: true, email: "admin@example.com"}
User#002: {email: "user@example.com"} ← isAdmin 속성 없음
User#003: {isAdmin: true, email: "admin2@example.com"}
GSI PK = isAdmin
GSI에 포함되는 항목:
isAdmin=true → User#001, User#003 (isAdmin 없는 항목은 GSI에 없음)
"관리자만 조회" 쿼리:
Query(GSI, PK=isAdmin, value=true) → User#001, User#003만 반환
→ 수백만 일반 사용자를 모두 스캔할 필요 없음
실용 사례: 처리되지 않은 메시지만 인덱싱하기. processedAt 속성이 없는 항목만 GSI에 포함 → 처리 대기 메시지만 효율적으로 조회.
Query와 Scan의 비용 차이를 실제 수치로 보면 직관적으로 이해된다.
[시나리오]
테이블: 100만 개 항목, 각 항목 평균 1KB
전체 테이블 크기: 약 1GB
파티션 키 기준으로 10,000개 항목이 userId=U001에 해당
Scan 실행 시:
전체 100만 개 항목 읽기 = ceil(1,000,000 × 1KB / 4KB) × 0.5 RCU
= 250,000 × 0.5 = 125,000 RCU 소비
Query 실행 시 (userId=U001):
10,000개 항목만 읽기 = ceil(10,000 × 1KB / 4KB) × 0.5 RCU
= 2,500 × 0.5 = 1,250 RCU 소비
비용 차이: 125,000 / 1,250 = 100배 차이!
FilterExpression 함정:
Query(userId=U001) + FilterExpression(status=COMPLETED)
10,000개 읽고 8,000개 필터링 → 1,250 RCU 소비 (필터 무관)
가상의 사례로, Lambda 함수가 매분 Scan으로 미처리 트랜잭션을 찾게 하면 RCU 소비와 청구 비용이 급증한다. Query + GSI 패턴으로 바꾸면 RCU 소비가 크게 줄어든다. Scan은 "절대 사용 금지"가 아니라 테이블 마이그레이션이나 데이터 백업처럼 명확한 이유가 있을 때만 쓴다.
DynamoDB의 Query는 한 번에 최대 1MB 데이터를 반환한다. 결과가 1MB를 초과하면 LastEvaluatedKey가 반환되고, 다음 페이지를 요청하려면 이 키를 ExclusiveStartKey로 전달해야 한다.
def query_all_orders(user_id: str) -> list:
all_items = []
last_evaluated_key = None
while True:
kwargs = {
'KeyConditionExpression': Key('userId').eq(user_id)
}
if last_evaluated_key:
kwargs['ExclusiveStartKey'] = last_evaluated_key
response = table.query(**kwargs)
all_items.extend(response['Items'])
last_evaluated_key = response.get('LastEvaluatedKey')
if not last_evaluated_key:
break # 마지막 페이지
return all_items페이지네이션을 무시하고 첫 페이지만 처리하면 데이터 누락이 발생한다. 특히 Lambda 함수에서 DynamoDB를 호출할 때 이 실수가 자주 발생한다.
Scan은 일반적으로 피해야 하지만, 데이터 마이그레이션이나 전체 테이블 처리가 필요할 때는 Parallel Scan으로 성능을 높일 수 있다.
import threading
def scan_segment(table, segment: int, total_segments: int, results: list):
"""각 세그먼트를 별도 스레드에서 스캔"""
response = table.scan(
Segment=segment,
TotalSegments=total_segments
)
results.extend(response['Items'])
def parallel_scan(table, num_threads: int = 10) -> list:
results = []
threads = []
for i in range(num_threads):
t = threading.Thread(
target=scan_segment,
args=(table, i, num_threads, results)
)
threads.append(t)
t.start()
for t in threads:
t.join()
return resultsParallel Scan에서 세그먼트 수는 보통 테이블 파티션 수 이하로 설정한다. 너무 많은 세그먼트는 오히려 overhead가 생긴다.
오늘 살펴본 파티션 키 카디널리티의 수학, LSI/GSI의 내부 차이, Single-Table Design 패턴은 DynamoDB를 "제대로" 사용하는 핵심이다. 다음 day에서는 읽기/쓰기 용량 단위를 계산하는 수학과 DynamoDB Streams의 내부 동작을 살펴본다.
선택지를 클릭하면 정답·해설이 펼쳐집니다.
문제 1
LSI를 사용해야 하는 상황과 GSI를 사용해야 하는 상황을 구분하는 기준은?
문제 2
다음 중 GSI Projection ALL 대신 KEYS_ONLY를 선택해야 하는 상황은?
문제 3
Single-Table Design을 사용하는 주된 이유는?
문제 4
GSI WCU를 기본 테이블 WCU의 절반으로 설정했더니 간헐적으로 ProvisionedThroughputExceededException이 발생한다. 원인은?
문제 5
DynamoDB 테이블에서 Query + FilterExpression을 사용해 100개의 항목만 반환받았는데 RCU 소비가 예상보다 훨씬 높다. 가장 가능성 높은 원인은?
문제 6
스타트업이 DynamoDB로 다음 접근 패턴이 있는 SNS 서비스를 구현한다: 1) 사용자별 게시글 목록(시간순), 2) 해시태그별 게시글 목록, 3) 특정 게시글의 좋아요 수. 가장 적합한 설계는?
문제 7
DynamoDB의 Query 작업에서 LastEvaluatedKey가 반환됐을 때의 올바른 처리 방법은?