목차

  1. 개요
  2. DynamoDB 기본 개념
  3. 테이블 스키마 설계
  4. Use Case 분석
  5. GSI 전략 결정
  6. 비용 분석
  7. 성능 및 안정성
  8. 구현 가이드
  9. AWS Console 설정 방법
  10. 운영 가이드
  11. 부록

1. 개요

1.1. 배경 및 목적

배경

대용량 이벤트 처리 시스템에서 DynamoDB를 Event Buffer로 활용하고 있습니다. 요청당 최대 6만 건의 이벤트를 수신하며, 처리 완료 후 소프트 딜리트 방식으로 관리합니다.

DynamoDB를 Event Buffer로 선택한 이유와 다른 스토리지 옵션(RDS, S3 등)과의 비교는 이전 글을 참고하세요.

핵심 과제: GSI 설계

DynamoDB 메인 테이블은 date#shard 파티션 키로 쓰기를 분산하지만, 운영상 다음 조회가 필요합니다:

조회 요구사항:
1. 날짜별 미처리 이벤트 조회 (deleted_at = null)
   - 빈도: 100회/일, 평균 200건 반환
   
2. 날짜별 실패 이벤트 조회 (retry >= 1 AND deleted_at = null)
   - 빈도: 50회/일, 평균 10건 반환

메인 테이블은 이러한 조회를 직접 지원하지 않으므로 GSI(Global Secondary Index) 설계가 필요합니다.

GSI 설계 핵심 질문

❓ GSI를 몇 개 만들 것인가?
❓ 각 GSI의 Projection Type은? (KEYS_ONLY / INCLUDE / ALL)
❓ Sparse Index를 어떻게 활용할 것인가?
❓ 비용과 성능의 최적 균형점은?

문서 목적

이 문서는 위 질문들에 대한 의사결정 과정과 근거를 다룹니다:

  • ✅ GSI 설계 옵션별 비용/성능 정량 분석
  • ✅ Sparse Index 패턴을 활용한 비용 최적화
  • ✅ Projection Type 선택 기준 및 트레이드오프
  • ✅ 실무 구현 가이드 및 운영 방안

1.2. 시스템 요구사항

처리량: 일평균 100,000건
보관 기간: 3개월 (TTL)
삭제 방식: 소프트 딜리트 (deleted_at)
실패율: 약 1%
조회 빈도: 
  - 미처리 조회: 100회/일
  - 실패 조회: 50회/일

1.3. 데이터 특성

샘플 데이터:

{
  "pk": "2025-11-10#4",
  "sk": "17627646002239228560",
  "batch_id": "c87fc8c9-4ee5-4e81-801c-c9dcc1562a78",
  "retry": 0,
  "json_data": "{...}",
  "created_at": "2025-11-10 17:50:00 223",
  "deleted_at": null
}

항목 크기:

  • 메인 테이블: 약 0.8 KB
  • 가장 큰 필드: json_data (약 300 bytes)

2. DynamoDB GSI 기본 개념

2.1. GSI (Global Secondary Index)란?

GSI는 메인 테이블과 다른 키로 조회할 수 있는 보조 인덱스입니다.

메인 테이블 PK/SK: date#shard / epochMilli
GSI PK/SK: 자유롭게 설정 가능

특징:
✅ 테이블 생성 후 추가 가능
✅ 독립적인 RCU/WCU
✅ Eventually Consistent (최종 일관성)
✅ 최대 20개 생성 가능

메인 테이블과 GSI의 관계:

메인 테이블 (전체 데이터)
    ↓ 자동 복사
GSI (선택된 속성만)

2.2. Sparse Index 패턴

핵심 아이디어: 조건을 만족하는 항목만 GSI에 포함

// 예시: 미처리 이벤트만 GSI에 포함
if (deleted_at == null) {
    gsi_pk = main_pk  // GSI에 포함됨
} else {
    gsi_pk = null     // GSI에서 제거됨
}

장점:

  • 저장 비용 대폭 절감 (99% 데이터 제외 가능)
  • 조회 속도 향상 (필요한 것만 조회)
  • 자동 필터링 효과

2.3. Projection Type 비교

GSI에 어떤 속성을 복사할지 결정하는 옵션입니다.

Type포함되는 속성저장 크기비용GetItem 필요
KEYS_ONLYPK, SK만 (4개)0.17 KB⭐⭐⭐✅ 필요
INCLUDE선택 속성0.4 KB⭐⭐△ 부분적
ALL전체 속성0.8 KB❌ 불필요

KEYS_ONLY:

{
  "gsi_pk": "2025-11-10#4",
  "gsi_sk": "1762764600...",
  "main_pk": "2025-11-10#4",
  "main_sk": "1762764600..."
}
// 4개 키만 저장

INCLUDE (batch_id, retry 포함):

{
  "gsi_pk": "2025-11-10#4",
  "gsi_sk": "1762764600...",
  "main_pk": "2025-11-10#4",
  "main_sk": "1762764600...",
  "batch_id": "c87fc8c9...",
  "retry": 0
}
// 키 + 선택 속성

ALL:

{
  // 메인 테이블의 모든 속성 (0.8 KB)
  "gsi_pk": "2025-11-10#4",
  "gsi_sk": "1762764600...",
  "main_pk": "2025-11-10#4",
  "main_sk": "1762764600...",
  "batch_id": "c87fc8c9...",
  "retry": 0,
  "json_data": "{...}",
  "created_at": "2025-11-10 17:50:00 223",
  // ... 모든 속성
}

2.4. 비용 구조

저장 비용: $0.25/GB/월
읽기 비용: $0.25/백만 RCU
쓰기 비용: $1.25/백만 WCU

GSI 비용 = GSI 저장 + GSI 쓰기
(GSI 읽기는 일반 읽기와 동일)

중요: GSI는 메인 테이블과 별도로 저장/쓰기 비용 발생!

2.5. DynamoDB 파티션 구조

파티션이란?

DynamoDB는 내부적으로 데이터를 파티션(Partition) 단위로 분산 저장합니다.

파티션은 DynamoDB가 자동 관리:

파티션 1개당 제약:
- 최대 크기: 10GB
- 최대 RCU: 3,000 RCU/초
- 최대 WCU: 1,000 WCU/초

중요: 
- 파티션 수는 DynamoDB가 자동으로 결정
- 데이터 크기와 처리량에 따라 자동 증가
- 개발자가 직접 제어 불가

파티션 키와 데이터 분산:

DynamoDB 내부 해시 함수로 파티션 결정

해시("2025-11-10") → 파티션 A
해시("2025-11-10#1") → 파티션 X
해시("2025-11-10#2") → 파티션 Y

핵심: 서로 다른 파티션 키는 높은 확률로 다른 파티션에 분산됨

핫파티션 문제

시나리오: 날짜만 파티션 키로 사용

애플리케이션에서 생성하는 PK:
┌──────────────┐
│ 2025-11-08   │ ━━━┓
│ 2025-11-09   │ ━━━┫
│ 2025-11-10   │ ━━━┫ DynamoDB 해시 함수
│ 2025-11-11   │ ━━━┫
└──────────────┘    ┃
                    ▼
        DynamoDB 내부 파티션:
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ Partition A  │ Partition B  │ Partition C  │ Partition D  │
│ 2025-11-08   │ 2025-11-09   │ 2025-11-10   │ 2025-11-11   │
│              │              │   🔥🔥🔥      │              │
│ 0 WCU        │ 0 WCU        │ 3000 WCU     │ 0 WCU        │
│              │              │   (초과!)     │              │
│ 0 items      │ 0 items      │ 100,000 items│ 0 items      │
└──────────────┴──────────────┴──────────────┴──────────────┘
      ✅              ✅            ❌ HOT!          ✅
   유휴 상태       유휴 상태        과부하        유휴 상태

문제 발생 과정:

1. 오늘(2025-11-10)의 모든 이벤트가 동일한 PK
   ↓
