본문 바로가기
Spring-Boot

스프링부트 CloudWatch, API 성공, 실패, 소요시간을 모니터링해 보자

by 준형코딩 2024. 10. 29.

들어가며 . . .

3개월 동안 혼자서 Flutter와 Spring Boot로 개발을 진행한 '캠프라이드'가 앱스토어 출시를 앞두고 있습니다. 앱 심사가 완료되면 다양한 개발자 커뮤니티와 에브리타임, 그리고 주변 지인분들을 통해 홍보를 진행할 예정입니다. 서비스 홍보 후에는 적지 않은 사용자 유입이 예상되는데, 현재는 다음과 같은 문제점들이 있었습니다.

  1. 예상치 못한 에러가 발생했을 때 이를 실시간으로 모니터링할 수 있는 방법이 없음
  2. API 응답 시간 저하나 에러 발생과 같은 문제를 조기에 발견하고 조치할 수 있는 모니터링 수단이 필요

이러한 문제들을 해결하기 위해 AWS CloudWatch를 도입하게 되었고, 이번 글에서는 Spring Boot 애플리케이션에서 CloudWatch를 활용하여 API 모니터링 시스템을 구축하는 방법을 상세히 다루어보도록 하겠습니다.

 

AWS CloudWatch vs Grafana: 어떤 도구를 선택해야 할까?

모니터링 도구를 선택할 때 AWS CloudWatch와 Grafana는 자주 비교되는 두 가지 옵션입니다. 각각의 특징을 살펴보겠습니다

 

AWS CloudWatch의 장점

  • AWS 서비스와의 원활한 통합
  • 별도 인프라 구축 없이 즉시 사용 가능
  • AWS 리소스에 최적화된 모니터링 기능
  • 사용량 기반 비용 체계

Grafana의 장점

  • 오픈소스로 무료 사용 가능
  • 다양한 데이터 소스 지원
  • 높은 수준의 커스터마이징
  • 자체 인프라에서 운영 가능

이번 포스팅에서는 별도의 인프라 구축 없이 바로 사용 가능하고 가볍지만 성능이 뛰어난 AWS CloudWatch를 사용한 구현 방법을 소개하겠습니다.

Spring Boot에서 CloudWatch 모니터링 구현하기

구현 과정은 크게 네 단계로 나눌 수 있습니다:

1. IAM 권한 설정

CloudWatch에 로그를 전송하기 위해서는 적절한 IAM 권한이 필요합니다. 다음과 같은 정책을 IAM 사용자 또는 역할에 연결해야 합니다

2. Logback 설정

먼저 로그를 기록하기 위한 Logback 설정을 추가합니다. 이 설정은 콘솔과 파일에 로그를 동시에 기록하며, 일자별로 로그 파일을 관리합니다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/api.log</file>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/api.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>7</maxHistory>
        </rollingPolicy>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="FILE" />
    </root>
</configuration>

3. API 모니터링을 위한 AOP 구현

Spring AOP를 사용하여 모든 API 호출을 인터셉트하고, 실행 시간과 성공/실패 여부를 기록합니다. (JwtException의 경우 디스패치 서블릿을 통과한후 컨트롤러 메서드가 실행될 때 동작하는 AOP를 통해서 처리할 수 없었기 때문에 JwtExceptionFilter에서 log를 남기고 CloudWatchService를 통해서 CloudWatch에 로그를 전송해 주었습니다.)

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class ApiMonitoringAspect {

    private final CloudWatchLogsService cloudWatchLogsService;
    private static final String LOG_FORMAT =
            "API: {} | Method: {} | Time: {}ms | Success: {} | Error: {}";

    @Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " +
            "@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
            "@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
            "@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
            "@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
    public Object monitorApi(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        String apiName = joinPoint.getSignature().getName();
        String method = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
                .getRequest().getMethod();

        try {
            Object result = joinPoint.proceed();
            String logMessage = String.format("API: %s | Method: %s | Time: %dms | Success: %s | Error: %s",
                    apiName, method, System.currentTimeMillis() - startTime, true, "-");

            log.info(LOG_FORMAT,
                    apiName,
                    method,
                    System.currentTimeMillis() - startTime,
                    true,
                    "-"
            );
            cloudWatchLogsService.sendLog(logMessage);
            return result;

        } catch (Exception e) {
            String logMessage = String.format("API: %s | Method: %s | Time: %dms | Success: %s | Error: %s",
                    apiName, method, System.currentTimeMillis() - startTime, false, e.getMessage());

            log.error(LOG_FORMAT,
                    apiName,
                    method,
                    System.currentTimeMillis() - startTime,
                    false,
                    e.getMessage()
            );
            cloudWatchLogsService.sendLog(logMessage);
            throw e;
        }
    }
}

