sales-4

이지예·2026년 9월 17일

개발

목록 보기
26/58

series: 멀티클라우드 견적 자동화 시스템

캐싱을 하기로 한 진짜 이유

1편에서 "견적 요청마다 벤더 API를 직접 호출하지 않는다"는 원칙을 정했다고 했습니다. 속도 문제도 있었지만, 더 중요한 이유는 따로 있었습니다.

AWS, Azure, GCP API를 모두 동시에 정상 상태로 유지하는 건 불가능에 가깝습니다. 자격증명 만료, 일시적 요청 제한, 벤더 쪽 장애, 네트워크 문제까지 — 셋 중 하나는 항상 뭔가 문제가 있다고 가정하는 게 현실적입니다.

이 전제를 받아들이면 설계가 달라집니다. "모든 벤더가 정상일 때"를 기본 경로로 짜면 안 되고, "한 벤더가 실패한 상태"를 정상적인 운영 상태 중 하나로 다뤄야 합니다.

실패 단위를 최대한 잘게 쪼갠다

동기화를 "AWS/Azure/GCP 전체"라는 하나의 트랜잭션으로 묶으면, 그중 하나만 실패해도 전체가 롤백되면서 멀쩡한 벤더의 캐시 갱신까지 막힙니다.

그래서 동기화를 벤더 × 리전 × OS 조합 단위로 쪼갰습니다.

public SyncReport syncAll() {
    List<SyncOutcome> outcomes = new ArrayList<>();
    for (VendorPriceClient client : priceClients) {
        for (String logicalRegion : properties.priceSync().regions()) {
            for (OsType os : properties.priceSync().osTypes()) {
                outcomes.add(syncSafely(client, logicalRegion, os));
            }
        }
    }
    return new SyncReport(Instant.now(), outcomes);
}

private SyncOutcome syncSafely(VendorPriceClient client, String region, OsType os) {
    try {
        List<InstancePrice> prices = client.fetchPrices(region, os);
        int saved = priceCacheWriter.upsertAll(client.vendorType(), region, os, prices);
        return SyncOutcome.success(client.vendorType(), region, os, saved);
    } catch (Exception e) {
        log.warn("가격 동기화 실패: vendor={}, region={}, os={}", client.vendorType(), region, os, e);
        return SyncOutcome.failure(client.vendorType(), region, os, e.getMessage());
    }
}

각 조합은 독립적으로 try-catch에 감싸여 있고, 하나가 예외를 던져도 나머지 조합의 순회는 멈추지 않습니다. AWS 자격증명이 아예 없는 개발 환경이라면, AWS 관련 조합만 전부 실패하고 Azure 조합은 정상적으로 수집됩니다.

결과를 숨기지 않는다

동기화가 "성공/실패"라는 이분법으로 끝나면 디버깅이 괴롭습니다. 어떤 벤더의 어떤 리전이 왜 실패했는지 로그를 뒤져야 합니다. 그래서 결과를 구조화된 형태로 그대로 API 응답에 노출했습니다.

public record SyncReport(Instant finishedAt, List<SyncOutcome> outcomes) {
    public long successCount() {
        return outcomes.stream().filter(SyncOutcome::success).count();
    }
    public long failureCount() {
        return outcomes.stream().filter(o -> !o.success()).count();
    }
    public int totalSyncedSkus() {
        return outcomes.stream().mapToInt(SyncOutcome::syncedSkus).sum();
    }
}

POST /api/v1/prices/sync를 호출하면 벤더·리전·OS 조합별로 success, syncedSkus, (실패 시) errorMessage가 담긴 배열이 그대로 돌아옵니다. "AWS는 왜 실패했지?"를 로그 파일을 뒤지지 않고 API 응답 하나로 바로 확인할 수 있습니다.

캐시 upsert: 매번 지우고 새로 넣지 않는다

가격을 다시 수집할 때마다 기존 데이터를 전부 삭제하고 새로 넣는 방식도 고려했지만, 그러면 동기화 도중 잠깐이라도 캐시가 비는 순간이 생깁니다. 대신 SKU 단위로 있으면 갱신, 없으면 삽입하는 upsert 방식을 택했습니다.

snapshotRepository.findByVendorAndSkuAndRegionAndOs(vendor, price.sku(), price.regionCode(), price.os())
        .ifPresentOrElse(
            existing -> existing.updatePrice(price.hourlyCostUsd()),
            () -> snapshotRepository.save(PriceSnapshot.builder()...build())
        );

이걸 뒷받침하기 위해 (vendor_id, sku, region, os)에 유니크 제약을 걸어뒀습니다. 다만 이 제약이 MySQL에서 실제로 의도대로 생성되는지는 H2로는 완전히 검증되지 않아서, 로컬 연동 테스트에서 한 번 더 확인해야 할 항목입니다.

이 설계가 실제로 준 이점

가장 체감한 효과는 개발 단계에서였습니다. AWS IAM 자격증명을 아직 발급받기 전에도, Azure는 인증이 필요 없어서 바로 실제 가격 데이터로 개발을 계속할 수 있었습니다. 만약 "모든 벤더가 성공해야 캐시를 갱신한다"는 식으로 짰다면, AWS 자격증명이 없다는 이유로 Azure 개발까지 막혔을 겁니다.

외부 시스템에 의존하는 코드를 짤 때 "실패하면 어떻게 하지"를 나중에 덧붙이는 게 아니라, 처음부터 실패를 기본 시나리오로 두고 설계하는 것의 차이를 실감한 부분이었습니다.

다음 편에서는 이렇게 쌓인 견적 데이터를 고객 제안용 PDF 견적서로 내보내면서 겪은 문제, 특히 한글 폰트 처리의 트레이드오프를 다룹니다.

profile
github: @dlwldP

0개의 댓글