[Production Traffic] DynamoDB 쿼리가 느려터졌다면? GSI로 빠르게 만들자 – 실전 튜닝 사례

2025. 7. 29. 23:27·Production Traffic

배경

최근 사용 중인 Golang 애플리케이션에서 특정 API의 응답 속도가 느려, 사용자에게 정보를 빠르게 제공하지 못하는 문제가 있었습니다.  
분석 결과, DynamoDB에서 `Scan`을 사용해 데이터를 조회하고 있었고, 이로 인해 성능이 크게 저하되고 있었습니다.

쿼리 효율화를 위해 Global Secondary Index(GSI)를 도입해 문제를 해결하고자 했습니다.

인프라 구성

0.25vCPU, 0.5GB을 가진 ECS Task 2대로 구성하였으며, 해당 애플리케이션은 다음과 같은 구조에서 동작합니다:

Client → ALB → ECS (Golang App) → DynamoDB

 

- POST Method: 새로운 데이터를 DynamoDB에 삽입
- GET Method: 특정 조건으로 데이터를 조회 (문제 발생 지점)

문제 상황

인프라 부하 테스트를 위해 [k6](https://k6.io/)를 사용하여 동시접속자 100명이 약 20초간 POST → GET 요청을 반복 수행하도록 설정했습니다.  

그 결과, 다음과 같은 성능 병목이 관측되었습니다:

- 테스트 중 전체 요청 수: 5,746건, 초당 요청 처리량: 278 RPS
- `GET /v1/product?id=xxx&requestid=yyy&uuid=zzz` 요청이 문제의 핵심
- DynamoDB 테이블은 `id`를 Partition Key로 사용하고 있었으나,
  실제 조회 조건은 `requestid`와 `uuid` 조합이 필요 → `Scan + FilterExpression` 사용 중
- 평균 응답 시간: 101.83ms
- P90 응답 시간: 197.79ms, P95 응답 시간: 276.16ms, 최대 응답 시간: 510.68ms
- 실패율: 0.01% (1건) 으로 무시 가능 수준

 

표면적으로 평균 응답 시간은 양호해 보이지만, 실제 서비스 상황에서는 다양한 요청 조건에서 Query Latency가 500ms 이상까지 증가하며 사용자 체감이 발생할 수 있는 수준이었습니다.

특히, 데이터가 누적됨에 따라 Scan 성능이 기하급수적으로 저하될 우려가 있었고, 이는 추후 확장성과 운영 안정성 측면에서도 **중대한 병목 요소**로 판단되었습니다.

해결방법

DynamoDB는 기본적으로 테이블의 파티션 키(Partition Key) 조합으로만 Query가 가능합니다. 하지만, 본 애플리케이션은 uuid와 requestid를 기준으로 조건 검색이 필요했기 때문에 Scan + FilterExpression이라는 비효율적인 방식으로 동작 중이었습니다.

이를 해결하기 위해 GSI(Global Secondary Index) 를 도입하여, uuid를 파티션 키로, requestid를 정렬 키로 설정하였습니다.

코드 변경: Scan → Query

기존에는 DynamoDB에서 데이터를 조회할 때 Scan + FilterExpression 방식으로 동작하고 있었습니다.
이 방식은 전체 테이블을 순회하므로, 데이터가 많아질수록 응답 시간이 길어지고 비용도 증가합니다.

이를 해결하기 위해, Global Secondary Index (GSI) 를 생성하고 Golang 애플리케이션 내 코드를 Scan 기반에서 Query 기반으로 리팩토링했습니다.

아래는 리팩토링된 전체 코드이며, 환경변수 TABLE_INDEX_NAME 이 존재할 경우 GSI 기반 쿼리(Query) 를 수행하고, 존재하지 않을 경우 fallback 으로 Scan 을 수행하는 구조입니다.

main.go (클릭하여 전체 코드 보기)
package main
import (
	"fmt"
	"net/http"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
	"github.com/gin-gonic/gin"
)

type Product struct {
	ID        string `json:"id"`
	UUID      string `json:"uuid"`
	RequestID string `json:"requestid"`
	Name      string `json:"name"`
	Price     int    `json:"price"`
}

func main() {
	tableName := os.Getenv("TABLE_NAME")
	indexName := os.Getenv("TABLE_INDEX_NAME")

	sess := session.Must(session.NewSession())
	svc := dynamodb.New(sess)

	router := gin.Default()

	router.GET("/v1/product", func(c *gin.Context) {
		id := c.Query("id")
		requestid := c.Query("requestid")
		uuid := c.Query("uuid")

		var result Product
		var err error

		if indexName != "" {
			// GSI 쿼리
			queryInput := &dynamodb.QueryInput{
				TableName:              aws.String(tableName),
				IndexName:              aws.String(indexName),
				KeyConditionExpression: aws.String("#uuid = :uuid AND #requestid = :requestid"),
				ExpressionAttributeNames: map[string]*string{
					"#uuid":      aws.String("uuid"),
					"#requestid": aws.String("requestid"),
				},
				ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
					":uuid":      {S: aws.String(uuid)},
					":requestid": {S: aws.String(requestid)},
				},
			}

			queryOutput, err := svc.Query(queryInput)
			if err != nil || len(queryOutput.Items) == 0 {
				c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
				return
			}
			err = dynamodbattribute.UnmarshalMap(queryOutput.Items[0], &result)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unmarshal result"})
				return
			}
		} else {
			// Scan fallback
			scanInput := &dynamodb.ScanInput{
				TableName: aws.String(tableName),
				FilterExpression: aws.String("#uuid = :uuid AND #requestid = :requestid"),
				ExpressionAttributeNames: map[string]*string{
					"#uuid":      aws.String("uuid"),
					"#requestid": aws.String("requestid"),
				},
				ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
					":uuid":      {S: aws.String(uuid)},
					":requestid": {S: aws.String(requestid)},
				},
			}

			scanOutput, err := svc.Scan(scanInput)
			if err != nil || len(scanOutput.Items) == 0 {
				c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
				return
			}
			err = dynamodbattribute.UnmarshalMap(scanOutput.Items[0], &result)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unmarshal result"})
				return
			}
		}

		c.JSON(http.StatusOK, result)
	})

	router.Run(":8080")
}

