Post

DB 레벨 페이지네이션 최적화 - 메모리에서 MongoDB로

전체 데이터를 메모리에 로드하던 방식에서 MongoDB의 SetSkip, SetLimit, CountDocuments를 활용한 DB 레벨 페이지네이션으로 전환하여 메모리 사용량 99% 감소와 응답 시간 80% 단축을 달성한 경험

DB 레벨 페이지네이션 최적화 - 메모리에서 MongoDB로

문제의 시작: 느려지는 목록 조회

AI 플랫폼의 사용자가 늘면서 목록 조회 API가 느려지기 시작했다.

1
2
3
[15:23:45] GET /api/agents - 응답 시간: 2.3s
[15:23:47] GET /api/files - 응답 시간: 1.8s
[15:23:50] GET /api/sessions - 응답 시간: 3.1s

원인은 명확했다. 전체 데이터를 메모리에 로드한 후 Go 코드에서 필터링, 정렬, 페이지네이션을 수행하고 있었다.

기존 구현의 문제점

안티패턴: 메모리 기반 페이지네이션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 문제 코드: 전체 데이터를 메모리에 로드
func (s *agentService) GetAgents(ctx context.Context, ...) ([]models.Agent, int, error) {
    // 1. 모든 데이터 조회
    cursor, err := collection.Find(ctx, bson.M{"created_by": userID})
    var allAgents []models.Agent
    cursor.All(ctx, &allAgents)  // 전체 데이터를 메모리로!

    // 2. 메모리에서 필터링
    var filtered []models.Agent
    for _, agent := range allAgents {
        if matchesSearchQuery(agent, searchQuery) &&
           matchesStatus(agent, status) &&
           matchesTags(agent, tags) {
            filtered = append(filtered, agent)
        }
    }

    // 3. 메모리에서 정렬
    sort.Slice(filtered, func(i, j int) bool {
        return filtered[i].UpdatedAt.After(filtered[j].UpdatedAt)
    })

    // 4. 메모리에서 페이지네이션
    total := len(filtered)
    start := (page - 1) * pageSize
    end := min(start + pageSize, total)
    return filtered[start:end], total, nil
}

무엇이 문제인가?

flowchart LR
    subgraph Before["기존 방식"]
        A1[MongoDB] -->|10,000건| B1[Go 메모리]
        B1 -->|필터링| C1[8,000건]
        C1 -->|정렬| D1[8,000건]
        D1 -->|페이지네이션| E1[20건 반환]
    end

    subgraph After["최적화 후"]
        A2[MongoDB] -->|20건| B2[Go 메모리]
        B2 --> E2[20건 반환]
    end
문제점영향
메모리 사용량10,000건 × 1KB = 10MB per request
네트워크 트래픽불필요한 9,980건 전송
응답 시간데이터 양에 비례하여 선형 증가
확장성데이터 증가 시 OOM 위험

해결책: DB 레벨 최적화

MongoDB의 네이티브 쿼리 기능을 최대한 활용했다.

1. DB 레벨 필터링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 수정 후: DB에서 필터링
func buildAgentFilter(userID, searchQuery, status string, tags []string) bson.M {
    filter := bson.M{"created_by": userID}

    // 검색어 필터 (DB 레벨)
    if searchQuery != "" {
        filter["$or"] = []bson.M{
            {"name": bson.M{"$regex": searchQuery, "$options": "i"}},
            {"description": bson.M{"$regex": searchQuery, "$options": "i"}},
        }
    }

    // 상태 필터 (DB 레벨)
    if status != "" {
        filter["status"] = status
    }

    // 태그 필터 (DB 레벨)
    if len(tags) > 0 {
        filter["tags"] = bson.M{"$all": tags}
    }

    return filter
}

2. DB 레벨 정렬

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 수정 후: DB에서 정렬
func buildSortOptions(sortBy string, sortOrder int) bson.M {
    sortFieldMap := map[string]string{
        "name":       "name",
        "created_at": "created_at",
        "updated_at": "updated_at",
        "status":     "status",
    }

    field, ok := sortFieldMap[sortBy]
    if !ok {
        field = "updated_at"  // 기본값
    }

    return bson.M{field: sortOrder}  // 1: 오름차순, -1: 내림차순
}

// 사용
findOpts := options.Find().SetSort(buildSortOptions(sortBy, sortOrder))

