매번 배포 진행 후 실패할 경우 ec2에 직접 접속해서 docker의 로그를 확인하는 것이 너무 비효율적이라 생각이 들었다..(물론 프로젝트 끝나고 든 생각이라 나중에 하긴 했다 ㅎㅎ)
그렇지만 스프린트를 이어나갈 가능성도 있고, 해두면 언제 에러가 나든 편리하게 이슈대응할 수 있을 것 같아서 한참 전에 파두었던 로깅 관련 이슈를 늦게서야 완성시킨 일을 써보려 한다.
1. MDC (Mapped Diagnostic Context)
MDC란?
MDC는 Mapped Diagnostic Context의 약자로, SLF4J와 같은 로깅 프레임워크에서 제공하는 Thread-Local 기반의 데이터 저장소이다. 멀티쓰레드 환경에서도 요청별로 로그 데이터를 구분할 수 있도록 쓰레드마다 고유의 값을 저장한다.
예를 들어, 특정 요청의 URI, 사용자 IP, HTTP 헤더, 요청 본문 등을 (Key, Value) 형태로 저장하여 로그 메시지에 자동으로 포함시킬 수 있다.
MDC를 왜 사용하는가?
- 멀티쓰레드 환경에서의 로그 데이터 관리
여러 요청이 동시에 처리되는 멀티쓰레드 환경에서는 로그 데이터가 뒤섞일 수 있다. MDC를 사용하면 각 쓰레드마다 독립적인 데이터를 저장해 요청별 정보를 로그에 추가할 수 있다. - 디버깅 효율성 향상
요청 URI, 사용자 IP와 같은 정보를 로그 메시지에 포함하면 문제 발생 시 어떤 요청이 원인이었는지 쉽게 파악할 수 있다. - 동적 컨텍스트 정보 추가
로그 메시지마다 동일한 컨텍스트 정보를 자동으로 포함시킬 수 있어, 코드를 간결하게 유지하면서도 풍부한 정보를 제공한다.
MDC의 동작 원리
- Thread-Local 기반
MDC는 Thread-Local을 사용해 각 쓰레드마다 독립적으로 데이터를 저장한다. 요청이 처리되는 동안 쓰레드 내에서 MDC 데이터를 참조하거나 수정할 수 있다. - 로깅 시 데이터 삽입
SLF4J 또는 Logback에서 %X{key}와 같은 패턴을 통해 MDC 데이터를 로그 메시지에 동적으로 삽입한다. - 요청 종료 후 데이터 초기화
요청이 끝난 후 MDC.clear()를 호출해 데이터를 정리한다. 그렇지 않으면 다음 요청에 데이터가 누적되어 잘못된 로그가 기록될 수 있다.
MDC를 Spring Boot에서 사용 방법
MDC는 주로 Filter를 통해 요청 데이터를 수집하여 MDC에 저장한 후, 요청이 종료되면 데이터를 초기화하는 방식으로 사용된다.
@Slf4j
public class MDCFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
// MDC 데이터 설정
MDC.put("Request URI", request.getRequestURI());
MDC.put("사용자 IP", request.getRemoteAddr());
// 필터 체인 실행
filterChain.doFilter(request, response);
} finally {
// 요청 종료 후 MDC 데이터 초기화
MDC.clear();
}
}
}
나는 각각의 역할에 따라 클래스를 RequestWrappingFilter, RequestBodyWrapper, MDCFilter, MDCFilterConfig, MDCManager로 나눠 MDC를 사용했다.
실제 코드 적용
RequestBodyWrapper
RequestBodyWrapper는 HttpServletRequestWrapper를 상속받아 요청 본문 데이터를 복사해 메모리에 저장하는 클래스이다. 요청 데이터는 메모리에 저장된 문자열로 관리되며, 이후 getInputStream()이나 getReader()를 호출할 때 저장된 데이터를 반환한다.
이 클래스를 구현한 이유는 요청 본문을 한 번 읽어도 이후 단계에서 동일한 데이터를 재사용할 수 있도록 하기 위함이다. 특히, 본문 데이터를 로그로 기록하거나, 요청 유효성 검사를 수행한 뒤에도 컨트롤러에서 데이터 접근이 가능하게 한다.
- HttpServletRequestWrapper를 상속받아 요청 본문 데이터를 한 번 읽은 후에도 재사용 가능하도록 구현했다.
- 요청 본문(Http Body) 데이터를 메모리에 저장하고, getInputStream()이나 getReader() 호출 시 저장된 데이터를 반환한다.
- 요청 데이터를 읽고 메모리에 저장한다.
@Getter
public class RequestBodyWrapper extends HttpServletRequestWrapper {
private final String requestBody;
public RequestBodyWrapper(HttpServletRequest request) {
super(request);
StringBuilder stringBuilder = new StringBuilder();
try (BufferedReader bufferedReader = request.getReader()) {
char[] charBuffer = new char[128];
int bytesRead;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
stringBuilder.append(charBuffer, 0, bytesRead);
}
} catch (IOException e) {
throw new BadRequestException(FailureCode.BAD_REQUEST);
}
requestBody = stringBuilder.toString();
}
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestBody.getBytes());
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException();
}
public int read() {
return byteArrayInputStream.read();
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
RequestWrappingFilter
RequestWrappingFilter는 HTTP 요청이 필터 체인을 통과하기 전에 요청 객체를 RequestBodyWrapper로 감싸는 필터로, 요청 본문 데이터를 복사하여 여러 번 읽을 수 있도록 보장하는 필터이다.
Spring의 HttpServletRequest는 요청 본문(Http Body)을 한 번 읽으면 더 이상 읽을 수 없는 구조이다. 이로 인해 Interceptor나 Filter에서 본문 데이터를 읽으면 컨트롤러에서 본문 데이터가 비어버리는 문제가 발생할 수 있다. 이를 해결하기 위해 RequestWrappingFilter는 요청 데이터를 복사해 RequestBodyWrapper로 감싸서 처리한다.
이 필터를 분리한 이유는 요청 데이터 복사 작업을 명확하게 분리하여, 요청 본문을 안전하게 여러 단계에서 사용할 수 있게 보장하기 위함이다.
- 요청 데이터를 복사하는 작업을 필터 체인에 추가하여 모든 요청에 대해 일관되게 적용된다.
- RequestBodyWrapper 객체로 원래 요청 객체를 대체하여 이후의 Interceptor, 컨트롤러, 또는 다른 필터에서도 동일한 데이터를 사용할 수 있게 한다.
- 요청 데이터를 직접 처리하지 않고, 요청 데이터를 처리하는 역할을 RequestBodyWrapper에 위임한다.
@Slf4j
public class RequestWrappingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 요청 데이터를 복사해 RequestBodyWrapper로 감싸기
RequestBodyWrapper requestBodyWrapper = new RequestBodyWrapper(request);
filterChain.doFilter(requestBodyWrapper, response);
}
}
둘의 관계와 작동원리는 다음과 같다.
- RequestWrappingFilter 실행
HTTP 요청이 들어오면, RequestWrappingFilter가 실행되어 HttpServletRequest 객체를 RequestBodyWrapper로 감싼다. - RequestBodyWrapper 생성
RequestWrappingFilter는 RequestBodyWrapper 객체를 생성하여 요청 데이터를 복사하고 저장한다. - 다음 단계로 전달
이후 필터 체인, Interceptor, 컨트롤러 등에서 이 감싸진 RequestBodyWrapper 객체를 사용하여 요청 데이터를 안전하게 재사용할 수 있다.
MDCManager
MDCManager는 MDC(Mapped Diagnostic Context) 데이터를 처리하고 관리하는 유틸리티 클래스다.
요청 데이터를 중앙에서 수집하고, 이를 로그에 활용할 수 있도록 변환하거나 저장하는 기능을 제공한다.
Spring Boot와 Logback 연동 시 요청별 고유 데이터를 MDC에 저장하고 로그 메시지에 포함시키는 작업을 담당한다.
주요 기능
- MDC 데이터 저장 및 관리: set()를 통해 MDC에 데이터를 저장하고, setJsonValue() 를 통해 객체를 JSON 형태로 변환해 MDC에 저장한다.
- HTTP 요청 데이터 수집: HttpServletRequest를 사용해 요청 URI, 사용자 IP, 헤더, 쿠키, 본문 등의 데이터를 수집한다.
- 중앙화된 MDC 데이터 접근: 모든 MDC 관련 작업을 이 클래스에서 처리하도록 구현하여 코드 중복을 방지하고 일관성을 유지한다.
@Slf4j
@Component
public class MDCManager {
public static final String MDC_REQUEST_URI = "Request URI";
public static final String MDC_USER_IP = "사용자 IP";
public static final String MDC_REQUEST_COOKIES = "Request Cookie";
public static final String MDC_REQUEST_ORIGIN = "Request Origin";
public static final String MDC_HEADER = "HTTP Header";
public static final String MDC_PARAMETER = "Parameter";
public static final String MDC_BODY = "HTTP Body";
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final MDCAdapter mdcAdapter = MDC.getMDCAdapter();
public static void set(String key, String value) {
mdcAdapter.put(key, value);
}
public static Object get(String key) {
return mdcAdapter.get(key);
}
public static void setJsonValue(String key, Object value) throws JsonProcessingException {
try {
if (value != null) {
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
mdcAdapter.put(key, json);
} else {
mdcAdapter.put(key, "내용이 없습니다.");
}
} catch (JsonProcessingException ex) {
throw ex;
}
}
public static String getRequestUri(HttpServletRequest request) {
return request.getRequestURI();
}
public static String getUserIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null)
ip = request.getRemoteAddr();
return ip;
}
public static Cookie[] getCookies(HttpServletRequest request) {
return request.getCookies();
}
public static String getRequestOrigin(HttpServletRequest request) {
return request.getHeader("Origin");
}
public static Map<String, String> getHeader(HttpServletRequest request) {
Map<String, String> headerMap = new HashMap<>();
request.getHeaderNames().asIterator()
.forEachRemaining(name -> {
if (!name.equals("user-agent")) {
headerMap.put(name, request.getHeader(name));
}
});
return headerMap;
}
public static Map<String, String> getParameter(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames().asIterator()
.forEachRemaining(name -> paramMap.put(name, request.getParameter(name)));
return paramMap;
}
public static String getBody(HttpServletRequest request) {
RequestBodyWrapper requestBodyWrapper = WebUtils.getNativeRequest(request, RequestBodyWrapper.class);
if (requestBodyWrapper != null) {
return requestBodyWrapper.getRequestBody();
}
return "requestBody 정보 없음";
}
}
MDCFilter
이 코드는 OncePerRequestFilter를 상속받아 구현된 MDCFilter로, 요청마다 특정 정보를 MDC에 저장하여 로그 메시지에 포함할 수 있도록 하는 역할을 한다.
- HttpServletRequest 데이터 수집: WebUtils.getNativeRequest()를 사용하여 HttpServletRequest 객체를 가져온다.
이를 통해 요청 URI, 사용자 IP, 쿠키, 헤더, 파라미터, 본문 데이터 등을 수집한다. - MDC에 데이터 저장: 수집된 데이터를 MDCManager를 통해 MDC의 키-값 형태로 저장한다.
이렇게 저장된 데이터는 로그 프레임워크(Logback)에서 %X{key} 형식으로 접근할 수 있다. - 필터 체인 실행: filterChain.doFilter()를 호출하여 요청을 다음 단계(다른 필터, 컨트롤러 등)로 전달한다.
@Slf4j
public class MDCFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
HttpServletRequest httpReq = WebUtils.getNativeRequest(request, HttpServletRequest.class);
MDCManager.setJsonValue(MDCManager.MDC_REQUEST_URI, MDCManager.getRequestUri(httpReq));
MDCManager.setJsonValue(MDCManager.MDC_USER_IP, MDCManager.getUserIP(httpReq));
MDCManager.setJsonValue(MDCManager.MDC_REQUEST_COOKIES, MDCManager.getCookies(httpReq));
MDCManager.setJsonValue(MDCManager.MDC_REQUEST_ORIGIN, MDCManager.getRequestOrigin(httpReq));
MDCManager.setJsonValue(MDCManager.MDC_HEADER, MDCManager.getHeader(httpReq));
MDCManager.setJsonValue(MDCManager.MDC_PARAMETER, MDCManager.getParameter(httpReq));
MDCManager.set(MDCManager.MDC_BODY, MDCManager.getBody(httpReq));
filterChain.doFilter(request, response);
}
}
Filter와 Manager는 각각의 역할이 명확히 다르기 때문에 분리했다.
MDCFilter는 요청 데이터를 수집하고 설정(전달)하는 역할에 집중하며, 요청 흐름 중 MDC 데이터를 초기화하는 작업을 담당한다.
반면, MDCManager는 데이터를 저장, 변환하는 로직을 제공하여 MDC 관련 작업을 중앙에서 관리하도록 분리했다.
Filter와 Manager 동작과정
- MDCFilter가 요청 데이터를 수집하고, MDCManager에 전달한다.
- MDCManager는 데이터를 JSON으로 변환하거나 단순 문자열로 저장하여 MDC에 기록한다.
- Logback과 연동된 로그 설정에서 MDC 데이터를 포함해 로그를 출력한다.
MDCFilterConfig
MDCFilterConfig는 Spring Boot에서 필터를 등록하고 실행 순서를 설정하는 필터 설정 클래스이다.
@Configuration을 통해 Spring에서 설정 클래스로 인식되며, @Profile("!local")을 통해 로컬 환경이 아닐 때만 활성화된다.
이 클래스는 두 가지 필터(RequestWrappingFilter와 MDCFilter)를 등록하고, 필터 체인에서 실행 순서를 지정한다.
- 필터 등록: FilterRegistrationBean을 사용해 RequestWrappingFilter와 MDCFilter를 Spring의 필터 체인에 등록한다.
- 필터 순서 설정: filterRegistrationBean.setOrder()를 통해 필터 실행 순서를 설정한다. RequestWrappingFilter가 먼저 실행되고, 이후 MDCFilter가 실행되도록 설정되어 있다.
- 환경별 활성화: @Profile("!local")을 사용해 로컬 환경에서는 필터가 등록되지 않도록 설정했다. 나는 이 로깅을 Discord로 받아보는데 로컬 환경에서 불필요한 에러 로깅을 알림으로 받을 필요는 없다고 생각되어서 설정한 조건이다.
@Profile("!local")
@Configuration
public class MDCFilterConfig {
@Bean
public FilterRegistrationBean<RequestWrappingFilter> secondFilter() {
FilterRegistrationBean<RequestWrappingFilter> filterRegistrationBean = new FilterRegistrationBean<>(new RequestWrappingFilter());
filterRegistrationBean.setOrder(0);
return filterRegistrationBean;
}
@Bean
public FilterRegistrationBean<MDCFilter> thirdFilter() {
FilterRegistrationBean<MDCFilter> filterRegistrationBean = new FilterRegistrationBean<>(new MDCFilter());
filterRegistrationBean.setOrder(1);
return filterRegistrationBean;
}
}
2. Logback
Logback이란?
Logback은 Java 기반의 로깅 프레임워크로, SLF4J(Simple Logging Facade for Java) API와 호환되며 성능, 유연성, 확장성을 고려해 설계된 라이브러리다. 이전 로깅 프레임워크인 Log4j의 후속작으로, 다양한 로그 출력 대상(Appender)을 설정하고 패턴을 활용해 로그 메시지의 형식을 자유롭게 정의할 수 있다.
Logback의 주요 역할
- 애플리케이션 상태 기록: 시스템 로그를 기록하여 에러 추적, 디버깅, 성능 분석 등을 가능하게 한다.
- 로그 출력 관리: 로그를 콘솔, 파일, 데이터베이스, 원격 서버 등 다양한 대상으로 전송할 수 있다.
- 유연한 로깅 제어: 로깅 레벨, 출력 형식, 대상 등을 설정하여 필요한 정보만 효율적으로 기록한다.
- 비동기 로깅 지원: 대규모 애플리케이션에서도 성능 저하 없이 로그를 처리할 수 있다.
Logback의 주요 구성 요소
- Logger: 로그를 생성하고 레벨(INFO, ERROR 등)을 정의한다. SLF4J 인터페이스와 연결되어 애플리케이션 코드에서 사용된다.
- Appender: 로그 메시지를 출력하는 대상(Console, File, Database 등)을 정의하며, 여러 Appender를 동시에 사용할 수 있다.
- Layout/Encoder: 로그 메시지를 특정 형식(JSON, XML 등)으로 변환한다.
- Filter: 특정 조건에 따라 로그 메시지를 필터링한다(예: ERROR 레벨 이상의 로그만 기록).
Logback의 주요 기능
- 로깅 레벨 제어: TRACE, DEBUG, INFO, WARN, ERROR 등 레벨에 따라 로그를 필터링하고 출력한다.
- Appender 지원 : 다양한 출력 대상으로 로그를 전송할 수 있다.
- ConsoleAppender: 콘솔에 로그 출력.
- FileAppender: 파일에 로그 저장.
- RollingFileAppender: 일정 조건(파일 크기, 날짜)에 따라 로그 파일을 교체.
- SocketAppender: 원격 서버로 로그 전송.
- 패턴 설정: %d, %thread, %level, %logger, %msg 등 패턴을 사용해 로그 메시지의 형식을 자유롭게 정의할 수 있다.
- MDC/Thread Context 지원: Thread-Local 데이터를 로그 메시지에 포함하여 요청별 디버깅을 용이하게 한다.
- 비동기 로깅: AsyncAppender를 사용하여 로그 처리 성능을 높이고, 메인 쓰레드의 부하를 줄일 수 있다.
- 환경별 설정: Spring Boot와 통합 시, 환경 변수나 프로파일에 따라 로깅 설정을 다르게 적용할 수 있다.
Logback을 왜 사용하는가?
Logback은 다양한 출력 대상을 지원하고, 설정을 유연하게 조합할 수 있어 애플리케이션의 로깅 요구를 충족한다. 특히, 비동기 로깅으로 성능 저하 없이 로그를 처리할 수 있고, MDC와의 연동을 통해 요청별 데이터를 로그에 손쉽게 포함할 수 있어 디버깅과 운영 효율성을 크게 높일 수 있다.
Logback을 Spring Boot에서 사용하는 예시
Spring Boot에서는 기본적으로 Logback을 로깅 프레임워크로 사용하며, 설정은 logback-spring.xml 파일로 작성한다.
<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 MDC: %X{Request URI}, %X{사용자 IP}%n</pattern>
</encoder>
</appender>
<!-- 로그 레벨 설정 -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
주요 구성 요소 설명
- Appender : 로그 출력 대상을 정의한다. 예시에서는 콘솔 출력을 위한 ConsoleAppender를 사용했다.
- Encoder: 로그 메시지의 출력 형식을 정의한다. (예: %d{yyyy-MM-dd HH:mm:ss}는 날짜/시간을 출력)
- Logger: root는 기본 로깅 설정을 정의하며, 특정 패키지나 클래스에 대해 별도의 로깅 설정을 추가할 수도 있다.
Spring Boot와의 통합
Spring Boot에서는 추가적인 설정 없이 Logback을 바로 사용할 수 있다.
application.yml이나 application.properties 파일을 통해 로깅 설정을 추가할 수도 있다.
logging:
level:
root: INFO
com.example.myapp: DEBUG
file:
path: /var/log/myapp
Spring Boot 설정 파일이 반영되는 방식
- logging.level: 로깅 레벨 설정.
- logging.file.path: 로그 파일 경로 지정.
실제 코드
logback-spring.xml
Spring 환경 변수에서 Discord 웹훅 URL을 가져오도록 <springProperty> 태그를 사용해 설정했다.
로그 설정은 console-appender.xml과 discord-appender.xml로 분리하여 관리했는데, 이는 무조건 분리해야 하는 것은 아니지만, 여러 환경(예: 개발, 테스트, 운영)에서 설정을 다르게 하거나, 출력 대상을 추가하거나 수정할 때 확장성을 높이고 유지보수를 간단하게 하기 위해 분리했다.
<appender-ref>가 없으면 Logback에서 어떤 Appender를 사용할지 알 수 없기 때문에 로그가 출력되지 않는다.
<appender-ref>는 root 로거나 특정 로거가 참조할 Appender를 명시적으로 지정하는 역할을 한다. 만약 이 태그가 생략되면, 로그 이벤트가 어떤 Appender로 전달되어야 할지 설정이 누락되므로 로그가 출력 대상에 기록되지 않는다.
여기서는 CONSOLE과 ASYNC_DISCORD Appender로 INFO 레벨 이상의 로그가 전달되어 콘솔에 출력되고 Discord로 전송된다.
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<springProperty name="discordLogUrl" source="feign.discord.webhook.log-url"/>
<include resource="console-appender.xml"/>
<include resource="discord-appender.xml"/>
<!-- 로그 레벨 지정 -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="ASYNC_DISCORD" />
</root>
</configuration>
console-appender.xml
로그를 콘솔에 출력하기 위한 ConsoleAppender를 설정했다.
로그 형식은 날짜, 쓰레드 이름, 로그 레벨, 클래스 이름, 메시지를 포함하도록 정의했다.
(Discord에 연동만 하고 싶을 경우엔 console-appender.xml는 없어도 괜찮다.)
<included>
<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>
</included>
discord-appender.xml
Discord 웹훅으로 로그를 전송하기 위한 DiscordLogAppender를 설정했다.
AsyncAppender를 추가하여 로그를 비동기로 처리하며, ERROR 레벨 이상의 로그만 전송하도록 필터링했다.
<included>
<appender name="DISCORD" class="org.dateroad.feign.discord.DiscordLogAppender">
<param name="discordLogUrl" value="${discordLogUrl}" />
</appender>
<appender name="ASYNC_DISCORD" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="DISCORD" />
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
</included>
DiscordLogAppender
DiscordLogAppender는 Logback의 AppenderBase를 상속받아 구현된 커스텀 Appender로, 로그 메시지를 Discord 웹훅(Webhook)을 통해 전송하는 역할을 한다. Logback의 강력한 로깅 시스템과 Discord의 실시간 알림 기능을 결합해, 중요한 로그 정보를 실시간으로 모니터링할 수 있도록 구현되었다.
동작 원리
- 로그 이벤트 처리: Logback에서 발생한 ILoggingEvent 객체를 받아, 로그 메시지와 MDC 데이터를 추출한다.
- Discord로 로그 전송: 로그 메시지를 Discord 웹훅 URL로 POST 요청을 보내 전송한다.
- 에러 처리 및 예외 로그 전송: 로그 전송 중 발생한 예외를 기록하고, 추가로 상세 예외 메시지를 Discord에 전송한다.
@Slf4j
@Profile("!local")
@Component
@Setter
public class DiscordLogAppender extends AppenderBase<ILoggingEvent> {
private String discordLogUrl;
@Override
public void start() {
if (discordLogUrl == null || discordLogUrl.isEmpty() ) {
addError("Discord is not configured properly. Please set the discordLogUrl");
return;
}
super.start();
}
@Override
protected void append(ILoggingEvent eventObject) {
Map<String, String> mdcPropertyMap = eventObject.getMDCPropertyMap();
String level = eventObject.getLevel().levelStr;
String exceptionBrief = "";
String exceptionDetail = "";
IThrowableProxy throwable = eventObject.getThrowableProxy();
if (throwable != null) {
exceptionBrief = throwable.getClassName() + ": " + throwable.getMessage();
}
if (exceptionBrief.equals("")) {
exceptionBrief = "EXCEPTION 정보가 남지 않았습니다.";
}
StringBuilder logMessage = new StringBuilder();
appendWithNewLine(logMessage, "[" + level + " - 문제 간략 내용] " + exceptionBrief);
appendWithNewLine(logMessage, "[Time] " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
appendWithNewLine(logMessage, "[" + MDCManager.MDC_REQUEST_URI + "] " + StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCManager.MDC_REQUEST_URI)));
appendWithNewLine(logMessage, "[" + MDCManager.MDC_USER_IP + "] " + StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCManager.MDC_USER_IP)));
appendWithNewLine(logMessage, "[" + MDCManager.MDC_HEADER + "] " + StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCManager.MDC_HEADER).replaceAll("[\\{\\{\\}]", "")));
appendWithNewLine(logMessage, "[" + MDCManager.MDC_REQUEST_COOKIES + "] " + StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCManager.MDC_REQUEST_COOKIES).replaceAll("[\\{\\{\\}]", "")));
appendWithNewLine(logMessage, "[" + MDCManager.MDC_PARAMETER + "] " + StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCManager.MDC_PARAMETER).replaceAll("[\\{\\{\\}]", "")));
appendWithNewLine(logMessage, "[" + MDCManager.MDC_BODY + "] " + StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCManager.MDC_BODY)));
sendToDiscord(logMessage.toString());
if (throwable != null) {
exceptionDetail = ThrowableProxyUtil.asString(throwable);
String exception = "[Exception 상세 내용] " + exceptionDetail.substring(0, 1000);
sendToDiscord(exception);
}
}
private void appendWithNewLine(StringBuilder sb, String text) {
sb.append(text).append("\n");
}
private void sendToDiscord(String logMessage) {
if (logMessage.length() > 2000) {
logMessage = logMessage.substring(0, 1950) + "......";
}
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> payload = new HashMap<>();
payload.put("content", logMessage);
HttpEntity<Map<String, String>> request = new HttpEntity<>(payload, headers);
try {
ResponseEntity<String> response = restTemplate.postForEntity(discordLogUrl, request, String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
addError("Failed to send log message to Discord: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Exception occurred while sending log message to Discord", e);
}
}
}
로그엔 MDC에 저장소에 있는 값들을 꺼내와 출력해줬고, Discord 웹훅 메세지 길이가 최대 2000자 여서, 오류가 나지 않게 하기 위해 확인해주는 로직도 추가해줬다.
MDC와 Logback을 같이 사용한 이유
MDC만 사용했을 경우, 요청별 데이터를 Thread-Local에 저장하여 요청의 컨텍스트 정보를 관리할 수 있지만, 이를 로그에 출력하기 위해서는 별도로 로그 출력 로직을 작성해야 한다. 이는 설정이 복잡하고 유지보수가 어려워질 수 있다.
반대로 Logback만 사용했을 경우, 다양한 로그 출력 대상과 유연한 로깅 레벨 제어가 가능하지만, 요청별 고유 데이터를 로그 메시지에 포함시키는 기능이 부족하다. 요청의 흐름을 추적하려면 추가적인 설정이나 코드 작업이 필요하다.
MDC와 Logback을 함께 사용하면, 요청별 데이터를 관리하고 로그 메시지에 자동으로 포함시킬 수 있어 설정과 구현이 간단해진다. Logback의 다양한 Appender를 통해 로그를 원하는 출력 대상으로 보낼 수 있고, 비동기 로깅으로 성능도 유지할 수 있다. 이 조합은 요청별 로그 관리와 출력의 유연성을 동시에 제공하여 운영 환경에서 디버깅과 문제 추적을 효율적으로 할 수 있을 것이라 판단되어 두 개를 같이 사용했다.
우리는 실제 운영 서버와 개발 서버를 분리했기 때문에 로깅 알람 채널도 분리했다. DiscordUrl만 두개 놓고 연결해주면 쉽게 가능하다.
실제로 에러가 발생했을 때는 다음 사진과 같이 온다. 물론 그냥 String 덩어리들이라 좀 안예쁜거 같긴하지만.. 예쁘게 색도 넣고 커스텀 할 수 있는 방법들도 있으니 각자 찾아서 더 해보길 바란다. 그래도 Request 정보들과 실제 Springboot에 찍히는 상세 내용까지 자세히 한 눈에 볼 수 있어서 좋았고, 디스코드 알림으로 빠르게 볼 수 있어서 이슈대응이 좀 더 수월했다.
실제 Appjam 개발 기간 내에는 이걸 미리 한다는 것이 리소스가 꽤 소모되는 것이라 나중에 2차 스프린트를 진행하면서 하게 되었는데, 클라이언트들도 저 에러를 기반으로 서버랑 얘기하니까 뭐가 문제인지 한 번에 알 수 있고, 원인도 빠르게 찾아져서 빠르게 설정하면 좋았을 걸..이라는 생각이 들었다.
추가로 이거 하면서 나중에 트슈 한 것까지 같이 쓰려했는데 너무 길어져서 다음 편에 계속 이어서 써볼게여..
(https://garden-ying.tistory.com/12 작성했다. 혹시라도 같은 문제가 있다면 참고하길 바란다!)
실제 깃허브 pr이니 참고하면 될 것 같다!
https://github.com/TeamDATEROAD/DATEROAD-SERVER/pull/293
참고 문헌
https://mangkyu.tistory.com/266
https://agileryuhaeul.tistory.com/entry/Logback-%EC%9D%B4%EB%9E%80
'Spring' 카테고리의 다른 글
[Spring/DB] 실제 운영 서버에서 ddl-auto update의 문제점과 Flyway 도입 (ddl-auto, Flyway) (0) | 2025.02.28 |
---|---|
[Spring/Test] 분산락을 통한 동시성 제어 - Troubleshooting (3) | 2024.12.08 |
[Spring] MDC + Logback을 활용한 Discord Webhook 연동하기(2) - Troubleshooting (1) | 2024.12.02 |
[Spring] JPA 지연 로딩으로 인한 equals 비교 오류 해결 - Troubleshooting (0) | 2024.10.04 |