AWS, Azure, GCP의 가격 조회 API는 인증 방식, 응답 스키마, 페이지네이션 방식이 전부 다릅니다.
| 인증 | 페이지네이션 | 응답 형태 | |
|---|---|---|---|
| Azure | 없음 (완전 공개) | NextPageLink가 null이 될 때까지 반복 | JSON 배열 |
| AWS | IAM 자격증명 (SigV4) | 토큰 기반 | JSON 문자열의 배열 (이중 인코딩) |
| GCP | API Key | 토큰 기반 | Core/RAM SKU가 분리된 카탈로그 |
이 차이를 상위 계층(스케줄러, 캐시 저장 로직)까지 새어 들어오게 하면, 벤더를 하나 추가할 때마다 스케줄러 코드를 건드려야 합니다. 그래서 계약을 하나 정의하고 벤더별 차이는 구현체 안에 가두기로 했습니다.
public interface VendorPriceClient {
VendorType vendorType();
List<InstancePrice> fetchPrices(String logicalRegion, OsType os);
}
"논리 리전 + OS를 받아서 InstancePrice 목록을 반환한다"는 계약만 남기고, 나머지는 전부 구현체 책임입니다. 스케줄러는 이 인터페이스만 알면 됩니다.
public record InstancePrice(
String sku,
int vcpu,
int memoryGb,
String regionCode,
OsType os,
BigDecimal hourlyCostUsd
) {}
GET https://prices.azure.com/api/retail/prices
?$filter=serviceName eq 'Virtual Machines' and armRegionName eq 'koreacentral'
가장 다루기 쉬운 벤더입니다. 인증이 없어서 RestClient로 바로 호출할 수 있습니다. NextPageLink가 null이 될 때까지 반복 호출하는 것만 신경 쓰면 됩니다.
String url = baseUrl + "?$filter=" + filter;
while (url != null) {
AzurePriceResponse response = restClient.get().uri(url).retrieve().body(AzurePriceResponse.class);
prices.addAll(response.items());
url = response.nextPageLink();
}
문제는 응답에 vCPU·메모리 정보가 없다는 것입니다. armSkuName(Standard_D4s_v5)만 주기 때문에, 이 이름을 파싱해서 스펙을 복원해야 합니다. 이 이야기는 분량이 커서 3편에서 따로 다룹니다.
AWS Price List Query API는 SDK(software.amazon.awssdk:pricing)를 쓰면 서명은 자동 처리되지만, 응답 구조가 까다롭습니다.
GetProductsResponse response = pricingClient.getProducts(GetProductsRequest.builder()
.serviceCode("AmazonEC2")
.filters(
Filter.builder().type(FilterType.TERM_MATCH).field("instanceType").value("t3.xlarge").build(),
Filter.builder().type(FilterType.TERM_MATCH).field("regionCode").value("ap-northeast-2").build(),
Filter.builder().type(FilterType.TERM_MATCH).field("operatingSystem").value("Linux").build()
)
.build());
response.priceList()는 JSON 객체가 아니라 JSON "문자열"의 배열로 옵니다. 한 번 더 파싱해야 하고, 그 안에서도
product.attributes.{vcpu, memory}
terms.OnDemand.*.priceDimensions.*.pricePerUnit.USD
로 깊게 중첩돼 있습니다. terms.OnDemand 아래 키가 고정 ID가 아니라 매번 랜덤한 해시라서, 첫 번째 값을 순회해서 꺼내는 방식으로 처리했습니다. 그리고 단위가 Hrs인 항목만 골라야 스토리지·라이선스 같은 다른 차원의 가격이 섞이지 않습니다.
리전도 주의할 점이 있습니다. Pricing API 엔드포인트 자체는 us-east-1에 고정되어 있습니다. 서울 리전(ap-northeast-2) 가격을 조회할 때도 클라이언트 리전은 us-east-1로 두고, 조회 대상 리전은 필터의 regionCode 값으로 지정해야 합니다. 처음엔 이 둘을 헷갈려서 계속 빈 응답을 받았습니다.
GCP Cloud Billing Catalog API는 AWS·Azure와 근본적으로 다른 구조입니다. t3.xlarge처럼 "머신 타입 하나에 가격 하나"가 아니라, Core SKU와 RAM SKU가 분리되어 있습니다.
services.list → Compute Engine 서비스 ID 조회
services/{serviceId}/skus.list → SKU별 가격 조회 (코어 단가, RAM 단가 각각)
그래서 n2-standard-4 같은 표준 머신 타입 단가를 얻으려면, 같은 패밀리의 코어 단가와 메모리 단가를 각각 수집한 다음 (코어 단가 × vCPU) + (메모리 단가 × GB)로 직접 합성해야 합니다. 이 방식은 정확도가 떨어질 여지가 있어서, GCP는 기본값을 비활성(enabled: false)으로 두고 필요할 때만 켜도록 했습니다.
@Component
public class PriceSyncService {
private final List<VendorPriceClient> priceClients; // Spring이 구현체를 자동 주입
public SyncReport syncAll() {
List<SyncOutcome> outcomes = new ArrayList<>();
for (VendorPriceClient client : priceClients) {
for (String region : properties.priceSync().regions()) {
for (OsType os : properties.priceSync().osTypes()) {
outcomes.add(syncSafely(client, region, os));
}
}
}
return new SyncReport(Instant.now(), outcomes);
}
}
List<VendorPriceClient>를 스프링이 자동으로 주입해주기 때문에, 벤더를 하나 더 추가하고 싶으면 VendorPriceClient 구현체 하나를 새로 만들기만 하면 됩니다. 스케줄러·캐시 저장 로직은 한 줄도 건드리지 않습니다.
다음 편에서는 이 중 가장 까다로웠던 문제 — Azure가 주지 않는 vCPU·메모리를 어떻게 복원했는지를 자세히 다룹니다.