2. 해시 함수 결과도 동일 → 단일 파티션으로 집중
   ↓
3. 해당 파티션만 과부하 (1,000 WCU 한계 초과)
   ↓
4. Throttling 발생 → 쓰기 실패 ❌

실시간 모니터링 예시:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
핫파티션 발생 시:

Partition C (2025-11-10)
WCU: ████████████████████████ 3000 🔥 (초과!)
RCU: ████████████████         2500

기타 파티션 (A, B, D)
WCU: ░░                       0 (유휴)
RCU: ░░                       0 (유휴)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

결과: 리소스 낭비 + 성능 저하 + 추가 비용 발생

파티션 vs 샤드

많은 개발자가 혼동하는 두 개념을 명확히 구분합니다:

개념관리 주체레벨목적개수
파티션DynamoDB (자동)물리적데이터 저장 단위가변 (자동 조정)
샤드개발자 (애플리케이션)논리적부하 분산 전략고정 (설계 시 결정)

관계:

샤드 (논리적)
   ↓
애플리케이션이 여러 개의 서로 다른 파티션 키 생성
   ↓
DynamoDB 해시 함수
   ↓
파티션 (물리적)
   ↓
여러 파티션에 분산 저장 (확률적)

중요:

❌ 잘못된 이해: "샤드 20개 = 파티션 20개"
✅ 올바른 이해: "샤드 20개 = 20개의 다른 PK → 여러 파티션에 분산될 가능성 ↑"

실제: DynamoDB가 데이터 크기와 처리량에 따라 파티션 수를 자동 결정

해결 방법: 샤딩

핫파티션 문제를 해결하려면 애플리케이션 레벨에서 파티션 키를 분산해야 합니다.

나쁜 설계: 날짜만 사용
PK = "2025-11-10"
→ 단일 파티션 집중 ❌

좋은 설계: 날짜#샤드 사용
PK = "2025-11-10#1" ~ "2025-11-10#20"
→ 여러 파티션에 분산 ✅

2.6. 샤딩 전략

설계 목표

2.5절에서 학습한 파티션 구조를 바탕으로, 핫파티션을 방지하는 샤딩 전략을 설계합니다.

핵심 원리:

여러 개의 서로 다른 파티션 키 생성
↓
DynamoDB 해시 함수가 여러 파티션에 분산
↓
각 파티션의 부하 감소

PK 구조: 날짜#샤드

설계: {날짜}#{샤드번호}

예시:
- 2025-11-10#1
- 2025-11-10#2
- 2025-11-10#3
- ...
- 2025-11-10#20

효과 시각화:

┌─────────────────────────────────────────────────────────────┐
│          애플리케이션에서 생성하는 20개 논리적 샤드           │
└─────────────────────────────────────────────────────────────┘

┌────────┬────────┬────────┬────────┬─────┬────────┐
│2025-   │2025-   │2025-   │2025-   │     │2025-   │
│11-10#1 │11-10#2 │11-10#3 │11-10#4 │ ... │11-10#20│
└────────┴────────┴────────┴────────┴─────┴────────┘
    │        │        │        │              │
    └────┬───┴───┬────┴───┬────┴──────┬──────┘
         │       │        │           │
         ▼       ▼        ▼           ▼
    DynamoDB 내부 해시 함수로 파티션 결정

┌─────────────────────────────────────────────────────────────┐
│        DynamoDB 내부 파티션 (자동 생성, 개수는 가변)          │
└─────────────────────────────────────────────────────────────┘

┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ Part X  │ Part Y  │ Part Z  │ Part W  │   ...   │ Part N  │
├─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│#1, #7   │#2, #15  │#3, #9   │#4, #11  │   ...   │#20      │
│#13      │#18      │#16      │         │         │         │
├─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│150 WCU  │150 WCU  │150 WCU  │150 WCU  │   ...   │150 WCU  │
│5K items │5K items │5K items │5K items │   ...   │5K items │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
   ✅        ✅        ✅        ✅                    ✅
              고르게 분산되어 각 파티션의 부하가 낮음

처리량 개선 효과:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
나쁜 설계 (날짜만):
단일 파티션
WCU: ████████████████████████ 3000 🔥 초과!

