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 패턴 확인¶
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 });
주의사항¶
-
404 에러 처리: Point Read는 문서가 없으면 404 에러 발생
-
429 에러 재시도: Rate limiting 발생 시 자동 재시도 설정 필수
-
Partition Key 일관성: 문서 생성 시와 조회 시 동일한 Partition Key 사용