Skip to content
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ dependencies {
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

// AWS SDK v2
implementation platform('software.amazon.awssdk:bom:2.21.0')
implementation 'software.amazon.awssdk:costexplorer'
implementation 'software.amazon.awssdk:sts'

// Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
90 changes: 90 additions & 0 deletions src/main/java/com/blockcloud/config/AwsConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.blockcloud.config;

import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.costexplorer.CostExplorerClient;

/**
* AWS 설정 클래스
* AWS Cost Explorer API 클라이언트를 설정합니다.
*/
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "aws")
@Getter
@Setter
public class AwsConfig {

private Credentials credentials;
private String region;

@Getter
@Setter
public static class Credentials {
private String accessKeyId;
private String secretAccessKey;
}

@Bean
public CostExplorerClient costExplorerClient() {
log.info("Initializing AWS Cost Explorer Client...");

try {
// AWS 자격 증명 검증
if (credentials == null) {
log.error("AWS credentials configuration is missing");
throw new IllegalArgumentException("AWS credentials configuration is missing");
}

String accessKeyId = credentials.getAccessKeyId();
String secretAccessKey = credentials.getSecretAccessKey();

log.info("AWS Access Key ID: {}", accessKeyId != null ? accessKeyId.substring(0, Math.min(8, accessKeyId.length())) + "..." : "null");

if (accessKeyId == null || accessKeyId.trim().isEmpty()) {
log.error("AWS Access Key ID is missing or empty");
throw new IllegalArgumentException("AWS Access Key ID is missing or empty");
}

if (secretAccessKey == null || secretAccessKey.trim().isEmpty()) {
log.error("AWS Secret Access Key is missing or empty");
throw new IllegalArgumentException("AWS Secret Access Key is missing or empty");
}

// 기본값 체크 (플레이스홀더 값들)
if ("your-access-key".equals(accessKeyId) ||
"your-secret-key".equals(secretAccessKey) ||
"AKIAXXXXXXXXXXXXXXXX".equals(accessKeyId) ||
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".equals(secretAccessKey)) {
log.error("Please configure valid AWS credentials in application.yml");
throw new IllegalArgumentException("Please configure valid AWS credentials in application.yml");
}

log.info("Creating AWS Basic Credentials...");
AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(
accessKeyId.trim(),
secretAccessKey.trim()
);

log.info("Building Cost Explorer Client...");
CostExplorerClient client = CostExplorerClient.builder()
.region(Region.US_EAST_1) // Cost Explorer는 us-east-1만 지원
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();

log.info("AWS Cost Explorer Client initialized successfully");
return client;

} catch (Exception e) {
log.error("Failed to initialize AWS Cost Explorer Client: {}", e.getMessage(), e);
throw new RuntimeException("Failed to initialize AWS Cost Explorer Client", e);
}
}
}
29 changes: 29 additions & 0 deletions src/main/java/com/blockcloud/config/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.blockcloud.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

/**
* 캐시 설정 클래스
* Caffeine 캐시를 설정합니다.
*/
@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(1, TimeUnit.HOURS)
.recordStats());
return cacheManager;
}
}
147 changes: 147 additions & 0 deletions src/main/java/com/blockcloud/controller/AwsCostController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.blockcloud.controller;

import com.blockcloud.dto.ResponseDto.CostForecastResponse;
import com.blockcloud.dto.ResponseDto.CostResponse;
import com.blockcloud.enums.AwsService;
import com.blockcloud.service.AwsCostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;

