💬 들어가며
개발 환경의 서비스는 on-premise
환경에서 돌아가고 있다.
한번은 서버 컴퓨터의 SSD
가 나갔는데, 서버가 떨어지지는 않았지만 뭔가 서버가 정상적으로 작동이 안되고 있었다.
마침 확인할 것이 있어서 서버에 들어가서 바로 알았지만, 아니었다면 한참 지나서 알았을 것이다.
그래서 빠른 시일 내에 서버에 문제가 있을 때 바로 인지할 수 있도록 health check
기능을 만들어야겠다고 생각했다.
그러다 최근 MongoDB
가 또 떨어지면서 MongoDB
가 필요한 특정 API
들이 작동하지 않았는데 API
가 작동이 안된다고 클라이언트에서 말해주기 전까지 MongoDB
가 떨어졌던 사실을 모르고 있었다.
3월 스프린트도 어느정도 마무리해서 얼른 health check
기능을 만들어보았다.
health check
기능을 처음 만들어봐서 이렇게 하는게 맞는지는 모르겠지만 일단 다음과 같이 내가 생각하는 대로 만들어보았다.
Actuator
연동Filter
를 통한Actuator
보안 설정Spring Schedule
기능을 통해Actuator health check endpoint
로 주기적인 헬스 체크- 헬스 체크 결과를 디스코드로 전송
참고로 디스코드에 알림을 전송하는 기능에 대한 내용은 이전 포스팅에 있다.
[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
연동은 다음과 같은 순서로 진행했다.
build.gradle
에 의존성 추가application-actuator.yml
파일에Actuator
관련 설정Actuator
연동 결과 확인health check
결과를 활용할 수 있도록 응답 데이터와 매핑되는DTO
구현
1-1. Actuator 의존성 추가
build.gradle
에 다음과 같이 의존성을 추가한다.
dependencies {
// actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
1-2. yml 파일에 Actuator 설정
별도로 설정을 하지 않으면 Actuator
의 base-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
으로 알림 전송 - 실패 시,
component
중Down
된 것을 파싱하여 알림 전송
참고로
- 파싱할 때 사용하는
DTO
는 1-4 과정에서 만든DTO
를 활용하며 - 디스코드 알림을 전송하는 기능은 이전 포스팅을 참고하면 된다.
[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
설정으로 들고 있기 때문에 어플리케이션 별로 다른 주소를 동적으로 로드해오기 위해 변수를 활용했다.
서비스 별로 port
와 context-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으로 알림이 온다.

💬 들어가며
개발 환경의 서비스는 on-premise
환경에서 돌아가고 있다.
한번은 서버 컴퓨터의 SSD
가 나갔는데, 서버가 떨어지지는 않았지만 뭔가 서버가 정상적으로 작동이 안되고 있었다.
마침 확인할 것이 있어서 서버에 들어가서 바로 알았지만, 아니었다면 한참 지나서 알았을 것이다.
그래서 빠른 시일 내에 서버에 문제가 있을 때 바로 인지할 수 있도록 health check
기능을 만들어야겠다고 생각했다.
그러다 최근 MongoDB
가 또 떨어지면서 MongoDB
가 필요한 특정 API
들이 작동하지 않았는데 API
가 작동이 안된다고 클라이언트에서 말해주기 전까지 MongoDB
가 떨어졌던 사실을 모르고 있었다.
3월 스프린트도 어느정도 마무리해서 얼른 health check
기능을 만들어보았다.
health check
기능을 처음 만들어봐서 이렇게 하는게 맞는지는 모르겠지만 일단 다음과 같이 내가 생각하는 대로 만들어보았다.
Actuator
연동Filter
를 통한Actuator
보안 설정Spring Schedule
기능을 통해Actuator health check endpoint
로 주기적인 헬스 체크- 헬스 체크 결과를 디스코드로 전송
참고로 디스코드에 알림을 전송하는 기능에 대한 내용은 이전 포스팅에 있다.
[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
연동은 다음과 같은 순서로 진행했다.
build.gradle
에 의존성 추가application-actuator.yml
파일에Actuator
관련 설정Actuator
연동 결과 확인health check
결과를 활용할 수 있도록 응답 데이터와 매핑되는DTO
구현
1-1. Actuator 의존성 추가
build.gradle
에 다음과 같이 의존성을 추가한다.
dependencies {
// actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
1-2. yml 파일에 Actuator 설정
별도로 설정을 하지 않으면 Actuator
의 base-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
으로 알림 전송 - 실패 시,
component
중Down
된 것을 파싱하여 알림 전송
참고로
- 파싱할 때 사용하는
DTO
는 1-4 과정에서 만든DTO
를 활용하며 - 디스코드 알림을 전송하는 기능은 이전 포스팅을 참고하면 된다.
[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
설정으로 들고 있기 때문에 어플리케이션 별로 다른 주소를 동적으로 로드해오기 위해 변수를 활용했다.
서비스 별로 port
와 context-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으로 알림이 온다.
