AIOps 란
AIOps(AI for IT Operations)는 대규모 IT 운영 데이터(로그, 메트릭, 이벤트 등)를 수집/분석하여 장애탐지, 원인 분석, 운영 의사결정을 자동화하거나 보조하는 접근 방식이다.
- 다양한 운영 데이터의 통합 수집
- 통계적 기법 + 머신러닝/LLM 기반 분석
- 운영자 의사결정을 돕는 인사이트/리포트/지동화
프로젝트 개요
본 프로젝트는 사내 여러 AWS 계정 및 프로젝트의 운영 데이터를 자동 수집하고, 이를 기반으로 AI 리포트를 자동 생성/시각화 하는 시스템을 구축하는 것이 목표였다.
구현한 주요 기능
- 다수의 AWS 계정/프로젝트에서 운영 데이터(Health/Cost/CloudWatch 등) 자동 수집
- 수집된 데이터를 기반으로 AI 리포트 자동 생성
- 사내 AI API gateway 연동
- 시스템 프롬프트 설계
- 운영 현황을 한눈에 볼 수 있는 대시보드 제공
주요 사용 기술
- Java 21
- SpringBoot 3.2.5
- AWS SDK for Java v2
프로젝트 구조
멀티 모듈 프로젝트 구성했다.
- api: 데이터 시각화 및 조회 API 서버
- batch: AWS RAW 데이터 수집 및 적재 배치 서버
- core: 프로젝트들이 공통으로 사용하는 기능 구현
AWS 데이터 수집 아키텍처
멀티 계정 접근 방식 - AWS STS
AWS STS(Security Token Service)를 이용해 임시 보완 자격을 얻어 각 계정의 리소스에 접근했다.
- 메인 계정 > 각 프로젝트 계정으로
AssumeRole - 프로젝트 수: 약 10개 연동
- AWS 계정 수: 약 30개 연동
장기 액세스 키를 각 계정에 배포하지 않아도 되므로, 보안상 유리하다.
AWS SDK Client 재사용 전략
AWS SDK v2의 Client는 thread-safe하며, 공식 문서에서도 재사용을 권장하고 있다. 이를 기반으로 Client 캐싱 전략을 적용했다.
적용 방식
ConcurrentHashMap기반 Client 캐시- 자바에서 멀티스레드 환경을 위해 설계된 고성능 해시 맵
computeIfAbsent()를 활용한 원자적(Client 생성 중복 방지) 생성- “ClientType + RoleArn” 조합으로 캐시 키 구성
장점
- 불필요한 AWS SDK Client 생성 방지
- STS AssumeRole 호출 최소화
- 멀티 스레드 환경에서 안전한 재사용
AWS Client Builder 구현
// ...
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.config.ConfigClient;
import software.amazon.awssdk.services.costexplorer.CostExplorerClient;
// ...
import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
@Component
public class AwsClientBuilder {
private final StsClient stsClient;
private final Map<String, Object> clientCache = new ConcurrentHashMap<>();
public AwsClientBuilder(@Value("${aws.access-key-id}") String accessKeyId,
@Value("${aws.secret-access-key}") String secretAccessKey) {
AwsCredentialsProvider baseCredentials = StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKeyId, secretAccessKey));
this.stsClient = StsClient.builder().region(Region.AP_NORTHEAST_2)
.credentialsProvider(baseCredentials).build();
}
private AwsCredentialsProvider assumedRoleCredentials(String roleArn) {
String roleSessionName = UUID.randomUUID().toString();
return StsAssumeRoleCredentialsProvider.builder().stsClient(stsClient).refreshRequest(
() -> AssumeRoleRequest.builder().roleArn(roleArn).roleSessionName(roleSessionName).build())
.build();
}
private String createCacheKey(Class<?> clientClass, String roleArn) {
return clientClass.getSimpleName() + ":" + roleArn;
}
@SuppressWarnings("unchecked")
public <T> T buildClient(Class<T> clientClass, String roleArn, Region region) {
String cacheKey = createCacheKey(clientClass, roleArn);
return (T) clientCache.computeIfAbsent(cacheKey, k -> {
AwsCredentialsProvider credentials = assumedRoleCredentials(roleArn);
if (clientClass == ConfigClient.class) {
return ConfigClient.builder().region(region).credentialsProvider(credentials).build();
} else if (clientClass == CloudWatchClient.class) {
return CloudWatchClient.builder().region(region).credentialsProvider(credentials).build();
} else if (clientClass == CostExplorerClient.class) {
return CostExplorerClient.builder().region(region).credentialsProvider(credentials).build();
}
// ... more client class
else {
throw new IllegalArgumentException("Unsupported client type");
}
});
}
public ConfigClient buildConfigClient(String roleArn) {
return buildClient(ConfigClient.class, roleArn, Region.AP_NORTHEAST_2);
}
public CostExplorerClient buildCostExplorerClient(String roleArn) {
return buildClient(CostExplorerClient.class, roleArn, Region.AWS_GLOBAL);
}
public CloudWatchClient buildCloudWatchClient(String roleArn) {
return buildClient(CloudWatchClient.class, roleArn, Region.AP_NORTHEAST_2);
}
// ... more client class builder
public void clearCache() {
clientCache.clear();
}
}
데이터 수집 - CostExplorer 연동 예시
데이터 중복 적재 방지 전략
데이터는 주기적으로 수집되며, 동일계정/서비스/기간에 대한 중복 적재가 발생하면 안 된다. 이를 위해 PK를 다음과 같이 구성했다.
- AWS 계정
- 서비스
- Granularity (DAILY / MONTHLY)
- 기간 시작일
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Entity
@ToString
public class CostMetric extends BaseEntity {
@Id
private String costMetricId;
@Column
private String accountId;
@Column
@Enumerated(EnumType.STRING)
private AwsAccount account;
@Column
@Enumerated(EnumType.STRING)
private AwsProject project;
@Column
private String roleArn;
@Column
private String service;
@Column
private BigDecimal amount;
@Column
private String unit;
@Column
private String granularity;
@Column
private LocalDate periodStart;
@Column
private LocalDate periodEnd;
public static CostMetric create(AwsAccount account, String service, Double amount, String unit,
String granularity, LocalDate periodStart, LocalDate periodEnd) {
String costMetricId = String.format("%s:%s:%s:%s:%s", account.getAccountId(), account,
granularity, periodStart, service);
return CostMetric.builder().costMetricId(costMetricId).accountId(account.getAccountId())
.account(account).project(account.getProject()).roleArn(account.getRoleArn())
.service(service).granularity(granularity).amount(amount)
.unit(unit).periodStart(periodStart).periodEnd(periodEnd).build();
}
}
설계 이유
- 비용 데이터는 정밀도가 중요하므로
BigDecimal사용 JpaRepository.saveAll()사용- 존재하면 merge, 없으면 insert
AWS API 호출 성능 vs 안전성
초기 문제
- Health API: 응답시간 약 2분
- CostExplorer API: 응답시간 약 30초
적용한 방법
- 계정 단위 병렬 처리 -
parallelStrem() - I/O 중심 작업이므로 CPU 병목은 크지 않음
결과
- Health API: 약 10초내로 단축
- CostExplorer API: 약 3초내로 단축
하지만 병렬처리를 제거했는데, 이유는 다음과 같다.
- AWS API는 계정/리전/서비스별 Rate Limit이 존재
- 안전성을 위해 순차 처리로 전환 + 배치로 데이터 적재
“성능 vs 안전성” 트레이드오프에서 운영 안전성을 우선한 결정이다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CostExplorerService {
private final AwsClientBuilder awsClientBuilder;
private final List<AwsAccount> accounts = AwsAccount.getAwsAccounts();
private final String AMORTIZED_COST = "AmortizedCost";
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private List<CostMetric> getCostAndUsage(LocalDate startDate, LocalDate endDate,
Granularity granularity, String metric) {
List<CostMetric> results = new ArrayList<>();
// parallelStream(): Rate Limit 때문에 추후에 제거
accounts.parallelStream().forEach(account -> {
CostExplorerClient client = awsClientBuilder.buildCostExplorerClient(roleArn);
try {
String roleArn = account.getRoleArn();
String nextToken = null;
do {
GetCostAndUsageRequest request = GetCostAndUsageRequest.builder().timePeriod(
DateInterval.builder().start(startDate.format(formatter))
.end(endDate.format(formatter)).build()).metrics(metric).granularity(granularity)
.groupBy(GroupDefinition.builder().type(GroupDefinitionType.DIMENSION).key("SERVICE")
.build()).nextPageToken(nextToken).build();
GetCostAndUsageResponse response = client.getCostAndUsage(request);
response.resultsByTime().forEach(result -> {
String start = result.timePeriod().start();
String end = result.timePeriod().end();
LocalDate periodStart = LocalDate.parse(start, formatter);
LocalDate periodEnd = LocalDate.parse(end, formatter);
result.groups().forEach(group -> {
String service = group.keys().getFirst(); // 서비스명
Double amount = Double.valueOf(group.metrics().get(metric).amount());
String unit = group.metrics().get(metric).unit();
CostMetric costMetric = CostMetric.create(account, service, amount,
unit, granularity.toString(), periodStart, periodEnd);
results.add(costMetric);
});
});
nextToken = response.nextPageToken();
} while (nextToken != null);
} catch (AwsServiceException e) {
System.out.println(e.getMessage());
}
});
return results;
}
/**
* date 파라미터 기준으로 전날 데이터 조회
* */
public List<CostMetric> getCostByDaily(LocalDate date) {
LocalDate startDate = date.minusDays(1);
List<CostMetric> cost = getCostAndUsage(startDate, date, Granularity.DAILY,
AMORTIZED_COST);
return cost;
}
public List<CostMetric> getCostByMonthly(LocalDate date) {
YearMonth lastMont = YearMonth.from(date).minusMonths(1);
LocalDate startDate = lastMont.atDay(1);
LocalDate endDate = lastMont.atEndOfMonth();
return getCostAndUsage(startDate, endDate, Granularity.MONTHLY, AMORTIZED_COST);
}
public List<CostMetric> getCosts(LocalDate startDate, LocalDate endDate) {
List<CostMetric> cost = getCostAndUsage(startDate, endDate, Granularity.DAILY,
AMORTIZED_COST);
return cost;
}
}