Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -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 에 싣는 리스너.
*
* <p>Log4j2 RoutingAppender 와 조합하면 <b>Job 별 로그 파일 분리</b>를 재기동 없이 구성할 수 있다.</p>
*
* <pre>
* &lt;Routing name="batchRouting"&gt;
* &lt;Routes pattern="$${ctx:batchJobName}"&gt;
* &lt;Route&gt;
* &lt;File name="job-${ctx:batchJobName}" fileName="logs/batch/${ctx:batchJobName}.log"&gt;...&lt;/File&gt;
* &lt;/Route&gt;
* &lt;/Routes&gt;
* &lt;/Routing&gt;
* </pre>
*
* <p>MDC 키: {@value #MDC_JOB_NAME}, {@value #MDC_JOB_EXECUTION_ID}, {@value #MDC_STEP_NAME}.
* 멀티스레드 Step(taskExecutor)에서는 MDC 가 워커 스레드로 자동 전파되지 않는다.</p>
*
* @author 실행환경 개발팀
* @since 5.1
* @version 1.0
* <pre>
* 개정이력(Modification Information)
*
* 수정일 수정자 수정내용
* ----------------------------------------------
* 2026.09.10 실행환경 개발팀 최초 생성
* </pre>
*/
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();
}

}
Original file line number Diff line number Diff line change
@@ -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 한 줄로 남기는 리스너.
*
* <p>전용 로거 {@value #SUMMARY_LOGGER_NAME}(INFO)로 출력하므로 로깅 설정에서 관제·수집 시스템으로 라우팅하기 쉽다.
* 상세 시계열 지표는 Spring Batch 5 에 내장된 Micrometer 관측(spring.batch.job / spring.batch.step 타이머)을 쓰고,
* 이 리스너는 실행 단위 요약 한 줄만 담당한다.</p>
*
* <p>출력 예:</p>
* <pre>
* job=dailyStatsJob executionId=42 status=COMPLETED exitCode=COMPLETED durationMs=15320 steps=2 read=10000 written=9987 skipped=13 filtered=0
* </pre>
*
* @author 실행환경 개발팀
* @since 5.1
* @version 1.0
* <pre>
* 개정이력(Modification Information)
*
* 수정일 수정자 수정내용
* ----------------------------------------------
* 2026.09.10 실행환경 개발팀 최초 생성
* </pre>
*/
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();
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}