

# DynamoDB의 구체화된 집계 쿼리에 글로벌 보조 인덱스 사용
<a name="bp-gsi-aggregation"></a>

빠르게 변화하는 데이터에 대해 근실시간 집계와 주요 지표를 유지하는 것이 빠르게 결정을 내려야 하는 비즈니스에 점점 더 중요해지고 있습니다. 예를 들어 음악 라이브러리에서 가장 많이 다운로드된 노래를 거의 실시간으로 표시하거나 전자 상거래 플랫폼에서 범주별로 유행하는 제품을 표시해야 할 수 있습니다.

DynamoDB는 기본적으로 항목 간에 `SUM` 또는 `COUNT` 같은 집계 작업을 지원하지 않으므로 읽기 시간에 이러한 값을 계산하려면 많은 수의 항목을 스캔해야 하므로 느리고 비용이 많이 들 수 있습니다. 대신 데이터가 변경될 때 집계를 *사전 계산*하고 결과를 테이블에 일반 항목으로 저장할 수 있습니다. 이 패턴을 *구체화된 집계*라고 합니다.

**Topics**
+ [예제 시나리오 및 액세스 패턴](#bp-gsi-aggregation-scenario)
+ [사전 계산 집계를 사용하는 이유](#bp-gsi-aggregation-why)
+ [테이블 설계](#bp-gsi-aggregation-table-design)
+ [스트림 및 AWS Lambda를 사용한 집계 파이프라인](#bp-gsi-aggregation-pipeline)
+ [희소 GSI 설계](#bp-gsi-aggregation-sparse-gsi)
+ [GSI 쿼리](#bp-gsi-aggregation-querying)
+ [고려 사항](#bp-gsi-aggregation-considerations)

## 예제 시나리오 및 액세스 패턴
<a name="bp-gsi-aggregation-scenario"></a>

다음 요구 사항이 있는 음악 라이브러리 애플리케이션을 고려해 보겠습니다.
+ 애플리케이션은 개별 노래 다운로드를 대용량(초당 수천 개)으로 기록합니다.
+ 사용자는 한 달 동안 가장 많이 다운로드된 노래를 한 자릿수 밀리초의 지연 시간으로 확인해야 합니다.
+ 또한 애플리케이션은 ‘이번 달 상위 10개 노래’ 및 ‘해당 월에 다운로드된 모든 노래’와 같은 쿼리를 지원해야 합니다.

이러한 규모에서 모든 다운로드 레코드를 스캔하여 읽기 시 다운로드 수를 계산하는 것은 비용이 많이 들 수 있습니다. 대신 다운로드가 발생할 때마다 업데이트되는 실행 횟수를 유지하고 효율적인 쿼리를 지원하는 방식으로 저장할 수 있습니다.

## 사전 계산 집계를 사용하는 이유
<a name="bp-gsi-aggregation-why"></a>

집계를 계산하는 방법에는 여러 가지가 있습니다. 다음 표에서는 일반적인 대안을 비교하고 DynamoDB의 구체화된 집계가 이러한 유형의 사용 사례에 가장 적합한 이유를 설명합니다.


| 접근 방식 | 단점 | 사용해야 하는 경우 | 
| --- | --- | --- | 
| 읽기 시 스캔 및 계산 | 모든 쿼리에 대한 모든 다운로드 레코드를 읽어야 합니다. 지연 시간은 데이터 볼륨과 함께 증가하며 상당한 읽기 용량을 소비합니다. | 지연 시간이 문제가 되지 않는 매우 작은 데이터세트에만 적합합니다. | 
| 외부 집계 저장소(예: Amazon ElastiCache) | 별도의 서비스를 관리해야 하므로 운영 복잡성이 증가합니다. DynamoDB와 캐시 간의 동기화 로직이 필요합니다. | 단순 수를 초과하는 밀리초 미만의 읽기 또는 복잡한 집계 로직이 필요한 경우. | 
| 쓰기 시 애플리케이션 수준 집계 | 집계 로직을 쓰기 경로에 결합합니다. 다운로드를 기록한 후 개수를 업데이트하기 전에 애플리케이션이 실패하면 집계가 일관되지 않게 됩니다. | 동기식이며 강력히 일관된 집계가 필요한 경우 추가 쓰기 지연 시간을 허용할 수 있습니다. | 
| 스트림 및 Lambda를 사용한 구체화된 집계 | 쓰기 경로에서 집계를 분리합니다. 집계는 최종적으로 일관됩니다(일반적으로 몇 초 뒤처짐). Lambda 간접 호출 비용을 추가합니다. | 읽기 지연 시간이 짧고 최종 일관성을 허용할 수 있는 실시간에 가까운 집계가 필요한 경우. 이는 이 페이지에서 설명하는 접근 방식입니다. | 

구체화된 집계 접근 방식은 쓰기 경로를 단순하게 유지하고(다운로드만 기록하고), 집계를 비동기 프로세스로 오프로드하고, 결과를 DynamoDB에 저장하여 한 자릿수 밀리초 지연 시간으로 쿼리할 수 있습니다.

## 테이블 설계
<a name="bp-gsi-aggregation-table-design"></a>

이 설계는 동일한 파티션 키(`songID`)를 공유하지만 서로 다른 정렬 키 패턴을 사용하여 두 항목 유형을 구분하는 두 개의 항목 유형이 있는 단일 테이블을 사용합니다.
+ **레코드 다운로드** - 개별 다운로드 이벤트입니다. 정렬 키는 `DownloadID`(각 다운로드의 고유 식별자)입니다.
+ **월별 집계 항목** - 매월 노래당 사전 계산된 다운로드 수입니다. 정렬 키는 `YYYY-MM` 형식의 월입니다(예: `2018-01`). 이러한 항목에는 실행 합계가 있는 `DownloadCount` 속성도 포함됩니다.

월별 집계 항목에만 `Month` 속성이 포함됩니다. 이러한 구분은 나중에 설명하는 희소 GSI 설계에 중요합니다.

다음 다이어그램은 두 항목 유형이 모두 있는 테이블 레이아웃을 보여줍니다.

![동일한 파티션 키(songID)를 공유하는 다운로드 레코드 및 월별 집계 항목을 보여주는 음악 라이브러리 테이블 레이아웃입니다.](http://docs.aws.amazon.com/ko_kr/amazondynamodb/latest/developerguide/images/AggregationQueries.png)



| 항목 유형 | 파티션 키(songID) | Sort key | 추가 속성 | 
| --- | --- | --- | --- | 
| 레코드 다운로드 | song1 | download-abc123 | UserID, Timestamp | 
| 월별 집계 | song1 | 2018-01 | Month=2018-01, DownloadCount=1,746,992 | 

## 스트림 및 AWS Lambda를 사용한 집계 파이프라인
<a name="bp-gsi-aggregation-pipeline"></a>

집계 파이프라인은 다음과 같이 작동합니다.

1. 노래가 다운로드되면 애플리케이션은 `Partition-Key=songID` 및 `Sort-Key=DownloadID`를 사용하여 테이블에 새 항목을 작성합니다.

1. DynamoDB Streams는 이 쓰기를 스트림 레코드로 캡처합니다.

1. 스트림에 연결된 Lambda 함수는 새 레코드를 처리합니다. `songID` 및 이번 달을 식별한 다음 `DownloadCount` 속성을 증가시켜 해당 월별 집계 항목을 업데이트합니다.

1. 그런 다음 업데이트된 집계 항목을 희소 GSI를 통해 쿼리할 수 있습니다.

Lambda 함수는 `ADD` 표현식과 함께 `UpdateItem` 직접 호출을 사용하여 다운로드 수를 원자적으로 증가시킵니다. 이렇게 하면 read-modify-write 레이스 조건이 방지됩니다.

```
import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('MusicLibrary')

def handler(event, context):
    for record in event['Records']:
        if record['eventName'] == 'INSERT':
            new_image = record['dynamodb']['NewImage']
            song_id = new_image['songID']['S']
            # Derive the month from the download timestamp
            timestamp = new_image['Timestamp']['S']
            month = timestamp[:7]  # Extract YYYY-MM

            table.update_item(
                Key={
                    'songID': song_id,
                    'SK': month
                },
                UpdateExpression='ADD DownloadCount :inc SET #m = :month',
                ExpressionAttributeNames={
                    '#m': 'Month'
                },
                ExpressionAttributeValues={
                    ':inc': 1,
                    ':month': month
                }
            )
```

**참고**  
업데이트된 집계 값을 작성한 후 Lambda 실행이 실패하면 스트림 레코드가 재시도될 수 있습니다. `ADD` 작업은 실행할 때마다 개수를 증가시키기 때문에 재시도는 동일한 다운로드에 대해 개수를 두 번 이상 증가시켜 *대략적인* 값을 남깁니다. 대부분의 분석 및 리더보드 사용 사례에서는 이 작은 오류 마진이 허용됩니다. 정확한 개수가 필요한 경우, 예를 들어 특정 `DownloadID`가 이미 처리되었는지 확인하는 조건 표현식을 사용하여 멱등성 로직을 추가하는 것이 좋습니다.

## 희소 GSI 설계
<a name="bp-gsi-aggregation-sparse-gsi"></a>

집계된 결과를 효율적으로 쿼리하려면 다음 키 스키마를 사용하여 글로벌 보조 인덱스를 생성합니다.
+ **GSI 파티션 키:** `Month`(문자열)
+ **GSI 정렬 키:** `DownloadCount`(숫자)

월별 집계 항목에만 `Month` 속성이 포함되어 있기 때문에 이 GSI는 *희소*합니다. 개별 다운로드 레코드에는 이 속성이 없으므로 인덱스에서 자동으로 제외됩니다. 즉, GSI에는 테이블의 전체 항목 중 작은 부분인 사전 계산된 집계 항목만 포함됩니다.

희소 GSI는 두 가지 주요 이점을 제공합니다.
+ **비용 절감** - 집계 항목만 인덱스에 복제되므로 테이블의 모든 항목이 포함된 인덱스에 비해 쓰기 용량과 스토리지를 훨씬 적게 소비합니다.
+ **더 빠른 쿼리** - 인덱스에는 쿼리에 필요한 데이터만 포함되어 있으므로 읽기가 효율적이고 한 자릿수 밀리초 지연 시간으로 결과를 반환합니다.

희소 인덱스 작동 방식에 대한 자세한 내용은 [희소 인덱스 활용](bp-indexes-general-sparse-indexes.md) 섹션을 참조하세요.

## GSI 쿼리
<a name="bp-gsi-aggregation-querying"></a>

희소 GSI를 사용하면 여러 유형의 쿼리에 효율적으로 응답할 수 있습니다.

**지정된 달에 가장 많이 다운로드된 노래를 가져오려면 다음을 수행합니다.**

```
aws dynamodb query \
    --table-name "MusicLibrary" \
    --index-name "MonthDownloadsIndex" \
    --key-condition-expression "#m = :month" \
    --expression-attribute-names '{"#m": "Month"}' \
    --expression-attribute-values '{":month": {"S": "2018-01"}}' \
    --scan-index-forward false \
    --limit 1
```

`ScanIndexForward`를 `false`로 설정하면 `DownloadCount`가 결과를 내림차순으로 정렬하고 `Limit=1`이 상위 노래만 반환합니다.

**지정된 달의 상위 10개 노래를 가져오려면 다음을 수행합니다.**

```
aws dynamodb query \
    --table-name "MusicLibrary" \
    --index-name "MonthDownloadsIndex" \
    --key-condition-expression "#m = :month" \
    --expression-attribute-names '{"#m": "Month"}' \
    --expression-attribute-values '{":month": {"S": "2018-01"}}' \
    --scan-index-forward false \
    --limit 10
```

**지정된 달에 다운로드한 모든 노래 가져오기**(다운로드 수 기준으로 정렬):

```
aws dynamodb query \
    --table-name "MusicLibrary" \
    --index-name "MonthDownloadsIndex" \
    --key-condition-expression "#m = :month" \
    --expression-attribute-names '{"#m": "Month"}' \
    --expression-attribute-values '{":month": {"S": "2018-01"}}' \
    --scan-index-forward false
```

## 고려 사항
<a name="bp-gsi-aggregation-considerations"></a>

이 패턴을 구현할 때는 다음을 염두에 두세요.
+ **최종 일관성** - 집계 값은 DynamoDB Streams 및 Lambda를 통해 비동기적으로 업데이트됩니다. 일반적으로 다운로드가 기록되는 시점과 집계가 업데이트되는 시점 사이에 몇 초의 지연이 있습니다. 즉, GSI는 실시간 데이터가 아닌 거의 실시간 데이터를 반영합니다.
+ **Lambda 동시성** - 테이블의 쓰기 볼륨이 많은 경우 여러 Lambda 간접 호출이 동일한 집계 항목을 동시에 업데이트하려고 시도할 수 있습니다. 원자성 `ADD` 작업은 이를 안전하게 처리하지만 Lambda 동시성 및 스로틀링 지표를 모니터링하여 함수가 스트림을 따라잡을 수 있도록 해야 합니다.
+ **GSI 쓰기 용량** - 희소 GSI에는 집계 항목만 포함되므로 기본 테이블보다 훨씬 적은 쓰기 용량이 필요합니다. 그러나 집계 업데이트 속도를 처리하기에 충분한 용량을 프로비저닝(또는 온디맨드 모드 사용)해야 합니다.
+ **대략적인 수** - 앞서 언급했듯이 Lambda 재시도로 인해 수가 약간 과대 계산될 수 있습니다. 정확한 수가 필요한 사용 사례의 경우 Lambda 함수에서 멱등성 검사를 구현합니다.