💬 들어가며

개발 환경의 서비스는 on-premise 환경에서 돌아가고 있다.

한번은 서버 컴퓨터의 SSD가 나갔는데, 서버가 떨어지지는 않았지만 뭔가 서버가 정상적으로 작동이 안되고 있었다.

마침 확인할 것이 있어서 서버에 들어가서 바로 알았지만, 아니었다면 한참 지나서 알았을 것이다.

 

그래서 빠른 시일 내에 서버에 문제가 있을 때 바로 인지할 수 있도록 health check 기능을 만들어야겠다고 생각했다.

그러다 최근 MongoDB가 또 떨어지면서 MongoDB가 필요한 특정 API들이 작동하지 않았는데
API가 작동이 안된다고 클라이언트에서 말해주기 전까지 MongoDB가 떨어졌던 사실을 모르고 있었다.

 

3월 스프린트도 어느정도 마무리해서 얼른 health check 기능을 만들어보았다.

health check 기능을 처음 만들어봐서 이렇게 하는게 맞는지는 모르겠지만 일단 다음과 같이 내가 생각하는 대로 만들어보았다.

  1. Actuator 연동
  2. Filter를 통한 Actuator 보안 설정
  3. Spring Schedule 기능을 통해 Actuator health check endpoint로 주기적인 헬스 체크
  4. 헬스 체크 결과를 디스코드로 전송

참고로 디스코드에 알림을 전송하는 기능에 대한 내용은 이전 포스팅에 있다.

 

[Spring] 서버 모니터링 - 1. GlobalExceptionHandler에서 에러 메시지를 Discord로 보내기

👣 들어가기 전에회사 프로젝트가 슬슬 외부로 나갈 준비(CBT)를 하면서 각종 모니터링 툴을 붙여야 할 때가 왔다. 그동안 테크 블로그나 유튜브에서 프로메테우스, 그라파나, Sentry, Pinpoint 등등

jinny-l.tistory.com

 

참고로 현재 개발 환경은 다음과 같다.

  • SpringBoot3
  • JDK 21
  • 멀티 모듈 구성
  • Spring Security 사용하지 않음
  • 배포 환경: docker

 


💗 Actuator와 Scheduler로 주기적인 헬스체크하기

1. Actuator 연동

Actuator는 스프링의 상태, 메트릭, 환경 정보 등을 쉽게 모니터링하고 관리할 수 있도록 해주는 라이브러리다.

엔드포인트를 통해 정보를 제공해주는데, 대표적으로 다음과 같은 엔드포인트를 제공해준다.

 

엔드포인트 설명 기본 활성화 여부
/actuator/health 애플리케이션의 헬스 상태 확인 ✅ 기본 활성화
/actuator/info 애플리케이션의 빌드 정보, 커스텀 정보 등 ❌ 기본 비활성
/actuator/metrics 애플리케이션의 다양한 메트릭 정보 제공 ❌ 기본 비활성
/actuator/metrics/{name} 특정 메트릭 값 조회 ❌ 기본 비활성
/actuator/env 애플리케이션의 환경변수, 프로퍼티 정보 조회 ❌ 기본 비활성
/actuator/beans 등록된 스프링 빈 정보 확인 ❌ 기본 비활성
/actuator/mappings 모든 요청 URL과 매핑된 핸들러 정보 ❌ 기본 비활성
/actuator/loggers 로깅 레벨 조회 및 변경 ❌ 기본 비활성
/actuator/httptrace 최근 HTTP 요청 정보 (기본 100개) ❌ 기본 비활성


이와 관련된 보다 자세한 내용은 공식 문서에서 확인 가능하다.

 

Endpoints :: Spring Boot

If you add a @Bean annotated with @Endpoint, any methods annotated with @ReadOperation, @WriteOperation, or @DeleteOperation are automatically exposed over JMX and, in a web application, over HTTP as well. Endpoints can be exposed over HTTP by using Jersey

docs.spring.io

 

 

당장은 health check 기능만 사용할 예정이라, 다른 엔드포인트는 활성화하지 않았고, Actuator 연동은 다음과 같은 순서로 진행했다.

  1. build.gradle에 의존성 추가
  2. application-actuator.yml 파일에 Actuator 관련 설정
  3. Actuator 연동 결과 확인
  4. health check 결과를 활용할 수 있도록 응답 데이터와 매핑되는 DTO 구현

 


1-1. Actuator 의존성 추가

build.gradle에 다음과 같이 의존성을 추가한다.