/**
* AWS 비용 관리 REST API 컨트롤러
* AWS Cost Explorer API를 사용하여 비용 정보를 제공합니다.
*/
@Slf4j
@RestController
@RequestMapping("/api/aws/costs")
@RequiredArgsConstructor
@Tag(name = "AWS Cost Management", description = "AWS 비용 관리 API")
public class AwsCostController {

private final AwsCostService awsCostService;

/**
* 전체 비용 조회 (일별)
*/
@GetMapping("/total")
@Operation(summary = "전체 비용 조회", description = "지정된 기간의 AWS 전체 비용을 일별로 조회합니다.")
public ResponseEntity<CostResponse> getTotalCost(
@Parameter(description = "시작 날짜 (yyyy-MM-dd)", example = "2025-06-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "종료 날짜 (yyyy-MM-dd)", example = "2025-10-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {

log.info("Request received - Get total cost: {} to {}", startDate, endDate);
CostResponse response = awsCostService.getTotalCost(startDate, endDate);
return ResponseEntity.ok(response);
}

/**
* 서비스별 비용 조회
*/
@GetMapping("/by-service")
@Operation(summary = "서비스별 비용 조회", description = "지정된 기간의 AWS 서비스별 비용을 조회합니다.")
public ResponseEntity<CostResponse> getCostByService(
@Parameter(description = "시작 날짜 (yyyy-MM-dd)", example = "2025-06-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "종료 날짜 (yyyy-MM-dd)", example = "2025-10-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {

log.info("Request received - Get cost by service: {} to {}", startDate, endDate);
CostResponse response = awsCostService.getCostByService(startDate, endDate);
return ResponseEntity.ok(response);
}

/**
* 특정 서비스 비용 조회
*/
@GetMapping("/service/{serviceName}")
@Operation(summary = "특정 서비스 비용 조회", description = "지정된 기간의 특정 AWS 서비스 비용을 조회합니다.")
public ResponseEntity<CostResponse> getCostBySpecificService(
@Parameter(
description = "AWS 서비스 이름",
example = "Amazon Elastic Compute Cloud - Compute",
schema = @Schema(implementation = AwsService.class)
)
@PathVariable String serviceName,
@Parameter(description = "시작 날짜 (yyyy-MM-dd)", example = "2025-06-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "종료 날짜 (yyyy-MM-dd)", example = "2025-10-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {

log.info("Request received - Get cost for service '{}': {} to {}",
serviceName, startDate, endDate);
CostResponse response = awsCostService.getCostBySpecificService(serviceName, startDate, endDate);
return ResponseEntity.ok(response);
}

/**
* 비용 예측 조회 (최대 3개월, 미래 날짜만)
* 주의: 예측을 위해서는 최소 2개월 이상의 과거 데이터가 필요합니다.
*/
@GetMapping("/forecast")
@Operation(summary = "비용 예측 조회", description = "지정된 기간의 AWS 비용을 예측합니다. (최대 3개월, 미래 날짜만, 최소 2개월 과거 데이터 필요)")
public ResponseEntity<CostForecastResponse> getCostForecast(
@Parameter(description = "시작 날짜 (yyyy-MM-dd)", example = "2025-10-23")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "종료 날짜 (yyyy-MM-dd)", example = "2025-11-23")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {

log.info("Request received - Get cost forecast: {} to {}", startDate, endDate);
CostForecastResponse response = awsCostService.getCostForecast(startDate, endDate);
return ResponseEntity.ok(response);
}

/**
* 월별 비용 조회 (최대 13개월)
*/
@GetMapping("/monthly")
@Operation(summary = "월별 비용 조회", description = "지정된 기간의 AWS 월별 비용을 조회합니다. (최대 13개월)")
public ResponseEntity<CostResponse> getMonthlyCost(
@Parameter(description = "시작 날짜 (yyyy-MM-dd)", example = "2024-06-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "종료 날짜 (yyyy-MM-dd)", example = "2025-10-01")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {

log.info("Request received - Get monthly cost: {} to {}", startDate, endDate);
CostResponse response = awsCostService.getMonthlyCost(startDate, endDate);
return ResponseEntity.ok(response);
}

/**
* 현재 월 비용 조회
*/
@GetMapping("/current-month")
@Operation(summary = "현재 월 비용 조회", description = "현재 월의 AWS 비용을 조회합니다.")
public ResponseEntity<CostResponse> getCurrentMonthCost() {
LocalDate startDate = LocalDate.now().withDayOfMonth(1);
LocalDate endDate = LocalDate.now().plusDays(1); // Cost Explorer는 미래 날짜 하루 포함

log.info("Request received - Get current month cost: {} to {}", startDate, endDate);
CostResponse response = awsCostService.getTotalCost(startDate, endDate);
return ResponseEntity.ok(response);
}

/**
* 지난 30일 비용 조회
*/
@GetMapping("/last-30-days")
@Operation(summary = "지난 30일 비용 조회", description = "지난 30일간의 AWS 비용을 조회합니다.")
public ResponseEntity<CostResponse> getLast30DaysCost() {
LocalDate endDate = LocalDate.now().plusDays(1);
LocalDate startDate = endDate.minusDays(30);

log.info("Request received - Get last 30 days cost: {} to {}", startDate, endDate);
CostResponse response = awsCostService.getTotalCost(startDate, endDate);
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.blockcloud.dto.ResponseDto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/**
* AWS 비용 예측 응답 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CostForecastResponse {

@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;

@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;

private String currency;

private BigDecimal totalForecastedCost;

private List<ForecastData> forecastData;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(Include.NON_NULL)
public static class ForecastData {
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate date;

private BigDecimal meanValue;

private BigDecimal predictionIntervalLowerBound;

private BigDecimal predictionIntervalUpperBound;
}
}
56 changes: 56 additions & 0 deletions src/main/java/com/blockcloud/dto/ResponseDto/CostResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.blockcloud.dto.ResponseDto;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/**
* AWS 비용 응답 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CostResponse {

@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;

@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;

private String currency;

private BigDecimal totalCost;

private List<CostByService> costByServices;

private List<DailyCost> dailyCosts;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class CostByService {
private String serviceName;
private BigDecimal cost;
private String unit;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DailyCost {
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate date;

private BigDecimal cost;
}
}
Loading