콘텐츠로 이동

CosmosDB Point Read를 활용한 트래픽 및 Latency 최적화 전략

Point Read를 활용해 Azure Cosmos DB 요청량과 지연 시간을 줄입니다.

문제 정의

고트래픽 환경에서 CosmosDB를 사용할 때 가장 큰 병목은 Query 방식의 데이터 조회입니다. 일반적인 SELECT 쿼리는 다음과 같은 문제를 야기합니다:

  • 높은 RU 소비: 단순 ID 조회도 3-10 RU 소비
  • 긴 응답 시간: 인덱스 스캔으로 인한 20-50ms 지연
  • 비용 증가: 트래픽이 증가할수록 RU 비용 급증
  • 확장성 제한: Cross-partition query 시 모든 파티션 스캔

이 문제를 해결하는 핵심은 Query를 Point Read로 전환하는 것입니다.


Point Read란?

Point Read는 ID와 Partition Key를 정확히 지정하여 문서를 직접 읽는 방식입니다.

// Query 방식 (비효율)
const { resources } = await container.items
    .query('SELECT * FROM c WHERE c.id = @id')
    .fetchAll();

// Point Read 방식 (효율)
const { resource } = await container.item(id, partitionKey).read();

성능 차이

항목 Query Point Read 개선율
RU 비용 3-10 RU 1 RU 70-90% 절감
응답 시간 20-50ms 5-10ms 50-80% 단축
처리량 (TPS) 1,000 10,000 10배 증가

예시 계산: - 초당 10,000건 조회 - Query: 50,000 RU/s (약 $300/월) - Point Read: 10,000 RU/s (약 $60/월) - 월간 $240 절감 (80% 비용 감소)


전환 전략: SELECT를 Point Read로 변경하기

1단계: 현재 쿼리 패턴 분석

대부분의 애플리케이션에서 가장 많이 사용되는 쿼리는 ID 기반 단건 조회입니다.

// 현재 코드 (문제)
async function getUser(userId) {
    const querySpec = {
        query: 'SELECT * FROM c WHERE c.id = @userId',
        parameters: [{ name: '@userId', value: userId }]
    };
    const { resources } = await container.items.query(querySpec).fetchAll();
    return resources[0];
}

문제점 분석: - ID를 알고 있지만 Query 엔진을 거침 - 불필요한 인덱스 스캔 발생 - Partition Key를 지정하지 않아 Cross-partition query 가능

2단계: Partition Key 구조 파악

Point Read를 사용하려면 Partition Key 값이 필요합니다.

// Container 정의 확인
const containerDefinition = {
    id: 'users',
    partitionKey: { paths: ['/id'] }  // 또는 '/userId', '/tenantId' 등
};

케이스별 전략:

Case A: ID가 Partition Key인 경우 (가장 단순)

// Container: partitionKey = '/id'
// 문서: { "id": "user123", ... }

async function getUser(userId) {
    const { resource } = await container.item(userId, userId).read();
    return resource;
}

Case B: 별도 필드가 Partition Key인 경우

// Container: partitionKey = '/tenantId'
// 문서: { "id": "user123", "tenantId": "tenant456", ... }

async function getUser(userId, tenantId) {
    const { resource } = await container.item(userId, tenantId).read();
    return resource;
}

Case C: Partition Key를 모르는 경우 (점진적 전환)

// 1차: Partition Key를 쿼리로 먼저 조회
const querySpec = {
    query: 'SELECT c.id, c.tenantId FROM c WHERE c.id = @userId',
    parameters: [{ name: '@userId', value: userId }]
};
const { resources } = await container.items.query(querySpec).fetchAll();

if (resources.length === 0) return null;

const { id, tenantId } = resources[0];

// 2차: Point Read로 전체 문서 조회
const { resource } = await container.item(id, tenantId).read();
return resource;

3단계: 효과 측정 및 검증

전환 전후 성능을 측정하여 효과를 검증합니다.

