
이전글 : https://frogcodepond.tistory.com/3
Java 애플리케이션에서 로깅 구현하기 (feat. SLF4J)
이번 포스팅에서는 java 어플리케이션에서 로깅을 구현하는 방법을 소개하겠습니다. 1. SLF4J와 Log4j의 차이SLF4J (Simple Logging Facade for Java)SLF4J는 Java에서 사용하는 로깅 프레임워크의 인터페이스 역
frogcodepond.tistory.com
(먼저 읽고 오시면 더 좋읍니다..)
자, 저번 Java에서 로깅하기에 이어 말씀드린대로
사용자가 api를 통해 보낸 req body와 내가 내보낸 res body를 로깅하는 법을 설명하겠습니다.
Spring으로 넘어옵시다!

1. Gradle 설정
먼저, Spring Boot 프로젝트의 build.gradle 파일을 설정합니다.
다음은 필요한 의존성을 포함한 예제 Gradle 설정입니다
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.2'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'org.example'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
java {
sourceCompatibility = '17'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
// lombok
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok'
}
test {
useJUnitPlatform()
}
이 설정으로 Spring Boot와 Lombok을 사용할 준비가 되었습니다.
2. 로깅 필터 구현
HTTP 요청과 응답을 로깅하는 필터를 구현합니다.
LoggingRequestFilter 클래스를 작성하여 요청과 응답을 로그로 남깁니다.
package org.example;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.WebUtils;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.UUID;
@Slf4j // 로그를 위한 Lombok 어노테이션
@Component // 스프링 컴포넌트로 등록
@RequiredArgsConstructor // 생성자 주입을 위한 Lombok 어노테이션
public class LoggingRequestFilter implements Filter {
// MDC(Mapped Diagnostic Context)에 저장될 HTTP 요청 정보의 키
public static final String HTTP_METHOD = "X-HTTP-METHOD";
public static final String HTTP_URL = "X-HTTP-URL";
public static final String CLIENT_IP = "X-CLIENT-IP";
public static final String HTTP_HEADER_TRANSACTION_ID = "X-HIT-TRANSACTION-ID";
private final ObjectMapper objectMapper; // JSON 파싱을 위한 ObjectMapper
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
throws IOException, ServletException {
// HTTP 요청과 응답을 캐스팅
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 특정 URI 패턴에 대해 필터를 적용하지 않음
// 민감한 정보가 들어오는 URI패턴은 걸러냅시다~~
if (request.getRequestURI().startsWith("/v3/api-docs/") || request.getRequestURI().startsWith("/docs") || request.getRequestURI().startsWith("/favicon.ico")) {
chain.doFilter(servletRequest, servletResponse);
return;
}
// 요청 정보와 트랜잭션 ID를 로그에 추가
makePrintRequest(servletRequest);
makeTransactionId(servletRequest, servletResponse);
// 요청과 응답을 캐싱하기 위한 래퍼
ContentCachingRequestWrapper requestToCache = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseToCache = new ContentCachingResponseWrapper(response);
try {
chain.doFilter(requestToCache, responseToCache);
} finally {
// 요청 본문을 가져와 로그에 출력 (비어있지 않은 경우에만)
String requestBody = getRequestBody(requestToCache);
if (!requestBody.isEmpty()) {
log.info("request body: {}", requestBody);
}
// 응답 본문을 로그에 출력
log.info("response body: {}", getResponseBody(responseToCache));
// 응답 본문을 실제 응답에 복사
responseToCache.copyBodyToResponse();
}
}
// 요청 본문을 가져오는 메서드
private String getRequestBody(ContentCachingRequestWrapper request) {
ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
if (wrapper != null) {
byte[] buf = wrapper.getContentAsByteArray();
if (buf.length > 0) {
try {
String requestBody = new String(buf, StandardCharsets.UTF_8);
Object parsedRequestBody = objectMapper.readValue(requestBody, Object.class);
return objectMapper.writeValueAsString(parsedRequestBody);
} catch (JsonProcessingException e) {
log.error("Error parsing request body", e);
}
}
}
return "";
}
// 응답 본문을 가져오는 메서드
private String getResponseBody(final HttpServletResponse response) throws IOException {
String payload = null;
ContentCachingResponseWrapper wrapper = WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
if (wrapper != null) {
byte[] buf = wrapper.getContentAsByteArray();
if (buf.length > 0) {
payload = new String(buf, StandardCharsets.UTF_8);
wrapper.copyBodyToResponse();
}
}
return null == payload ? "" : payload;
}
// 요청 정보를 MDC에 추가하는 메서드
private void makePrintRequest(ServletRequest servletRequest) {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String requestURIWithQueryString = Optional.ofNullable(httpServletRequest.getRequestURI())
.map(uri -> {
try {
// URI와 쿼리 문자열을 결합하여 디코딩
return URLDecoder.decode(uri + Optional.ofNullable(httpServletRequest.getQueryString()).map(qs -> "?" + qs).orElse(""), StandardCharsets.UTF_8.name());
} catch (IOException e) {
log.error("Error decoding URL", e);
return uri;
}
})
.orElse("");
MDC.put(HTTP_METHOD, httpServletRequest.getMethod());
MDC.put(HTTP_URL, requestURIWithQueryString);
MDC.put(CLIENT_IP, getClientIp(httpServletRequest));
}
// 트랜잭션 ID를 생성하여 MDC와 응답 헤더에 추가하는 메서드
private void makeTransactionId(ServletRequest servletRequest, ServletResponse servletResponse) {
HttpServletRequest req = (HttpServletRequest) servletRequest;
String transactionId = Optional.ofNullable(req.getHeader(HTTP_HEADER_TRANSACTION_ID)).orElse(UUID.randomUUID().toString());
MDC.put(HTTP_HEADER_TRANSACTION_ID, transactionId);
HttpServletResponse res = (HttpServletResponse) servletResponse;
res.addHeader(HTTP_HEADER_TRANSACTION_ID, transactionId);
}
// 클라이언트 IP를 가져오는 메서드
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-RealIP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("REMOTE_ADDR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
이 필터는 HTTP 요청과 응답을 캐싱하고, 요청 본문과 응답 본문을 로그로 남깁니다.
또한, 각 요청에 대해 고유한 트랜잭션 ID를 생성하여 로깅에 포함합니다.
3. Logback 설정
마지막으로, Logback 설정 파일 (logback-spring.xml)을 작성합니다.
<configuration>
<!-- Spring Boot 기본 로그 설정 포함 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 색상 변환기 설정 -->
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/>
<!-- 로그 파일 경로 및 패턴 설정 -->
<property name="LOG_PATH" value="./logs/"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss} [%p] [%X{X-HIT-TRANSACTION-ID}] [%X{X-HTTP-METHOD}] [%X{X-HTTP-URL}] [%X{X-CLIENT-IP}] [%logger{30}:%line] %msg%n"/>
<!-- 일반 로그 파일 설정 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/logger.log</file>
<encoder>
<charset>UTF-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/logger-%d{yyyy-MM-dd}-%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>90</maxHistory>
<totalSizeCap>100MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- 콘솔 로그 설정 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<charset>UTF-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- 환경별 로그 설정 -->
<!-- 테스트 환경 로그 설정 -->
<springProfile name="test">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- 로컬 환경 로그 설정 -->
<springProfile name="local">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- 개발 환경 로그 설정 -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</springProfile>
<!-- 스테이지 환경 로그 설정 -->
<springProfile name="stage">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</springProfile>
<!-- 실제 운영 환경 로그 설정 -->
<springProfile name="real">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</springProfile>
</configuration>
환경에 맞게 로그를 설정하고, HTTP 요청과 응답을 로깅합시다
(Logback 에 대한 상세 내용은 이전 포스팅을 참고해주세요!)
4. 실제 적용
이제 한번 실제로 적용해봅시다.


결과를 보면?
[GET]
2024-07-12 16:43:00 [INFO] [d21abd3f-eb13-4cda-bea7-ce0a0f029c3c] [GET] [/frog/get?name=못생긴펭귄 ] [127.0.0.1] [org.example.TestController:12] 난 못생긴펭귄 이다 돈내놔
2024-07-12 16:43:00 [INFO] [d21abd3f-eb13-4cda-bea7-ce0a0f029c3c] [GET] [/frog/get?name=못생긴펭귄 ] [127.0.0.1] [o.example.LoggingRequestFilter:66] response body: {"name":"잘생긴 개구리","msg":"살려줘요"}
[POST] (dto body에 넣어서..)
2024-07-12 16:41:09 [INFO] [15bba79a-300e-42db-9ca0-aeac35276a9a] [POST] [/frog/post] [127.0.0.1] [org.example.TestController:19] 난 못생긴펭귄2다. 가진거 다 내놔
2024-07-12 16:41:09 [INFO] [15bba79a-300e-42db-9ca0-aeac35276a9a] [POST] [/frog/post] [127.0.0.1] [o.example.LoggingRequestFilter:63] request body: {"name":"못생긴펭귄2","msg":"가진거 다 내놔"}
2024-07-12 16:41:09 [INFO] [15bba79a-300e-42db-9ca0-aeac35276a9a] [POST] [/frog/post] [127.0.0.1] [o.example.LoggingRequestFilter:66] response body: {"name":"잘생긴 개구리","msg":"살려줘요"}
이렇게 각 트랜잭션에 아이디가 발급되어서 추적할 때 편하게
어떤 req, res body를 전달받고 전달 해줬는지 확인할 수 있었습니다.
'개발 > Spring' 카테고리의 다른 글
서킷브레이커는 또 무엇일까... (1) | 2024.11.28 |
---|---|
Spring과 Kafka를 활용한 트랜잭셔널 아웃박스 패턴 구현 (0) | 2024.11.21 |
MSA 아키텍처로의 전환을 고려한 트랜잭션 처리 및 이벤트 기반 설계 (3) | 2024.11.15 |
Junit & Mock 기반 테스트 코드 도입기 (4) | 2024.09.23 |
스프링 간단한 커스텀 인증 필터 구현하기 (0) | 2024.06.26 |
깨굴딱지의 코드연못입니다
올챙이가 개구리로 거듭나듯, 끊임없는 노력으로 진화하는 개발자의 길을 걷습니다. 🐸