결제 실패, 중단, 타임아웃 등의 이벤트 발생 시, 이를 로깅하고 알림을 통해 관리자 또는 사용자에게 문제를 전달해야 한다.
로그 관리는 여러가지 방식으로 수행할 수 있으며, Logback 라이브러리에서는 다양한 Appender를 통해 이를 지원한다. 나는 그 중에서 ELK 스택을 통해 로그 관리를 수행하기로 했다. 그 이유는 다음과 같다.
- ElasticSearch는 분산형 아키텍처를 기반으로 설계되어 대규모 데이터도 빠르게 처리하고 확장 가능
- => 이를 통해 대규모 시스템에서 발생하는 대량의 로그를 실시간으로 저장하고 검색 가능
- Elasticsearch는 역색인(inverted index)을 활용하여 로그 데이터를 실시간으로 검색하고 분석 가능
- Kibana를 통해 로그 데이터를 직관적으로 시각화 가능
- => 대시보드를 구성하여 실시간 모니터링, 알림 설정 등이 가능
- Logback에서 지원하는 LogstashTcpSocketAppender를 통해 쉽게 Logstash 사용이 가능하며, Logstash를 통해 로그를 수집하고, 필요한 경우 필터링 및 변환 작업을 거친 뒤 Elasticsearch에 전달 가능
- ELK 스택은 오픈소스로 제공됨
- => 무료이며 자료가 많음
로그를 관리하기 위해 ELK 스택을 프로젝트에 도입해보자! Logback과 Docker를 사용하면 간단하게 추가할 수 있다.
설정
ELK 추가
...
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.10.0
container_name: elasticsearch
ports:
- "9200:9200"
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- xpack.security.http.ssl.enabled=false
volumes:
- es-data:/usr/share/elasticsearch/data
logstash:
image: docker.elastic.co/logstash/logstash:8.10.0
container_name: logstash
ports:
- "5044:5044" # Logstash beats input port
- "9600:9600" # Logstash monitoring API port
volumes:
- ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf
environment:
- xpack.monitoring.enabled=false
kibana:
image: kibana:8.10.1
container_name: kibana
ports:
- "5601:5601"
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
depends_on:
- elasticsearch
...
build.gradle
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
logstash.conf
input {
tcp {
port => 5044
codec => json
}
}
output {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
index => "application-logs-%{+YYYY.MM.dd}"
}
}
Logback
<configuration>
<property name="LOG_FILE" value="todomon.log"/>
<!-- Logstash로 전송할 Appender -->
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>localhost:5044</destination>
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<!-- 콘솔 출력 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 파일 출력 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_FILE}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>application.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>5</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Logger 설정 -->
<root level="info">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="LOGSTASH"/>
</root>
</configuration>
property
: xml 파일 내부에서 변수로 사용할 값을 정의appender
: 로그를 쓰는(?) 방법에 대한 정의CONSOLE
: 콘솔에 로그를 출력하기 위함FILE
: 로그를 파일로 저장하기 위함. RollingFileAppender를 사용하여 5일치의 로그를 관리함LOGSTASH
: 로그를 logstash로 전달하기 위함
root
: 루트 로거의 속성으로는 오직 단 하나의 level 속성만 허용
Logging Filter
필터를 사용한 이유
기본적으로 컨트롤러에 대한 모든 요청을 로깅하기 위해 Logging Filter를 추가하기로 하였다.
필터 외에도 스프링 인터셉터나 AOP를 사용하는 방식도 있겠지만, 로깅 작업은 스프링과 무관하게 동작해야 하며 혹시 모를 request에 대한 조작이 필요할 수 있기에 이에 부합하는 필터를 사용하도록 하였다!
또한, 로깅 기능을 어드바이스로 만들어 모든 컨트롤러를 대상으로 AOP를 적용할 수도 있겠으나.. 컨트롤러는 타입과 실행 메소드가 모두 제각각이라 포인트컷(적용할 메소드 선별)의 작성이 어렵다.
컨트롤러의 타입이 일정하지 않고 호출 패턴도 제각각이기 때문에 AOP는 제 기준에서는 부적절하다고 생각했다.
간단히 필터로 구현해보자!
package com.maruhxn.todomon.core.global.util.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
@Slf4j
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 요청 정보 수집
String requestPath = httpRequest.getRequestURI();
String method = httpRequest.getMethod();
String queryParams = httpRequest.getQueryString();
String requestBody = getRequestBody(httpRequest);
String clientIp = httpRequest.getRemoteAddr();
log.debug("Incoming Request: Path={}, Method={}, QueryParams={}, ClientIP={}, Body={}",
requestPath, method, queryParams, clientIp, requestBody);
// 필터 체인 실행
chain.doFilter(request, response);
// 응답 정보 수집
int status = httpResponse.getStatus();
log.debug("Response: Path={}, Status={}", requestPath, status);
}
private String getRequestBody(HttpServletRequest request) throws IOException {
if (request.getContentType() != null && request.getContentType().contains(MediaType.APPLICATION_JSON_VALUE)) {
StringBuilder body = new StringBuilder();
BufferedReader reader = request.getReader();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
return body.toString();
}
return "N/A";
}
}
WebConfig
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<Filter> logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoggingFilter());
filterRegistrationBean.setOrder(Integer.MIN_VALUE);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
- 우선순위롤
Integer.MIN_VALUE
로 설정하여 최우선으로 실행되도록 하였다
'getReader() has already been called for this request' 에러
getReader()
는 InputStream
을 반환하여 request를 읽어내는데 스트림 방식으로 읽기 때문에 한 번 닫힌 스트림은 이후 컨트롤러에서 사용할 수 없다. 이를 해결하려면, 요청 데이터를 한 번만 읽어도 여러 번 재사용할 수 있도록 래핑해야 한다.
=> 요청을 래핑할 수 있도록 CachedBodyHttpServletRequest
를 만들어 적용해주자!
@Slf4j
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (request instanceof HttpServletRequest httpServletRequest) {
CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(httpServletRequest);
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 요청 정보 수집
String requestPath = wrappedRequest.getRequestURI();
String method = wrappedRequest.getMethod();
String queryParams = wrappedRequest.getQueryString();
String requestBody = wrappedRequest.getReader().lines().reduce("", String::concat);
String clientIp = wrappedRequest.getRemoteAddr();
log.debug("Incoming Request: Path={}, Method={}, QueryParams={}, ClientIP={}, Body={}",
requestPath, method, queryParams, clientIp, requestBody);
// 필터 체인 실행
chain.doFilter(wrappedRequest, response);
// 응답 정보 수집
int status = httpResponse.getStatus();
log.debug("Response: Path={}, Status={}", requestPath, status);
} else {
chain.doFilter(request, response);
}
}
private static class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = request.getInputStream().readAllBytes();
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return byteArrayInputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
// No implementation needed
}
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
}
}
로그 추가 규칙 정의 (코드는 생략)
- trace와 debug 중 debug 만을 사용하도록 하겠다
- debug ==> 특정 유저와 관련된 단순 조회 로직 수행 시
- info ==> DB 쓰기 로직 수행 시 & 클라이언트측 에러 발생 시
- warn ==> 서버측 가벼운 에러 발생 시
- error => 서버측 치명적인 에러 발생 시
'Project' 카테고리의 다른 글
[TODOMON] EP.17 배포 후 쿠키 공유 문제 해결 with 도메인 연결 및 SSL 인증서 (0) | 2025.02.09 |
---|---|
[TODOMON] EP.16 코드 리팩토링 (0) | 2025.02.09 |
[TODOMON] EP.14 중복 쿼리 제거하기 with OSIV, AOP, JWT (0) | 2025.02.07 |
[TODOMON] EP.13 OneToOne 양방향 관계 조회 시 지연 로딩이 적용되지 않는 문제 (0) | 2025.02.07 |
[TODOMON] EP.12 아무것도 모르는 나의 동시성 문제 해결기 (0) | 2025.02.05 |