AWS Marketplace 연동 가이드 | 01. AWS Marketplace Seller 계정 생성부터 x-amzn-marketplace-token 토큰 수신까지
AWS Marketplace 연동 가이드 | 02. AWS Marketplace Client 생성하기
AWS Marketplace 연동 가이드 | 03. ResolveCustomer
AWS Marketplace 연동 가이드 | 04. BatchMeterUsage
BatchMeterUsage
BatchMeterUsage는 AWS로 미터링 정보를 1시간마다 1번씩 전송해야합니다.

AWS MP로 미터링 정보를 전송하기 위해서는 아래의 과정이 필요합니다
- 미터링 정보 조회
- MeteringRawData
- AWS 미터링 단위로 변환하기 :
- MetringRawData → AwsBillingData
- AWS 미터링 정보 BatchMeterUsage API에 맞춰 변형하기 :
- AwsBillingData → AwsBillingRequestBuilder → List
- BatchMeterUsageRequest → BatchMeterUsage API 호출
- AWS로 과금 정보 전송
AWS 미터링 단위로 변환하기
AWS MP에 제품을 등록하면 AWS을 통해서 Billing 됩니다. 저희가 보내주는 metering 정보에 전적으로 의존해서 고객에게 과금이 됩니다. 만약 metering 정보를 실수로 더 보내거나 덜 보내는 경우, 고객에에 의도치않은 금액이 청구될 수 있습니다.
AWS MP 제품을 등록할 때 dimension이라는 단위를 입력해야합니다. 각 dimension 별로 사용량에 정보를 Unit으로 환산해서 전송하면 dimension 별로 과금액이 청구됩니다.
dimension에 대한 자세한 설명은 아래 링크를 확인해주세요.
2024.01.16 - [개발/AWS Marketplace] - AWS Marketplace SaaS Subscription 과금 모델이 적합한지 미리 검토하는 방법

AWS Marketplace에서는 1시간 단위의 과금 모델을 사용하고 있기 때문에, 기존에 존재하던 과금 방식 월 단위 과금이라면 변환 공식이 필요합니다. 아래의 예시에서 MeteringRawData 클래스는 월 단위 과금량을 담고 있고, AwsMeteringData 클래스는 시간 단위 과금량을 저장합니다.
/**
* @param rawData 매 시 0, 20, 40분마다 갱신되는 미터링 정보
* @param productType 매 정각마다 갱신되는 정보
*/
public AwsMeteringData(MeteringRawData rawData, String productType) {
this.pcode = rawData.getPcode();
this.time = DateUtil.getHourTrim(rawData.getTime());
this.productType = productType;
this.host = rawData.getHost();
this.cpu = rawData.getCpu();
this.urls = rawData.getUrls();
this.logs = getAwsLogUnit(rawData);
this.k8sContainers = rawData.getK8sContainers();
this.k8sAppContainers = rawData.getK8sAppContainers();
this.session = getAwsSessionUnit(rawData);
this.updateTime = new Date();
}
변환 공식은 연동하는 서비스마다 달라집니다. 하나만 예시를 들면 아래와 같이 변환 공식을 적용할 수 있습니다. 아래의 공식을 결정하실 떄에는 이미 존재하는 과금 정보가 어떻게 집계되는지를 아주 자세히 파악해보셔야 합니다.
/**
* 100만 로그 = 1 Log Unit = 0.04$ = 50원
* 25000 로그 = AWS 1 Unit = 0.001$ = 50/40 원
*
* '100만 Log Unit 당 50원'과 AWS MP 상에서 제공하는 최소 가격 단위가 0.001인점을 고려하여 25_000으로 나누어서 Unit을 전송함
*/
private int getAwsLogUnit(MeteringRawData rawData) {
// 매 시간 쌓여있는 총 로그 수 (보관기간이 반영되어 있음)
long allLogsExistsInYardbase = rawData.getLogs();
if (allLogsExistsInYardbase == 0) {
// 사용량이 없으면 과금하지 않음
return 0;
}
// AWS Marketplace는 매 시간 과금이기 때문에
// 현재 Yard에 남아있는 로그를 24시간으로 나누어 '1시간 평균 로그 양'에 대해서 AWS Log Unit을 계산해야
// AWS Marketplace를 사용하지 않는 과금 방식과 최대한 비슷한 과금량이 산정됨
long oneHourAvg = allLogsExistsInYardbase / HOURS_PER_DAY;
if (oneHourAvg < AWS_LOG_UNIT) {
// 로그를 하나라도 사용했으면 1 AWS LogUnit으로 과금
return 1;
} else {
// 반올림
return (int) Math.round((double) oneHourAvg / AWS_LOG_UNIT);
}
}
💡과금량은 Integer로 소수점을 지원하지 않습니다.
dimension 별 quantity는 Integer이기 때문에 소수점 아래 자리는 절삭됩니다. 이 부분을 반올림/올림 처리하면 사용자의 과금액이 그만큼 늘어납니다. 정답은 없고 사내 정책에 맞추어서 작성하면 됩니다. 아래는 AwsMeteringData 데이터로 변환한 뒤, 실제로 AWS MP에 과금량을 전송하는 BatchMeterUsage API의 RequestDTO입니다.
// BatchMeterUsage API에서 사용하는 포멧 package com.amazonaws.services.marketplacemetering.model; public class UsageRecord implements Serializable, Cloneable, StructuredPojo { private java.util.Date timestamp; private String customerIdentifier; private String dimension; private Integer quantity; }
AWS 미터링 정보 BatchMeterUsage API에 맞춰 변형하기
BatchMeterUsage에서 API는 BatchMeterUsageRequest 객체를 요청으로 받습니다. 이 객체는 아래와 같은 구조로 되어 있습니다.

