최근 프로젝트를 진행하며 요청 ID 를 통해 로그를 추적
하고 싶은 욕망이 생겼습니다.
사실 이는 이전 프로젝트에서도 작업한 내용인데, Logback 에 익숙치 않아 코드가 매우 더러워 마음이 편치 않았었습니다.
그런데 최근 관련된 Spring Boot 문서를 읽던 중, 이를 아주 깔끔하게 해결하는 방법을 찾아 한번 공유해 보도록 하겠습니다.
Spring Boot 는 다양한 logging framework 를 지원합니다.
기본 구현체로 Logback 을 사용하긴 하지만 Log4j
, Java Util Logging
또한 지원합니다.
때문에 spring boot 는 framework 간 격차를 줄이기 위해 다음 목록의 스프링 변수를 시스템 변수 (Environment Variable)
로 제공, 통합하여 로깅을 진행합니다.
그런데 문서를 읽어보면 아래와 같은 logging.pattern.level
에 대한 추가 설명이 붙어있습니다.
이를 번역해 보면 다음과 같습니다.
💡 Tip
MDC 혹은 어느 커스텀한 내용을 로그에 붙이고 싶다면
LOG_LEVEL_PATTERN
(Logback 의 경우logging.level.pattern
도 가능) 을 활용할 수 있습니다.예를들어
logging.pattern.level=user:%X{user} %5p
처럼 설정할 경우, 기본 로그 형식이 아래처럼 구성될 수 있습니다.2019-08-30 12:30:04.031 user: someone INFO 22174 --- [ nio-8080-exec-0] demo. ControllerHandling authenticated request
이 때
user
는 MDC 의 어느 entry 로, MDC 값이 설정된 경우 위처럼(someone)
표시됩니다.
즉, logging.pattern.level
을 이용하면 별도의 Logback 설정 없이 Spring Boot 가 MDC 값에 맞춰 로그를 찍는다는 것입니다.
위 문서로 logging.pattern.level
딸깍 한번으로 로그가 찍힌다는 사실을 알았습니다.
따라서 우리가 진행해야 될 과정은 필요한 값을 MDC 로 넣어주는 기능만 구성하면 됩니다.
즉, API 요청이 들어왔을 때 request ID 를 MDC 에 설정, 끝났을 때 MDC 값을 clear 하는 기능이 필요합니다.
이는 다양한 형태로 구현될 수 있지만, 저같은 경우 ServletRequestListener
를 이용했습니다.
package jakarta.servlet;
import java.util.EventListener;
public interface ServletRequestListener extends EventListener {
/**
* The request is about to go out of scope of the web application. The default implementation is a NO-OP.
*
* @param sre Information about the request
*/
default void requestDestroyed(ServletRequestEvent sre) {
}
/**
* The request is about to come into scope of the web application. The default implementation is a NO-OP.
*
* @param sre Information about the request
*/
default void requestInitialized(ServletRequestEvent sre) {
}
}
ServletRequestListener
는 어플리케이션의 가장 첫번째 servlet (혹은 filter) 가 요청을 맞이할 때
, 요청이 벗어날 때
어느 작업을 처리할 수 있게 해주는 interface 입니다. [1]
즉, 요청 ID
를 MDC 에 제공하기에 아주 적합합니다.
따라서 이를 활용해 아래처럼 요청 ID
를 MDC 에 넣고 빼주는 코드를 구성할 수 있습니다.
@Slf4j
@Component
public class RequestIdHandler implements ServletRequestListener {
private static final int ID_LENGTH = 8;
private static final String ridKeyOnMdc = "REQUEST_ID";
@Override
public void requestInitialized(ServletRequestEvent sre) {
log.debug("Initializing request ID");
MDC.put(ridKeyOnMdc, genRandomId());
log.debug("Request ID has been initialized.");
}
@Override
public void requestDestroyed(ServletRequestEvent sre) {
log.debug("Destroying request ID");
MDC.remove(ridKeyOnMdc);
log.debug("Request ID has been destroyed.");
}
private static String genRandomId() {
try {
String uuid = UUID.randomUUID().toString()
.replaceAll("-", "")
.substring(0, ID_LENGTH);
return String.format(
"%s-%s",
uuid.substring(0, ID_LENGTH / 2),
uuid.substring(ID_LENGTH / 2)
);
} catch (Exception e) {
log.error("Failed to generate random ID", e);
return "UNKNOWN_ID";
}
}
}
application.yaml
설정과 테스트이제 필요한 구성품이 모두 갖춰져 있으므로, application.yaml
에 속성을 추가할 차례입니다.
logging:
level:
org.leisureup: debug # debug 로그도 함 봐보기 위해 설정
pattern:
level: '[RID:%10X{REQUEST_ID}] %5p'
이전 문서에 나와있듯, MDC 에 설정되는 key 값 (REQUEST_ID)
를 위처럼 작성합니다.
이제 실제로 한번 테스트를 진행해 보겠습니다.
테스트를 위해 아래처럼 간단한 endpoint 를 구성해 줍니다.
@GetMapping("/hello")
public ResponseEntity<String> hello() {
log.info("hello;");
try {
return ResponseEntity.ok("hello");
} finally {
log.info("And good bye!");
}
}
이제 어플리케이션을 실행해보면 다음과 같은 특징을 확인할 수 있습니다.
1. MDC 에 값이 설정되지 않으면 빈 값으로 로그가 출력된다.
ServletRequestListener
는 웹 요청이 들어올때만 작동됩니다.
때문에 위처럼 application boot 시 찍히는 로그는 요청 ID
가 빈 값으로 넣어져 출력되는 것을 볼 수 있습니다.
2. API 요청이 들어오면 요청 ID
가 포함되 로그가 출력된다.
이전 테스트로 구성한 /hello
endpoint 에 요청하면 요청 ID
가 생성됩니다.
때문에 로그에서 볼 수 있듯 0ca2-57d3
가 MDC 에 생성, 삭제 되며 로그에 출력되는 것을 볼 수 있습니다.
3. (당연하지만)
어플리케이션의 범위를 벗어난 로그에도 요청 ID
가 포함된다.
기본적으로 MDC 는 ThreadLocal
에서 작동합니다.
때문에 MDC 에 저장된 요청 ID
는 thread 가 같다면 동일하고, 따라서 SQL debug 로그에도 요청 ID
가 포함된 것을 볼 수 있습니다.
(물론 async 가 얹혀지면 좀 다르겠지만)
이렇게 딸깍 한번으로 로그에 요청 ID
를 넣어봤습니다.
물론 더 커스텀한 로깅을 진행하려면 Logback 설정이 불가피하지만, 추가 설정 없이 반 필수적인 요청 ID 처리
를 완료할 수 있는게 참 매력적인것 같습니다.