CertNote/ 출퇴근 학습 노트
로드맵
KOEN
CLF-C02 · FoundationalCloud Practitioner - Foundational
DVA-C02 · AssociateDeveloper - Associate
  • Week 1
    • 1.개발자 시점에서 본 AWS: 리전, 인프라, 그리고 책임의 경계
    • 2.IAM의 4대 엔터티: User, Group, Role, Policy
    • 3.STS와 정책 조건: 임시 자격증명의 깊은 세계
    • 4.AWS CLI와 SDK: 자격증명 체이닝부터 SigV4까지
    • 5.Week 1 종합: 인프라와 IAM이 만드는 신뢰의 사슬
  • Week 2
  • Week 3
  • Week 4
  • Week 5
  • Week 6
    • 1.DynamoDB: NoSQL의 철학과 파티션 설계의 수학
    • 2.DynamoDB: 파티션 키 설계의 수학, LSI/GSI 내부 동작, Single-Table Design
    • 3.DynamoDB: RCU/WCU 수학, DAX 아키텍처, Streams 처리
    • 4.DynamoDB: 트랜잭션, 조건부 쓰기, TTL의 내부 동작
    • 5.Week 6 종합 복습: DynamoDB 전체 지형도
  • Week 7
  • Week 8
  • Week 9
  • Week 10
  • Week 11
  • Week 12
  • Week 13
SAA-C03 · AssociateSolutions Architect - Associate
SOA-C02 · AssociateCloudOps Engineer - Associate
SAP-C02 · ProfessionalSolutions Architect - Professional
DOP-C02 · ProfessionalDevOps Engineer - Professional
SCS-C03 · SpecialtySecurity - Specialty
MLA-C01 · AssociateMachine Learning Engineer - Associate
AIF-C01 · FoundationalAI Practitioner - Foundational
DEA-C01 · AssociateData Engineer - Associate
MLS-C01 · SpecialtyMachine Learning - Specialty
합격 후기

© CertNote · 주간랩스

  • 소개
  • 수정 기록
  • 문의
  • 이용약관
  • 개인정보처리방침
← DVA-C02/Week 6/Day 2

기준: DVA-C02 시험 가이드·오류 제보

DVA-C02· AssociateWeek 6 · Day 2읽기 약 13분

Day 2 - DynamoDB: 파티션 키 설계의 수학, LSI/GSI 내부 동작, Single-Table Design

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 results

Write Sharding의 단점은 읽기 시 Scatter-Gather 패턴이 필요하다는 것이다 — 모든 샤드를 쿼리한 후 결과를 합쳐야 한다. 읽기 복잡도가 증가하므로 "핫 파티션이 심각한 쓰기 집중 시나리오"에서만 사용한다.

Write Sharding은 분산 데이터베이스 이론에서 "horizontal partitioning"의 응용이다. Cassandra의 "wide partitions" 문제를 해결하는 방법과 동일한 원리다. 샤드 수(Fan-out Factor)는 예상 피크 WCU / 파티션당 WCU 한도(1,000)로 계산한다. 피크 WCU가 8,000이면 최소 8개 샤드가 필요하다. 10개로 잡으면 여유분이 생긴다.

LSI vs GSI — 내부 저장 방식의 근본적 차이

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는 기본 테이블과 같은 파티션에 있으므로:

  • 강력한 일관성 읽기 지원 (같은 노드에서 읽기 가능)
  • 별도 RCU/WCU 불필요 (기본 테이블 용량 공유)
  • 테이블 생성 시에만 정의 가능 (이후 추가/삭제 불가)
  • 단일 파티션 항목 컬렉션 10GB 제한 (PK 기준으로 합산)

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는 별도 파티션이므로:

  • 최종 일관성 읽기만 (비동기 복제 때문)
  • 별도 RCU/WCU 필요 (독립적인 파티션)
  • 언제든 추가/삭제 가능
  • Projection 설정 (KEYS_ONLY, INCLUDE, ALL)
특성LSIGSI
파티션 키기본 테이블과 동일다른 속성 가능
정렬 키다른 속성다른 속성
저장 위치기본 파티션 내부별도 파티션
생성 시점테이블 생성 시만언제든
일관성강력한 일관성 지원최종 일관성만
용량기본 테이블 공유별도 설정
최대 개수5개20개
항목 컬렉션 한도10GB (PK 기준)해당 없음

GSI의 비동기 복제 지연은 일반적으로 밀리초 수준이지만, 테이블에 쓰기 부하가 높거나 GSI WCU가 부족하면 수 초까지 늘어날 수 있다. 특히 GSI WCU가 기본 테이블 WCU보다 낮으면 GSI가 쓰기를 따라가지 못해 지연이 증가한다. 심각한 경우 기본 테이블 쓰기 자체가 throttling된다 — 이것이 "GSI WCU ≥ 기본 테이블 WCU"를 유지해야 하는 이유다.

GSI Throttling — DynamoDB의 대표적인 함정

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 — 인덱스에 어떤 속성을 저장할까

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']

Single-Table Design — DynamoDB의 표준 패턴

관계형 DB에서 개발자는 보통 엔티티별로 테이블을 만든다 — Users 테이블, Orders 테이블, Products 테이블. DynamoDB에서 이 방식을 그대로 따르면 성능 문제가 생긴다.

예를 들어 "특정 사용자의 최근 주문 10개와 사용자 프로필"을 한 화면에 보여줄 때:

  • 멀티테이블 방식: GetItem(Users) + Query(Orders) = 2번의 네트워크 왕복
  • 단일 테이블 방식: Query(단일 테이블) = 1번의 네트워크 왕복

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 인덱싱

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 vs Scan의 비용 차이 수학

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은 "절대 사용 금지"가 아니라 테이블 마이그레이션이나 데이터 백업처럼 명확한 이유가 있을 때만 쓴다.

Query의 페이지네이션 처리

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를 호출할 때 이 실수가 자주 발생한다.

Parallel Scan — 대규모 데이터 마이그레이션

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 results

Parallel 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가 반환됐을 때의 올바른 처리 방법은?

이전DynamoDB: NoSQL의 철학과 파티션 설계의 수학Week 6 · Day 1다음 DynamoDB: RCU/WCU 수학, DAX 아키텍처, Streams 처리Week 6 · Day 3

참고 공식 자료

  • DVA-C02 공식 시험 안내(새 탭에서 열림)
  • Amazon DynamoDB 공식 문서(새 탭에서 열림)
  • AWS Lambda 공식 문서(새 탭에서 열림)

이 페이지

  • 파티션 키 카디널리티의 수학
  • LSI vs GSI — 내부 저장 방식의 근본적 차이
  • GSI Throttling — DynamoDB의 대표적인 함정
  • GSI Projection — 인덱스에 어떤 속성을 저장할까
  • Single-Table Design — DynamoDB의 표준 패턴
  • Sparse Index — 조건부 GSI 인덱싱
  • Query vs Scan의 비용 차이 수학
  • Query의 페이지네이션 처리
  • Parallel Scan — 대규모 데이터 마이그레이션
  • 연습 문제