From 56cbe4119996eb9a4786ff0e98da2408cab746d7 Mon Sep 17 00:00:00 2001 From: comghost <5248980+comghost@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:28:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20Job=C2=B7Step=20?= =?UTF-8?q?=EC=8B=9D=EB=B3=84=EC=9E=90=EB=A5=BC=20MDC=20=EC=97=90=20?= =?UTF-8?q?=EC=8B=A3=EB=8A=94=20=EB=A6=AC=EC=8A=A4=EB=84=88=EC=99=80=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=9A=94=EC=95=BD=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치 로그는 여러 Job 이 한 파일에 섞여 Job 별로 나누거나 실행 단위로 추적하기 어렵고, 실행이 끝났을 때 읽기·쓰기·건너뜀 건수를 한 줄로 남기는 표준 지점이 없어 관제 시스템이 상세 로그를 파싱해야 한다. - EgovBatchMdcListener(JobExecutionListener + StepExecutionListener): Job 이름· JobExecution ID·Step 이름을 SLF4J MDC(batchJobName·batchJobExecutionId· batchStepName)에 넣고 종료 시 지운다. Log4j2 RoutingAppender 와 조합하면 Job 별 로그 파일 분리를 재기동 없이 구성할 수 있다 - EgovBatchSummaryListener(JobExecutionListener): Job 종료 시 전용 로거 EGOV_BATCH_SUMMARY 에 job= executionId= status= exitCode= durationMs= steps= read= written= skipped= filtered= 형식의 한 줄을 남긴다. 값은 Step 실행 결과를 합산한다 - 두 리스너 모두 빈으로 등록해 Job 에 붙이며 기존 클래스 수정·의존 추가 없음. 멀티스레드 Step 에서 MDC 가 워커 스레드로 전파되지 않는 점은 javadoc 에 명시 EgovBatchListenersTest 3건 추가 — MDC 키 주입과 Step·Job 종료 시 제거, 실행 요약의 Step 건수 합산과 형식, 시작·종료 시각이 없을 때 durationMs=-1 과 JobInstance 없을 때 job=?. --- .../core/operation/EgovBatchMdcListener.java | 87 +++++++++++++++ .../operation/EgovBatchSummaryListener.java | 103 ++++++++++++++++++ .../operation/EgovBatchListenersTest.java | 90 +++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchMdcListener.java create mode 100644 Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchSummaryListener.java create mode 100644 Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/operation/EgovBatchListenersTest.java diff --git a/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchMdcListener.java b/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchMdcListener.java new file mode 100644 index 00000000..c890f938 --- /dev/null +++ b/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchMdcListener.java @@ -0,0 +1,87 @@ +/* + * Copyright 2009-2026 MOIS(Ministry of the Interior and Safety). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.egovframe.rte.bat.core.operation; + +import org.slf4j.MDC; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; + +/** + * Job·Step 식별자를 SLF4J MDC 에 싣는 리스너. + * + *

Log4j2 RoutingAppender 와 조합하면 Job 별 로그 파일 분리를 재기동 없이 구성할 수 있다.

+ * + *
+ * <Routing name="batchRouting">
+ *     <Routes pattern="$${ctx:batchJobName}">
+ *         <Route>
+ *             <File name="job-${ctx:batchJobName}" fileName="logs/batch/${ctx:batchJobName}.log">...</File>
+ *         </Route>
+ *     </Routes>
+ * </Routing>
+ * 
+ * + *

MDC 키: {@value #MDC_JOB_NAME}, {@value #MDC_JOB_EXECUTION_ID}, {@value #MDC_STEP_NAME}. + * 멀티스레드 Step(taskExecutor)에서는 MDC 가 워커 스레드로 자동 전파되지 않는다.

+ * + * @author 실행환경 개발팀 + * @since 5.1 + * @version 1.0 + *
+ * 개정이력(Modification Information)
+ *
+ * 수정일		수정자				수정내용
+ * ----------------------------------------------
+ * 2026.09.10	실행환경 개발팀		최초 생성
+ * 
+ */ +public class EgovBatchMdcListener implements JobExecutionListener, StepExecutionListener { + + /** Job 이름 MDC 키 */ + public static final String MDC_JOB_NAME = "batchJobName"; + /** JobExecution ID MDC 키 */ + public static final String MDC_JOB_EXECUTION_ID = "batchJobExecutionId"; + /** Step 이름 MDC 키 */ + public static final String MDC_STEP_NAME = "batchStepName"; + + @Override + public void beforeJob(JobExecution jobExecution) { + MDC.put(MDC_JOB_NAME, jobExecution.getJobInstance().getJobName()); + MDC.put(MDC_JOB_EXECUTION_ID, String.valueOf(jobExecution.getId())); + } + + @Override + public void afterJob(JobExecution jobExecution) { + MDC.remove(MDC_JOB_NAME); + MDC.remove(MDC_JOB_EXECUTION_ID); + MDC.remove(MDC_STEP_NAME); + } + + @Override + public void beforeStep(StepExecution stepExecution) { + MDC.put(MDC_STEP_NAME, stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + MDC.remove(MDC_STEP_NAME); + return stepExecution.getExitStatus(); + } + +} diff --git a/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchSummaryListener.java b/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchSummaryListener.java new file mode 100644 index 00000000..6dbf0b33 --- /dev/null +++ b/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/operation/EgovBatchSummaryListener.java @@ -0,0 +1,103 @@ +/* + * Copyright 2009-2026 MOIS(Ministry of the Interior and Safety). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.egovframe.rte.bat.core.operation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.StepExecution; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * Job 종료 시 실행 결과를 key=value 한 줄로 남기는 리스너. + * + *

전용 로거 {@value #SUMMARY_LOGGER_NAME}(INFO)로 출력하므로 로깅 설정에서 관제·수집 시스템으로 라우팅하기 쉽다. + * 상세 시계열 지표는 Spring Batch 5 에 내장된 Micrometer 관측(spring.batch.job / spring.batch.step 타이머)을 쓰고, + * 이 리스너는 실행 단위 요약 한 줄만 담당한다.

+ * + *

출력 예:

+ *
+ * job=dailyStatsJob executionId=42 status=COMPLETED exitCode=COMPLETED durationMs=15320 steps=2 read=10000 written=9987 skipped=13 filtered=0
+ * 
+ * + * @author 실행환경 개발팀 + * @since 5.1 + * @version 1.0 + *
+ * 개정이력(Modification Information)
+ *
+ * 수정일		수정자				수정내용
+ * ----------------------------------------------
+ * 2026.09.10	실행환경 개발팀		최초 생성
+ * 
+ */ +public class EgovBatchSummaryListener implements JobExecutionListener { + + /** 실행 요약 전용 로거명 */ + public static final String SUMMARY_LOGGER_NAME = "EGOV_BATCH_SUMMARY"; + + private static final Logger SUMMARY = LoggerFactory.getLogger(SUMMARY_LOGGER_NAME); + + @Override + public void afterJob(JobExecution jobExecution) { + SUMMARY.info(buildSummary(jobExecution)); + } + + /** + * JobExecution 의 실행 요약 문자열(key=value 한 줄)을 만든다. 건수는 Step 실행 결과를 합산한다. + * + * @param jobExecution 요약할 JobExecution + * @return 요약 한 줄 + */ + static String buildSummary(JobExecution jobExecution) { + long readCount = 0; + long writeCount = 0; + long skipCount = 0; + long filterCount = 0; + for (StepExecution stepExecution : jobExecution.getStepExecutions()) { + readCount += stepExecution.getReadCount(); + writeCount += stepExecution.getWriteCount(); + skipCount += stepExecution.getSkipCount(); + filterCount += stepExecution.getFilterCount(); + } + + StringBuilder builder = new StringBuilder(); + builder.append("job=").append(jobExecution.getJobInstance() == null + ? "?" : jobExecution.getJobInstance().getJobName()); + builder.append(" executionId=").append(jobExecution.getId()); + builder.append(" status=").append(jobExecution.getStatus()); + builder.append(" exitCode=").append(jobExecution.getExitStatus() == null + ? "?" : jobExecution.getExitStatus().getExitCode()); + builder.append(" durationMs=").append(durationMillis(jobExecution.getStartTime(), jobExecution.getEndTime())); + builder.append(" steps=").append(jobExecution.getStepExecutions().size()); + builder.append(" read=").append(readCount); + builder.append(" written=").append(writeCount); + builder.append(" skipped=").append(skipCount); + builder.append(" filtered=").append(filterCount); + return builder.toString(); + } + + private static long durationMillis(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime == null || endTime == null) { + return -1; + } + return Duration.between(startTime, endTime).toMillis(); + } + +} diff --git a/Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/operation/EgovBatchListenersTest.java b/Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/operation/EgovBatchListenersTest.java new file mode 100644 index 00000000..3c9500c2 --- /dev/null +++ b/Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/operation/EgovBatchListenersTest.java @@ -0,0 +1,90 @@ +package org.egovframe.rte.bat.core.operation; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobInstance; +import org.springframework.batch.core.StepExecution; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * EgovBatchMdcListener 의 MDC 키 주입·제거와 EgovBatchSummaryListener 의 실행 요약 형식을 검증한다. + */ +public class EgovBatchListenersTest { + + @AfterEach + public void clearMdc() { + MDC.clear(); + } + + private JobExecution jobExecution(String jobName, long executionId) { + JobExecution jobExecution = new JobExecution(executionId); + jobExecution.setJobInstance(new JobInstance(1L, jobName)); + return jobExecution; + } + + @Test + public void testMdcListenerPutsAndClearsJobAndStepKeys() { + EgovBatchMdcListener listener = new EgovBatchMdcListener(); + JobExecution jobExecution = jobExecution("mdcJob", 11L); + + listener.beforeJob(jobExecution); + assertEquals("mdcJob", MDC.get(EgovBatchMdcListener.MDC_JOB_NAME)); + assertEquals("11", MDC.get(EgovBatchMdcListener.MDC_JOB_EXECUTION_ID)); + + StepExecution stepExecution = new StepExecution("step1", jobExecution); + listener.beforeStep(stepExecution); + assertEquals("step1", MDC.get(EgovBatchMdcListener.MDC_STEP_NAME)); + + listener.afterStep(stepExecution); + assertNull(MDC.get(EgovBatchMdcListener.MDC_STEP_NAME)); + assertEquals("mdcJob", MDC.get(EgovBatchMdcListener.MDC_JOB_NAME)); + + listener.afterJob(jobExecution); + assertNull(MDC.get(EgovBatchMdcListener.MDC_JOB_NAME)); + assertNull(MDC.get(EgovBatchMdcListener.MDC_JOB_EXECUTION_ID)); + } + + @Test + public void testSummaryAggregatesStepCounts() { + JobExecution jobExecution = jobExecution("dailyStatsJob", 42L); + jobExecution.setStatus(BatchStatus.COMPLETED); + jobExecution.setExitStatus(ExitStatus.COMPLETED); + jobExecution.setStartTime(LocalDateTime.now().minusSeconds(15)); + jobExecution.setEndTime(LocalDateTime.now()); + StepExecution step1 = jobExecution.createStepExecution("step1"); + step1.setReadCount(10000); + step1.setWriteCount(9987); + step1.setReadSkipCount(13); + StepExecution step2 = jobExecution.createStepExecution("step2"); + step2.setReadCount(500); + step2.setWriteCount(500); + step2.setFilterCount(7); + + String summary = EgovBatchSummaryListener.buildSummary(jobExecution); + + assertTrue(summary.startsWith("job=dailyStatsJob executionId=42 status=COMPLETED exitCode=COMPLETED durationMs="), summary); + assertTrue(summary.endsWith(" steps=2 read=10500 written=10487 skipped=13 filtered=7"), summary); + } + + @Test + public void testSummaryHandlesMissingTimesAndInstance() { + JobExecution jobExecution = new JobExecution(7L); + jobExecution.setStatus(BatchStatus.FAILED); + + String summary = EgovBatchSummaryListener.buildSummary(jobExecution); + + assertTrue(summary.startsWith("job=? executionId=7 status=FAILED"), summary); + assertTrue(summary.contains(" durationMs=-1 "), summary); + assertTrue(summary.endsWith(" steps=0 read=0 written=0 skipped=0 filtered=0"), summary); + } + +}