이 요청 정보의 계층을 이해해보면 아래와 같습니다.
- 하나의 AWS MP 제품(productCode)을 여러개의 계정(customerIdentifier)에서 구독할 수 있습니다. 구매자 별로 과금량을 전송하는 것이 아니라, AWS MP 제품 별로 과금량을 전송해야 합니다.
- 각 계정(customerIdentifier) 별로 dimension 별 총 사용량(quantity)을 1시간(timestamp)마다 전송해야한다
- dimension 별 총 사용량(quantity)은 Tag 별 사용량(allocatedUsageQuantity)의 총합이다
연동해야하는 서비스는 아래와 같은 정책으로 미터링 정보를 생성하고 있습니다.
💡 혹시 아래의 설명이 이해가 잘 되지 않는다면
글 보다는 코드를 훑어보시는 것이 더 잘 이해되실 수 있습니다.
- 하나의 Account(customerIdentifier)이 여러 개의 Project를 생성할 수 있다
- Project가 생성되면 고유한 식별자가 추가된다(Tag)
- 미터링 정보(allocatedUsageQuantity)는 Project 별로 생성된다
- Project를 생성할 때 Project의 타입에 따라서 집계되는 dimension 정보가 1개 이상 지정되어 있다.
이를 요청 정보에 맵핑하면 아래와 같습니다.

