diff --git a/build.gradle b/build.gradle index f36ec6b..07ccd90 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/blockcloud/config/AwsConfig.java b/src/main/java/com/blockcloud/config/AwsConfig.java new file mode 100644 index 0000000..339bbaf --- /dev/null +++ b/src/main/java/com/blockcloud/config/AwsConfig.java @@ -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); + } + } +} diff --git a/src/main/java/com/blockcloud/config/CacheConfig.java b/src/main/java/com/blockcloud/config/CacheConfig.java new file mode 100644 index 0000000..3a88fa8 --- /dev/null +++ b/src/main/java/com/blockcloud/config/CacheConfig.java @@ -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; + } +} diff --git a/src/main/java/com/blockcloud/controller/AwsCostController.java b/src/main/java/com/blockcloud/controller/AwsCostController.java new file mode 100644 index 0000000..0789d41 --- /dev/null +++ b/src/main/java/com/blockcloud/controller/AwsCostController.java @@ -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 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 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 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 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 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 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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/CostForecastResponse.java b/src/main/java/com/blockcloud/dto/ResponseDto/CostForecastResponse.java new file mode 100644 index 0000000..b5b03c4 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/CostForecastResponse.java @@ -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; + + @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; + } +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/CostResponse.java b/src/main/java/com/blockcloud/dto/ResponseDto/CostResponse.java new file mode 100644 index 0000000..23a84ed --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/CostResponse.java @@ -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 costByServices; + + private List 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; + } +} diff --git a/src/main/java/com/blockcloud/enums/AwsService.java b/src/main/java/com/blockcloud/enums/AwsService.java new file mode 100644 index 0000000..83a7688 --- /dev/null +++ b/src/main/java/com/blockcloud/enums/AwsService.java @@ -0,0 +1,179 @@ +package com.blockcloud.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * AWS 서비스 목록 Enum + * AWS Cost Explorer에서 사용되는 주요 서비스들을 정의합니다. + */ +@Schema(description = "AWS 서비스 목록") +public enum AwsService { + + // Compute Services + @Schema(description = "Amazon Elastic Compute Cloud - Compute") + EC2_COMPUTE("Amazon Elastic Compute Cloud - Compute"), + @Schema(description = "Amazon Elastic Compute Cloud - Container") + EC2_CONTAINER("Amazon Elastic Compute Cloud - Container"), + @Schema(description = "Amazon Elastic Container Service") + ECS("Amazon Elastic Container Service"), + @Schema(description = "Amazon Elastic Kubernetes Service") + EKS("Amazon Elastic Kubernetes Service"), + @Schema(description = "AWS Lambda") + LAMBDA("AWS Lambda"), + @Schema(description = "AWS Batch") + BATCH("AWS Batch"), + @Schema(description = "Amazon Lightsail") + LIGHTSAIL("Amazon Lightsail"), + + // Storage Services + @Schema(description = "Amazon Simple Storage Service") + S3("Amazon Simple Storage Service"), + @Schema(description = "Amazon Elastic Block Store") + EBS("Amazon Elastic Block Store"), + @Schema(description = "Amazon Elastic File System") + EFS("Amazon Elastic File System"), + @Schema(description = "Amazon FSx") + FSX("Amazon FSx"), + @Schema(description = "AWS Backup") + BACKUP("AWS Backup"), + + // Database Services + @Schema(description = "Amazon Relational Database Service") + RDS("Amazon Relational Database Service"), + @Schema(description = "Amazon DynamoDB") + DYNAMODB("Amazon DynamoDB"), + @Schema(description = "Amazon Redshift") + REDSHIFT("Amazon Redshift"), + @Schema(description = "Amazon ElastiCache") + ELASTICACHE("Amazon ElastiCache"), + @Schema(description = "Amazon DocumentDB") + DOCUMENTDB("Amazon DocumentDB"), + @Schema(description = "Amazon Neptune") + NEPTUNE("Amazon Neptune"), + @Schema(description = "Amazon Timestream") + TIMESTREAM("Amazon Timestream"), + + // Networking Services + @Schema(description = "Amazon Virtual Private Cloud") + VPC("Amazon Virtual Private Cloud"), + @Schema(description = "Amazon CloudFront") + CLOUDFRONT("Amazon CloudFront"), + @Schema(description = "Amazon Route 53") + ROUTE53("Amazon Route 53"), + @Schema(description = "Amazon API Gateway") + API_GATEWAY("Amazon API Gateway"), + @Schema(description = "AWS Direct Connect") + DIRECT_CONNECT("AWS Direct Connect"), + @Schema(description = "AWS Global Accelerator") + GLOBAL_ACCELERATOR("AWS Global Accelerator"), + @Schema(description = "AWS PrivateLink") + PRIVATE_LINK("AWS PrivateLink"), + + // Security Services + @Schema(description = "AWS Identity and Access Management") + IAM("AWS Identity and Access Management"), + @Schema(description = "AWS Key Management Service") + KMS("AWS Key Management Service"), + @Schema(description = "AWS Secrets Manager") + SECRETS_MANAGER("AWS Secrets Manager"), + @Schema(description = "AWS Certificate Manager") + CERTIFICATE_MANAGER("AWS Certificate Manager"), + @Schema(description = "AWS WAF") + WAF("AWS WAF"), + @Schema(description = "AWS Shield") + SHIELD("AWS Shield"), + @Schema(description = "Amazon GuardDuty") + GUARDDUTY("Amazon GuardDuty"), + + // Management Services + @Schema(description = "Amazon CloudWatch") + CLOUDWATCH("Amazon CloudWatch"), + @Schema(description = "AWS CloudTrail") + CLOUDTRAIL("AWS CloudTrail"), + @Schema(description = "AWS Config") + CONFIG("AWS Config"), + @Schema(description = "AWS Systems Manager") + SYSTEMS_MANAGER("AWS Systems Manager"), + @Schema(description = "AWS Trusted Advisor") + TRUSTED_ADVISOR("AWS Trusted Advisor"), + @Schema(description = "AWS Personal Health Dashboard") + PERSONAL_HEALTH_DASHBOARD("AWS Personal Health Dashboard"), + + // Analytics Services + @Schema(description = "Amazon Kinesis") + KINESIS("Amazon Kinesis"), + @Schema(description = "Amazon EMR") + EMR("Amazon EMR"), + @Schema(description = "Amazon QuickSight") + QUICKSIGHT("Amazon QuickSight"), + @Schema(description = "Amazon Athena") + ATHENA("Amazon Athena"), + @Schema(description = "AWS Glue") + GLUE("AWS Glue"), + @Schema(description = "AWS Lake Formation") + LAKE_FORMATION("AWS Lake Formation"), + + // AI/ML Services + @Schema(description = "Amazon SageMaker") + SAGEMAKER("Amazon SageMaker"), + @Schema(description = "Amazon Comprehend") + COMPREHEND("Amazon Comprehend"), + @Schema(description = "Amazon Translate") + TRANSLATE("Amazon Translate"), + @Schema(description = "Amazon Rekognition") + REKOGNITION("Amazon Rekognition"), + @Schema(description = "Amazon Polly") + POLLY("Amazon Polly"), + @Schema(description = "Amazon Lex") + LEX("Amazon Lex"), + + // Application Services + @Schema(description = "Amazon Simple Queue Service") + SQS("Amazon Simple Queue Service"), + @Schema(description = "Amazon Simple Notification Service") + SNS("Amazon Simple Notification Service"), + @Schema(description = "Amazon Simple Email Service") + SES("Amazon Simple Email Service"), + @Schema(description = "Amazon Simple Notification Service - Mobile") + SNS_MOBILE("Amazon Simple Notification Service - Mobile"), + @Schema(description = "Amazon WorkSpaces") + WORKSPACES("Amazon WorkSpaces"), + @Schema(description = "Amazon AppStream 2.0") + APPSTREAM("Amazon AppStream 2.0"), + + // Developer Tools + @Schema(description = "AWS CodeBuild") + CODEBUILD("AWS CodeBuild"), + @Schema(description = "AWS CodePipeline") + CODEPIPELINE("AWS CodePipeline"), + @Schema(description = "AWS CodeCommit") + CODECOMMIT("AWS CodeCommit"), + @Schema(description = "AWS CodeDeploy") + CODEDEPLOY("AWS CodeDeploy"), + @Schema(description = "AWS X-Ray") + X_RAY("AWS X-Ray"), + @Schema(description = "AWS Cloud9") + CLOUD9("AWS Cloud9"), + + // Other Services + @Schema(description = "AWS Support") + SUPPORT("AWS Support"), + @Schema(description = "AWS Marketplace") + MARKETPLACE("AWS Marketplace"), + @Schema(description = "AWS Data Transfer") + DATA_TRANSFER("AWS Data Transfer"), + @Schema(description = "Tax") + TAX("Tax"), + @Schema(description = "Other") + OTHER("Other"); + + private final String serviceName; + + AwsService(String serviceName) { + this.serviceName = serviceName; + } + + public String getServiceName() { + return serviceName; + } +} \ No newline at end of file diff --git a/src/main/java/com/blockcloud/exception/error/ErrorCode.java b/src/main/java/com/blockcloud/exception/error/ErrorCode.java index a14bd8c..0d131b7 100644 --- a/src/main/java/com/blockcloud/exception/error/ErrorCode.java +++ b/src/main/java/com/blockcloud/exception/error/ErrorCode.java @@ -58,7 +58,15 @@ public enum ErrorCode { NOT_FOUND_DEPLOYMENT(40402, HttpStatus.NOT_FOUND, "존재하지 않는 배포입니다."), DEPLOYMENT_ALREADY_RUNNING(40901, HttpStatus.CONFLICT, "이미 실행 중인 배포가 있습니다."), TERRAFORM_VALIDATION_FAILED(40010, HttpStatus.BAD_REQUEST, "Terraform 코드 검증에 실패했습니다."), - TERRAFORM_APPLY_FAILED(50002, HttpStatus.INTERNAL_SERVER_ERROR, "Terraform 배포에 실패했습니다."); + TERRAFORM_APPLY_FAILED(50002, HttpStatus.INTERNAL_SERVER_ERROR, "Terraform 배포에 실패했습니다."), + + // AWS Cost Explorer Errors + AWS_DATA_UNAVAILABLE(50301, HttpStatus.SERVICE_UNAVAILABLE, "AWS 비용 데이터를 사용할 수 없습니다. 충분한 과거 데이터가 없거나 아직 준비되지 않았습니다."), + AWS_LIMIT_EXCEEDED(42901, HttpStatus.TOO_MANY_REQUESTS, "AWS API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요."), + AWS_COST_EXPLORER_ERROR(50201, HttpStatus.BAD_GATEWAY, "AWS Cost Explorer API 호출에 실패했습니다."), + AWS_HISTORICAL_DATA_LIMIT(40013, HttpStatus.BAD_REQUEST, "AWS Cost Explorer는 최대 13개월의 과거 데이터만 제공합니다."), + AWS_FORECAST_LIMIT(40014, HttpStatus.BAD_REQUEST, "AWS Cost Explorer 예측은 최대 3개월의 미래 데이터만 제공합니다."), + AWS_INSUFFICIENT_DATA(40015, HttpStatus.BAD_REQUEST, "예측을 위한 충분한 과거 데이터가 없습니다. 최소 2개월 이상의 과거 데이터가 필요합니다."); private final Integer code; private final HttpStatus httpStatus; diff --git a/src/main/java/com/blockcloud/service/AwsCostService.java b/src/main/java/com/blockcloud/service/AwsCostService.java new file mode 100644 index 0000000..4fa2dfe --- /dev/null +++ b/src/main/java/com/blockcloud/service/AwsCostService.java @@ -0,0 +1,370 @@ +package com.blockcloud.service; + +import com.blockcloud.dto.ResponseDto.CostForecastResponse; +import com.blockcloud.dto.ResponseDto.CostResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.costexplorer.CostExplorerClient; +import software.amazon.awssdk.services.costexplorer.model.*; +import com.blockcloud.exception.CommonException; +import com.blockcloud.exception.error.ErrorCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * AWS Cost Explorer API를 사용한 비용 관리 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AwsCostService { + + private final CostExplorerClient costExplorerClient; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * 전체 비용 조회 (일별) + */ + @Cacheable(value = "totalCost", key = "#startDate + '-' + #endDate") + public CostResponse getTotalCost(LocalDate startDate, LocalDate endDate) { + log.info("Fetching total cost from AWS for period: {} to {}", startDate, endDate); + + try { + GetCostAndUsageRequest request = GetCostAndUsageRequest.builder() + .timePeriod(DateInterval.builder() + .start(startDate.format(DATE_FORMATTER)) + .end(endDate.format(DATE_FORMATTER)) + .build()) + .granularity(Granularity.DAILY) + .metrics("UnblendedCost") + .build(); + + GetCostAndUsageResponse response = costExplorerClient.getCostAndUsage(request); + + BigDecimal totalCost = BigDecimal.ZERO; + List dailyCosts = new ArrayList<>(); + + for (ResultByTime result : response.resultsByTime()) { + String date = result.timePeriod().start(); + String costAmount = result.total().get("UnblendedCost").amount(); + + // null 안전성을 위한 BigDecimal 생성 + BigDecimal cost = costAmount != null ? new BigDecimal(costAmount) : BigDecimal.ZERO; + + totalCost = totalCost.add(cost); + + dailyCosts.add(CostResponse.DailyCost.builder() + .date(LocalDate.parse(date, DATE_FORMATTER)) + .cost(cost) + .build()); + } + + String currency = response.resultsByTime().isEmpty() ? "USD" + : response.resultsByTime().get(0).total().get("UnblendedCost").unit(); + + return CostResponse.builder() + .startDate(startDate) + .endDate(endDate) + .currency(currency) + .totalCost(totalCost) + .dailyCosts(dailyCosts) + .build(); + } catch (Exception e) { + log.error("Failed to fetch total cost from AWS: {}", e.getMessage(), e); + log.error("Exception type: {}", e.getClass().getSimpleName()); + log.error("Exception details: ", e); + throw new CommonException(ErrorCode.AWS_COST_EXPLORER_ERROR); + } + } + + /** + * 서비스별 비용 조회 + */ + @Cacheable(value = "costByService", key = "#startDate + '-' + #endDate") + public CostResponse getCostByService(LocalDate startDate, LocalDate endDate) { + log.info("Fetching cost by service from AWS for period: {} to {}", startDate, endDate); + + try { + GetCostAndUsageRequest request = GetCostAndUsageRequest.builder() + .timePeriod(DateInterval.builder() + .start(startDate.format(DATE_FORMATTER)) + .end(endDate.format(DATE_FORMATTER)) + .build()) + .granularity(Granularity.MONTHLY) + .groupBy(GroupDefinition.builder() + .type(GroupDefinitionType.DIMENSION) + .key("SERVICE") + .build()) + .metrics("UnblendedCost") + .build(); + + GetCostAndUsageResponse response = costExplorerClient.getCostAndUsage(request); + + BigDecimal totalCost = BigDecimal.ZERO; + List costByServices = new ArrayList<>(); + String currency = "USD"; + + for (ResultByTime result : response.resultsByTime()) { + for (Group group : result.groups()) { + String serviceName = group.keys().get(0); + String costAmount = group.metrics().get("UnblendedCost").amount(); + String unit = group.metrics().get("UnblendedCost").unit(); + + // null 안전성을 위한 BigDecimal 생성 + BigDecimal cost = costAmount != null ? new BigDecimal(costAmount) : BigDecimal.ZERO; + + totalCost = totalCost.add(cost); + currency = unit != null ? unit : "USD"; + + costByServices.add(CostResponse.CostByService.builder() + .serviceName(serviceName) + .cost(cost) + .unit(currency) + .build()); + } + } + + // 비용이 높은 순으로 정렬 + costByServices.sort((a, b) -> b.getCost().compareTo(a.getCost())); + + return CostResponse.builder() + .startDate(startDate) + .endDate(endDate) + .currency(currency) + .totalCost(totalCost) + .costByServices(costByServices) + .build(); + } catch (Exception e) { + log.error("Failed to fetch cost by service from AWS: {}", e.getMessage(), e); + log.error("Exception type: {}", e.getClass().getSimpleName()); + log.error("Exception details: ", e); + throw new CommonException(ErrorCode.AWS_COST_EXPLORER_ERROR); + } + } + + /** + * 특정 서비스의 비용 조회 + */ + @Cacheable(value = "serviceCost", key = "#serviceName + '-' + #startDate + '-' + #endDate") + public CostResponse getCostBySpecificService(String serviceName, LocalDate startDate, LocalDate endDate) { + log.info("Fetching cost for service '{}' from AWS for period: {} to {}", + serviceName, startDate, endDate); + + try { + Expression filter = Expression.builder() + .dimensions(DimensionValues.builder() + .key("SERVICE") + .values(serviceName) + .build()) + .build(); + + GetCostAndUsageRequest request = GetCostAndUsageRequest.builder() + .timePeriod(DateInterval.builder() + .start(startDate.format(DATE_FORMATTER)) + .end(endDate.format(DATE_FORMATTER)) + .build()) + .granularity(Granularity.DAILY) + .metrics("UnblendedCost") + .filter(filter) + .build(); + + GetCostAndUsageResponse response = costExplorerClient.getCostAndUsage(request); + + BigDecimal totalCost = BigDecimal.ZERO; + List dailyCosts = new ArrayList<>(); + String currency = "USD"; + + for (ResultByTime result : response.resultsByTime()) { + String date = result.timePeriod().start(); + String costAmount = result.total().get("UnblendedCost").amount(); + String unit = result.total().get("UnblendedCost").unit(); + + // null 안전성을 위한 BigDecimal 생성 + BigDecimal cost = costAmount != null ? new BigDecimal(costAmount) : BigDecimal.ZERO; + currency = unit != null ? unit : "USD"; + + totalCost = totalCost.add(cost); + + dailyCosts.add(CostResponse.DailyCost.builder() + .date(LocalDate.parse(date, DATE_FORMATTER)) + .cost(cost) + .build()); + } + + return CostResponse.builder() + .startDate(startDate) + .endDate(endDate) + .currency(currency) + .totalCost(totalCost) + .dailyCosts(dailyCosts) + .build(); + } catch (Exception e) { + log.error("Failed to fetch cost for service '{}' from AWS: {}", serviceName, e.getMessage(), e); + log.error("Exception type: {}", e.getClass().getSimpleName()); + log.error("Exception details: ", e); + throw new CommonException(ErrorCode.AWS_COST_EXPLORER_ERROR); + } + } + + /** + * 비용 예측 조회 (최대 3개월 일별) + */ + @Cacheable(value = "costForecast", key = "#startDate + '-' + #endDate") + public CostForecastResponse getCostForecast(LocalDate startDate, LocalDate endDate) { + log.info("Fetching cost forecast from AWS for period: {} to {}", startDate, endDate); + + // AWS Cost Explorer 예측은 최대 3개월까지만 지원 + LocalDate maxEndDate = LocalDate.now().plusMonths(3); + if (endDate.isAfter(maxEndDate)) { + log.warn("Requested end date {} is after the maximum allowed date {}. Adjusting to maximum allowed date.", + endDate, maxEndDate); + endDate = maxEndDate; + } + + // 예측은 미래 날짜에 대해서만 가능 + LocalDate minStartDate = LocalDate.now().plusDays(1); + if (startDate.isBefore(minStartDate)) { + log.warn("Requested start date {} is before the minimum allowed date {}. Adjusting to minimum allowed date.", + startDate, minStartDate); + startDate = minStartDate; + } + + try { + GetCostForecastRequest request = GetCostForecastRequest.builder() + .timePeriod(DateInterval.builder() + .start(startDate.format(DATE_FORMATTER)) + .end(endDate.format(DATE_FORMATTER)) + .build()) + .metric(Metric.UNBLENDED_COST) + .granularity(Granularity.DAILY) + .build(); + + GetCostForecastResponse response = costExplorerClient.getCostForecast(request); + + BigDecimal totalForecast = BigDecimal.ZERO; + List forecastDataList = new ArrayList<>(); + + for (ForecastResult result : response.forecastResultsByTime()) { + String date = result.timePeriod().start(); + BigDecimal meanValue = new BigDecimal(result.meanValue()); + + totalForecast = totalForecast.add(meanValue); + + // null 안전성을 위한 BigDecimal 생성 (없으면 응답에서 필드를 제외) + BigDecimal lowerBound = result.predictionIntervalLowerBound() != null + ? new BigDecimal(result.predictionIntervalLowerBound()) + : null; + + BigDecimal upperBound = result.predictionIntervalUpperBound() != null + ? new BigDecimal(result.predictionIntervalUpperBound()) + : null; + + forecastDataList.add(CostForecastResponse.ForecastData.builder() + .date(LocalDate.parse(date, DATE_FORMATTER)) + .meanValue(meanValue) + .predictionIntervalLowerBound(lowerBound) + .predictionIntervalUpperBound(upperBound) + .build()); + } + + return CostForecastResponse.builder() + .startDate(startDate) + .endDate(endDate) + .currency("USD") + .totalForecastedCost(totalForecast) + .forecastData(forecastDataList) + .build(); + } catch (Exception e) { + log.error("Failed to fetch cost forecast from AWS: {}", e.getMessage(), e); + log.error("Exception type: {}", e.getClass().getSimpleName()); + log.error("Exception details: ", e); + + if (e.getMessage() != null) { + String errorMessage = e.getMessage(); + log.error("Error message: {}", errorMessage); + + if (errorMessage.contains("Insufficient amount of historical data")) { + throw new CommonException(ErrorCode.AWS_INSUFFICIENT_DATA); + } else if (errorMessage.contains("forecast")) { + throw new CommonException(ErrorCode.AWS_FORECAST_LIMIT); + } else if (errorMessage.contains("historical data beyond 14 months")) { + throw new CommonException(ErrorCode.AWS_HISTORICAL_DATA_LIMIT); + } + } + throw new CommonException(ErrorCode.AWS_COST_EXPLORER_ERROR); + } + } + + /** + * 월별 비용 추세 조회 (최대 13개월) + */ + @Cacheable(value = "monthlyCost", key = "#startDate + '-' + #endDate") + public CostResponse getMonthlyCost(LocalDate startDate, LocalDate endDate) { + log.info("Fetching monthly cost from AWS for period: {} to {}", startDate, endDate); + + // AWS Cost Explorer는 최대 13개월 + 현재 월의 데이터만 제공 + LocalDate maxStartDate = LocalDate.now().minusMonths(13).withDayOfMonth(1); + if (startDate.isBefore(maxStartDate)) { + log.warn("Requested start date {} is before the maximum allowed date {}. Adjusting to maximum allowed date.", + startDate, maxStartDate); + startDate = maxStartDate; + } + + try { + GetCostAndUsageRequest request = GetCostAndUsageRequest.builder() + .timePeriod(DateInterval.builder() + .start(startDate.format(DATE_FORMATTER)) + .end(endDate.format(DATE_FORMATTER)) + .build()) + .granularity(Granularity.MONTHLY) + .metrics("UnblendedCost") + .build(); + + GetCostAndUsageResponse response = costExplorerClient.getCostAndUsage(request); + + BigDecimal totalCost = BigDecimal.ZERO; + List monthlyCosts = new ArrayList<>(); + String currency = "USD"; + + for (ResultByTime result : response.resultsByTime()) { + String date = result.timePeriod().start(); + String costAmount = result.total().get("UnblendedCost").amount(); + String unit = result.total().get("UnblendedCost").unit(); + + // null 안전성을 위한 BigDecimal 생성 + BigDecimal cost = costAmount != null ? new BigDecimal(costAmount) : BigDecimal.ZERO; + currency = unit != null ? unit : "USD"; + + totalCost = totalCost.add(cost); + + monthlyCosts.add(CostResponse.DailyCost.builder() + .date(LocalDate.parse(date, DATE_FORMATTER)) + .cost(cost) + .build()); + } + + return CostResponse.builder() + .startDate(startDate) + .endDate(endDate) + .currency(currency) + .totalCost(totalCost) + .dailyCosts(monthlyCosts) + .build(); + } catch (Exception e) { + log.error("Failed to fetch monthly cost from AWS: {}", e.getMessage(), e); + log.error("Exception type: {}", e.getClass().getSimpleName()); + log.error("Exception details: ", e); + + if (e.getMessage() != null && e.getMessage().contains("historical data beyond 14 months")) { + throw new CommonException(ErrorCode.AWS_HISTORICAL_DATA_LIMIT); + } + throw new CommonException(ErrorCode.AWS_COST_EXPLORER_ERROR); + } + } +} \ No newline at end of file