3. DB 레벨 페이지네이션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 수정 후: DB에서 페이지네이션
func (s *agentService) GetAgents(ctx context.Context, ...) ([]models.Agent, int, error) {
    filter := buildAgentFilter(userID, searchQuery, status, tags)

    // 1. 총 개수 조회 (필터 적용)
    total, err := collection.CountDocuments(ctx, filter)
    if err != nil {
        return nil, 0, err
    }

    // 2. DB 레벨 페이지네이션
    skip := int64((page - 1) * pageSize)
    limit := int64(pageSize)

    findOpts := options.Find().
        SetSort(buildSortOptions(sortBy, sortOrder)).
        SetSkip(skip).
        SetLimit(limit)

    // 3. 필요한 데이터만 조회
    cursor, err := collection.Find(ctx, filter, findOpts)
    var agents []models.Agent
    cursor.All(ctx, &agents)

    return agents, int(total), nil
}

Aggregation Pipeline으로 복잡한 쿼리 처리

공유 에이전트처럼 권한 체크와 조인이 필요한 경우는 Aggregation Pipeline을 사용했다.

$facet으로 데이터와 카운트 동시 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
func (s *agentService) ListSharedAgent(ctx context.Context, ...) ([]models.Agent, int, error) {
    pipeline := []bson.M{
        // 1. 필터링
        {"$match": bson.M{
            "visibility": "shared",
            "status":     "active",
        }},

        // 2. 권한 테이블과 조인
        {"$lookup": bson.M{
            "from":         "permissions",
            "localField":   "_id",
            "foreignField": "resource_id",
            "as":           "permissions",
        }},

        // 3. 권한 체크
        {"$match": bson.M{
            "permissions.user_id": userID,
        }},

        // 4. 정렬
        {"$sort": bson.M{"updated_at": -1}},

        // 5. $facet: 데이터와 카운트 동시 조회
        {"$facet": bson.M{
            "data": []bson.M{
                {"$skip": skip},
                {"$limit": limit},
            },
            "total": []bson.M{
                {"$count": "count"},
            },
        }},
    }

    cursor, _ := collection.Aggregate(ctx, pipeline)

    var result []struct {
        Data  []models.Agent `bson:"data"`
        Total []struct {
            Count int `bson:"count"`
        } `bson:"total"`
    }
    cursor.All(ctx, &result)

    total := 0
    if len(result[0].Total) > 0 {
        total = result[0].Total[0].Count
    }

    return result[0].Data, total, nil
}

$facet의 장점

sequenceDiagram
    participant App as Application
    participant DB as MongoDB

    Note over App,DB: $facet 없이 (2번 쿼리)
    App->>DB: CountDocuments(filter)
    DB-->>App: total: 1000
    App->>DB: Find(filter).Skip().Limit()
    DB-->>App: data: [20 items]

    Note over App,DB: $facet 사용 (1번 쿼리)
    App->>DB: Aggregate(pipeline with $facet)
    DB-->>App: {data: [20 items], total: 1000}

특수 케이스: DB 필드가 없는 정렬

모든 정렬을 DB 레벨로 옮길 수 있는 건 아니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// DB 필드가 없는 정렬 기준
// - tool_count: Tool Set의 도구 개수 (별도 컬렉션)
// - message_count: 세션의 메시지 개수 (별도 컬렉션)
// - agent_name: 세션의 에이전트 이름 (조인 필요)

func (s *sessionService) GetSessions(ctx context.Context, ...) ([]models.Session, int, error) {
    sortBy := params.SortBy

    // DB 정렬 가능 여부 판단
    dbSortable := map[string]bool{
        "created_at": true,
        "updated_at": true,
        "name":       true,
    }

    if dbSortable[sortBy] {
        // DB 레벨 정렬 + 페이지네이션
        return s.getSessionsWithDBSort(ctx, ...)
    }

    // 메모리 정렬 필요 (message_count, agent_name 등)
    // 이 경우에도 DB 레벨 필터링은 적용
    return s.getSessionsWithMemorySort(ctx, ...)
}

인덱스 추가로 쿼리 성능 향상

DB 레벨 필터링/정렬이 빠르려면 인덱스가 필수다.

추가된 인덱스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// pkg/database/mongo.go
func (d *mongoDB) CreateIndexes() error {
    indexes := []mongo.IndexModel{
        // Agents Collection
        {Keys: bson.D{{"status", 1}}},
        {Keys: bson.D{{"name", 1}}},
        {Keys: bson.D{{"updated_at", -1}}},
        {Keys: bson.D{{"tags", 1}}},
        // 복합 인덱스: 가장 빈번한 쿼리 패턴
        {Keys: bson.D{
            {"created_by", 1},
            {"status", 1},
            {"updated_at", -1},
        }},

        // Files Collection
        {Keys: bson.D{
            {"bucket_name", 1},
            {"status", 1},
            {"uploaded_at", -1},
        }},
    }

    // EnsureIndexes 패턴: 중복 인덱스는 자동 건너뜀
    _, err := collection.Indexes().CreateMany(ctx, indexes)
    return err
}