위 코드 구조의 핵심은 다음과 같습니다:

- TABLE_INDEX_NAME 환경변수가 존재하면 → GSI 기반 Query

- 환경변수가 없으면 → 기존 Scan + FilterExpression fallback

- uuid를 Partition Key로, requestid를 Sort Key로 하는 GSI가 필요

성능 개선 결과 (Index 적용 후) -

- 전체 요청 수: 7,024건, 초당 요청 처리량: 341.59 RPS

- 평균 응답 시간: 35.56ms

- P90 응답 시간: 79.77ms, P95 응답 시간: 119.79ms, 최대 응답 시간: 413.33ms

- 실패율: 0.01% (1건)

GSI 설정 이후 평균 응답 시간 101.83ms → 35.56ms 로 65% 이상 성능 개선되었으며, RPS 약 23% 증가 (278 → 341) 했습니다.

회고

GSI 하나로 성능이 3배 빨라졌다 – 설정 하나의 위력

최근에 작은 프로젝트를 하나 운영하면서 "그렇게 중요해 보이지 않았던 설정 하나가 얼마나 치명적일 수 있는지" 뼈저리게 느꼈다.

처음엔 그냥 DynamoDB 테이블 하나 만들고, id 기준으로 Primary Key를 설정해 데이터를 쌓고 있었다. 애플리케이션은 Go(Golang)으로 작성했고, AWS ECS EC2 Task에서 실행되도록 구성했으며, 해당 Task에서 직접 DynamoDB와 연동해 데이터를 조회하는 구조였다. 처음에는 문제 없어 보였다. 실제로 API도 잘 동작했고, 예상한 대로 결과도 나왔다. 