좋은 설계 (날짜#샤드):
여러 파티션 분산
Part X: ████░░░░░░░░░░░░░░░░  150 WCU ✅
Part Y: ████░░░░░░░░░░░░░░░░  150 WCU ✅
Part Z: ████░░░░░░░░░░░░░░░░  150 WCU ✅
...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

샤드 수 선택

계산 공식:

필요 샤드 수 = ceil(피크 WCU ÷ 파티션당 안전 WCU)

예시:
피크 WCU: 3,000
파티션당 안전 WCU: 800 (한계 1,000의 80%)
필요 샤드 수: ceil(3,000 ÷ 800) = 4개

여유율 고려 (2.5배): 4 × 2.5 = 10개
안전 선택: 20개 (넉넉한 여유)

샤드 수별 특성:

샤드 수예상 효과조회 복잡도평가
1개핫파티션 위험 높음간단 (1회 쿼리)❌ 비추천
5개일부 분산, 여유 부족보통 (5회 쿼리)⚠️ 최소한
10개적절한 분산보통 (10회 쿼리)✅ 적절
20개충분한 분산 + 여유복잡 (20회 쿼리)권장
50개과도한 분산매우 복잡 (50회 쿼리)⚠️ 오버엔지니어링

선택: 20개 샤드

이유:
✅ 충분한 논리적 분산
✅ 파티션당 부하 15% (여유 85%)
✅ 향후 5배 트래픽 증가 대응
✅ 병렬 조회 가능

트레이드오프:
⚠️ 조회 시 20번 쿼리 필요
→ 하지만 병렬 처리로 총 50ms 이내 완료 (허용 가능)

SK 구조: 시간 기반 정렬

SK (정렬 키): {EpochMilli}{7자리 랜덤}

예시: 17627646002239228560

구조:
├─ 앞 13자리: 1762764600223 (밀리초 타임스탬프)
└─ 뒤 7자리: 9228560 (랜덤, 충돌 방지)

목적:
- 시간순 정렬 (앞 13자리)
- 고유성 보장 (뒤 7자리)
- 재시도 시 SK만 재생성 (간단)

구현 코드

PK/SK 생성 로직:

object KeyGenerator {
    private const val SHARD_COUNT = 20
    
    /**
     * 파티션 키 생성: 날짜#샤드
     * 랜덤 샤드 할당으로 부하 분산
     */
    fun generatePartitionKey(date: String): String {
        val shardNumber = Random.nextInt(1, SHARD_COUNT + 1)
        return "$date#$shardNumber"
    }
    
    /**
     * 정렬 키 생성: EpochMilli + 7자리 랜덤
     * 시간순 정렬 + 고유성 보장
     */
    fun generateSortKey(): String {
        val epochMilli = System.currentTimeMillis()
        val random = Random.nextInt(1000000, 10000000)
        return "$epochMilli$random"
    }
}

// 사용 예시
val message = Message(
    messagePk = KeyGenerator.generatePartitionKey("2025-11-10"),
    messageSk = KeyGenerator.generateSortKey(),
    // ... 기타 속성
)

// 결과:
// messagePk: "2025-11-10#7"
// messageSk: "17627646002239228560"

날짜별 조회 (20개 샤드 병렬):

fun findByDate(
    date: String,
    startEpoch: Long,
    endEpoch: Long
): List {
    val allItems = ConcurrentLinkedQueue()
    
    // 20개 샤드 병렬 조회
    (1..20).toList().parallelStream().forEach { shard ->
        val pk = "$date#$shard"
        val skStart = "${startEpoch}0000000"
        val skEnd = "${endEpoch}9999999"
        
        val items = table.query(
            QueryConditional.sortBetween(
                Key.builder()
                    .partitionValue(pk)
                    .sortValue(skStart)
                    .build(),
                Key.builder()
                    .partitionValue(pk)
                    .sortValue(skEnd)
                    .build()
            )
        ).items().toList()
        
        allItems.addAll(items)
    }
    
    return allItems.sortedBy { it.messageSk }
}

핵심 요약

┌─────────────────────────────────────────────────┐
│          샤딩 전략 핵심 원리                        │
├─────────────────────────────────────────────────┤
│                                                 │
│  1. 20개의 논리적 샤드 생성                          │
│     → PK: "2025-11-10#1" ~ "2025-11-10#20"      │
│                                                 │
│  2. 랜덤 샤드 할당                                 │
│     → 이벤트마다 Random.nextInt(1, 20)            │
│                                                 │
│  3. DynamoDB 자동 분산                            │
│     → 해시 함수가 여러 파티션에 배치                   │
│                                                 │
│  4. 부하 분산 효과                                 │
│     → 3,000 WCU ÷ 20샤드 = 150 WCU/샤드           │
│     → 파티션 한계(1,000 WCU)의 15%만 사용            │
│                                                 │
│  5. 조회 시 병렬 처리                               │
│     → 20개 샤드 동시 조회 (50ms 이내)                │
│                                                 │
└─────────────────────────────────────────────────┘

3. 테이블 스키마 설계

3.1. 메인 테이블 구조

@DynamoDbBean
data class Message(
    // 메인 키
    @DynamoDbPartitionKey
    var messagePk: String = "",  // "2025-11-10#4"
    
    @DynamoDbSortKey
    var messageSk: String = "",  // "17627646002239228560"
    
    // 비즈니스 속성
    var batchId: String = "",
    var retry: Int = 0,
    var jsonData: String = "",
    var createdAt: String = "",
    var deletedAt: String? = null,  // 소프트 딜리트
    var expirationTime: Long? = null,  // TTL
    
    // GSI 1: 미처리 전체
    @DynamoDbSecondaryPartitionKey(indexNames = ["pending-index"])
    var pendingMessagePk: String? = null,
    
    @DynamoDbSecondarySortKey(indexNames = ["pending-index"])
    var pendingMessageSk: String? = null,
    
    // GSI 2: 실패 중 미처리
    @DynamoDbSecondaryPartitionKey(indexNames = ["failed-pending-index"])
    var failedPendingMessagePk: String? = null,
    
    @DynamoDbSecondarySortKey(indexNames = ["failed-pending-index"])
    var failedPendingMessageSk: String? = null,
)

3.2. 데이터 라이프사이클

1. 생성 (retry=0)
   └─ pending_pk: "2025-11-10#4"  ✅ GSI 1에 포함
   └─ failed_pending_pk: null     ❌ GSI 2에 미포함

2. 처리 실패 → 재시도 (retry=1)
   └─ pending_pk: "2025-11-10#4"  ✅ GSI 1에 유지
   └─ failed_pending_pk: "2025-11-10#4"  ✅ GSI 2에 추가

3. 처리 완료
   └─ deleted_at: "2025-11-10 18:00:00"
   └─ pending_pk: null  ❌ GSI 1에서 제거
   └─ failed_pending_pk: null  ❌ GSI 2에서 제거

4. TTL 만료 (3개월 후)
   └─ 메인 테이블에서 자동 삭제

4. Use Case 분석

4.1. UC1: 날짜별 미처리 이벤트 조회

요구사항:

특정 날짜의 미처리 이벤트를 조회
조회 빈도: 100회/일
평균 반환: 200개/조회
전체 본문 필요 (json_data 포함)

쿼리 패턴:

// 20개 샤드 병렬 조회
for (shard in 1..20) {
    query(
        pk = "2025-11-10#$shard",
        sk between startEpoch and endEpoch
    )
}
// GSI 1 (pending-index) 사용

조회 특성:

  • 반환 건수가 많음 (평균 200개)
  • 모든 속성이 필요 (json_data 포함)
  • 높은 빈도 (100회/일)

4.2. UC2: 날짜별 실패 이벤트 조회

요구사항:

특정 날짜의 실패 이벤트 조회 (retry >= 1)
조회 빈도: 50회/일
평균 반환: 10개/조회 (전체의 1%)
상세 정보 필요 (에러 메시지 확인)

쿼리 패턴:

// 20개 샤드 병렬 조회
for (shard in 1..20) {
    query(
        pk = "2025-11-10#$shard",
        sk between startEpoch and endEpoch
    )
}
// GSI 2 (failed-pending-index) 사용

조회 특성:

  • 반환 건수가 적음 (평균 10개)
  • 전체 속성 필요 (에러 분석)
  • 중간 빈도 (50회/일)

4.3. 조회 패턴 특성

Use Case빈도반환 건수본문 필요특징
UC1높음 (100회/일)200개✅ 필수대량 조회, 즉시 처리
UC2중간 (50회/일)10개✅ 필수소량 조회, 상세 분석

5. GSI 전략 결정

5.1. 검토한 옵션들

GSI 1 (pending-index) - 미처리 이벤트 조회용

옵션저장 크기읽기 방식응답 시간비용
KEYS_ONLY0.17 KBGSI Query + GetItem(200건)150ms$
INCLUDE0.4 KBGSI Query + GetItem(일부)80ms$$
ALL0.8 KBGSI Query만50ms$$$

GSI 2 (failed-pending-index) - 실패 이벤트 조회용

옵션저장 크기읽기 방식응답 시간비용
KEYS_ONLY0.17 KBGSI Query + GetItem(10건)30ms$
INCLUDE0.4 KBGSI Query + GetItem(일부)25ms$$
ALL0.8 KBGSI Query만20ms$$$

5.2. GSI 1 (pending-index): ALL 선택

선택 이유:

✅ 읽기 성능 최우선
   - 200개 항목 조회 시 GetItem 200회 방지
   - 응답 시간: 150ms → 50ms (66% 개선)
   
✅ 높은 조회 빈도 (100회/일)
   - 2N 문제가 심각함
   - GetItem 비용: 200건 × 100회 = 20,000회/일
   
✅ 즉시 처리 필요
   - 운영자 대시보드에서 실시간 모니터링
   - 미처리 건수 즉시 파악 필요
   
⚠️ 비용 증가 수용
   - 성능 > 비용 (이 케이스에서는)
   - 저장 비용 증가분: +$0.42/월
   - ROI: 읽기 성능 3배 향상

의사결정 포인트:

KEYS_ONLY vs ALL 비교:

성능:
- KEYS_ONLY: 150ms (GSI 50ms + GetItem 100ms)
- ALL: 50ms (GSI만)
→ 3배 빠름 ⭐

비용 (일 100회 조회):
- KEYS_ONLY: 저장 $0.14 + 읽기 $0.05 = $0.19/월
- ALL: 저장 $0.56 + 읽기 $0.02 = $0.58/월
→ +$0.39/월 증가

결론:
- $0.39/월로 3배 성능 향상
- 운영 효율성 극대화
- 사용자 경험 개선

5.3. GSI 2 (failed-pending-index): KEYS_ONLY 선택

선택 이유:

✅ 비용 최소화
   - 실패 이벤트는 전체의 1%만
   - GSI 크기 최소화 (99% 절감)
   
✅ 적은 반환 건수 (평균 10개)
   - GetItem 10회는 부담 없음
   - 응답 시간: 30ms (충분히 빠름)
   
✅ 낮은 조회 빈도 (50회/일)
   - 2N 문제가 심각하지 않음
   - GetItem 비용: 10건 × 50회 = 500회/일
   
✅ Sparse Index 효과 극대화
   - 저장 비용: $0.01/월 (매우 저렴)

의사결정 포인트:

KEYS_ONLY vs ALL 비교:

비용 (일 50회 조회):
- KEYS_ONLY: 저장 $0.01 + 읽기 $0.00 = $0.01/월
- ALL: 저장 $0.06 + 읽기 $0.00 = $0.06/월
→ 6배 차이

성능:
- KEYS_ONLY: 30ms (GSI 10ms + GetItem 20ms)
- ALL: 20ms (GSI만)
→ 10ms 차이 (무시 가능)

결론:
- 10ms 차이는 사용자가 체감 못함
- $0.05/월 절감 (작지만 의미 있음)
- Sparse Index 장점 극대화

5.4. 의사결정 매트릭스

┌─────────────────────────────────────────────────────┐
│              GSI 전략 의사결정                       │
├─────────────────────────────────────────────────────┤
│                                                      │
│  GSI 1 (pending-index)                              │
│  ├─ 조회 빈도: 높음 (100회/일)                       │
│  ├─ 반환 건수: 많음 (200개)                          │
│  ├─ 성능 요구: 매우 높음 (즉시 처리)                  │
│  └─ 선택: ALL (성능 우선) ⭐                         │
│                                                      │
│  GSI 2 (failed-pending-index)                       │
│  ├─ 조회 빈도: 중간 (50회/일)                        │
│  ├─ 반환 건수: 적음 (10개)                           │
│  ├─ 성능 요구: 보통 (분석용)                         │
│  └─ 선택: KEYS_ONLY (비용 우선) ⭐                  │
│                                                      │
└─────────────────────────────────────────────────────┘

6. 비용 분석

6.1. 저장 비용

메인 테이블:

일평균: 100,000건
보관 기간: 90일 (3개월)
항목 크기: 0.8 KB

총 데이터:
100,000건/일 × 90일 × 0.8 KB = 7.2 GB

저장 비용:
7.2 GB × $0.25/GB = $1.80/월

GSI 1 (pending-index) - 3가지 옵션 비교:

조건: 미처리 1% (deleted_at = null)
항목 수: 100,000건 × 1% = 1,000건

┌────────────────────────────────────────────────────┐
│ KEYS_ONLY                                          │
│ - 항목 크기: 0.17 KB                                │
│ - 총 크기: 1,000 × 0.17 KB = 0.17 GB               │
│ - 비용: 0.17 GB × $0.25 = $0.042/월               │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ INCLUDE (batch_id, retry 등 7개 속성)              │
│ - 항목 크기: 0.4 KB                                 │
│ - 총 크기: 1,000 × 0.4 KB = 0.4 GB                 │
│ - 비용: 0.4 GB × $0.25 = $0.10/월                  │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ ALL (선택) ⭐                                       │
│ - 항목 크기: 0.8 KB                                 │
│ - 총 크기: 1,000 × 0.8 KB = 0.8 GB                 │
│ - 비용: 0.8 GB × $0.25 = $0.20/월                  │
└────────────────────────────────────────────────────┘

차이:
- ALL vs KEYS_ONLY: +$0.158/월 (약 4배)
- ALL vs INCLUDE: +$0.10/월 (약 2배)

GSI 2 (failed-pending-index) - 3가지 옵션 비교:

조건: 실패 중 미처리 0.01% (retry >= 1 AND deleted_at = null)
항목 수: 100,000건 × 0.01% = 10건

┌────────────────────────────────────────────────────┐
│ KEYS_ONLY (선택) ⭐                                 │
│ - 항목 크기: 0.17 KB                                │
│ - 총 크기: 10 × 0.17 KB = 0.0017 GB                │
│ - 비용: 0.0017 GB × $0.25 = $0.0004/월            │
│         (거의 무시 가능)                            │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ INCLUDE                                            │
│ - 항목 크기: 0.4 KB                                 │
│ - 총 크기: 10 × 0.4 KB = 0.004 GB                  │
│ - 비용: 0.004 GB × $0.25 = $0.001/월              │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ ALL                                                │
│ - 항목 크기: 0.8 KB                                 │
│ - 총 크기: 10 × 0.8 KB = 0.008 GB                  │
│ - 비용: 0.008 GB × $0.25 = $0.002/월              │
└────────────────────────────────────────────────────┘

차이:
- 모두 $0.002/월 이하로 무시 가능
- KEYS_ONLY 선택으로 원칙 준수

저장 비용 총합:

메인 테이블: $1.80/월
GSI 1 (ALL): $0.20/월
GSI 2 (KEYS_ONLY): $0.0004/월

총 저장 비용: $2.00/월

6.2. 쓰기 비용

메인 테이블:

일평균: 100,000건
WCU: 1 WCU/건 (1KB 이하)

쓰기 비용:
100,000건/일 × 30일 × $1.25/백만건 = $3.75/월

GSI 1 (pending-index) - 3가지 옵션 비교:

쓰기 발생 시점:
1. 생성 시: pending_pk 설정 (100,000건)
2. 처리 완료 시: pending_pk → null (99,000건)

총 쓰기: 199,000건/일

┌────────────────────────────────────────────────────┐
│ 모든 옵션 동일 (KEYS_ONLY, INCLUDE, ALL)           │
│ - WCU 계산: 항목 크기에 관계없이 1 WCU             │
│ - 총 쓰기: 199,000건/일 × 30일 = 5,970,000건/월   │
│ - 비용: 5,970,000 × $1.25/백만 = $7.46/월        │
└────────────────────────────────────────────────────┘

참고: DynamoDB WCU는 1KB 단위로 계산
- 0.17 KB: 1 WCU
- 0.4 KB: 1 WCU
- 0.8 KB: 1 WCU
→ 모두 1 WCU로 동일!

GSI 2 (failed-pending-index) - 3가지 옵션 비교:

쓰기 발생 시점:
1. 재시도 시: failed_pending_pk 설정 (1,000건)
2. 처리 완료 시: failed_pending_pk → null (990건)

총 쓰기: 1,990건/일

┌────────────────────────────────────────────────────┐
│ 모든 옵션 동일                                      │
│ - 총 쓰기: 1,990건/일 × 30일 = 59,700건/월        │
│ - 비용: 59,700 × $1.25/백만 = $0.07/월           │
└────────────────────────────────────────────────────┘

쓰기 비용 총합:

메인 테이블: $3.75/월
GSI 1: $7.46/월 (모든 옵션 동일)
GSI 2: $0.07/월 (모든 옵션 동일)

총 쓰기 비용: $11.28/월

6.3. 읽기 비용

UC1: 미처리 조회 (GSI 1) - 3가지 옵션 비교:

조회 빈도: 100회/일
평균 반환: 200건/조회
샤드 수: 20개 (병렬 조회)

┌────────────────────────────────────────────────────┐
│ KEYS_ONLY                                          │
│                                                     │
│ GSI Query:                                         │
│ - 200건 × 0.17 KB = 34 KB                          │
│ - RCU: 34 KB ÷ 4 KB = 9 RCU                       │
│ - 비용: 9 × 100회 × 30일 × $0.25/백만 = $0.007   │
│                                                     │
│ GetItem (메인 테이블):                              │
│ - 200건 × 0.8 KB = 160 KB                          │
│ - RCU: 160 KB ÷ 4 KB = 40 RCU                     │
│ - 비용: 40 × 100 × 30 × $0.25/백만 = $0.030      │
│                                                     │
│ 총 비용: $0.037/월                                  │
│ 응답 시간: 150ms (GSI 50ms + GetItem 100ms)       │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ INCLUDE                                            │
│                                                     │
│ GSI Query:                                         │
│ - 200건 × 0.4 KB = 80 KB                           │
│ - RCU: 80 KB ÷ 4 KB = 20 RCU                      │
│ - 비용: 20 × 100 × 30 × $0.25/백만 = $0.015      │
│                                                     │
│ GetItem (json_data만 필요):                        │
│ - 100건 × 0.8 KB = 80 KB                           │
│ - RCU: 80 KB ÷ 4 KB = 20 RCU                      │
│ - 비용: 20 × 100 × 30 × $0.25/백만 = $0.015      │
│                                                     │
│ 총 비용: $0.030/월                                  │
│ 응답 시간: 80ms (GSI 50ms + GetItem 30ms)         │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ ALL (선택) ⭐                                       │
│                                                     │
│ GSI Query만:                                       │
│ - 200건 × 0.8 KB = 160 KB                          │
│ - RCU: 160 KB ÷ 4 KB = 40 RCU                     │
│ - 비용: 40 × 100 × 30 × $0.25/백만 = $0.030      │
│                                                     │
│ GetItem: 불필요 (0원)                              │
│                                                     │
│ 총 비용: $0.030/월                                  │
│ 응답 시간: 50ms (GSI만) ⭐⭐⭐                      │
└────────────────────────────────────────────────────┘

비교:
- KEYS_ONLY: $0.037/월, 150ms
- INCLUDE: $0.030/월, 80ms
- ALL: $0.030/월, 50ms ⭐

결론: ALL은 비용 동일하면서 가장 빠름!

UC2: 실패 조회 (GSI 2) - 3가지 옵션 비교:

조회 빈도: 50회/일
평균 반환: 10건/조회

┌────────────────────────────────────────────────────┐
│ KEYS_ONLY (선택) ⭐                                 │
│                                                     │
│ GSI Query:                                         │
│ - 10건 × 0.17 KB = 1.7 KB                          │
│ - RCU: 1.7 KB ÷ 4 KB = 1 RCU                      │
│ - 비용: 1 × 50 × 30 × $0.25/백만 = $0.0004       │
│                                                     │
│ GetItem:                                           │
│ - 10건 × 0.8 KB = 8 KB                             │
│ - RCU: 8 KB ÷ 4 KB = 2 RCU                        │
│ - 비용: 2 × 50 × 30 × $0.25/백만 = $0.0008       │
│                                                     │
│ 총 비용: $0.0012/월 (무시 가능)                    │
│ 응답 시간: 30ms                                     │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ INCLUDE                                            │
│ - 총 비용: $0.0010/월                              │
│ - 응답 시간: 25ms                                   │
└────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────┐
│ ALL                                                │
│ - 총 비용: $0.0015/월                              │
│ - 응답 시간: 20ms                                   │
└────────────────────────────────────────────────────┘

비교:
- 모두 $0.002/월 이하로 무시 가능
- 응답 시간 차이 10ms (체감 불가)
- KEYS_ONLY 선택 (원칙적 선택)

읽기 비용 총합:

UC1 (미처리): $0.030/월
UC2 (실패): $0.0012/월

총 읽기 비용: $0.031/월

6.4. 총 비용 및 ROI

총 비용 비교표:

┌──────────────────────────────────────────────────────────────┐
│                   월간 비용 상세 (선택된 옵션)                │
├──────────────────────────────────────────────────────────────┤
│ 항목                  │ 비용        │ 비고                   │
├──────────────────────────────────────────────────────────────┤
│ 메인 테이블 저장       │ $1.80      │ 7.2 GB                │
│ 메인 테이블 쓰기       │ $3.75      │ 300만건               │
├──────────────────────────────────────────────────────────────┤
│ GSI 1 저장 (ALL)      │ $0.20      │ 0.8 GB                │
│ GSI 1 쓰기            │ $7.46      │ 597만건               │
│ GSI 1 읽기            │ $0.030     │ 100회/일 × 200건      │
├──────────────────────────────────────────────────────────────┤
│ GSI 2 저장 (KEYS)     │ $0.0004    │ 0.0017 GB             │
│ GSI 2 쓰기            │ $0.07      │ 5.97만건              │
│ GSI 2 읽기            │ $0.0012    │ 50회/일 × 10건        │
├──────────────────────────────────────────────────────────────┤
│ 합계                  │ $13.28/월  │                       │
└──────────────────────────────────────────────────────────────┘

옵션별 비용 비교:

시나리오 1: GSI 없이 메인 테이블만
- 저장: $1.80
- 쓰기: $3.75
- 읽기: $0.20 (Scan 비용 높음)
- 총: $5.75/월
- 문제: 조회 불가능 (deleted_at 필터링 불가)

시나리오 2: GSI 1 KEYS_ONLY + GSI 2 KEYS_ONLY
- 저장: $1.80 + $0.042 + $0.0004 = $1.84
- 쓰기: $3.75 + $7.46 + $0.07 = $11.28
- 읽기: $0.037 + $0.0012 = $0.038
- 총: $13.16/월
- 문제: GSI 1 조회 시 200번 GetItem (느림)

시나리오 3: GSI 1 INCLUDE + GSI 2 KEYS_ONLY
- 저장: $1.80 + $0.10 + $0.0004 = $1.90
- 쓰기: $11.28 (동일)
- 읽기: $0.030 + $0.0012 = $0.031
- 총: $13.21/월
- 특징: 균형잡힌 선택

시나리오 4: GSI 1 ALL + GSI 2 KEYS_ONLY (선택) ⭐
- 저장: $1.80 + $0.20 + $0.0004 = $2.00
- 쓰기: $11.28 (동일)
- 읽기: $0.030 + $0.0012 = $0.031
- 총: $13.28/월
- 특징: 최고 성능, 합리적 비용

시나리오 5: GSI 1 ALL + GSI 2 ALL
- 저장: $1.80 + $0.20 + $0.002 = $2.00
- 쓰기: $11.28 (동일)
- 읽기: $0.030 + $0.0015 = $0.032
- 총: $13.28/월
- 특징: 미세한 차이, 과도한 선택

ROI 분석 (선택된 옵션):

추가 비용: $13.28 - $5.75 = $7.53/월

효과:
✅ 조회 기능 활성화 (불가능 → 가능)
✅ 응답 시간 3배 개선 (150ms → 50ms)
✅ 운영 효율성 향상 (즉시 모니터링)
✅ Sparse Index로 99% 저장 절감
✅ 확장성 확보 (처리량 10배 증가 대비)

투자 대비 가치:
- 월 $7.53으로 핵심 기능 구현
- 운영 효율성으로 인건비 절감 효과
- 장애 대응 시간 단축

결론: 매우 높은 ROI ⭐⭐⭐

7. 성능 및 안정성

7.1. 응답 시간 비교

UC1: 미처리 조회 (200개 반환):

옵션GSI 쿼리GetItem총 시간개선율
KEYS_ONLY50ms100ms150ms-
INCLUDE50ms30ms80ms47% ↑
ALL50ms0ms50ms67% ↑

UC2: 실패 조회 (10개 반환):

옵션GSI 쿼리GetItem총 시간체감
KEYS_ONLY10ms20ms30ms무시 가능 ⭐
INCLUDE10ms10ms20ms무시 가능
ALL10ms0ms10ms무시 가능

인사이트:

  • GSI 1 (ALL): 67% 성능 향상 - 매우 중요
  • GSI 2 (KEYS_ONLY): 10ms 차이 - 무시 가능
  • 사용자는 50ms 이하 응답을 즉각적으로 인식

7.2. Throttling 위험도

피크 시나리오: 초당 10회 조회

┌─────────────────────────────────────────────┐
│ 옵션별 부하 (초당)                           │
├─────────────────────────────────────────────┤
│ GSI 1 - KEYS_ONLY                           │
│ - RCU: 485 RCU/s                            │
│ - 요청: 280 req/s (28 * 10)          ⚠️    │
│ - 평가: 요청 수 과다, 복잡도 높음            │
│                                             │
│ GSI 1 - INCLUDE                             │
│ - RCU: 280 RCU/s                            │
│ - 요청: 220 req/s (22 * 10)          ✅    │
│ - 평가: 균형잡힌 선택                       │
│                                             │
│ GSI 1 - ALL ⭐                              │
│ - RCU: 400 RCU/s                            │
│ - 요청: 200 req/s (20 * 10)          ✅    │
│ - 평가: 최적, 단순함                        │
└─────────────────────────────────────────────┘

DynamoDB On-Demand 제한:
- 자동 스케일링 (사실상 무제한)
- 하지만 요청 수가 많으면 네트워크 오버헤드
- 단순한 패턴이 유리

스파이크 시나리오: 초당 100회 조회 (10배)

GSI 1 - KEYS_ONLY:
- RCU: 4,850 RCU/s ← 높음
- 요청: 2,800 req/s ← 매우 복잡
- 위험도: ⚠️⚠️⚠️

GSI 1 - ALL:
- RCU: 4,000 RCU/s ← 높지만 관리 가능
- 요청: 2,000 req/s ← 단순
- 위험도: ⭐ 안정적

결론: ALL은 안정성과 성능 모두 우수 ⭐

7.3. 확장성 고려사항

수평 확장 (10배 증가):

현재: 일 10만건
10배 증가: 일 100만건

저장 비용:
- 메인: $1.80 → $18.00
- GSI 1 (ALL): $0.20 → $2.00
- GSI 2 (KEYS): $0.0004 → $0.004
- 총: $2.00 → $20.00 (10배)

쓰기 비용:
- 메인: $3.75 → $37.50
- GSI 1: $7.46 → $74.60
- GSI 2: $0.07 → $0.70
- 총: $11.28 → $112.80 (10배)

읽기 비용:
- 조회 빈도 동일 가정
- $0.031 (변화 없음)

총 비용: $13.28 → $132.83/월

평가:
- 선형적 증가 (예측 가능)
- 여전히 합리적 수준
- Auto Scaling으로 자동 대응

샤딩 확장:

현재: 20개 샤드
확장: 40개 샤드 (쓰기 부하 2배 분산)

변경 사항:
- PK 범위만 변경 (#1~#40)
- 조회 시 병렬 수 증가 (20 → 40)
- 비용 변화 없음
- 파티션당 부하 1/2로 감소

주의:
- 조회 시 40개 병렬 요청
- 네트워크 오버헤드 증가
- 적절한 샤드 수 선택 중요

8. 구현 가이드

8.1. Terraform 설정

resource "aws_dynamodb_table" "event_message" {
  name           = "EventMessage"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "message_pk"
  range_key      = "message_sk"

  # 메인 테이블 속성
  attribute {
    name = "message_pk"
    type = "S"
  }

  attribute {
    name = "message_sk"
    type = "S"
  }

  # GSI 1 속성
  attribute {
    name = "pending_message_pk"
    type = "S"
  }

  attribute {
    name = "pending_message_sk"
    type = "S"
  }

  # GSI 2 속성
  attribute {
    name = "failed_pending_message_pk"
    type = "S"
  }

  attribute {
    name = "failed_pending_message_sk"
    type = "S"
  }

  # GSI 1: 미처리 전체 (ALL)
  global_secondary_index {
    name            = "pending-index"
    hash_key        = "pending_message_pk"
    range_key       = "pending_message_sk"
    projection_type = "ALL"  # 모든 속성 복사
  }

  # GSI 2: 실패건 (KEYS_ONLY)
  global_secondary_index {
    name            = "failed-pending-index"
    hash_key        = "failed_pending_message_pk"
    range_key       = "failed_pending_message_sk"
    projection_type = "KEYS_ONLY"  # 키만 복사
  }

  # TTL 설정
  ttl {
    attribute_name = "expiration_time"
    enabled        = true
  }

  tags = {
    Environment = "production"
    Service     = "event-processing"
  }
}

8.2. 코드 구현 (Kotlin)

데이터 클래스:

@DynamoDbBean
data class Message(
    @get:DynamoDbPartitionKey
    @get:DynamoDbAttribute("message_pk")
    var messagePk: String = "",
    
    @get:DynamoDbSortKey
    @get:DynamoDbAttribute("message_sk")
    var messageSk: String = "",
    
    // 기본 속성들
    @get:DynamoDbAttribute("batch_id")
    var batchId: String = "",
    
    @get:DynamoDbAttribute("retry")
    var retry: Int = 0,
    
    @get:DynamoDbAttribute("json_data")
    var jsonData: String = "",
    
    @get:DynamoDbAttribute("created_at")
    var createdAt: String = "",
    
    @get:DynamoDbAttribute("deleted_at")
    var deletedAt: String? = null,
    
    @get:DynamoDbAttribute("expiration_time")
    var expirationTime: Long? = null,
    
    // GSI 1
    @get:DynamoDbSecondaryPartitionKey(indexNames = ["pending-index"])
    @get:DynamoDbAttribute("pending_message_pk")
    var pendingMessagePk: String? = null,
    
    @get:DynamoDbSecondarySortKey(indexNames = ["pending-index"])
    @get:DynamoDbAttribute("pending_message_sk")
    var pendingMessageSk: String? = null,
    
    // GSI 2
    @get:DynamoDbSecondaryPartitionKey(indexNames = ["failed-pending-index"])
    @get:DynamoDbAttribute("failed_pending_message_pk")
    var failedPendingMessagePk: String? = null,
    
    @get:DynamoDbSecondarySortKey(indexNames = ["failed-pending-index"])
    @get:DynamoDbAttribute("failed_pending_message_sk")
    var failedPendingMessageSk: String? = null,
) {
    companion object {
        private val TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss SSS")
        private const val SHARD_COUNT = 20
    }
    
    /**
     * 생성 후 GSI 속성 초기화
     */
    fun initializeGsiAttributes(): Message {
        // GSI 1: 미처리 이벤트
        if (deletedAt == null) {
            pendingMessagePk = messagePk
            pendingMessageSk = messageSk
        }
        
        // GSI 2: 실패 이벤트
        if (retry >= 1 && deletedAt == null) {
            failedPendingMessagePk = messagePk
            failedPendingMessageSk = messageSk
        }
        
        // TTL 설정 (3개월 후)
        expirationTime = Instant.now()
            .plus(90, ChronoUnit.DAYS)
            .epochSecond
        
        return this
    }
    
    /**
     * 처리 완료 → 소프트 딜리트
     */
    fun markAsCompleted(): Message {
        deletedAt = LocalDateTime.now().format(TIMESTAMP_FORMATTER)
        pendingMessagePk = null
        pendingMessageSk = null
        failedPendingMessagePk = null
        failedPendingMessageSk = null
        return this
    }
    
    /**
     * 재시도용 복사
     */
    fun createRetry(errorMessage: String): Message {
        val newSk = generateSortKey()
        return this.copy(
            messageSk = newSk,
            retry = this.retry + 1,
            jsonData = updateJsonWithError(errorMessage),
            createdAt = LocalDateTime.now().format(TIMESTAMP_FORMATTER),
            // GSI 속성 업데이트
            pendingMessagePk = this.messagePk,
            pendingMessageSk = newSk,
            failedPendingMessagePk = this.messagePk,
            failedPendingMessageSk = newSk,
        )
    }
    
    private fun generateSortKey(): String {
        val epochMilli = System.currentTimeMillis()
        val random = (1000000..9999999).random()
        return "$epochMilli$random"
    }
    
    private fun updateJsonWithError(errorMessage: String): String {
        // JSON에 에러 메시지 추가 로직
        return jsonData // 실제 구현 필요
    }
}

Repository 구현:

@Repository
class MessageRepository(
    private val dynamoDbClient: DynamoDbClient,
    private val enhancedClient: DynamoDbEnhancedClient
) {
    private val table = enhancedClient.table(
        "EventMessage",
        TableSchema.fromBean(Message::class.java)
    )
    
    /**
     * 메시지 생성
     */
    fun create(message: Message): Message {
        val initialized = message.initializeGsiAttributes()
        table.putItem(initialized)
        return initialized
    }
    
    /**
     * 메시지 완료 처리
     */
    fun markAsCompleted(pk: String, sk: String) {
        val message = table.getItem(
            Key.builder()
                .partitionValue(pk)
                .sortValue(sk)
                .build()
        ) ?: throw IllegalArgumentException("Message not found")
        
        table.putItem(message.markAsCompleted())
    }
    
    /**
     * 재시도 메시지 생성
     */
    fun createRetry(pk: String, sk: String, errorMessage: String): Message {
        val original = table.getItem(
            Key.builder()
                .partitionValue(pk)
                .sortValue(sk)
                .build()
        ) ?: throw IllegalArgumentException("Message not found")
        
        val retry = original.createRetry(errorMessage)
        table.putItem(retry)
        
        // 원본은 완료 처리
        table.putItem(original.markAsCompleted())
        
        return retry
    }
}

8.3. 조회 예시

UC1: 미처리 조회:

fun findPendingByDate(
    date: String,
    startEpoch: Long,
    endEpoch: Long
): List<Message> {
    val pendingIndex = table.index("pending-index")
    val allItems = ConcurrentLinkedQueue<Message>()
    
    // 20개 샤드 병렬 조회
    (1..20).toList().parallelStream().forEach { shard ->
        val pk = "$date#$shard"
        val skStart = "${startEpoch}0000000"
        val skEnd = "${endEpoch}9999999"
        
        val items = pendingIndex.query(
            QueryConditional.sortBetween(
                Key.builder()
                    .partitionValue(pk)
                    .sortValue(skStart)
                    .build(),
                Key.builder()
                    .partitionValue(pk)
                    .sortValue(skEnd)
                    .build()
            )
        ).items().toList()
        
        allItems.addAll(items)
    }
    
    // ALL projection이므로 GetItem 불필요!
    return allItems.sortedBy { it.messageSk }
}

UC2: 실패 조회:

fun findFailedByDate(
    date: String,
    startEpoch: Long,
    endEpoch: Long
): List<Message> {
    val failedIndex = table.index("failed-pending-index")
    val allItems = ConcurrentLinkedQueue<Message>()
    
    (1..20).toList().parallelStream().forEach { shard ->
        val pk = "$date#$shard"
        val skStart = "${startEpoch}0000000"
        val skEnd = "${endEpoch}9999999"
        
        val items = failedIndex.query(
            QueryConditional.sortBetween(
                Key.builder()
                    .partitionValue(pk)
                    .sortValue(skStart)
                    .build(),
                Key.builder()
                    .partitionValue(pk)
                    .sortValue(skEnd)
                    .build()
            )
        ).items().toList()
        
        allItems.addAll(items)
    }
    
    // KEYS_ONLY이므로 GetItem 필요
    return allItems.map { item ->
        table.getItem(
            Key.builder()
                .partitionValue(item.messagePk)
                .sortValue(item.messageSk)
                .build()
        )
    }.sortedBy { it.messageSk }
}

통계 조회:

fun getStatistics(date: String): Map<String, Int> {
    val pending = findPendingByDate(
        date,
        startEpoch = getStartOfDay(date),
        endEpoch = getEndOfDay(date)
    )
    
    val failed = findFailedByDate(
        date,
        startEpoch = getStartOfDay(date),
        endEpoch = getEndOfDay(date)
    )
    
    return mapOf(
        "total_pending" to pending.size,
        "total_failed" to failed.size,
        "success_rate" to if (pending.isNotEmpty()) {
            ((pending.size - failed.size) * 100 / pending.size)
        } else 0
    )
}

9. AWS Console 설정 방법

9.1. GSI 생성 단계

Step 1: 테이블 생성

  1. AWS Console → DynamoDB → Tables → Create table
  2. 기본 설정:
    Table name: EventMessage
    Partition key: message_pk (String)
    Sort key: message_sk (String)
  3. Table settings: On-demand 선택
  4. Create table 클릭

Step 2: GSI 1 생성 (pending-index)

  1. 생성된 테이블 선택 → Indexes
  2. Create index 클릭
  3. 설정:
    Partition key: pending_message_pk (String)
    Sort key: pending_message_sk (String)
    Index name: pending-index
  4. Attribute projections:
    • All 선택 ⭐
    • 이유: 모든 속성 필요, 읽기 성능 최우선
  5. Create index 클릭

Step 3: GSI 2 생성 (failed-pending-index)

  1. Create index 다시 클릭
  2. 설정:
    Partition key: failed_pending_message_pk (String)
    Sort key: failed_pending_message_sk (String)
    Index name: failed-pending-index
  3. Attribute projections:
    • Keys only 선택 ⭐
    • 이유: 비용 최소화, 소량 조회
  4. Create index 클릭

Step 4: TTL 설정

  1. Additional settings
  2. Time to Live (TTL) 섹션
  3. Manage TTL 클릭
  4. 설정:
    TTL attribute: expiration_time
    ✅ Enable TTL
  5. Save 클릭

Step 5: 확인

  1. Indexes 탭에서 2개 GSI 확인:
    ✅ pending-index (ALL projection)
    ✅ failed-pending-index (KEYS_ONLY projection)
  2. StatusACTIVE인지 확인
  3. GSI 생성에는 수 분 소요될 수 있음

9.2. 주의사항

Projection Type 변경 불가:

⚠️ GSI 생성 후 Projection Type 변경 불가능
→ 잘못 선택 시 GSI 삭제 후 재생성 필요
→ 재생성 시 데이터 다시 복사 (시간 소요)

예방:
- 테스트 환경에서 먼저 검증
- Terraform으로 IaC 관리 권장

GSI 생성 시간:

빈 테이블: 즉시 (수 초)
데이터 있는 테이블: 항목 수에 비례
  - 10만건: 약 5-10분
  - 100만건: 약 30-60분
  - 1000만건: 수 시간

주의:
- 생성 중에도 메인 테이블 사용 가능
- GSI는 CREATING 상태에서 사용 불가
- ACTIVE 상태 확인 후 사용

비용 발생 시점:

GSI 생성 시작부터 비용 발생
- Backfilling 중: Write 비용 (기존 데이터 복사)
- ACTIVE 후: Storage + Write 비용

최적화:
- 테이블이 비어있을 때 GSI 생성
- 또는 Sparse Index로 최소 항목만 복사

Sparse Index 확인:

GSI에 항목이 예상보다 적다면:
1. 메인 테이블 항목 확인
   - pending_message_pk 값 확인
   - null이면 GSI에 미포함 (정상)
2. 코드 확인
   - initializeGsiAttributes() 호출 확인
3. CloudWatch 확인
   - GSI ItemCount 메트릭 확인

10. 운영 가이드

10.1. 모니터링 지표

CloudWatch 메트릭:

필수 모니터링:

1. ConsumedReadCapacityUnits (GSI별)
   - GSI 1 (pending-index)
   - GSI 2 (failed-pending-index)
   
2. ConsumedWriteCapacityUnits (GSI별)
   - 쓰기 부하 추적
   
3. ThrottledRequests (GSI별)
   - Throttling 발생 감지
   
4. UserErrors
   - 클라이언트 에러 추적
   
5. SystemErrors
   - DynamoDB 내부 에러

애플리케이션 메트릭:

추가 모니터링:

6. 평균 응답 시간
   - UC1 (미처리 조회): 목표 <100ms
   - UC2 (실패 조회): 목표 <50ms
   
7. GSI 항목 수 (추정)
   - CloudWatch: ApproximateItemCount
   - Sparse Index 효과 확인
   
8. 조회 실패율
   - 애플리케이션 로그 분석
   
9. GetItem 호출 빈도
   - GSI 2 사용 시 모니터링

대시보드 구성:

┌────────────────────────────────────────────────┐
│ DynamoDB GSI 모니터링 대시보드                  │
├────────────────────────────────────────────────┤
│                                                 │
│ [메인 테이블]                                   │
│  - Read Capacity: 45 RCU/s                     │
│  - Write Capacity: 120 WCU/s                   │
│  - Item Count: 9,000,000                       │
│                                                 │
│ [GSI 1: pending-index]                         │
│  - Read Capacity: 40 RCU/s                     │
│  - Write Capacity: 200 WCU/s                   │
│  - Item Count: 1,000 (1%)                      │
│  - Throttled: 0                                │
│                                                 │
│ [GSI 2: failed-pending-index]                  │
│  - Read Capacity: 2 RCU/s                      │
│  - Write Capacity: 2 WCU/s                     │
│  - Item Count: 10 (0.01%)                      │
│  - Throttled: 0                                │
│                                                 │
│ [응답 시간]                                     │
│  - UC1 평균: 52ms ✅                            │
│  - UC2 평균: 28ms ✅                            │
│                                                 │
└────────────────────────────────────────────────┘

10.2. CloudWatch 알람 설정

Terraform 예시:

# GSI Throttling 알람
resource "aws_cloudwatch_metric_alarm" "gsi1_throttle" {
  alarm_name          = "event-message-gsi1-throttled"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "ThrottledRequests"
  namespace           = "AWS/DynamoDB"
  period              = 300
  statistic           = "Sum"
  threshold           = 10
  alarm_description   = "GSI 1 throttled requests detected"
  treat_missing_data  = "notBreaching"
  
  dimensions = {
    TableName = "EventMessage"
    GlobalSecondaryIndexName = "pending-index"
  }
  
  alarm_actions = [aws_sns_topic.alerts.arn]
}

resource "aws_cloudwatch_metric_alarm" "gsi2_throttle" {
  alarm_name          = "event-message-gsi2-throttled"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "ThrottledRequests"
  namespace           = "AWS/DynamoDB"
  period              = 300
  statistic           = "Sum"
  threshold           = 5
  alarm_description   = "GSI 2 throttled requests detected"
  treat_missing_data  = "notBreaching"
  
  dimensions = {
    TableName = "EventMessage"
    GlobalSecondaryIndexName = "failed-pending-index"
  }
  
  alarm_actions = [aws_sns_topic.alerts.arn]
}

# 높은 읽기 용량 알람
resource "aws_cloudwatch_metric_alarm" "high_rcu" {
  alarm_name          = "event-message-high-read-capacity"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "ConsumedReadCapacityUnits"
  namespace           = "AWS/DynamoDB"
  period              = 300
  statistic           = "Sum"
  threshold           = 100000
  alarm_description   = "High read capacity usage on GSI 1"
  treat_missing_data  = "notBreaching"
  
  dimensions = {
    TableName = "EventMessage"
    GlobalSecondaryIndexName = "pending-index"
  }
  
  alarm_actions = [aws_sns_topic.alerts.arn]
}

# System Errors 알람
resource "aws_cloudwatch_metric_alarm" "system_errors" {
  alarm_name          = "event-message-system-errors"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "SystemErrors"
  namespace           = "AWS/DynamoDB"
  period              = 60
  statistic           = "Sum"
  threshold           = 5
  alarm_description   = "DynamoDB system errors detected"
  treat_missing_data  = "notBreaching"
  
  dimensions = {
    TableName = "EventMessage"
  }
  
  alarm_actions = [aws_sns_topic.critical_alerts.arn]
}

# SNS Topic
resource "aws_sns_topic" "alerts" {
  name = "dynamodb-alerts"
}

resource "aws_sns_topic_subscription" "alerts_email" {
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "email"
  endpoint  = "team@example.com"
}

11. 부록

11.1. 대안 아키텍처 검토

이벤트 처리 시스템의 대안들을 간단히 비교합니다.

Option 1: Kafka + ClickHouse

아키텍처:
이벤트 → Kafka (버퍼링) → Flink (처리) → ClickHouse (저장)

장점:
✅ 초고속 처리 (수백만 TPS)
✅ 무제한 메시지 보관
✅ 복잡한 분석 쿼리 (SQL)
✅ 시계열 데이터에 최적화

단점:
❌ 운영 복잡도 매우 높음
❌ 비용 높음 (~$500/월)
❌ 인프라 관리 필요 (Kafka, Zookeeper, ClickHouse)
❌ 학습 곡선 가파름

적합한 경우:
- 처리량이 매우 높음 (일 1000만건+)
- 복잡한 분석 쿼리 필요
- 전문 인프라 팀 보유

Option 2: AWS Kinesis + DynamoDB

아키텍처:
이벤트 → Kinesis Data Streams → Lambda → DynamoDB

장점:
✅ AWS 네이티브 (관리 용이)
✅ 자동 스케일링
✅ 버퍼링으로 안정성 향상
✅ 실시간 처리

단점:
❌ 추가 비용 (~$100/월)
❌ 복잡도 증가 (레이어 추가)
❌ Kinesis Shard 관리 필요

비용:
- Kinesis: $0.015/shard/시간 = $10.8/월 (1 shard)
- Lambda: $0.20/백만 요청 = $6/월
- DynamoDB: $13.28/월 (GSI 포함)
- 총: ~$30/월

적합한 경우:
- 스파이크 트래픽이 심함
- 실시간 스트림 처리 필요
- 여러 소비자가 동일 이벤트 처리

Option 3: PostgreSQL RDS

아키텍처:
이벤트 → RDS PostgreSQL (단일 테이블)

장점:
✅ 익숙한 SQL
✅ 강력한 쿼리 기능
✅ 트랜잭션 지원
✅ 간단한 구조

단점:
❌ 확장성 제한 (수직 확장만)
❌ 쓰기 부하 집중
❌ 인덱스 관리 필요
❌ 파티셔닝 복잡

비용:
- RDS db.t3.medium: $60/월
- 스토리지 (100GB): $11.5/월
- 총: ~$71.5/월

적합한 경우:
- 복잡한 관계형 쿼리 필요
- 트랜잭션 필수
- 처리량이 낮음 (일 10만건 이하)
- PostgreSQL 전문성 보유

Option 4: Amazon Timestream

아키텍처:
이벤트 → Timestream (시계열 DB)

장점:
✅ 시계열 데이터에 최적화
✅ 자동 데이터 티어링
✅ SQL 쿼리 지원
✅ 서버리스

단점:
❌ 비용 높음 (쓰기 중심 시)
❌ 제한적인 데이터 모델
❌ 업데이트 불가 (Append-only)

비용:
- 쓰기: $0.50/GB = $48/월 (100MB/일)
- 메모리 스토리지: $0.036/GB-시간 = $26/월
- 총: ~$74/월

적합한 경우:
- 순수 시계열 데이터
- 집계 쿼리 중심
- Append-only 패턴

Option 5: ScyllaDB

아키텍처:
이벤트 → ScyllaDB (Cassandra 호환)

장점:
✅ DynamoDB 호환 API
✅ 10배 빠른 성능
✅ 자체 호스팅 시 저렴
✅ 무제한 확장성

단점:
❌ 관리형 없음 (자체 운영 필요)
❌ 인프라 전문성 필요
❌ 학습 곡선

비용:
- 자체 호스팅: ~$50/월 (EC2 + EBS)
- ScyllaDB Cloud: ~$100/월

적합한 경우:
- 매우 높은 처리량
- 자체 인프라 보유
- DynamoDB에서 마이그레이션

Option 6: DynamoDB (선택) ⭐

아키텍처:
이벤트 → DynamoDB (GSI 활용)

장점:
✅ 완전 관리형 (운영 최소)
✅ 간단한 구조
✅ 적정 비용 ($13/월)
✅ 충분한 성능 (일 10만건)
✅ AWS 네이티브

단점:
⚠️ 복잡한 쿼리 제한
⚠️ 관계형 쿼리 불가
⚠️ 스키마 변경 어려움

비용:
- 메인 테이블: $5.55/월
- GSI 1 (ALL): $7.66/월
- GSI 2 (KEYS): $0.07/월
- 총: $13.28/월

적합한 경우:
- 대부분의 이벤트 처리 시스템 ⭐
- 단순한 쿼리 패턴
- 관리 오버헤드 최소화
- AWS 인프라 활용

비교 매트릭스:

옵션처리량비용운영쿼리추천도
Kafka + ClickHouse⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐대규모
Kinesis + DynamoDB⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐실시간
PostgreSQL RDS⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐관계형
Timestream⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐시계열
ScyllaDB⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐전문가
DynamoDB⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐대부분

결론: 일 10만건, 단순 쿼리, 운영 최소화 → DynamoDB 최적

11.2. 참고 자료

공식 문서:

profile
일하며 겪은 문제를 나눠요

0개의 댓글