Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Step3-3] observability 추가. #111

Merged
merged 6 commits into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}
15 changes: 15 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5")

// Observability - Actuator & Metrics
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")

// Observability - Distributed Tracing
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-zipkin")

// AspectJ - 필요한 의존성
implementation("org.springframework.boot:spring-boot-starter-aop")

// Observability - Logging
implementation("net.logstash.logback:logstash-logback-encoder:7.4")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

Expand Down
60 changes: 60 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
services:
# Zipkin - 분산 트레이싱
zipkin:
image: openzipkin/zipkin
ports:
- "9411:9411"
networks:
- onem-network

# Prometheus - 메트릭 수집
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- onem-network
# Prometheus는 독립적으로 실행

# Grafana - 시각화 대시보드
grafana:
image: grafana/grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_SECURITY_ADMIN_USER=admin
# CORS 및 임베딩 허용 설정
- GF_SECURITY_ALLOW_EMBEDDING=true
- GF_SECURITY_COOKIE_SAMESITE=none
- GF_SERVER_SERVE_FROM_SUB_PATH=true
# CORS 설정
- GF_SERVER_ROOT_URL=http://localhost:3000
- GF_LOG_LEVEL=debug
volumes:
- grafana-storage:/var/lib/grafana
networks:
- onem-network
depends_on:
- prometheus

# 애플리케이션 컨테이너 (옵션)
# onem-app:
# build: .
# ports:
# - "8080:8080"
# environment:
# - SPRING_PROFILES_ACTIVE=docker
# networks:
# - onem-network
# depends_on:
# - zipkin

networks:
onem-network:
driver: bridge

volumes:
grafana-storage:
9 changes: 9 additions & 0 deletions prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
global:
scrape_interval: 15s

scrape_configs:
- job_name: 'spring-actuator'
metrics_path: '/actuator/prometheus'
scrape_interval: 5s
static_configs:
- targets: ['host.docker.internal:8080']
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package community.whatever.onembackendjava.config;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.util.ContentCachingRequestWrapper;

import java.util.Enumeration;
import java.util.Map;
import java.util.StringJoiner;

/**
* HTTP 요청 로깅을 담당하는 클래스
* 로깅 로직을 RequestLoggingFilter에서 분리하여 관리
*/
@Component
public class RequestLogger {

private static final Logger log = LoggerFactory.getLogger(RequestLogger.class);

/**
* 요청 정보 로깅
*/
public void logRequest(HttpServletRequest request, ContentCachingRequestWrapper requestWrapper,
HttpServletResponse response, long duration) {
String requestMethod = request.getMethod();
String requestUri = request.getRequestURI();
int statusCode = response.getStatus();

// 기본 요청 정보 로깅
log.info("REQUEST: {} {} | Status: {} | Duration: {}ms | IP: {}",
requestMethod,
requestUri,
statusCode,
duration,
getClientIp(request)
);

// 쿼리 파라미터 개별 로깅
logQueryParameters(request);

// 헤더 로깅
logRequestHeaders(request);

// 요청 본문 로깅 (POST, PUT, PATCH 요청인 경우)
if (requestMethod.equals("POST") || requestMethod.equals("PUT") || requestMethod.equals("PATCH")) {
logRequestBody(requestWrapper);
}
}

/**
* 쿼리 파라미터 로깅
*/
private void logQueryParameters(HttpServletRequest request) {
Map<String, String[]> queryParams = request.getParameterMap();
if (!queryParams.isEmpty()) {
log.info("Query Parameters:");
queryParams.forEach((key, values) -> {
if (values.length == 1) {
log.info(" {} = {}", key, values[0]);
} else {
StringJoiner valueJoiner = new StringJoiner(", ", "[", "]");
for (String value : values) {
valueJoiner.add(value);
}
log.info(" {} = {}", key, valueJoiner.toString());
}
});
}
}

/**
* 요청 헤더 로깅
*/
private void logRequestHeaders(HttpServletRequest request) {
log.info("Request Headers:");
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
log.info(" {} = {}", headerName, request.getHeader(headerName));
}
}

