diff --git a/.gitignore b/.gitignore index dbe08ede..cebbd223 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,9 @@ stop-dev-tunnel.sh /CLAUDE.md /.claude +### logs ### +logs/ + ### env ### .env diff --git a/src/main/java/com/techfork/domain/recommendation/config/RecommendationConfig.java b/src/main/java/com/techfork/domain/recommendation/config/RecommendationConfig.java new file mode 100644 index 00000000..57ff9bb4 --- /dev/null +++ b/src/main/java/com/techfork/domain/recommendation/config/RecommendationConfig.java @@ -0,0 +1,30 @@ +package com.techfork.domain.recommendation.config; + +import com.techfork.global.filter.MdcTaskDecorator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +public class RecommendationConfig { + + /** + * 추천 생성 전용 executor. + * 검색(SearchConfig)과 스레드풀을 분리하여 추천 배치 작업이 실시간 검색에 영향을 주지 않도록 한다. + */ + @Bean(name = "recommendationAsyncExecutor") + public Executor recommendationAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(20); + executor.setThreadNamePrefix("recommendation-"); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java b/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java index 4161aedd..278f412b 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java @@ -25,6 +25,7 @@ import com.techfork.global.util.VectorUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Primary; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -33,6 +34,7 @@ import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.stream.Collectors; /** @@ -55,6 +57,8 @@ public class LlmRecommendationService implements RecommendationService { private final TimeDecayStrategy timeDecayStrategy; private final RecommendationProperties properties; private final VectorQueryBuilder vectorQueryBuilder; + @Qualifier("recommendationAsyncExecutor") + private final Executor recommendationAsyncExecutor; private static final String POSTS_INDEX = "posts"; private static final String TITLE_EMBEDDING_FIELD = "titleEmbedding"; @@ -154,7 +158,7 @@ private List searchCandidates(float[] userProfileVector, User user log.error("kNN 검색 실패", e); return Collections.emptyList(); } - }); + }, recommendationAsyncExecutor); CompletableFuture>> keywordSearchFuture = CompletableFuture.supplyAsync(() -> { // 키워드가 없으면 BM25 검색 생략 @@ -178,7 +182,7 @@ private List searchCandidates(float[] userProfileVector, User user log.error("BM25 검색 실패", e); return Collections.emptyList(); } - }); + }, recommendationAsyncExecutor); // 4. 두 검색 완료 대기 CompletableFuture allSearches = CompletableFuture.allOf(vectorSearchFuture, keywordSearchFuture); diff --git a/src/main/java/com/techfork/global/config/SearchConfig.java b/src/main/java/com/techfork/domain/search/config/SearchConfig.java similarity index 83% rename from src/main/java/com/techfork/global/config/SearchConfig.java rename to src/main/java/com/techfork/domain/search/config/SearchConfig.java index 4dfa7d35..23765815 100644 --- a/src/main/java/com/techfork/global/config/SearchConfig.java +++ b/src/main/java/com/techfork/domain/search/config/SearchConfig.java @@ -1,24 +1,25 @@ -package com.techfork.global.config; +package com.techfork.domain.search.config; -import java.util.concurrent.Executor; -import java.util.concurrent.ThreadPoolExecutor; +import com.techfork.global.filter.MdcTaskDecorator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + @Configuration public class SearchConfig { @Bean(name = "searchAsyncExecutor") public Executor searchAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(20); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setThreadNamePrefix("SearchExec-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); - return executor; } -} +} \ No newline at end of file diff --git a/src/main/java/com/techfork/domain/source/config/RssCrawlingJobConfig.java b/src/main/java/com/techfork/domain/source/config/RssCrawlingJobConfig.java index 9d86fa0e..20e905c6 100644 --- a/src/main/java/com/techfork/domain/source/config/RssCrawlingJobConfig.java +++ b/src/main/java/com/techfork/domain/source/config/RssCrawlingJobConfig.java @@ -8,6 +8,7 @@ import com.techfork.domain.source.batch.RssToPostProcessor; import com.techfork.domain.source.dto.RssFeedItem; import com.techfork.domain.source.listener.RssCrawlingJobListener; +import com.techfork.global.filter.MdcTaskDecorator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; @@ -168,6 +169,7 @@ public TaskExecutor summaryTaskExecutor() { executor.setMaxPoolSize(2); executor.setQueueCapacity(10); executor.setThreadNamePrefix("summary-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); @@ -181,6 +183,7 @@ public TaskExecutor embeddingTaskExecutor() { executor.setMaxPoolSize(20); executor.setQueueCapacity(50); executor.setThreadNamePrefix("embedding-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); diff --git a/src/main/java/com/techfork/domain/source/listener/RssCrawlingJobListener.java b/src/main/java/com/techfork/domain/source/listener/RssCrawlingJobListener.java index 2482f454..e8bbf09b 100644 --- a/src/main/java/com/techfork/domain/source/listener/RssCrawlingJobListener.java +++ b/src/main/java/com/techfork/domain/source/listener/RssCrawlingJobListener.java @@ -1,8 +1,10 @@ package com.techfork.domain.source.listener; import com.techfork.domain.source.service.WebhookNotificationService; +import com.techfork.global.constant.MdcKey; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.batch.core.BatchStatus; import org.springframework.batch.core.JobExecution; import org.springframework.batch.core.JobExecutionListener; @@ -30,9 +32,10 @@ public class RssCrawlingJobListener implements JobExecutionListener { @Override public void beforeJob(JobExecution jobExecution) { - Long jobExecutionId = jobExecution.getId(); + MDC.put(MdcKey.REQUEST_ID, "batch-" + jobExecution.getId()); + MDC.put(MdcKey.USER_ID, "system"); log.info("RSS crawling job started: jobExecutionId={}, startTime={}", - jobExecutionId, jobExecution.getStartTime()); + jobExecution.getId(), jobExecution.getStartTime()); } @Override @@ -44,6 +47,7 @@ public void afterJob(JobExecution jobExecution) { } else { handleJobFailure(jobExecution, "Job failed with status: " + batchStatus); } + MDC.clear(); } /** diff --git a/src/main/java/com/techfork/global/config/AsyncConfig.java b/src/main/java/com/techfork/global/config/AsyncConfig.java new file mode 100644 index 00000000..2e4b4c44 --- /dev/null +++ b/src/main/java/com/techfork/global/config/AsyncConfig.java @@ -0,0 +1,32 @@ +package com.techfork.global.config; + +import com.techfork.global.filter.MdcTaskDecorator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + /** + * @Async 기본 executor. + * MdcTaskDecorator로 부모 스레드의 MDC 컨텍스트를 자식 스레드에 복사한다. + */ + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("async-"); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/techfork/global/constant/MdcKey.java b/src/main/java/com/techfork/global/constant/MdcKey.java new file mode 100644 index 00000000..4eeeec03 --- /dev/null +++ b/src/main/java/com/techfork/global/constant/MdcKey.java @@ -0,0 +1,11 @@ +package com.techfork.global.constant; + +public final class MdcKey { + private MdcKey() {} + + public static final String REQUEST_ID = "requestId"; + public static final String USER_ID = "userId"; + public static final String METHOD = "method"; + public static final String URI = "uri"; + public static final String CLIENT_IP = "clientIp"; +} \ No newline at end of file diff --git a/src/main/java/com/techfork/global/filter/MdcLoggingFilter.java b/src/main/java/com/techfork/global/filter/MdcLoggingFilter.java new file mode 100644 index 00000000..0d600e20 --- /dev/null +++ b/src/main/java/com/techfork/global/filter/MdcLoggingFilter.java @@ -0,0 +1,72 @@ +package com.techfork.global.filter; + +import com.techfork.global.constant.MdcKey; +import jakarta.servlet.FilterChain; +import lombok.extern.slf4j.Slf4j; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * HTTP 요청마다 MDC에 트레이싱 컨텍스트를 설정하는 필터. + * 요청 종료 시 MDC를 정리하여 스레드 풀 재사용 시 누수를 방지한다. + *