dependencies {
    // actuator
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

 


 

1-2. yml 파일에 Actuator 설정

별도로 설정을 하지 않으면 Actuatorbase-path/actuator 다.

예를 들어 서버 주소가 localhost:8080라면 health check 엔드포인트는 http://localhost:8080/actuator/health 가 된다.

 

나는 base-path 를 바꾸고 싶어서 관련 설정을 했고, health check 결과의 자세한 상태 정보를 보고 싶어서 show-details 옵션을 추가해주었다.

 

application-actuator.yml

management:
  endpoints:
    web:
      base-path: 내가 원하는 base-path 기재, 예: /monitoring
  endpoint:
    health:
      show-details: always

 


1-3. Actuator 연동 결과 확인

서버를 실행하고 설정한 health check 엔드포인트로 API를 요청해서 결과를 확인해보면 다음과 같다.

 

show-details 옵션 추가했을 때

{
  "status": "UP",
  "components": {
    "db": {
      "status": "UP",
      "details": {
        "database": "PostgreSQL",
        "validationQuery": "isValid()"
      }
    },
    "diskSpace": {
      "status": "UP",
      "details": {
        "total": 숫자,
        "free": 숫자,
        "threshold": 숫자,
        "path": "경로",
        "exists": true
      }
    },
    "mongo": {
      "status": "UP",
      "details": {
        "maxWireVersion": 숫자
      }
    },
    "ping": {
      "status": "UP"
    },
    "redis": {
      "status": "UP",
      "details": {
        "cluster_size": 숫자,
        "slots_up": 숫자,
        "slots_fail": 0
      }
    }
  }
}

 

show-details 옵션이 없을 때

{
  "status": "UP"
}

 

show-details 옵션이 없으면 어떤 component에 문제가 생겼는지 확인할 수 없어서 옵션을 넣어주었고,

이 결과를 활용하기 위해 Json 구조와 매핑되는 DTO를 만들어줄 것이다.

 


 

1-4. Health Check 응답 데이터와 매핑되는 DTO 구현

응답 데이터중 details에 포함되는 데이터는 사용하지 않을 예정이고, status만 확인해서 down 되었을 경우 빠르게 알림을 보내 이 상황을 인지할 수 있도록 만들 예정이다.

 

따라서 활용할 Json 데이터는 다음과 같은 구조가 되겠다.

{
  "status": "UP",
  "components": {
    "db": {
      "status": "UP"
    },
    "diskSpace": {
      "status": "UP"
      }
    },
    "mongo": {
      "status": "UP"
    },
    "ping": {
      "status": "UP"
    },
    "redis": {
      "status": "UP"
    }
  }
}

 

위 구조와 매칭되는 DTO를 만들었다.

 

HealthCheckDto.java

public record HealthCheckDto(
        String status,
        Map<String, Component> components
) {
    public record Component(
            String status
    ) {
    }
}

 


 

2. 보안 설정

액츄에이터의 정보에 접근하면 퍼블릭으로 공개하면 안되는 민감한 정보들이 담겨 있다.

보안에 민감한 요소에 아무나 접근하면 안되기 때문에 어떻게 처리를 해야할지 고민을 많이 했다.

 

스프링 시큐리티를 사용하지 않고 있기 때문에 자체적으로 처리를 해야 하는데

일단은 회사 내부망에서만 접근이 가능하도록 IP 기반 보안 처리를 했다.

 

실제 서비스를 시작하게 되면 내부에서도 특정 권한을 가진 사람만 접근할 수 있도록 권한 기반 처리도 해야할 듯 한데

일단은 IP 기반으로 구현해보려고 한다.

 


 

2-1. WhiteList를 처리하는 Filter 구현

기존에 사용하고 있던 Jwt 토큰을 검증하는 JwtFilter가 있는데

해당 필터 앞에서 화이트리스트를 처리하여 JwtFilter에서 토큰 검증을 하지 않도록 하는 WhiteListFilter를 추가해주었다.

 

WhilteListFilter 전체 코드는 다음과 같다.

 

  • JwtFilter보다 먼저 실행될 수 있도록 Order 숫자를 높게 주었고
  • 화이트리스트에 있는 API 엔드포인트이면서, 내부망에서 요청 시 attribute에 식별할 수 있는 값을 넣어주었다.
  • 추가로 개발 시 포스트맨이나 다른 툴로 요청할 때도 통과할 수 있도록 localhost 검증 로직도 추가해주었다.
    (localhost 처리는 포스트맨뿐만이 아니라 나중에 배포된 후 스케줄러로 헬스체크할 때 localhost로 요청이 가기 때문에 필요하다.)

 

WhiteListFilter.java

@Order(2)
@Component
@RequiredArgsConstructor
public class WhiteListFilter implements Filter {

    // 내부 IP용 URL 화이트리스트
    private static final String[] INTERNAL_WHITE_LIST = new String[]{
            "헬스체크 API 주소",
            "예: /actuator**"
    };

    // 내부 IP 접두사
    private static final String INTERNAL_IP_PREFIX = "192.168.100.";

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;