/**
* 요청 본문 로깅
*/
private void logRequestBody(ContentCachingRequestWrapper requestWrapper) {
byte[] content = requestWrapper.getContentAsByteArray();
if (content.length > 0) {
String requestBody = new String(content);
log.info("Request Body: {}", requestBody);
}
}

/**
* 클라이언트 IP 주소 추출
*/
private String getClientIp(HttpServletRequest request) {
String clientIp = request.getHeader("X-Forwarded-For");
if (clientIp == null || clientIp.isEmpty() || "unknown".equalsIgnoreCase(clientIp)) {
clientIp = request.getHeader("Proxy-Client-IP");
}
if (clientIp == null || clientIp.isEmpty() || "unknown".equalsIgnoreCase(clientIp)) {
clientIp = request.getHeader("WL-Proxy-Client-IP");
}
if (clientIp == null || clientIp.isEmpty() || "unknown".equalsIgnoreCase(clientIp)) {
clientIp = request.getRemoteAddr();
}
return clientIp;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package community.whatever.onembackendjava.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;

import java.io.IOException;

/**
* 모든 HTTP 요청과 응답을 로깅하는 필터
*/
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {

private final RequestLogger requestLogger;

public RequestLoggingFilter(RequestLogger requestLogger) {
this.requestLogger = requestLogger;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
long startTime = System.currentTimeMillis();

try {
filterChain.doFilter(requestWrapper, response);
} finally {
long duration = System.currentTimeMillis() - startTime;
requestLogger.logRequest(request, requestWrapper, response, duration);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@ public class UrlShortenService {
private final UrlMappingManager urlMappingManager;
private final AppEnvironment appEnvironment;
private final RandomKeyGenerator randomKeyGenerator;

private static final long ONE_HOUR = 60 * 60 * 1000;
private final String envPrefix = appEnvironment.getPrefix();

private static final long ONE_HOUR = 60 * 60 * 1000;

public UrlShortenService(UrlMappingManager urlMappingManager, AppEnvironment appEnvironment) {
this.urlMappingManager = urlMappingManager;
Expand Down
50 changes: 49 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1 +1,49 @@
spring.application.name=onem-backend
spring:
application:
name: onem-backend

# Zipkin 트레이싱 설정
zipkin:
sender:
type: web
base-url: http://localhost:9411
service:
name: ${spring.application.name}

# 액츄에이터 설정
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics,loggers
endpoint:
health:
show-details: always
tracing:
sampling:
probability: 1.0
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: true

# 로깅 설정
logging:
pattern:
level: '%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]'
console: '%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight{%-5level} [%blue{%t}] %yellow{%C{1.}} : %msg%n%throwable'
level:
root: INFO
community.whatever.onembackendjava: DEBUG
community.whatever.onembackendjava.config.RequestLogger: INFO
org.springframework.web: INFO
org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG
file:
name: logs/onem-backend.log
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 30
total-size-cap: 500MB
48 changes: 48 additions & 0 deletions src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 콘솔 출력을 위한 appender -->
<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>

<!-- 파일 출력을 위한 appender -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/onem-backend.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/onem-backend.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- HTTP 요청에 대한 appender -->
<appender name="HTTP_REQUEST_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/http-requests.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/http-requests.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<!-- JSON 형식으로 로그 출력 -->
</encoder>
</appender>

<!-- HTTP 요청 로깅을 위한 logger -->
<logger name="community.whatever.onembackendjava.config.RequestLogger" level="INFO" additivity="false">
<appender-ref ref="HTTP_REQUEST_FILE" />
<appender-ref ref="CONSOLE" />
</logger>

<!-- 쿼리 파라미터 로깅을 위한 logger -->
<logger name="org.springframework.web.servlet.DispatcherServlet" level="DEBUG" />

<!-- 기본 로거 설정 -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
</configuration>