서버 에러 시 슬랙에 알림 보내기
우리 프로젝트에선 prod server와 dev server 두 가지를 사용하고 있는데
오류가 발생할 때마다 서버가 직접 ec2에 접속해서 에러 로그를 확인하는 방식이 매우 불편했다.
또한 클라이언트에서 오류가 발생했다고 서버에게 말을 해주기 전에 서버에서 발생하는 오류를 파악하고 싶었다
따라서 배포용 서버와 개발용 서버에서 발생하는 에러들을 아래처럼 두개의 채널에 전송하기로 하였다
또한 4XX, 5XX을 각각 클라이언트에러와 서버에러로 구분하여 알림을 보내기로 하였다
배포용 서버에서 발생하는 에러 -> # iluvit-prod-error 채널
개발용 서버에서 발생하는 에러 -> # iluvit-dev-error 채널
(1) 슬랙 웹 훅 설정
새 채널 생성에서 iluvit-dev-error, iluvit-prod-error 채널을 각각 생성해준다
두 채널 다 같은 방식으로 설정하면 되므로 iluvit-dev-error 를 기준으로 살펴보자~!
채널 세부 정보 보기 -> 통합 -> 앱 -> 앱 추가
Incoming WebHooks 를 검색하면 뜨는 앱들 중 하나를 설치해준다
이름과 아이콘을 지정할 수 있다
웹후크 URL은 애플리케이션 yml 환경 변수로 들어가야 하므로 잘 복사해두어야한다!
(2) 스프링에 의존성 추가 및 설정
build.gradle에 아래와 같은 의존성을 추가해준다
implementation 'com.slack.api:slack-api-client:1.29.0'
application.yml 에 아래 코드를 추가해준다
아래 설정은 로깅 레벨을 조정하는 데 사용된다. 로깅 레벨은 메시지의 중요도에 따라 다른 수준으로 설정할 수 있다
아래 설정에서는 Hibernate SQL 관련 로그는 info 레벨로, FIS.iLUVit 관련된 모든 로그는 info 레벨로, FIS.iLUVit.controller와 FIS.ILUVit.service 로그는 warn 레벨로 설정되어 있다.
주요 로깅 레벨 : TRACE < DEBUG < INFO < WARN < ERROR
Debug 와 Trace 레벨은 많은 양의 로그가 쌓이므로 운영 단계에서 해당 레벨의 로깅을 할 경우 용량 감당이 안 될 수 있다.
Debug, Trace 레벨의 로깅은 개발 단계에서만 사용하고 배포 단계에서는 사용하지 말자
// application.yml
---
spring:
profiles:
active: dev
---
spring:
profiles:
active: prod
---
// === 아래부터 추가되는 부분 ===
logging:
level:
org.hibernate.SQL: info
FIS.iLUVit: info
FIS.iLUVit.controller: warn
FIS.iLUVit.service: warn
우리는 dev profile과 prod profile을 따로 쓰고 있으므로
각 채널에서 생성한 webhook url을 각각의 파일에 등록해준다
// application-dev.yml
logging:
level:
root: INFO
profiles: dev
webhook-uri: {#iluvit-dev-error 채널에 등록한 webhook url 넣기}
log-dir: './logs'
// application-prod.yml
logging:
level:
root: INFO
profiles: prod
webhook-uri: {#iluvit-prod-error 채널에 등록한 webhook url 넣기}
log-dir: './logs'
- logging.profiles : 프로필에 대한 로그 설정을 제어한다. 각각 dev, profile 프로필에 해당하는 로그 설정을 다루고 있다
- log-dir : 로그파일 생성 경로를 지정해준 것으로 아래쪽에서 logback-spring.xml을 작성할 때 사용된다
(3) SlackErrorLogger 만들기
exception 폴더 내에 ( 우리 프로그램 기준 ) SlackErrorLogger 라는 클래스를 만들어 슬랙에 알림을 전송하는 코드를 따로 분리하였다
import com.slack.api.Slack;
import com.slack.api.model.Attachment;
import com.slack.api.model.Field;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import static com.slack.api.webhook.WebhookPayloads.payload;
@Getter
@Slf4j
@RequiredArgsConstructor
@Component
public class SlackErrorLogger {
private final Slack slackClient = Slack.getInstance();
@Value("${webhook-uri}")
private String webhookUrl;
/**
* 서버 에러 전송
*/
public void sendSlackAlertErrorLog(String errMessage, HttpServletRequest request) {
try {
slackClient.send(webhookUrl, payload(p -> p
.text("서버 에러 발생! 백엔드 측의 빠른 확인 요망")
// attachment는 list 형태여야 합니다.
.attachments(
List.of(generateSlackAttachment(errMessage, request, "ff0000" ))
)
));
} catch (IOException slackError) {
// slack 통신 시 발생한 예외에서 Exception을 던져준다면 재귀적인 예외가 발생합니다.
// 따라서 로깅으로 처리하였고, 아이러빗 서버 에러는 아니므로 `error` 레벨보다 낮은 레벨로 설정했습니다.
log.debug("Slack 통신과의 예외 발생");
}
}
/**
* 클라이언트 에러 전송
*/
public void sendSlackAlertWarnLog(String errMessage, HttpServletRequest request) {
try {
slackClient.send(webhookUrl, payload(p -> p
.text("클라이언트 에러 발생! 프론트엔드 측의 빠른 확인 요망")
// attachment는 list 형태여야 합니다.
.attachments(
List.of(generateSlackAttachment(errMessage, request,"ffff00" ))
)
));
} catch (IOException slackError) {
log.debug("Slack 통신과의 예외 발생");
}
}
// attachment 생성 메서드
private Attachment generateSlackAttachment(String errMessage, HttpServletRequest request, String color) {
String requestTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").format(LocalDateTime.now());
String xffHeader = request.getHeader("X-FORWARDED-FOR"); // 프록시 서버일 경우 client IP는 여기에 담길 수 있습니다.
return Attachment.builder()
.color(color) // 왼쪽 띠의 색
.title(requestTime + " 발생 에러 로그")
// Field도 List 형태로 담아주어야 합니다.
.fields(List.of(
generateSlackField("Request IP", xffHeader == null ? request.getRemoteAddr() : xffHeader),
generateSlackField("Request URL", request.getRequestURL() + " " + request.getMethod()),
generateSlackField("Error Message", errMessage)
)
)
.build();
}
// Field 생성 메서드
private Field generateSlackField(String title, String value) {
return Field.builder()
.title(title)
.value(value)
.valueShortEnough(false)
.build();
}
}
(4) ControllerAdvice에 코드 추가하기
@RestControllerAdvice 어노테이션이 붙어있는 ControllerAdvice에서 SlackErrorLogger의 sendSlackAlertErrorLog, sendSlackAlertWarnLog함수를 사용하여 슬랙 알림을 보냅니다. 저희 프로그램은 아래와 같은 기준으로 코드를 작성하였고 추후 변경할 가능성도 있습니다
- Exception을 커스텀하여 4XX 에러를 보내주는 부분: log.warn 레벨로 로그를 찍고 sendSlackAlertWarnLog를 호출
- 그 외 나머지 : log.error 레벨로 로그를 찍고 sendSlackAlertErrorLog를 호출
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalControllerAdvice extends ResponseEntityExceptionHandler {
private final SlackErrorLogger slackErrorLogger;
@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception e, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
log.error("[InternalExceptionHandler {} {} errMessage={}\n",
httpServletRequest.getMethod(),
httpServletRequest.getRequestURI(),
e.getMessage()
);
slackErrorLogger.sendSlackAlertErrorLog(e.getMessage(), httpServletRequest); // 슬랙 알림 보내는 메서드
return makeErrorResponseEntity(e.getMessage());
}
/**
* validation 에서 Exception 발생시 자동으로 handleMethodArgumentNotValid 호출
*/
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatus status, WebRequest request) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
final List<String> errorList = e.getBindingResult()
.getAllErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
log.warn("[MethodArgumentNotValidException {} {} errMessage={}\n",
httpServletRequest.getMethod(),
httpServletRequest.getRequestURI(),
e.getMessage()
);
slackErrorLogger.sendSlackAlertErrorLog(e.getMessage(), httpServletRequest); // 슬랙 알림 보내는 메서드
return makeErrorResponseEntity(errorList.toString());
}
...
생략
...
/**
* Presentation 관련 에러 등록
*/
@ExceptionHandler(PresentationException.class)
public ResponseEntity<ErrorResponse> PresenterExceptionHandler(PresentationException e, HttpServletRequest request) {
log.warn("[PresentationException] {} {} errMessage={}\n",
request.getMethod(),
request.getRequestURI(),
e.getErrorResult().getMessage()
);
slackErrorLogger.sendSlackAlertWarnLog(e.getErrorResult().getMessage(), request); // 슬랙 알림 보내는 메서드
return makeErrorResponseEntity(e.getErrorResult());
}
...
생략
...
/**
* create ResponseEntity
*/
private ResponseEntity<ErrorResponse> makeErrorResponseEntity(ErrorResult errorResult) {
return ResponseEntity.status(errorResult.getHttpStatus())
.body(new ErrorResponse(errorResult.getHttpStatus(), errorResult.getMessage()));
}
private ResponseEntity<ErrorResponse> makeErrorResponseEntity(HttpStatus httpStatus, String message) {
return ResponseEntity.status(httpStatus)
.body(new ErrorResponse(httpStatus,message));
}
private ResponseEntity<Object> makeErrorResponseEntity(final String errorMessage) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(HttpStatus.BAD_REQUEST, errorMessage));
}
}
로그 파일 관리
슬랙에 에러 알림을 보내는 것과 별개로~!!
로그를 콘솔에만 출력하면 로그가 많이 찍혔을 때는 위쪽 로그가 보이지 않게 되기도 하고, 기록이 남지 않아 나중에 확인하기가 어려운 등 불편한 점이 많아 log 파일을 생성하여 일자별로 log를 저장하는 기능을 추가하였다
잘 구현된다면 로컬과 ec2에 각각 아래의 사진들과 같이 일자별 log파일이 생성된다
Logback 이란 ?
Logback은 SLF4j의 구현체이며 Spring Boot 환경이라면 별도의 dependency 추가 없이 기본적으로 포함되어 있다.
logback-spring.xml 이란?
콘솔 로그의 수준을 변경하는 방법은 application.yml 과 logback-spring.xml 에서 설정하는 방법이 있다.
application.yml는 설정 난이도가 쉽지만 실제 제품에 사용하기 한계가 있고 세부적인 설정이 불편하기 때문에 logback-spring.xml로 관리하는 편이 더 편리하다. logback-spring.xml 은 크게 appender와 logger 부분으로 나눌 수 있다
- appender : 콘솔, 파일, DB 등 로그를 출력하는 방법을 지정
- logger : 출력할 곳을 지정
logback-spring.xml 작성
전체 코드는 아래와 같다
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 로그 설정 -->
<springProperty name="LOG_DIR" source="log-dir"/>
<property name="LOG_PATH_NAME" value="${LOG_DIR}/data/"/>
<property name="ERROR_LOG_PATH_NAME" value="${LOG_DIR}/error/"/>
<property name="LOG_PATTERN_CONSOLE" value="%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %highlight([ %-5level]) | %cyan(%logger{35}) - %msg%n" />
<property name="LOG_PATTERN_FILE" value="[%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %-5level %logger{35} - %msg%n" />
<property name="MAX_HISTORY" value="365" />
<!-- 콘솔 출력 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN_CONSOLE}</pattern>
</encoder>
</appender>
<!-- 파일로 저장-->
<appender name="DATA" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 일자별로 로그파일 적용하기 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH_NAME}data_%d{yyyyMMdd}.log</fileNamePattern>
<maxHistory>${MAX_HISTORY}</maxHistory> <!-- 일자별 백업파일의 보관기간 -->
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN_FILE}</pattern>
</encoder>
</appender>
<!-- 에러의 경우는 별도 파일 저장 -->
<appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch> <!-- 해당 레벨만 기록한다. -->
<onMismatch>DENY</onMismatch> <!-- 다른 수준의 레벨은 기록하지 않는다.(상위 레벨도 기록 안함), 상위 수준의 레벨에 대한 기록을 원하면 ACCEPT 로 하면 기록된다. -->
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- .gz,.zip 등을 넣으면 자동 일자별 로그파일 압축가능 -->
<fileNamePattern>${ERROR_LOG_PATH_NAME}error_%d{yyyyMMdd}.log</fileNamePattern>
<maxHistory>${MAX_HISTORY}</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN_FILE}</pattern>
</encoder>
</appender>
<springProfile name="dev,prod"> <!-- 프로필 별로 로그를 관리한다 -->
<logger name="내 프로젝트 패키지 명" level="INFO" additivity="false">
<appender-ref ref="STDOUT"/>
<appender-ref ref="DATA"/>
</logger>
<logger name="내 프로젝트 패키지 명" level="ERROR" additivity="false">
<appender-ref ref="ERROR"/>
</logger>
</springProfile>
</configuration>
하나씩 파악해보자~!
yml 파일에서 log-dir을 불러온다
프로젝트 디렉토리 안에 log 폴더가 있고
그 아래 data 폴더와 error 폴더에 각각 로그파일이 생성된다
<springProperty name="LOG_DIR" source="log-dir"/>
<property name="LOG_PATH_NAME" value="${LOG_DIR}/data/"/>
<property name="ERROR_LOG_PATH_NAME" value="${LOG_DIR}/error/"/>
<property name="LOG_DIR" value="./logs"/> 라고 작성하지 못한 이유는
codedeploy를 통해 CD를 할 때 ./logs 경로를 인식하지 못하기 때문이다
따라서 로컬에서는 아래 첫번째 코드와 같이 설정해주고 github action secret에서는 두번째 코드와 같이 ec2 경로에 알맞는 log-dir을 적어주어야 정상적으로 CD가 작동된다
이 부분때문에 많이 헤맸다...
// application-dev.yml & application-prod.yml
log-dir: './logs'
logs-dir: '/home/ubuntu/iluvit/logs'
글자 색이 있는 로그를 파일에 저장하면 이스케이프 문자가 보여 로그를 읽기가 어려우니
파일에 저장할 땐 글자 색깔을 없애기 위해 LOG_PATTERN_CONSOLE과 LOG_PATTERN_FILE을 분리하였다
<property name="LOG_PATTERN_CONSOLE" value="%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %highlight([ %-5level]) | %cyan(%logger{35}) - %msg%n" />
<property name="LOG_PATTERN_FILE" value="[%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %-5level %logger{35} - %msg%n" />
만약 우리 프로젝트와 달리 dev,prod 프로필 별로 다르게 로그 관리를 하고 싶다면 아래와 같은 방법으로 <springPriofile name=""> 을 여러개 사용하여 구현할 수 있다
<springProfile name="dev"> <!-- 프로필 별로 로그를 관리한다 -->
<logger name="내 프로젝트 패키지 명" level="DEBUG" additivity="false">
<appender-ref ref="STDOUT"/>
<appender-ref ref="DATA"/>
</logger>
<logger name="내 프로젝트 패키지 명" level="WARN" additivity="false">
<appender-ref ref="ERROR"/>
</logger>
</springProfile>
<springProfile name="prod"> <!-- 프로필 별로 로그를 관리한다 -->
<logger name="내 프로젝트 패키지 명" level="INFO" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
<logger name="내 프로젝트 패키지 명" level="ERROR" additivity="false">
<appender-ref ref="ERROR"/>
</logger>
</springProfile>
로그를 파일에 저장할 때는 gitignore에 log 파일을 꼭 추가하자.
그렇지 않으면 불필요한 로그 파일들이 깃허브에 올라가게 된다.