배경
대용량 이벤트 처리 시스템에서 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를 어떻게 활용할 것인가?
❓ 비용과 성능의 최적 균형점은?
문서 목적
이 문서는 위 질문들에 대한 의사결정 과정과 근거를 다룹니다:
처리량: 일평균 100,000건
보관 기간: 3개월 (TTL)
삭제 방식: 소프트 딜리트 (deleted_at)
실패율: 약 1%
조회 빈도:
- 미처리 조회: 100회/일
- 실패 조회: 50회/일
샘플 데이터:
{
"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
}
항목 크기:
json_data (약 300 bytes)GSI는 메인 테이블과 다른 키로 조회할 수 있는 보조 인덱스입니다.
메인 테이블 PK/SK: date#shard / epochMilli
GSI PK/SK: 자유롭게 설정 가능
특징:
✅ 테이블 생성 후 추가 가능
✅ 독립적인 RCU/WCU
✅ Eventually Consistent (최종 일관성)
✅ 최대 20개 생성 가능
메인 테이블과 GSI의 관계:
메인 테이블 (전체 데이터)
↓ 자동 복사
GSI (선택된 속성만)
핵심 아이디어: 조건을 만족하는 항목만 GSI에 포함
// 예시: 미처리 이벤트만 GSI에 포함
if (deleted_at == null) {
gsi_pk = main_pk // GSI에 포함됨
} else {
gsi_pk = null // GSI에서 제거됨
}
장점:
GSI에 어떤 속성을 복사할지 결정하는 옵션입니다.
| Type | 포함되는 속성 | 저장 크기 | 비용 | GetItem 필요 |
|---|---|---|---|---|
| KEYS_ONLY | PK, 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",
// ... 모든 속성
}
저장 비용: $0.25/GB/월
읽기 비용: $0.25/백만 RCU
쓰기 비용: $1.25/백만 WCU
GSI 비용 = GSI 저장 + GSI 쓰기
(GSI 읽기는 일반 읽기와 동일)
중요: GSI는 메인 테이블과 별도로 저장/쓰기 비용 발생!
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 (유휴)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
결과: 리소스 낭비 + 성능 저하 + 추가 비용 발생
많은 개발자가 혼동하는 두 개념을 명확히 구분합니다:
| 개념 | 관리 주체 | 레벨 | 목적 | 개수 |
|---|---|---|---|---|
| 파티션 | DynamoDB (자동) | 물리적 | 데이터 저장 단위 | 가변 (자동 조정) |
| 샤드 | 개발자 (애플리케이션) | 논리적 | 부하 분산 전략 | 고정 (설계 시 결정) |
관계:
샤드 (논리적)
↓
애플리케이션이 여러 개의 서로 다른 파티션 키 생성
↓
DynamoDB 해시 함수
↓
파티션 (물리적)
↓
여러 파티션에 분산 저장 (확률적)
중요:
❌ 잘못된 이해: "샤드 20개 = 파티션 20개"
✅ 올바른 이해: "샤드 20개 = 20개의 다른 PK → 여러 파티션에 분산될 가능성 ↑"
실제: DynamoDB가 데이터 크기와 처리량에 따라 파티션 수를 자동 결정
핫파티션 문제를 해결하려면 애플리케이션 레벨에서 파티션 키를 분산해야 합니다.
나쁜 설계: 날짜만 사용
PK = "2025-11-10"
→ 단일 파티션 집중 ❌
좋은 설계: 날짜#샤드 사용
PK = "2025-11-10#1" ~ "2025-11-10#20"
→ 여러 파티션에 분산 ✅
2.5절에서 학습한 파티션 구조를 바탕으로, 핫파티션을 방지하는 샤딩 전략을 설계합니다.
핵심 원리:
여러 개의 서로 다른 파티션 키 생성
↓
DynamoDB 해시 함수가 여러 파티션에 분산
↓
각 파티션의 부하 감소
설계: {날짜}#{샤드번호}
예시:
- 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 (정렬 키): {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 이내) │
│ │
└─────────────────────────────────────────────────┘
@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,
)
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개월 후)
└─ 메인 테이블에서 자동 삭제
요구사항:
특정 날짜의 미처리 이벤트를 조회
조회 빈도: 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) 사용
조회 특성:
요구사항:
특정 날짜의 실패 이벤트 조회 (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) 사용
조회 특성:
| Use Case | 빈도 | 반환 건수 | 본문 필요 | 특징 |
|---|---|---|---|---|
| UC1 | 높음 (100회/일) | 200개 | ✅ 필수 | 대량 조회, 즉시 처리 |
| UC2 | 중간 (50회/일) | 10개 | ✅ 필수 | 소량 조회, 상세 분석 |
GSI 1 (pending-index) - 미처리 이벤트 조회용
| 옵션 | 저장 크기 | 읽기 방식 | 응답 시간 | 비용 |
|---|---|---|---|---|
| KEYS_ONLY | 0.17 KB | GSI Query + GetItem(200건) | 150ms | $ |
| INCLUDE | 0.4 KB | GSI Query + GetItem(일부) | 80ms | $$ |
| ALL | 0.8 KB | GSI Query만 | 50ms | $$$ |
GSI 2 (failed-pending-index) - 실패 이벤트 조회용
| 옵션 | 저장 크기 | 읽기 방식 | 응답 시간 | 비용 |
|---|---|---|---|---|
| KEYS_ONLY | 0.17 KB | GSI Query + GetItem(10건) | 30ms | $ |
| INCLUDE | 0.4 KB | GSI Query + GetItem(일부) | 25ms | $$ |
| ALL | 0.8 KB | GSI Query만 | 20ms | $$$ |
선택 이유:
✅ 읽기 성능 최우선
- 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배 성능 향상
- 운영 효율성 극대화
- 사용자 경험 개선
선택 이유:
✅ 비용 최소화
- 실패 이벤트는 전체의 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 장점 극대화
┌─────────────────────────────────────────────────────┐
│ GSI 전략 의사결정 │
├─────────────────────────────────────────────────────┤
│ │
│ GSI 1 (pending-index) │
│ ├─ 조회 빈도: 높음 (100회/일) │
│ ├─ 반환 건수: 많음 (200개) │
│ ├─ 성능 요구: 매우 높음 (즉시 처리) │
│ └─ 선택: ALL (성능 우선) ⭐ │
│ │
│ GSI 2 (failed-pending-index) │
│ ├─ 조회 빈도: 중간 (50회/일) │
│ ├─ 반환 건수: 적음 (10개) │
│ ├─ 성능 요구: 보통 (분석용) │
│ └─ 선택: KEYS_ONLY (비용 우선) ⭐ │
│ │
└─────────────────────────────────────────────────────┘
메인 테이블:
일평균: 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/월
메인 테이블:
일평균: 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/월
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/월
총 비용 비교표:
┌──────────────────────────────────────────────────────────────┐
│ 월간 비용 상세 (선택된 옵션) │
├──────────────────────────────────────────────────────────────┤
│ 항목 │ 비용 │ 비고 │
├──────────────────────────────────────────────────────────────┤
│ 메인 테이블 저장 │ $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 ⭐⭐⭐
UC1: 미처리 조회 (200개 반환):
| 옵션 | GSI 쿼리 | GetItem | 총 시간 | 개선율 |
|---|---|---|---|---|
| KEYS_ONLY | 50ms | 100ms | 150ms | - |
| INCLUDE | 50ms | 30ms | 80ms | 47% ↑ |
| ALL | 50ms | 0ms | 50ms | 67% ↑ ⭐ |
UC2: 실패 조회 (10개 반환):
| 옵션 | GSI 쿼리 | GetItem | 총 시간 | 체감 |
|---|---|---|---|---|
| KEYS_ONLY | 10ms | 20ms | 30ms | 무시 가능 ⭐ |
| INCLUDE | 10ms | 10ms | 20ms | 무시 가능 |
| ALL | 10ms | 0ms | 10ms | 무시 가능 |
인사이트:
피크 시나리오: 초당 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은 안정성과 성능 모두 우수 ⭐
수평 확장 (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개 병렬 요청
- 네트워크 오버헤드 증가
- 적절한 샤드 수 선택 중요
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"
}
}
데이터 클래스:
@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
}
}
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
)
}
Step 1: 테이블 생성
Table name: EventMessage
Partition key: message_pk (String)
Sort key: message_sk (String)Step 2: GSI 1 생성 (pending-index)
Partition key: pending_message_pk (String)
Sort key: pending_message_sk (String)
Index name: pending-indexStep 3: GSI 2 생성 (failed-pending-index)
Partition key: failed_pending_message_pk (String)
Sort key: failed_pending_message_sk (String)
Index name: failed-pending-indexStep 4: TTL 설정
TTL attribute: expiration_time
✅ Enable TTLStep 5: 확인
✅ pending-index (ALL projection)
✅ failed-pending-index (KEYS_ONLY projection)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 메트릭 확인
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 ✅ │
│ │
└────────────────────────────────────────────────┘
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"
}
이벤트 처리 시스템의 대안들을 간단히 비교합니다.
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 최적 ⭐
공식 문서: