Skip to content

Commit 0bcdbee

Browse files
authored
✨ Feature - 비동기 Job 스케줄링에 에러로그 추적을 위한 MDC를 도입한다
✨ Feature - 비동기 Job 스케줄링에 에러로그 추적을 위한 MDC를 도입한다
2 parents 2d924c8 + d9b8578 commit 0bcdbee

9 files changed

Lines changed: 211 additions & 52 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package sopt.comfit.global.config;
2+
3+
import io.micrometer.context.ContextRegistry;
4+
import io.micrometer.context.ContextSnapshotFactory;
5+
import jakarta.annotation.PostConstruct;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.slf4j.MDC;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import reactor.core.publisher.Hooks;
12+
13+
@Slf4j
14+
@Configuration
15+
public class ContextPropagationConfig {
16+
17+
@PostConstruct
18+
public void init() {
19+
Hooks.enableAutomaticContextPropagation();
20+
21+
ContextRegistry.getInstance().registerThreadLocalAccessor(
22+
"security.context",
23+
SecurityContextHolder::getContext,
24+
SecurityContextHolder::setContext,
25+
SecurityContextHolder::clearContext
26+
);
27+
28+
// MDC 등록
29+
ContextRegistry.getInstance().registerThreadLocalAccessor(
30+
"mdc",
31+
MDC::getCopyOfContextMap,
32+
context -> {
33+
if (context != null) {
34+
MDC.setContextMap(context);
35+
}
36+
},
37+
MDC::clear
38+
);
39+
40+
log.info("Context Propagation 활성화 - MDC, SecurityContext 등록 완료");
41+
42+
43+
}
44+
45+
@Bean
46+
public ContextSnapshotFactory contextSnapshotFactory() {
47+
return ContextSnapshotFactory.builder().build();
48+
}
49+
}

src/main/java/sopt/comfit/global/config/ReactorContextPropagationConfig.java

Lines changed: 0 additions & 27 deletions
This file was deleted.

src/main/java/sopt/comfit/global/config/SwaggerConfig.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import org.springframework.context.annotation.Configuration;
1010
import org.springframework.context.annotation.Profile;
1111

