diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java index c98ece1f..2bc388af 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java @@ -14,9 +14,11 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.domain.auth.model.AuthCredential; +import site.icebang.domain.workflow.dto.RequestContext; import site.icebang.domain.workflow.dto.WorkflowCardDto; import site.icebang.domain.workflow.dto.WorkflowCreateDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; +import site.icebang.domain.workflow.service.RequestContextService; import site.icebang.domain.workflow.service.WorkflowExecutionService; import site.icebang.domain.workflow.service.WorkflowService; @@ -26,6 +28,7 @@ public class WorkflowController { private final WorkflowService workflowService; private final WorkflowExecutionService workflowExecutionService; + private final RequestContextService requestContextService; @GetMapping("") public ApiResponse> getWorkflowList( @@ -53,8 +56,10 @@ public ApiResponse createWorkflow( @PostMapping("/{workflowId}/run") public ResponseEntity runWorkflow(@PathVariable Long workflowId) { + + RequestContext context = requestContextService.extractRequestContext(); // HTTP 요청/응답 스레드를 블로킹하지 않도록 비동기 실행 - workflowExecutionService.executeWorkflow(workflowId); + workflowExecutionService.executeWorkflow(workflowId, context); return ResponseEntity.accepted().build(); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java new file mode 100644 index 00000000..1812cd32 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java @@ -0,0 +1,22 @@ +package site.icebang.domain.workflow.dto; + +import lombok.Data; + +/** 요청 컨텍스트 정보를 담는 DTO 클래스 분산 추적, 클라이언트 정보 등을 포함하여 워크플로우 실행 시 필요한 컨텍스트를 관리합니다. */ +@Data +public class RequestContext { + + private final String traceId; + private final String clientIp; + private final String userAgent; + + /** + * 스케줄러 실행용 컨텍스트를 생성하는 정적 팩토리 메서드 HTTP 요청이 아닌 스케줄된 작업에서 사용됩니다. + * + * @param traceId 분산 추적 ID + * @return 스케줄러용 RequestContext 객체 (clientIp와 userAgent는 기본값 설정) + */ + public static RequestContext forScheduler(String traceId) { + return new RequestContext(traceId, "scheduler", "quartz-scheduler"); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java index 38c1ae38..8dea91f4 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java @@ -8,11 +8,21 @@ public class ExecutionMdcManager { private static final String SOURCE_ID = "sourceId"; private static final String EXECUTION_TYPE = "executionType"; private static final String TRACE_ID = "traceID"; + private static final String CLIENT_IP = "clientIp"; + private static final String USER_AGENT = "userAgent"; - public void setWorkflowContext(Long workflowId, String traceId) { + public void setWorkflowContext( + Long workflowId, String traceId, String clientIp, String userAgent) { MDC.put(SOURCE_ID, workflowId.toString()); MDC.put(EXECUTION_TYPE, "WORKFLOW"); MDC.put(TRACE_ID, traceId); + + if (clientIp != null) { + MDC.put(CLIENT_IP, clientIp); + } + if (userAgent != null) { + MDC.put(USER_AGENT, userAgent); + } } public void setWorkflowContext(Long workflowId) { diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java index 1c3a0796..111b2e89 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java @@ -1,9 +1,6 @@ package site.icebang.domain.workflow.model; import java.time.Instant; -import java.util.UUID; - -import org.slf4j.MDC; import lombok.Getter; import lombok.NoArgsConstructor; @@ -20,18 +17,18 @@ public class WorkflowRun { private Instant finishedAt; private Instant createdAt; - private WorkflowRun(Long workflowId) { + private WorkflowRun(Long workflowId, String traceId) { this.workflowId = workflowId; // MDC에서 현재 요청의 traceId를 가져오거나, 없으면 새로 생성 - this.traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); + this.traceId = traceId; this.status = "RUNNING"; this.startedAt = Instant.now(); this.createdAt = this.startedAt; } /** 워크플로우 실행 시작을 위한 정적 팩토리 메소드 */ - public static WorkflowRun start(Long workflowId) { - return new WorkflowRun(workflowId); + public static WorkflowRun start(Long workflowId, String traceId) { + return new WorkflowRun(workflowId, traceId); } /** 워크플로우 실행 완료 처리 */ diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java index a3076d1f..d0bc46f1 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import site.icebang.domain.workflow.service.RequestContextService; import site.icebang.domain.workflow.service.WorkflowExecutionService; /** @@ -30,6 +31,7 @@ @RequiredArgsConstructor public class WorkflowTriggerJob extends QuartzJobBean { private final WorkflowExecutionService workflowExecutionService; + private final RequestContextService requestContextService; /** * Quartz 스케줄러에 의해 트리거가 발동될 때 호출되는 메인 실행 메소드입니다. @@ -45,6 +47,6 @@ public class WorkflowTriggerJob extends QuartzJobBean { protected void executeInternal(JobExecutionContext context) { Long workflowId = context.getJobDetail().getJobDataMap().getLong("workflowId"); log.info("Quartz가 WorkflowTriggerJob을 실행합니다. WorkflowId={}", workflowId); - workflowExecutionService.executeWorkflow(workflowId); + workflowExecutionService.executeWorkflow(workflowId, requestContextService.quartzContext()); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java new file mode 100644 index 00000000..a4c501af --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java @@ -0,0 +1,37 @@ +package site.icebang.domain.workflow.service; + +import java.util.UUID; + +import org.slf4j.MDC; +import org.springframework.stereotype.Service; + +import site.icebang.domain.workflow.dto.RequestContext; + +/** 요청 컨텍스트 정보를 추출하고 관리하는 서비스 MDC(Mapped Diagnostic Context)를 사용하여 분산 추적 정보를 처리합니다. */ +@Service +public class RequestContextService { + + /** + * HTTP 요청으로부터 컨텍스트 정보를 추출합니다. + * + * @return 추출된 요청 컨텍스트 + */ + public RequestContext extractRequestContext() { + String traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); + String clientIp = MDC.get("clientIp"); + String userAgent = MDC.get("userAgent"); + + return new RequestContext(traceId, clientIp, userAgent); + } + + /** + * Quartz 스케줄러용 컨텍스트를 생성합니다. 스케줄된 작업에서는 HTTP 요청 정보가 없으므로 traceId만 포함됩니다. + * + * @return 스케줄러용 요청 컨텍스트 + */ + public RequestContext quartzContext() { + String traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); + + return RequestContext.forScheduler(traceId); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java index 4d781cee..d536f4de 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor; import site.icebang.domain.workflow.dto.JobDto; +import site.icebang.domain.workflow.dto.RequestContext; import site.icebang.domain.workflow.dto.TaskDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.manager.ExecutionMdcManager; @@ -47,11 +48,12 @@ public class WorkflowExecutionService { private final WorkflowMapper workflowMapper; @Async("traceExecutor") - public void executeWorkflow(Long workflowId) { - WorkflowRun workflowRun = WorkflowRun.start(workflowId); + public void executeWorkflow(Long workflowId, RequestContext context) { + WorkflowRun workflowRun = WorkflowRun.start(workflowId, context.getTraceId()); workflowRunMapper.insert(workflowRun); - mdcManager.setWorkflowContext(workflowId, workflowRun.getTraceId()); + mdcManager.setWorkflowContext( + workflowId, context.getTraceId(), context.getClientIp(), context.getUserAgent()); try { workflowLogger.info("========== 워크플로우 실행 시작: WorkflowId={} ==========", workflowId); diff --git a/apps/user-service/src/main/java/site/icebang/global/filter/logging/ClientLoggingFilter.java b/apps/user-service/src/main/java/site/icebang/global/filter/logging/ClientLoggingFilter.java new file mode 100644 index 00000000..97c87b36 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/filter/logging/ClientLoggingFilter.java @@ -0,0 +1,71 @@ +package site.icebang.global.filter.logging; + +import java.io.IOException; + +import org.slf4j.MDC; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; + +/** 클라이언트 요청 정보를 MDC에 설정하는 필터 모든 HTTP 요청에 대해 클라이언트 IP와 User-Agent 정보를 추출하여 로깅 컨텍스트에 저장합니다. */ +@Component +@Order(1) // 필터 체인에서 첫 번째로 실행되도록 우선순위 설정 +public class ClientLoggingFilter implements Filter { + + /** + * HTTP 요청을 필터링하여 클라이언트 정보를 MDC에 설정합니다. + * + * @param request 서블릿 요청 객체 + * @param response 서블릿 응답 객체 + * @param chain 필터 체인 + * @throws IOException 입출력 예외 + * @throws ServletException 서블릿 예외 + */ + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + String ip = getClientIp(httpRequest); + String userAgent = httpRequest.getHeader("User-Agent"); + + try { + MDC.put("clientIp", ip); + MDC.put("userAgent", userAgent); + + chain.doFilter(request, response); + } finally { + MDC.remove("clientIp"); + MDC.remove("userAgent"); + } + } + + /** + * 프록시 환경을 고려하여 클라이언트의 실제 IP 주소를 추출합니다. 로드 밸런서나 프록시 서버를 통해 들어오는 요청의 원본 IP를 찾습니다. + * + * @param request HTTP 요청 객체 + * @return 클라이언트의 실제 IP 주소 + */ + private String getClientIp(HttpServletRequest request) { + + String[] headers = { + "X-Forwarded-For", // 표준 프록시 헤더 + "Proxy-Client-IP", // Apache 프록시 + "WL-Proxy-Client-IP", // WebLogic 프록시 + "HTTP_X_FORWARDED_FOR", // HTTP 프록시 + "HTTP_CLIENT_IP", // HTTP 클라이언트 IP + "REMOTE_ADDR" // 원격 주소 + }; + + for (String header : headers) { + String ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.split(",")[0].trim(); + } + } + + return request.getRemoteAddr(); + } +} diff --git a/apps/user-service/src/main/resources/log4j2-develop.yml b/apps/user-service/src/main/resources/log4j2-develop.yml index 21790eea..f869d73b 100644 --- a/apps/user-service/src/main/resources/log4j2-develop.yml +++ b/apps/user-service/src/main/resources/log4j2-develop.yml @@ -12,10 +12,10 @@ Configuration: value: "UTF-8" # DEBUG 환경용 콘솔 패턴 - 더 간단하고 가독성 좋게 - name: "console-layout-pattern" - value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] %d{HH:mm:ss} [%t] %n %logger{20} - %msg%n%n " + value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{HH:mm:ss} [%t] %n %logger{20} - %msg%n%n " # 파일용 패턴 - Promtail이 파싱하기 쉽게 구조화 (UTC 시간 사용) - name: "file-layout-pattern" - value: "[%X{traceId}] [%X{spanId}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" + value: "[%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" # 개발 환경용 로그 파일들 - 절대경로나 상대경로 설정 - name: "dev-log" value: ${log-path}/develop/app.log diff --git a/apps/user-service/src/main/resources/log4j2-production.yml b/apps/user-service/src/main/resources/log4j2-production.yml index 79d920fc..efeb7fa1 100644 --- a/apps/user-service/src/main/resources/log4j2-production.yml +++ b/apps/user-service/src/main/resources/log4j2-production.yml @@ -13,10 +13,10 @@ Configuration: value: "UTF-8" # 프로덕션 환경용 콘솔 패턴 - 구조화된 로그 - name: "console-layout-pattern" - value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] %d{HH:mm:ss}{UTC} [%t] %logger{20} - %msg%n" + value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{HH:mm:ss}{UTC} [%t] %logger{20} - %msg%n" # 파일용 패턴 - Promtail이 파싱하기 쉽게 구조화 (UTC 시간 사용) - name: "file-layout-pattern" - value: "[%X{traceId}] [%X{spanId}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" + value: "[%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" # 프로덕션 환경용 로그 파일들 - name: "prod-log" value: ${log-path}/production/app.log diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java index 23c4eaa4..69fd0f4a 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java @@ -2,6 +2,7 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; @@ -18,6 +19,7 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; +import site.icebang.domain.workflow.dto.RequestContext; import site.icebang.domain.workflow.service.WorkflowExecutionService; import site.icebang.integration.setup.support.IntegrationTestSupport; @@ -62,6 +64,7 @@ void runWorkflow_success() throws Exception { .build()))); // 📌 2. 비동기 호출된 executeWorkflow 메소드가 1초 이내에 1번 실행되었는지 검증 - verify(mockWorkflowExecutionService, timeout(1000).times(1)).executeWorkflow(workflowId); + verify(mockWorkflowExecutionService, timeout(1000).times(1)) + .executeWorkflow(workflowId, any(RequestContext.class)); } }