AIOps 란

AIOps(AI for IT Operations)는 대규모 IT 운영 데이터(로그, 메트릭, 이벤트 등)를 수집/분석하여 장애탐지, 원인 분석, 운영 의사결정을 자동화하거나 보조하는 접근 방식이다.

  • 다양한 운영 데이터의 통합 수집
  • 통계적 기법 + 머신러닝/LLM 기반 분석
  • 운영자 의사결정을 돕는 인사이트/리포트/지동화

AIOps란 무엇인가요?

프로젝트 개요

본 프로젝트는 사내 여러 AWS 계정 및 프로젝트의 운영 데이터를 자동 수집하고, 이를 기반으로 AI 리포트를 자동 생성/시각화 하는 시스템을 구축하는 것이 목표였다.

구현한 주요 기능

  • 다수의 AWS 계정/프로젝트에서 운영 데이터(Health/Cost/CloudWatch 등) 자동 수집
  • 수집된 데이터를 기반으로 AI 리포트 자동 생성
  • 운영 현황을 한눈에 볼 수 있는 대시보드 제공

주요 사용 기술

프로젝트 구조

멀티 모듈 프로젝트 구성했다.

  • 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;
  }
}