4. CloudWatch 로그 서비스 구현

AWS SDK를 사용하여 CloudWatch에 로그를 전송하는 서비스를 구현합니다. 이 서비스는 로그 그룹과 스트림을 생성하고, 비동기적으로 로그를 전송합니다. 

@Service
@Slf4j
public class CloudWatchLogsService {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    private CloudWatchLogsClient cloudWatchLogsClient;
    private final String LOG_GROUP = "/spring-boot/api-monitoring";
    private final String LOG_STREAM = "api-logs";
    private String sequenceToken;
    private final ExecutorService executor = Executors.newSingleThreadExecutor();

    @PostConstruct
    public void init() {
        // AWS 자격증명 생성
        AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);

        // CloudWatch 클라이언트 생성
        cloudWatchLogsClient = CloudWatchLogsClient.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                .build();

        try {
            createLogGroupAndStream();
        } catch (Exception e) {
            log.error("Error initializing CloudWatch Logs", e);
        }
    }

    private void createLogGroupAndStream() {
        // Create log group if not exists
        try {
            cloudWatchLogsClient.createLogGroup(CreateLogGroupRequest.builder()
                    .logGroupName(LOG_GROUP)
                    .build());
            log.info("Created CloudWatch log group: {}", LOG_GROUP);
        } catch (ResourceAlreadyExistsException e) {
            log.info("CloudWatch log group already exists: {}", LOG_GROUP);
        }

        // Create log stream
        try {
            cloudWatchLogsClient.createLogStream(CreateLogStreamRequest.builder()
                    .logGroupName(LOG_GROUP)
                    .logStreamName(LOG_STREAM)
                    .build());
            log.info("Created CloudWatch log stream: {}", LOG_STREAM);
        } catch (Exception e) {
            log.error("Error creating CloudWatch log stream", e);
        }
    }

    public void sendLog(String message) {
        executor.submit(() -> {
            try {
                PutLogEventsRequest request = PutLogEventsRequest.builder()
                        .logGroupName(LOG_GROUP)
                        .logStreamName(LOG_STREAM)
                        .sequenceToken(sequenceToken)
                        .logEvents(List.of(
                                InputLogEvent.builder()
                                        .timestamp(System.currentTimeMillis())
                                        .message(message)
                                        .build()
                        ))
                        .build();

                PutLogEventsResponse response = cloudWatchLogsClient.putLogEvents(request);
                sequenceToken = response.nextSequenceToken();
                log.debug("Successfully sent log to CloudWatch");

            } catch (Exception e) {
                log.error("Error sending log to CloudWatch", e);
            }
        });
    }
}

모니터링 결과 확인하기

구현이 완료되면 CloudWatch 콘솔에서 다음과 같은 정보를 실시간으로 확인할 수 있습니다.

  • API 호출의 성공/실패 여부
  • API 실행 시간
  • 에러 메시지 (실패 시)
  • 시간대별 API 호출 패턴
  • 다양한 EC2 상태

API 성공 / 실패 / 실행시간 모니터링
다양한 EC2 지표 모니터링

마치며..

앱 출시를 앞두고 모니터링 기능이 없어서 에러가 나면 어쩌나 걱정이 많았는데 모니터링에서 API 성공, 실패 여부와 소요 시간을 알려주니 마음이 굉장히 놓였다. 앞으로 지속적으로 모니터링하면서 slow query가 발생하거나 에러가 발생한다면 빠르게 대응할 수 있을 것 같다는 생각이 들었다. 그리고 CI/CD를 넘어 마지막 단계라고 생각했던 모니터링을 구축하면서 내가 드디어 나만의 서비스를 런칭하고 운영해나갈 수 있겠구나 라는 것을 실감하게 되어 굉장히 뿌듯했다. 앞으로 계속해서 서비스를 발전시켜나가야겠다.