인덱스 효과

항목BeforeAfter
쿼리 방식Collection ScanIndex Scan
쿼리 시간100ms5ms
개선율-95% 단축

헬퍼 함수로 코드 일관성 확보

페이지네이션 정규화 로직이 여러 곳에서 중복됐다. 헬퍼 함수로 통합했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// internal/utils/param.go
func NormalizePagination(page, pageSize, defaultPageSize int) (int, int) {
    if page < 1 {
        page = 1
    }
    if pageSize < 1 {
        pageSize = defaultPageSize
    }
    return page, pageSize
}

// 사용 예시
func (s *orgService) ListOrganizations(ctx context.Context, page, pageSize int) ([]models.Organization, int64, error) {
    page, pageSize = utils.NormalizePagination(page, pageSize, 20)

    skip := int64((page - 1) * pageSize)
    limit := int64(pageSize)
    // ...
}

결과

성능 개선

메트릭BeforeAfter개선율
메모리 사용량10MB/요청20KB/요청99.8% 감소
응답 시간150ms30ms80% 단축
네트워크 트래픽10,000건20건99.8% 감소

최적화 범위

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
17개 메서드 최적화 완료:
├── Organization Service (3개)
│   ├── ListMembers
│   ├── ListOrganizations
│   └── ListTeams
├── Agent Service (3개)
│   ├── GetAgents
│   ├── ListSharedAgent
│   └── ListPublicAgent
├── File Service (5개)
│   ├── ListUserFiles
│   ├── ListAllFiles
│   ├── ListAgentFiles
│   ├── ListSessionFiles
│   └── ListSharedFiles
├── Session Service (2개)
│   ├── GetSessions
│   └── ListSharedSessions
├── Permission Service (4개)
│   ├── ListAllPermissionRequests
│   ├── ListOrganizationPermissionRequests
│   ├── ListRequestsForMyResources
│   └── ListPermissionRequests
└── 기타 (MCP, ToolSets, User, Ownership)

배운 점

1. DB가 잘하는 일은 DB에게

1
2
3
4
5
6
7
8
9
10
// 안티패턴: Go에서 필터링
for _, item := range allItems {
    if item.Status == "active" {
        filtered = append(filtered, item)
    }
}

// 올바른 패턴: DB에서 필터링
filter := bson.M{"status": "active"}
cursor, _ := collection.Find(ctx, filter)

데이터베이스는 필터링, 정렬, 페이지네이션에 최적화되어 있다. 이 작업을 애플리케이션 레이어로 가져오면 성능이 급격히 저하된다.

2. $facet으로 왕복 줄이기

1
2
3
4
5
6
7
8
9
10
11
12
// CountDocuments + Find = 2번 쿼리
total, _ := collection.CountDocuments(ctx, filter)
cursor, _ := collection.Find(ctx, filter, opts)

// $facet = 1번 쿼리
pipeline := []bson.M{
    {"$match": filter},
    {"$facet": bson.M{
        "data":  []bson.M{{"$skip": skip}, {"$limit": limit}},
        "total": []bson.M{{"$count": "count"}},
    }},
}

3. 인덱스는 필수

DB 레벨 쿼리가 빠르려면 적절한 인덱스가 있어야 한다. 인덱스 없이 DB 레벨 필터링을 하면 오히려 느려질 수 있다.

4. 점진적 최적화

모든 메서드를 한 번에 바꾸지 않았다. 가장 느린 API부터 시작해서 17개 메서드를 순차적으로 최적화했다.

결론

목록 조회 성능 문제의 근본 원인은 메모리 기반 페이지네이션이었다. 해결책은 간단했다:

  1. DB 레벨 필터링: $match, $or, $regex
  2. DB 레벨 정렬: SetSort(), $sort
  3. DB 레벨 페이지네이션: SetSkip(), SetLimit(), $skip, $limit
  4. 인덱스 추가: 자주 사용되는 필터/정렬 필드

가장 중요한 원칙: 데이터베이스가 잘하는 일은 데이터베이스에게 맡겨라.

This post is licensed under CC BY 4.0 by the author.