+ * MDC 필드: + * - requestId: 요청 단위 고유 ID (UUID) + * - userId: 인증된 사용자 ID (비인증 요청은 "anonymous") + * - method: HTTP 메서드 + * - uri: 요청 URI + * - clientIp: 클라이언트 IP (X-Forwarded-For 우선) + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class MdcLoggingFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + long startTime = System.currentTimeMillis(); + try { + MDC.put(MdcKey.REQUEST_ID, UUID.randomUUID().toString().replace("-", "").substring(0, 12)); + MDC.put(MdcKey.METHOD, request.getMethod()); + MDC.put(MdcKey.URI, request.getRequestURI()); + MDC.put(MdcKey.CLIENT_IP, resolveClientIp(request)); + MDC.put(MdcKey.USER_ID, "anonymous"); + + filterChain.doFilter(request, response); + + log.info("{} {} {} {}ms", + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + System.currentTimeMillis() - startTime); + } finally { + MDC.clear(); + } + } + + private String resolveClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (isValid(ip)) { + return ip.split(",")[0].trim(); + } + ip = request.getHeader("X-Real-IP"); + if (isValid(ip)) { + return ip; + } + return request.getRemoteAddr(); + } + + private boolean isValid(String ip) { + return ip != null && !ip.isBlank() && !"unknown".equalsIgnoreCase(ip); + } +} diff --git a/src/main/java/com/techfork/global/filter/MdcTaskDecorator.java b/src/main/java/com/techfork/global/filter/MdcTaskDecorator.java new file mode 100644 index 00000000..a0a03f1c --- /dev/null +++ b/src/main/java/com/techfork/global/filter/MdcTaskDecorator.java @@ -0,0 +1,28 @@ +package com.techfork.global.filter; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +import java.util.Map; + +/** + * 부모 스레드의 MDC 컨텍스트를 자식 스레드로 복사하는 TaskDecorator. + * ThreadPoolTaskExecutor에 등록하여 @Async, AsyncItemProcessor 등에서 MDC가 전파되도록 한다. + */ +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map mdcContext = MDC.getCopyOfContextMap(); + return () -> { + try { + if (mdcContext != null) { + MDC.setContextMap(mdcContext); + } + runnable.run(); + } finally { + MDC.clear(); + } + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java index cd920a50..b5780c9b 100644 --- a/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java @@ -4,6 +4,7 @@ import com.techfork.domain.user.entity.User; import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.constant.Constants; +import com.techfork.global.constant.MdcKey; import com.techfork.global.exception.GeneralException; import com.techfork.global.security.jwt.JwtUtil; import com.techfork.global.security.oauth.UserPrincipal; @@ -14,6 +15,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -66,6 +68,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse securityContext.setAuthentication(authentication); SecurityContextHolder.setContext(securityContext); + MDC.put(MdcKey.USER_ID, String.valueOf(userId)); log.debug("Set authentication for user: {}", userId); } } catch (Exception e) { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 9329f70d..9fffb2d1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -38,11 +38,8 @@ spring: uris: http://elasticsearch:9200 logging: - level: - com.techfork: INFO - org.springframework.batch: INFO - org.hibernate.SQL: INFO - org.hibernate.type.descriptor.sql.BasicBinder: WARN + file: + path: /app/logs apple: private-key-path: /app/keys/AuthKey_${APPLE_KEY_ID}.p8 \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index d127e594..c7cf4c60 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -37,13 +37,6 @@ spring: elasticsearch: uris: http://elasticsearch:9200 -logging: - level: - com.techfork: DEBUG - org.springframework.batch: INFO - org.hibernate.SQL: DEBUG - org.hibernate.type.descriptor.sql.BasicBinder: TRACE - # Actuator 설정 - Resilience4j 모니터링 management: endpoints: diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..bef5f03d --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,132 @@ + + + + + 2000 + + + + + + + + + + + %d{HH:mm:ss.SSS} %highlight(%-5level) [%cyan(%thread)] [rid=%X{requestId:-?} uid=%X{userId:-?}] %boldWhite(%logger{36}) - %msg%n + UTF-8 + + + + + + + + + + + + + + + + + + + + + + + + + + + ${LOG_DIR}/${APP_NAME}.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [rid=%X{requestId:-?} uid=%X{userId:-?} ip=%X{clientIp:-?} %X{method:-?} %X{uri:-?}] %logger{36} - %msg%n + UTF-8 + + + ${LOG_DIR}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 3GB + + + + + ${LOG_DIR}/${APP_NAME}-error.log + + ERROR + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [rid=%X{requestId:-?} uid=%X{userId:-?} ip=%X{clientIp:-?} %X{method:-?} %X{uri:-?}] %logger{36} - %msg%n + UTF-8 + + + ${LOG_DIR}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 60 + 1GB + + + + + ${LOG_DIR}/${APP_NAME}-crawler.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [rid=%X{requestId:-?}] %logger{36} - %msg%n + UTF-8 + + + ${LOG_DIR}/archive/${APP_NAME}-crawler.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 7 + 1GB + + + + + 0 + 1024 + false + + + + + 0 + 256 + true + + + + + 0 + 512 + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/techfork/domain/recommendation/evaluation/RecommendationEvaluationService.java b/src/test/java/com/techfork/domain/recommendation/evaluation/RecommendationEvaluationService.java index cf61246f..92fc00af 100644 --- a/src/test/java/com/techfork/domain/recommendation/evaluation/RecommendationEvaluationService.java +++ b/src/test/java/com/techfork/domain/recommendation/evaluation/RecommendationEvaluationService.java @@ -28,6 +28,8 @@ import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; /** * 추천 시스템 성능 평가를 위한 전용 서비스 @@ -60,7 +62,8 @@ public RecommendationEvaluationService( ) { super(elasticsearchClient, userProfileDocumentRepository, recommendedPostRepository, recommendationHistoryRepository, readPostRepository, postRepository, - mmrService, timeDecayStrategy, properties, vectorQueryBuilder); + mmrService, timeDecayStrategy, properties, vectorQueryBuilder, + Executors.newSingleThreadExecutor()); this.elasticsearchClient = elasticsearchClient; this.userProfileDocumentRepository = userProfileDocumentRepository; this.vectorQueryBuilder = vectorQueryBuilder;