12-
@Profile("!prod")
1312
@Configuration
1413
public class SwaggerConfig {
1514

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package sopt.comfit.global.logging;
2+
3+
import io.micrometer.context.ContextSnapshot;
4+
import io.micrometer.context.ContextSnapshotFactory;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.stereotype.Component;
7+
8+
import java.util.concurrent.Callable;
9+
import java.util.concurrent.ExecutorService;
10+
import java.util.concurrent.Executors;
11+
import java.util.concurrent.Future;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class ContextAwareExecutor {
16+
17+
private final ContextSnapshotFactory contextSnapshotFactory;
18+
19+
20+
public <T> Future<T> submit(ExecutorService executor, Callable<T> task) {
21+
// 현재 스레드의 모든 ThreadLocal 캡처
22+
ContextSnapshot snapshot = contextSnapshotFactory.captureAll();
23+
24+
return executor.submit(() -> {
25+
// VT에서 컨텍스트 복원 후 실행
26+
try (ContextSnapshot.Scope scope = snapshot.setThreadLocals()) {
27+
return task.call();
28+
}
29+
});
30+
}
31+
32+
public ExecutorService newVirtualThreadExecutor() {
33+
return Executors.newVirtualThreadPerTaskExecutor();
34+
}
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package sopt.comfit.global.logging;
2+
3+
import org.slf4j.MDC;
4+
5+
import java.util.UUID;
6+
7+
public class MdcUtils {
8+
9+
public static final String TRACE_ID = "traceId";
10+
public static final String JOB_ID = "jobId";
11+
public static final String USER_ID = "userId";
12+
13+
private MdcUtils() {
14+
}
15+
16+
public static void generateTraceId() {
17+
String traceId = UUID.randomUUID().toString().substring(0, 8);
18+
MDC.put(TRACE_ID, traceId);
19+
}
20+
21+
public static void setJobId(Long jobId) {
22+
MDC.put(JOB_ID, String.valueOf(jobId));
23+
}
24+
25+
public static void setUserId(Long userId) {
26+
MDC.put(USER_ID, String.valueOf(userId));
27+
}
28+
29+
public static void clear() {
30+
MDC.clear();
31+
}
32+
}

src/main/java/sopt/comfit/global/security/filter/JwtAuthenticationFilter.java

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.springframework.stereotype.Component;
1414
import org.springframework.web.filter.OncePerRequestFilter;
1515
import sopt.comfit.global.constants.Constants;
16+
import sopt.comfit.global.logging.MdcUtils;
1617
import sopt.comfit.global.security.info.JwtAuthenticationToken;
1718
import sopt.comfit.global.security.info.JwtUserInfo;
1819
import sopt.comfit.global.security.manager.JwtAuthenticationManager;
@@ -34,32 +35,40 @@ protected void doFilterInternal(HttpServletRequest request,
3435
HttpServletResponse response,
3536
FilterChain filterChain) throws ServletException, IOException {
3637

38+
try {
39+
MdcUtils.generateTraceId();
3740

38-
String header = request.getHeader(Constants.PREFIX_AUTH);
39-
log.info("header:{}",header);
41+
String header = request.getHeader(Constants.PREFIX_AUTH);
42+
log.info("header:{}", header);
4043

41-
if (header == null || !header.startsWith("Bearer ")) {
42-
filterChain.doFilter(request, response);
43-
return;
44-
}
45-
String token = HeaderUtil.refineHeader(request, Constants.PREFIX_AUTH, Constants.BEARER);
46-
Claims claim = jwtUtil.validateToken(token);
47-
log.info("claim: getUserId() = {}", claim.get(Constants.CLAIM_USER_ID, Long.class));
44+
if (header == null || !header.startsWith("Bearer ")) {
45+
filterChain.doFilter(request, response);
46+
return;
47+
}
48+
String token = HeaderUtil.refineHeader(request, Constants.PREFIX_AUTH, Constants.BEARER);
49+
Claims claim = jwtUtil.validateToken(token);
50+
log.info("claim: getUserId() = {}", claim.get(Constants.CLAIM_USER_ID, Long.class));
51+
52+
JwtUserInfo jwtUserInfo = JwtUserInfo.from(claim);
4853

49-
JwtUserInfo jwtUserInfo = JwtUserInfo.from(claim);
54+
MdcUtils.setUserId(jwtUserInfo.userId());
5055

51-
JwtAuthenticationToken unAuthenticatedToken = new JwtAuthenticationToken(jwtUserInfo);
56+
JwtAuthenticationToken unAuthenticatedToken = new JwtAuthenticationToken(jwtUserInfo);
5257

53-
JwtAuthenticationToken authenticatedToken = (JwtAuthenticationToken) jwtAuthenticationManager.authenticate(unAuthenticatedToken);
58+
JwtAuthenticationToken authenticatedToken = (JwtAuthenticationToken) jwtAuthenticationManager.authenticate(unAuthenticatedToken);
5459

55-
log.info("Authentication Successful: {}", authenticatedToken);
60+
log.info("Authentication Successful: {}", authenticatedToken);
5661

57-
authenticatedToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
62+
authenticatedToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
5863

59-
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
60-
securityContext.setAuthentication(authenticatedToken);
61-
SecurityContextHolder.setContext(securityContext);
62-
filterChain.doFilter(request, response);
64+
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
65+
securityContext.setAuthentication(authenticatedToken);
66+
SecurityContextHolder.setContext(securityContext);
67+
68+
filterChain.doFilter(request, response);
69+
} finally {
70+
MdcUtils.clear();
71+
}
6372
}
6473

6574
}

src/main/java/sopt/comfit/report/infra/service/RetryableAiCallerService.java

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import reactor.core.publisher.Mono;
1010
import reactor.util.retry.Retry;
1111
import sopt.comfit.global.exception.BaseException;
12+
import sopt.comfit.global.logging.ContextAwareExecutor;
1213
import sopt.comfit.report.exception.AIReportErrorCode;
1314
import sopt.comfit.report.infra.dto.CreateReportAiRequestDto;
1415
import sopt.comfit.report.infra.dto.PreparedDataDto;
@@ -32,6 +33,7 @@ public class RetryableAiCallerService {
3233
private final ObjectMapper objectMapper;
3334
private final JsonUtils jsonUtils;
3435
private static final int MAX_RETRY = 2;
36+
private final ContextAwareExecutor contextAwareExecutor;
3537

3638
// Feign 동기 호출
3739
public String callSync(String prompt) {
@@ -126,20 +128,23 @@ public String callSyncWithField(String prompt, String taskName, String requiredF
126128

127129
// 동기 병렬 호출
128130
public String callParallelWithVirtualThread(PreparedDataDto data, String perspectivesJson) {
129-
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
130-
Future<String> densityFuture = executor.submit(() ->
131+
try (ExecutorService executor = contextAwareExecutor.newVirtualThreadExecutor()) {
132+
Future<String> densityFuture = contextAwareExecutor.submit(executor, () ->
131133
callSyncWithField(
132134
AIReportParallelPromptBuilder.buildDensity(data, perspectivesJson),
133135
"Density", "density")); // callSyncWithField로 변경
134-
Future<String> appealPointFuture = executor.submit(() ->
136+
137+
Future<String> appealPointFuture = contextAwareExecutor.submit(executor, () ->
135138
callSyncWithField(
136139
AIReportParallelPromptBuilder.buildAppealPoint(data, perspectivesJson),
137140
"AppealPoint", "appealPoint"));
138-
Future<String> suggestionFuture = executor.submit(() ->
141+
142+
Future<String> suggestionFuture = contextAwareExecutor.submit(executor, () ->
139143
callSyncWithField(
140144
AIReportParallelPromptBuilder.buildSuggestion(data, perspectivesJson),
141145
"Suggestion", "suggestion"));
142-
Future<String> guidanceFuture = executor.submit(() ->
146+
147+
Future<String> guidanceFuture = contextAwareExecutor.submit(executor, () ->
143148
callSyncWithField(
144149
AIReportParallelPromptBuilder.buildGuidance(data, perspectivesJson),
145150
"Guidance", "guidance"));

src/main/java/sopt/comfit/report/job/AIReportJobWorker.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.springframework.data.redis.core.StringRedisTemplate;
88
import org.springframework.stereotype.Component;
99
import sopt.comfit.global.constants.Constants;
10+
import sopt.comfit.global.logging.MdcUtils;
1011
import sopt.comfit.report.domain.AIReportJob;
1112
import sopt.comfit.report.dto.command.MatchExperienceCommandDto;
1213
import sopt.comfit.report.infra.dto.PreparedDataDto;
@@ -67,9 +68,15 @@ private void listen() {
6768
}
6869

6970
private void processJob(Long jobId) {
70-
reportJobService.startProcessing(jobId);
7171

7272
try {
73+
//MDC 설정
74+
MdcUtils.generateTraceId();
75+
MdcUtils.setJobId(jobId);
76+
77+
log.info("Job 처리 시작");
78+
reportJobService.startProcessing(jobId);
79+
7380
MatchExperienceCommandDto command = buildCommand(jobId);
7481
PreparedDataDto data = aiReportQueryService.prepareData(command);
7582

@@ -87,6 +94,8 @@ private void processJob(Long jobId) {
8794
} catch (Exception e) {
8895
log.error("Job 처리 실패 - jobId: {}", jobId, e);
8996
reportJobService.fail(jobId);
97+
} finally {
98+
MdcUtils.clear();
9099
}
91100
}
92101

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<configuration>
3+
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
4+
5+
<!-- 일반 콘솔 패턴 (로컬 개발용 - 읽기 쉬움) -->
6+
<property name="CONSOLE_LOG_PATTERN"
7+
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [traceId=%X{traceId:-}] [jobId=%X{jobId:-}] [userId=%X{userId:-}] %-5level %logger{36} - %msg%n"/>
8+
9+
<!-- JSON 패턴 (운영용 - Loki 파싱) -->
10+
<property name="JSON_LOG_PATTERN"
11+
value='{"timestamp":"%d{yyyy-MM-dd HH:mm:ss.SSS}","thread":"%thread","traceId":"%X{traceId:-}","jobId":"%X{jobId:-}","userId":"%X{userId:-}","level":"%level","logger":"%logger{36}","message":"%msg"}%n'/>
12+
13+
<!-- 일반 콘솔 Appender (로컬용) -->
14+
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
15+
<encoder>
16+
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
17+
<charset>UTF-8</charset>
18+
</encoder>
19+
</appender>
20+
21+
<!-- JSON 콘솔 Appender (운영용) -->
22+
<appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender">
23+
<encoder>
24+
<pattern>${JSON_LOG_PATTERN}</pattern>
25+
<charset>UTF-8</charset>
26+
</encoder>
27+
</appender>
28+
29+
<!-- 로컬: 일반 포맷 (읽기 쉬움) -->
30+
<springProfile name="local">
31+
<root level="INFO">
32+
<appender-ref ref="CONSOLE"/>
33+
</root>
34+
</springProfile>
35+
36+
<!-- 운영: JSON 포맷 (Loki 파싱용) -->
37+
<springProfile name="dev,prod">
38+
<root level="INFO">
39+
<appender-ref ref="CONSOLE_JSON"/>
40+
</root>
41+
</springProfile>
42+
43+
<!-- 패키지별 로그 레벨 -->
44+
<logger name="sopt.comfit" level="DEBUG"/>
45+
<logger name="sopt.comfit.report" level="DEBUG"/>
46+
<logger name="org.springframework" level="WARN"/>
47+
<logger name="org.hibernate" level="WARN"/>
48+
</configuration>

0 commit comments

Comments
 (0)