async function measurePerformance(userId, tenantId) {
    // Query 방식 측정
    const queryStart = Date.now();
    const querySpec = {
        query: 'SELECT * FROM c WHERE c.id = @userId',
        parameters: [{ name: '@userId', value: userId }]
    };
    const queryResponse = await container.items.query(querySpec).fetchAll();
    const queryDuration = Date.now() - queryStart;
    const queryRU = queryResponse.requestCharge;

    // Point Read 방식 측정
    const pointReadStart = Date.now();
    const pointReadResponse = await container.item(userId, tenantId).read();
    const pointReadDuration = Date.now() - pointReadStart;
    const pointReadRU = pointReadResponse.requestCharge;

    // 결과 출력
    console.log({
        query: {
            duration: `${queryDuration}ms`,
            ru: queryRU,
            costPerMonth: `$${(queryRU * 10000 * 30 * 24 * 3600 / 1000000 * 6).toFixed(2)}`
        },
        pointRead: {
            duration: `${pointReadDuration}ms`,
            ru: pointReadRU,
            costPerMonth: `$${(pointReadRU * 10000 * 30 * 24 * 3600 / 1000000 * 6).toFixed(2)}`
        },
        improvement: {
            latencyReduction: `${((queryDuration - pointReadDuration) / queryDuration * 100).toFixed(1)}%`,
            ruReduction: `${((queryRU - pointReadRU) / queryRU * 100).toFixed(1)}%`,
            costSaving: `$${((queryRU - pointReadRU) * 10000 * 30 * 24 * 3600 / 1000000 * 6).toFixed(2)}/월`
        }
    });
}

실제 측정 예시:

{
  query: { duration: '45ms', ru: 5.2, costPerMonth: '$811.20' },
  pointRead: { duration: '8ms', ru: 1.0, costPerMonth: '$156.00' },
  improvement: {
    latencyReduction: '82.2%',
    ruReduction: '80.8%',
    costSaving: '$655.20/월'
  }
}


트래픽 최적화: Partition Key 캐싱 전략

Partition Key를 매번 조회하는 것은 비효율적입니다. 캐싱을 통한 최적화가 필수입니다.

전략: In-Memory Cache + Query Fallback

class CosmosOptimizer {
    constructor(container) {
        this.container = container;
        this.partitionKeyCache = new Map(); // ID → Partition Key 매핑
        this.cacheTTL = 300000; // 5분
    }

    async getById(id) {
        // 1. 캐시에서 Partition Key 조회
        const cached = this.partitionKeyCache.get(id);

        if (cached && cached.expires > Date.now()) {
            // Point Read 사용
            const { resource } = await this.container.item(id, cached.partitionKey).read();
            return resource;
        }

        // 2. 캐시 미스 - Query로 조회
        const querySpec = {
            query: 'SELECT * FROM c WHERE c.id = @id',
            parameters: [{ name: '@id', value: id }]
        };
        const { resources } = await this.container.items.query(querySpec).fetchAll();

        if (resources.length === 0) return null;

        const item = resources[0];

        // 3. Partition Key 캐싱
        this.partitionKeyCache.set(id, {
            partitionKey: item.tenantId || item.id, // Container 설정에 따라 조정
            expires: Date.now() + this.cacheTTL
        });

        return item;
    }
}

효과 분석: - 첫 조회: Query 사용 (5 RU, 45ms) - 이후 조회: Point Read 사용 (1 RU, 8ms) - 캐시 히트율 90% 가정 시: 평균 1.4 RU, 11.7ms - 캐시 없을 때 대비: RU 72% 절감, 응답 시간 74% 단축


Latency 최적화: Connection Pool 및 병렬 처리

문제: 순차 처리로 인한 대기 시간

// 나쁜 예: 순차 처리
async function getMultipleUsers(userIds) {
    const users = [];
    for (const id of userIds) {
        const user = await container.item(id, id).read();
        users.push(user.resource);
    }
    return users; // 100개 조회 시 800ms (8ms × 100)
}

해결: 병렬 처리