이렇게 매핑 방식을 코드로 변환하면 다음과 같습니다.
import com.amazonaws.services.marketplacemetering.model.BatchMeterUsageRequest; | |
import com.amazonaws.services.marketplacemetering.model.Tag; | |
import com.amazonaws.services.marketplacemetering.model.UsageAllocation; | |
import com.amazonaws.services.marketplacemetering.model.UsageRecord; | |
import java.util.ArrayList; | |
import java.util.Date; | |
import java.util.List; | |
import java.util.stream.Collectors; | |
public class AwsBillingRequestBuilder { | |
/** | |
* AWS MP 제품 별 총 과금량 | |
* | |
* 하나의 제품을 여러 개의 계정이 구독할 수 있음 | |
*/ | |
private final StringKeyLinkedMap<List<AccountUsage>> productCodeMap = new StringKeyLinkedMap<List<AccountUsage>>() { | |
@Override | |
protected List<AccountUsage> create(String awsProductCode) { | |
return new ArrayList<>(); | |
} | |
}; | |
public AwsBillingRequestBuilder(){ | |
} | |
public void add(AccountUsage accountUsage) { | |
this.productCodeMap.intern(accountUsage.awsProductCode).add(accountUsage); | |
} | |
public List<BatchMeterUsageRequest> build(){ | |
List<BatchMeterUsageRequest> allProductRequests = new ArrayList<>(); | |
StringEnumer keyEn = productCodeMap.keys(); | |
while (keyEn.hasMoreElements()) { | |
// AWS MP 제품 코드 조회 | |
String awsProductCode = keyEn.nextString(); | |
// AWS MP 제품을 구독중인 계정 목록 조회 | |
List<AccountUsage> accountUsages = productCodeMap.get(awsProductCode); | |
// AWS MP 제품에 포함된 미터링 정보를 최대 25개씩 자름 | |
List<List<UsageRecord>> usageRecordsUpTo25 = splitUsageRecordsUpTo25(accountUsages.stream() | |
.map(AccountUsage::toUsageRecord) | |
.flatMap(List::stream) | |
.collect(Collectors.toList())); | |
// 잘라진 미터링 정보를 요청 정보로 변경 | |
List<BatchMeterUsageRequest> oneProductRequests = usageRecordsUpTo25.stream() | |
.map(usageRecords -> new BatchMeterUsageRequest() | |
.withProductCode(awsProductCode) | |
.withUsageRecords(usageRecords)) | |
.collect(Collectors.toList()); | |
allProductRequests.addAll(oneProductRequests); | |
} | |
return allProductRequests; | |
} | |
/** | |
* BatchMeterUsage can process up to 25 UsageRecords at a time. | |
* | |
* The maximum payload size can't be more than 1 MB. | |
* This includes input attribute keys (for example, UsageRecords, AllocatedUsageQuantity, tags). | |
*/ | |
private List<List<UsageRecord>> splitUsageRecordsUpTo25(List<UsageRecord> usageRecords) { | |
List<List<UsageRecord>> result = new ArrayList<>(); | |
for (int i = 0; i < usageRecords.size(); i += 25) { | |
int end = Math.min(i + 25, usageRecords.size()); | |
List<UsageRecord> sublist = usageRecords.subList(i, end); | |
result.add(sublist); | |
} | |
return result; | |
} | |
/** | |
* 계정 별 사용량 | |
* | |
* 하나의 계정에서 여러 개의 dimension(ProductType)를 사용할 수 있고 | |
* 각 dimension 별로 여러 개의 Project가 있을 수 있음 | |
*/ | |
public static class AccountUsage { | |
private final String awsProductCode; | |
private final Date timestamp; | |
private final String customerIdentifier; | |
// dimension - pcode 별 과금액 | |
private final StringKeyLinkedMap<List<ProjectUsage>> dimensionMap = new StringKeyLinkedMap<List<ProjectUsage>>() { | |
@Override | |
protected List<ProjectUsage> create(String key) { | |
return new ArrayList<>(); | |
} | |
}; | |
public AccountUsage(String awsProductCode, Date timestamp, String customerIdentifier) { | |
this.awsProductCode = awsProductCode; | |
this.timestamp = timestamp; | |
this.customerIdentifier = customerIdentifier; | |
} | |
public void add(ProjectUsage projectUsage) { | |
dimensionMap.intern(projectUsage.identifier).add(projectUsage); | |
} | |
public void addAll(List<ProjectUsage> projectUsages) { | |
projectUsages.forEach(this::add); | |
} | |
/* | |
* UsageRecord : 하나의 AWS MP 계정으로 구독한 dimension 별 총 사용량 | |
* - timestamp | |
* - customerIdentifier | |
* - dimension : productType | |
* - quantity | |
* - List<UsageAllocation> : pcode 별 사용량 | |
* - allocatedUsageQuantity | |
* - List<Tag> | |
* - key | |
* - value | |
*/ | |
public List<UsageRecord> toUsageRecord(){ | |
List<UsageRecord> allDimensionUsage = new ArrayList<>(); | |
StringEnumer keyEn = dimensionMap.keys(); | |
while (keyEn.hasMoreElements()) { | |
String dimension = keyEn.nextString(); | |
/** | |
* The sum of AllocatedUsageQuantity of UsageAllocation must equal the UsageQuantity, which is the aggregate usage. | |
*/ | |
INT totalQuantity = new INT(); | |
List<UsageAllocation> usageAllocations = dimensionMap.get(dimension) | |
.stream() | |
.peek(byPcode -> totalQuantity.value += byPcode.quantity) | |
.map(ProjectUsage::toUsageAllocation) | |
.collect(Collectors.toList()); | |
UsageRecord oneDimensionUsage = new UsageRecord() | |
.withTimestamp(timestamp) | |
.withCustomerIdentifier(customerIdentifier) | |
.withDimension(dimension) | |
.withUsageAllocations(usageAllocations) | |
.withQuantity(totalQuantity.value); | |
allDimensionUsage.add(oneDimensionUsage); | |
} | |
return allDimensionUsage; | |
} | |
} | |
/** | |
* 프로젝트 별 사용량 | |
* | |
* 프로젝트에 포함된 dimension(ProductType) 정보가 함께 저장되어야 dimension 별 합산이 가능하다 | |
* | |
* | identifier | | |
* |------------------------------------------------------| | |
* | server_host | | |
* | kubernetes_application_container | | |
* | kubernetes_container | | |
* | database_host | | |
* | log | | |
* | database_vcpu | | |
* | browser_session | | |
* | |
*/ | |
public static class ProjectUsage { | |
/* | |
* 1. dimension = API identifier + Description | |
* 2. API identifier can have up to 60 characters | |
* 3. API identifier consists of alphanumeric and underbar. | |
*/ | |
private final String identifier; | |
private final long pcode; | |
private final int quantity; | |
/** | |
* Maximum tags across UsageAllocation list – 5 | |
*/ | |
private final List<Tag> tags; | |
public static ProjectUsage forAppCore(long pcode, int quantity) { | |
return new ProjectUsage("application_vcpu", pcode, quantity); | |
} | |
public static ProjectUsage forServerHost(long pcode, int quantity) { | |
return new ProjectUsage("server_host", pcode, quantity); | |
} | |
public static ProjectUsage forK8sContainerWithApp(long pcode, int quantity) { | |
return new ProjectUsage("kubernetes_application_container", pcode, quantity); | |
} | |
public static ProjectUsage forK8sContainers(long pcode, int quantity) { | |
return new ProjectUsage("kubernetes_container", pcode, quantity); | |
} | |
public static ProjectUsage forDBHost(long pcode, int quantity) { | |
return new ProjectUsage("database_host", pcode, quantity); | |
} | |
public static ProjectUsage forLog(long pcode, int quantity) { | |
return new ProjectUsage("log", pcode, quantity); | |
} | |
public static ProjectUsage forDBCore(long pcode, int quantity) { | |
return new ProjectUsage("database_vcpu", pcode, quantity); | |
} | |
public static ProjectUsage forBrowserSession(long pcode, int quantity) { | |
return new ProjectUsage("browser_session", pcode, quantity); | |
} | |
private ProjectUsage(String identifier, long pcode, int quantity) { | |
this.identifier = identifier; | |
this.pcode = pcode; | |
this.quantity = quantity; | |
this.tags = new ArrayList<>(); | |
this.addTag("pcode", String.valueOf(this.pcode)); | |
this.addTag("identifier", identifier); | |
} | |
/** | |
* Two UsageAllocations can't have the same tags (that is, the same combination of tag keys and values). | |
* If that's the case, they must use the same UsageAllocation. | |
*/ | |
public UsageAllocation toUsageAllocation(){ | |
return new UsageAllocation() | |
.withAllocatedUsageQuantity(quantity) | |
.withTags(tags); | |
} | |
/** | |
* Characters allowed for the tag key and value – a-zA-Z0-9+ -=._:\/@ | |
*/ | |
public void addTag(String key, String value){ | |
this.tags.add(new Tag() | |
.withKey(key) | |
.withValue(value)); | |
} | |
} | |
} |
BatchMeterUsageRequest → BatchMeterUsage API 호출하기
마지막 단계는 API를 호출하면 됩니다. 실패했을 경우를 대비해서 꼼꼼하게 작성하면 됩니다.
/**
* AWS Marketplace에 AWS 미터링 정보를 전송한다
*/
public void batchMeterUsage(BatchMeterUsageRequest request) throws WhaTapError {
int retry = 0;
List<UsageRecord> preprocess = request.getUsageRecords();
while (retry < conf.aws_marketplace_metring_retry_count) {
List<UsageRecord> unprocessed = doBatchMeterUsage(request.getProductCode(), preprocess);
if (unprocessed.isEmpty()) {
if (conf.debug_aws_marketplace) {
logger.info("[AWS MP] batchMeterUsage request success. productCode : {}", request.getProductCode());
}
return;
}
preprocess = unprocessed;
retry++;
}
// retry도 실패한 경우
int unprocessed = preprocess.size();
if (unprocessed > 0) {
String productCode = request.getProductCode();
for (UsageRecord usageRecord : request.getUsageRecords()) {
Date timestamp = usageRecord.getTimestamp();
String dimension = usageRecord.getDimension();
Integer quantity = usageRecord.getQuantity();
logger.error("[AWS MP] batchMeterUsage request failed. productCode : {}, timestamp : {}, dimension : {}, quantity : {}",
productCode, timestamp, dimension, quantity);
}
preprocess.forEach(record -> logger.error(record.toString()));
}
}
/**
* @param awsProductCode AWS Marketplace 상품 코드
* @param usageRecords 전송해야하는 미터링 정보
* @return 처리에 실패한 미터링 정보, retry 처리되어야 한다
*/
private List<UsageRecord> doBatchMeterUsage(String awsProductCode, List<UsageRecord> usageRecords) throws WhaTapError {
try {
BatchMeterUsageRequest request = new BatchMeterUsageRequest()
.withProductCode(awsProductCode)
.withUsageRecords(usageRecords);
BatchMeterUsageResult batchMeterUsageResult = getAwsMarketplaceClient().batchMeterUsage(request);
for (UsageRecordResult result : batchMeterUsageResult.getResults()) {
String meteringRecordId = result.getMeteringRecordId();
String status = result.getStatus();
UsageRecord usageRecord = result.getUsageRecord();
Date timestamp = usageRecord.getTimestamp();
String dimension = usageRecord.getDimension();
Integer quantity = usageRecord.getQuantity();
logger.info("[AWS MP] batchMeterUsageResult : status : {}, meteringRecordId : {}, timestamp : {}, dimension : {}, quantity : {}",
status, meteringRecordId, timestamp, dimension, quantity);
}
return batchMeterUsageResult.getUnprocessedRecords();
} catch (Exception e) {
logger.error("[AWS MP] " + e.getMessage());
return usageRecords;
}
}
'AWS > AWS Marketplace' 카테고리의 다른 글
AWS Marketplace 연동 가이드 | 03. ResolveCustomer (0) | 2024.01.17 |
---|---|
AWS Marketplace 연동 가이드 | 02. AWS Marketplace Client 생성하기 (0) | 2024.01.17 |
AWS Marketplace 연동 가이드 | 01. AWS Marketplace Seller 계정 생성부터 x-amzn-marketplace-token 토큰 수신까지 (0) | 2024.01.17 |
AWS Marketplace AssumeRole 적용하기 (0) | 2024.01.17 |
AWS API에서 AccessKey, SecretAccessKey 대신 Role ARN 사용하기 (0) | 2024.01.16 |