Scan으로는 문제가 많았고, 이 서비스는 클라이언트에서 uuid랑 requestid로 데이터를 조회해야 했는데, 테이블은 id 기준으로 구성돼 있었다. 즉, 조건에 맞는 데이터를 가져오려면 무조건 Scan + FilterExpression으로 모든 항목을 돌면서 일일이 체크해야 했다.

작은 데이터셋에선 티가 안 났다. 근데 k6로 부하 테스트를 돌려보니 문제가 명확해졌다.

구분 Scan 사용 시:

평균 응답 시간 101.83ms
P90 197.79ms
P95 276.16ms
최대 510.68ms

이건 단순히 느린 수준이 아니라, 지속적으로 사용하기에는 위험했다. 특히 사용자가 많아지거나, 테이블이 더 커졌을 때 상황은 문제가 커졌다.

GSI 하나로 체감 성능이 달라졌다

그래서 도입한 게 Global Secondary Index(GSI) 였다.

  • Partition Key: uuid
  • Sort Key: requestid

딱 우리가 조회하고 싶은 구조 그대로를 Index로 설계해서, 기존의 Scan을 Query로 대체했다. 단, 이게 모든 환경에서 항상 적용될 수는 없기에 코드 내에 TABLE_INDEX_NAME 이라는 환경 변수를 사용해서 인덱스 존재 여부에 따라 Scan ↔ Query를 자동 전환하도록 구성했다.

그리고 다시 성능을 측정해봤다.

구분 GSI 적용 후:

평균 응답 시간 35.56ms
P90 79.77ms
P95 119.79ms
최대 413.33ms

응답 속도가 3배 가까이 줄어들었다.
특히 평균 응답 시간이 100ms → 35ms로 줄어든 건 정말 체감이 클 수밖에 없다.

그리고 데이터는 절대 그대로 있지 않는다

지금은 괜찮다고 안심할 수 없다.

데이터는 무조건 늘어나게 되어 있다. 1MB, 10MB였던 데이터가 몇 달 후엔 1GB, 10GB가 되고, 그때도 Scan을 돌리고 있다면? 성능 차이는 선형이 아니라 기하급수적으로 벌어질 것이다. 지금 당장은 안 터질 수도 있다.

하지만 언젠간 반드시 병목이 된다.

마무리

DynamoDB는 굉장히 빠르고 유연한 서비스다.
하지만 아무리 좋은 기술도 잘못 설계된 구조 위에서는 성능을 보장할 수 없다.

  • 데이터 접근 패턴을 먼저 정의하고
  • 그에 맞는 PK, SK, GSI 구조를 잡고
  • fallback 구조도 코드에 녹여두고

이런 기초적인 설계가 가장 중요하다는 걸 이번에 다시 깨달았다.
앞으로는 절대 Scan부터 쓰는 일은 없을 것 같다.

 

 

 

 

저작자표시 비영리 변경금지 (새창열림)

'Production Traffic' 카테고리의 다른 글

[Production Traffic] 특정 API 호출 시 CPU와 Memory가 급증할 경우를 대비한 사전 시뮬레이션  (0) 2025.08.03
[Production Traffic] MySQL 인덱스 전후 성능 차이, 실무 부하 테스트로 증명하기  (0) 2025.08.02
'Production Traffic' 카테고리의 다른 글
  • [Production Traffic] 특정 API 호출 시 CPU와 Memory가 급증할 경우를 대비한 사전 시뮬레이션
  • [Production Traffic] MySQL 인덱스 전후 성능 차이, 실무 부하 테스트로 증명하기
dml113
dml113
dml113의 AWS 이야기
  • dml113
    Cloud
    dml113
  • 전체
    오늘
    어제
    • 분류 전체보기 (34)
      • Project (0)
      • Kubernetes (17)
        • CNCF (12)
        • TroubleShooting (1)
      • AWS Service (9)
      • Linux (3)
      • Github (2)
      • Production Traffic (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
dml113
[Production Traffic] DynamoDB 쿼리가 느려터졌다면? GSI로 빠르게 만들자 – 실전 튜닝 사례
상단으로

티스토리툴바