// 좋은 예: 병렬 처리
async function getMultipleUsers(userIds) {
    const promises = userIds.map(id => 
        container.item(id, id).read().then(r => r.resource)
    );
    return await Promise.all(promises); // 100개 조회 시 ~10ms
}

효과: - 순차: 100건 × 8ms = 800ms - 병렬: ~10ms (네트워크 RTT) - 98.8% 응답 시간 단축

Connection 재사용

// 싱글톤 패턴으로 Connection Pool 재사용
let cosmosClient = null;

function getCosmosClient() {
    if (!cosmosClient) {
        cosmosClient = new CosmosClient({
            endpoint: process.env.COSMOS_ENDPOINT,
            key: process.env.COSMOS_KEY,
            connectionPolicy: {
                requestTimeout: 10000,
                retryOptions: {
                    maxRetryAttemptCount: 3,
                    fixedRetryIntervalInMilliseconds: 100
                }
            }
        });
    }
    return cosmosClient;
}

효과: - Connection 재사용으로 초기화 오버헤드 제거 - 첫 요청 이후 2-3ms 응답 시간 개선


실전 적용 사례

Before: Query 기반 (문제 상황)

// 월간 비용: $800, 평균 응답 시간: 50ms
async function getUserProfile(userId) {
    const querySpec = {
        query: 'SELECT * FROM c WHERE c.id = @userId',
        parameters: [{ name: '@userId', value: userId }]
    };
    const { resources } = await container.items.query(querySpec).fetchAll();
    return resources[0];
}

After: Point Read + Cache (최적화)

// 월간 비용: $160, 평균 응답 시간: 12ms
const pkCache = new Map();

async function getUserProfile(userId) {
    const cached = pkCache.get(userId);

    if (cached && cached.expires > Date.now()) {
        const { resource } = await container.item(userId, cached.pk).read();
        return resource;
    }

    // Fallback: Query (첫 조회만)
    const { resources } = await container.items.query({
        query: 'SELECT * FROM c WHERE c.id = @userId',
        parameters: [{ name: '@userId', value: userId }]
    }).fetchAll();

    if (resources[0]) {
        pkCache.set(userId, { pk: resources[0].id, expires: Date.now() + 300000 });
    }

    return resources[0];
}

성과

지표 Before After 개선
월간 비용 $800 $160 80% 절감
평균 응답 시간 50ms 12ms 76% 단축
P99 응답 시간 120ms 20ms 83% 단축
최대 TPS 2,000 20,000 10배 증가

적용 가이드

1. 현재 Query 패턴 확인

# Application Insights에서 가장 많이 호출되는 Query 조회
# 대부분 "WHERE c.id = @id" 패턴이 상위권

2. Container의 Partition Key 확인

const containerProperties = await container.read();
console.log(containerProperties.resource.partitionKey);
// 예: { paths: ['/id'] } 또는 { paths: ['/tenantId'] }

3. 점진적 전환

  • Phase 1: 캐싱 없이 Point Read 전환 → 비용 70% 절감
  • Phase 2: In-Memory Cache 추가 → 추가 50% 절감
  • Phase 3: 병렬 처리 최적화 → 응답 시간 90% 단축

4. 모니터링

// RU 소비량 추적
const { resource, requestCharge } = await container.item(id, pk).read();
console.log(`Point Read: ${requestCharge} RU`);

// Application Insights로 메트릭 전송
telemetryClient.trackMetric({ name: 'CosmosDB_RU', value: requestCharge });

주의사항

  1. 404 에러 처리: Point Read는 문서가 없으면 404 에러 발생

    try {
        const { resource } = await container.item(id, pk).read();
        return resource;
    } catch (error) {
        if (error.code === 404) return null;
        throw error;
    }
    

  2. 429 에러 재시도: Rate limiting 발생 시 자동 재시도 설정 필수

  3. Partition Key 일관성: 문서 생성 시와 조회 시 동일한 Partition Key 사용


참고 자료

참고 문서