        if (isRequestFromInternalServer(request)
                || isRequestFromLocalhost(request)
        ) {
            request.setAttribute(CoreConfig.WHITE_LIST.getKey(), true);
        } else {
            request.setAttribute(CoreConfig.WHITE_LIST.getKey(), false);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    private boolean isRequestFromInternalServer(HttpServletRequest request) {
        String ip = request.getRemoteAddr();
        String uri = request.getRequestURI();

        return ip.startsWith(INTERNAL_IP_PREFIX) &&
                PatternMatchUtils.simpleMatch(INTERNAL_WHITE_LIST, uri);
    }

    private boolean isRequestFromLocalhost(HttpServletRequest request) {
        String ip = request.getRemoteAddr();
        String uri = request.getRequestURI();

        try {
            InetAddress inetAddress = InetAddress.getByName(ip);
            return inetAddress.isLoopbackAddress()
                    && PatternMatchUtils.simpleMatch(INTERNAL_WHITE_LIST, uri);
        } catch (UnknownHostException e) {
            return false;
        }
    }
}

 


 

2-2. JwtFilter에서 화이트리스트 처리

이후 실행되는 JwtFilter에서 화이트리스트면 토큰 검증하지 않고 패스하는 로직을 추가했다.

 

JwtFilter.java

@Order(3)
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter { 

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        if (isRequestUriInWhiteList(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 생략
    }

    public boolean isRequestUriInWhiteList(HttpServletRequest request) {
        return (Boolean) request.getAttribute(CoreConfig.WHITE_LIST.getKey());
    }
}

 


 

3. Spring Schedule로 주기적인 헬스 체크

헬스 체크 로직은 다음과 같이 설계했다.

  • 특정 주기마다 헬스 체크 엔드포인트로 헬스 체크
  • 성공 시에는 로그를 찍지만 1시간에 한번(정각)씩 디스코드로 알림
    (성공했는데 매번 알림을 보내면 스팸성 알림이 될 것 같아서 1시간에 한번으로 했다.)
  • 실패 시에는 로그를 찍고 바로 디스코드로 알림 전송

이 로직을 2개의 객체가 나눠서 처리하게 된다.

  • AppHealthCheckIndicator: 헬스 체크 엔드포인트로 API 요청 및 결과 로깅
  • HealthCheckScheduler: 주기적으로 HealthCheckIndicator를 호출하여 헬스 체크 진행

 

객체를 2개로 나눈 이유는 책임 분리도 있지만, 현재 프로젝트가 멀티 모듈 구조로 되어 있어서

각 어플리케이션에서 indicator를 호출할 수 있도록 한 이유도 있다.

 


 

3-1. yml에 discord 웹훅 주소 추가

환경 별로 다른 디스코드 채널을 사용하기 때문에 기존에 사용하고 있던 yml 파일에 환경별 웹훅 주소를 기재해주었다.

 

 

application-discord.yml

spring:
  config:
    activate:
      on-profile: dev, test
discord:
  webhook:
    error: 에러 알림 웹훅 url
    health-check: 헬스체크 웹훅 url ## 이 부분 추가!

---

spring:
  config:
    activate:
      on-profile: prod
discord:
  webhook:
    error: 에러 알림 웹훅 url
    health-check: 헬스체크 웹훅 url ## 이 부분 추가!

 


 

3-2. HealthIndicator 구현

이제 HealthIndicator를 만들 것인데 다음 기능을 한다.

  • healthcheck 엔드포인트로 API 요청
  • 성공 시, component 개별 항목을 파싱하지는 않고 status all up 으로 알림 전송
  • 실패 시, componentDown된 것을 파싱하여 알림 전송

 

참고로

 

[Spring] 서버 모니터링 - 1. GlobalExceptionHandler에서 에러 메시지를 Discord로 보내기

👣 들어가기 전에회사 프로젝트가 슬슬 외부로 나갈 준비(CBT)를 하면서 각종 모니터링 툴을 붙여야 할 때가 왔다. 그동안 테크 블로그나 유튜브에서 프로메테우스, 그라파나, Sentry, Pinpoint 등등

jinny-l.tistory.com

 

AppHealthIndicator.java

@Profile("!test")
@Slf4j
@Component
@RequiredArgsConstructor
public class AppHealthIndicator {

    private final ObjectMapper objectMapper;
    private final RestClient restClient;
    private final DiscordClient discordClient;

    @Value("${discord.webhook.health-check}")
    private String healthCheckWebhook;

    public void doHealthCheck(String healthCheckUrl, String appName) {
        try {
            HealthCheckDto healthCheckDto = retrieveHealthStatus(healthCheckUrl);

            log.info("[Health Check ✅] {}", healthCheckDto);

            if (shouldSendAlert()) {
                discordClient.send(
                        healthCheckWebhook,
                        buildDiscordPayload(
                                appName,
                                DiscordMessageColor.GREEN,
                                List.of(new Field("status", "ALL UP"))
                        )
                );
            }

        } catch (Exception e) {
            HealthCheckDto healthCheckDto = parseExceptionToHealthCheckDto((HttpStatusCodeException) e, appName);

            log.error("[Health Check ❌] {} ", e.getMessage());

            List<Field> fields = extractDownField(healthCheckDto);

            discordClient.send(
                    healthCheckWebhook,
                    buildDiscordPayload(
                            appName,
                            DiscordMessageColor.RED,
                            fields
                    )
            );
        }
    }

    private HealthCheckDto retrieveHealthStatus(String healthCheckUrl) throws JsonProcessingException {
        String response = restClient.get()
                .uri(healthCheckUrl)
                .retrieve()
                .body(String.class);

        return objectMapper.readValue(response, HealthCheckDto.class);
    }

    private boolean shouldSendAlert() {
        LocalDateTime now = LocalDateTime.now();
        return now.getMinute() == 0;
    }

    private HealthCheckDto parseExceptionToHealthCheckDto(HttpStatusCodeException e, String appName) {
        try {
            return objectMapper.readValue(e.getResponseBodyAsString(), HealthCheckDto.class);

        } catch (JsonProcessingException ex) {
            discordClient.send(
                    healthCheckWebhook,
                    buildDiscordPayload(
                            appName,
                            DiscordMessageColor.RED,
                            List.of(new Field("status", "헬스 체크 서버 오류"))
                    )
            );

            throw new CommonException(CommonError.INTERNAL_SERVER_ERROR);
        }
    }

    private List<Field> extractDownField(HealthCheckDto healthCheckDto) {
        return healthCheckDto.components().entrySet().stream()
                .filter(entry -> entry.getValue().status().equals("DOWN"))
                .map(entry -> Field.of(entry.getKey(), entry.getValue()))
                .toList();
    }

    private DiscordEmbedMessage buildDiscordPayload(
            String appName,
            DiscordMessageColor color,
            List<Field> fields
    ) {
        return DiscordEmbedMessage.builder()
                .content(String.format("# 💗 %s 서버 HEALTH CHECK", appName))
                .embed(
                        Embed.builder()
                                .color(color)
                                .fields(fields)
                                .build()
                ).build();
    }
}

 


 

3-3. Scheduler 구현

현재 프로젝트는 멀티 모듈 구조로 여러 개의 어플리케이션이 있다.

알림 채널을 어플리케이션별로 나누면 좋겠지만, 혼자 일하고 있기 때문에 개발 편의상 채널이 여러 개가 되면 오히려 관리하기가 어려울 것 같아서 한 개의 채널을 사용하기로 했다.

 

이렇게 되면 어떤 서버에서 알림이 오는지 구분하기 어렵기 때문에 디스코드 알림에 어떤 채널인지 APP_NAME으로 같이 넣어주었다.

그리고 스케줄링은 30초 간격으로 설정했다.

 

HealthCheckSceduler.java

⚠️ @Scheduled 어노테이션 사용 시 실행 파일 혹은 Configuration에 @EnableScheduling 어노테이션을 꼭 설정해줘야 한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class HealthCheckScheduler {

    private static final String APP_NAME = "APP";

    private final AppHealthIndicator appHealthIndicator;

    @Value("${health-check-url}")
    private String healthCheckUrl;

    @Scheduled(fixedDelay = 30000)
    public void run() throws JsonProcessingException {
        appHealthIndicator.doHealthCheck(
                healthCheckUrl,
                APP_NAME
        );
    }
}

 

스케줄러에서 @Value를 통해 가져오는 헬스체크 엔드포인트는 아까 만든 application-actuator.yml 파일에 추가했다.

 

해당 yml 파일은 어플리케이션별로 설정하지 않고, common 설정으로 들고 있기 때문에 어플리케이션 별로 다른 주소를 동적으로 로드해오기 위해 변수를 활용했다.

서비스 별로 portcontext-path가 다르기 때문에 해당 값과 health check 엔드포인트 주소를 조합했다.

 

참고로 주소가 localhost인 이유는 배포 환경인 도커 내부에서 헬스체크하기 위함이다.

 

application-actuator.yml

management:
  endpoints:
    web:
      base-path:
  endpoint:
    health:
      show-details: always


## 이거 추가
health-check-url: http://localhost:${server.port}${server.servlet.context-path}${management.endpoints.web.base-path}/health

 


4. 디스코드 알림 결과 확인

실패 시

헬스 체크 실패 시,  어떤 component가 down 된 것인지, 알림이 온다.

 

성공 시

헬스 체크에 성공하면 굳이 component를 파싱하지 않고 ALL UP으로 알림이 온다.