{
return "";
}
+ /**
+ * Reader 상태 초기화
+ * Job 재실행 시 이전 실행의 상태를 클리어하여 새로 데이터를 읽을 수 있도록 함
+ */
+ private void resetReaderState() {
+ // Chunk 모드 상태 초기화
+ this.currentBatch = null;
+ this.initialized = false;
+
+ // Legacy 모드 상태 초기화
+ this.legacyDataList = null;
+ this.legacyNextIndex = 0;
+
+ // 통계 초기화
+ this.totalApiCalls = 0;
+ this.completedApiCalls = 0;
+
+ // 하위 클래스 상태 초기화 훅 호출
+ resetCustomState();
+
+ log.debug("[{}] Reader 상태 초기화 완료", getReaderName());
+ }
+
+ /**
+ * 하위 클래스 커스텀 상태 초기화 훅
+ * Chunk 모드에서 사용하는 currentBatchIndex, allImoNumbers 등의 필드를 초기화할 때 오버라이드
+ *
+ * 예시:
+ *
+ * @Override
+ * protected void resetCustomState() {
+ * this.currentBatchIndex = 0;
+ * this.allImoNumbers = null;
+ * this.dbMasterHashes = null;
+ * }
+ *
+ */
+ protected void resetCustomState() {
+ // 기본 구현: 아무것도 하지 않음
+ // 하위 클래스에서 필요 시 오버라이드
+ }
+
/**
* API 호출 통계 업데이트
*/
diff --git a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java
index 4f0d7f9..e954a9c 100644
--- a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java
+++ b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java
@@ -64,7 +64,7 @@ public class MaritimeApiWebClientConfig {
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
- .maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
+ .maxInMemorySize(30 * 1024 * 1024)) // 30MB 버퍼
.build();
}
@@ -80,7 +80,7 @@ public class MaritimeApiWebClientConfig {
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
- .maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
+ .maxInMemorySize(50 * 1024 * 1024)) // 50MB 버퍼 (AIS GetTargets 응답 ~20MB+)
.build();
}
@@ -96,7 +96,7 @@ public class MaritimeApiWebClientConfig {
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
- .maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
+ .maxInMemorySize(30 * 1024 * 1024)) // 30MB 버퍼
.build();
}
}
diff --git a/src/main/java/com/snp/batch/global/partition/PartitionConfig.java b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java
new file mode 100644
index 0000000..60bc58f
--- /dev/null
+++ b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java
@@ -0,0 +1,50 @@
+package com.snp.batch.global.partition;
+
+import lombok.Getter;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 파티션 관리 대상 테이블 설정
+ *
+ * Daily 파티션: 매일 실행
+ * Monthly 파티션: 매월 말일에만 실행
+ */
+@Getter
+@Component
+public class PartitionConfig {
+
+ /**
+ * Daily 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM_DD)
+ */
+ private final List dailyPartitionTables = List.of(
+ // 추후 daily 파티션 테이블 추가
+ );
+
+ /**
+ * Monthly 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM)
+ */
+ private final List monthlyPartitionTables = List.of(
+ new PartitionTableInfo(
+ "snp_data",
+ "ais_target",
+ "message_timestamp",
+ 2 // 미리 생성할 개월 수
+ )
+ );
+
+ /**
+ * 파티션 테이블 정보
+ */
+ public record PartitionTableInfo(
+ String schema,
+ String tableName,
+ String partitionColumn,
+ int periodsAhead // 미리 생성할 기간 수 (daily: 일, monthly: 월)
+ ) {
+ public String getFullTableName() {
+ return schema + "." + tableName;
+ }
+ }
+}
diff --git a/src/main/java/com/snp/batch/global/partition/PartitionManagerJobConfig.java b/src/main/java/com/snp/batch/global/partition/PartitionManagerJobConfig.java
new file mode 100644
index 0000000..ca132c8
--- /dev/null
+++ b/src/main/java/com/snp/batch/global/partition/PartitionManagerJobConfig.java
@@ -0,0 +1,68 @@
+package com.snp.batch.global.partition;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobExecutionListener;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+/**
+ * 파티션 관리 Job Config
+ *
+ * 스케줄: 매일 00:10 (0 10 0 * * ?)
+ *
+ * 동작:
+ * - Daily 파티션: 매일 실행
+ * - Monthly 파티션: 매월 말일에만 실행 (Job 내부에서 말일 감지)
+ */
+@Slf4j
+@Configuration
+public class PartitionManagerJobConfig {
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final PartitionManagerTasklet partitionManagerTasklet;
+
+ public PartitionManagerJobConfig(
+ JobRepository jobRepository,
+ PlatformTransactionManager transactionManager,
+ PartitionManagerTasklet partitionManagerTasklet) {
+ this.jobRepository = jobRepository;
+ this.transactionManager = transactionManager;
+ this.partitionManagerTasklet = partitionManagerTasklet;
+ }
+
+ @Bean(name = "partitionManagerStep")
+ public Step partitionManagerStep() {
+ return new StepBuilder("partitionManagerStep", jobRepository)
+ .tasklet(partitionManagerTasklet, transactionManager)
+ .build();
+ }
+
+ @Bean(name = "partitionManagerJob")
+ public Job partitionManagerJob() {
+ log.info("Job 생성: partitionManagerJob");
+
+ return new JobBuilder("partitionManagerJob", jobRepository)
+ .listener(new JobExecutionListener() {
+ @Override
+ public void beforeJob(JobExecution jobExecution) {
+ log.info("[partitionManagerJob] 파티션 관리 Job 시작");
+ }
+
+ @Override
+ public void afterJob(JobExecution jobExecution) {
+ log.info("[partitionManagerJob] 파티션 관리 Job 완료 - 상태: {}",
+ jobExecution.getStatus());
+ }
+ })
+ .start(partitionManagerStep())
+ .build();
+ }
+}
diff --git a/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java
new file mode 100644
index 0000000..e904116
--- /dev/null
+++ b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java
@@ -0,0 +1,220 @@
+package com.snp.batch.global.partition;
+
+import com.snp.batch.global.partition.PartitionConfig.PartitionTableInfo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 파티션 관리 Tasklet
+ *
+ * 스케줄: 매일 실행
+ * - Daily 파티션: 매일 생성
+ * - Monthly 파티션: 매월 말일에만 생성
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PartitionManagerTasklet implements Tasklet {
+
+ private final JdbcTemplate jdbcTemplate;
+ private final PartitionConfig partitionConfig;
+
+ private static final String PARTITION_EXISTS_SQL = """
+ SELECT EXISTS (
+ SELECT 1 FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = ?
+ AND c.relname = ?
+ AND c.relkind = 'r'
+ )
+ """;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
+ LocalDate today = LocalDate.now();
+ boolean isLastDayOfMonth = isLastDayOfMonth(today);
+
+ log.info("========================================");
+ log.info("파티션 관리 Job 시작");
+ log.info("실행 일자: {}", today);
+ log.info("월 말일 여부: {}", isLastDayOfMonth);
+ log.info("========================================");
+
+ // Daily 파티션 처리 (매일)
+ processDailyPartitions(today);
+
+ // Monthly 파티션 처리 (매월 말일만)
+ if (isLastDayOfMonth) {
+ processMonthlyPartitions(today);
+ } else {
+ log.info("Monthly 파티션: 말일이 아니므로 스킵");
+ }
+
+ log.info("========================================");
+ log.info("파티션 관리 Job 완료");
+ log.info("========================================");
+
+ return RepeatStatus.FINISHED;
+ }
+
+ /**
+ * 매월 말일 여부 확인
+ */
+ private boolean isLastDayOfMonth(LocalDate date) {
+ return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth();
+ }
+
+ /**
+ * Daily 파티션 처리
+ */
+ private void processDailyPartitions(LocalDate today) {
+ List tables = partitionConfig.getDailyPartitionTables();
+
+ if (tables.isEmpty()) {
+ log.info("Daily 파티션: 대상 테이블 없음");
+ return;
+ }
+
+ log.info("Daily 파티션 처리 시작: {} 개 테이블", tables.size());
+
+ for (PartitionTableInfo table : tables) {
+ processDailyPartition(table, today);
+ }
+ }
+
+ /**
+ * 개별 Daily 파티션 생성
+ */
+ private void processDailyPartition(PartitionTableInfo table, LocalDate today) {
+ List created = new ArrayList<>();
+ List skipped = new ArrayList<>();
+
+ for (int i = 0; i <= table.periodsAhead(); i++) {
+ LocalDate targetDate = today.plusDays(i);
+ String partitionName = getDailyPartitionName(table.tableName(), targetDate);
+
+ if (partitionExists(table.schema(), partitionName)) {
+ skipped.add(partitionName);
+ } else {
+ createDailyPartition(table, targetDate, partitionName);
+ created.add(partitionName);
+ }
+ }
+
+ log.info("[{}] Daily 파티션 - 생성: {}, 스킵: {}",
+ table.tableName(), created.size(), skipped.size());
+ }
+
+ /**
+ * Monthly 파티션 처리
+ */
+ private void processMonthlyPartitions(LocalDate today) {
+ List tables = partitionConfig.getMonthlyPartitionTables();
+
+ if (tables.isEmpty()) {
+ log.info("Monthly 파티션: 대상 테이블 없음");
+ return;
+ }
+
+ log.info("Monthly 파티션 처리 시작: {} 개 테이블", tables.size());
+
+ for (PartitionTableInfo table : tables) {
+ processMonthlyPartition(table, today);
+ }
+ }
+
+ /**
+ * 개별 Monthly 파티션 생성
+ */
+ private void processMonthlyPartition(PartitionTableInfo table, LocalDate today) {
+ List created = new ArrayList<>();
+ List skipped = new ArrayList<>();
+
+ for (int i = 0; i <= table.periodsAhead(); i++) {
+ LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1);
+ String partitionName = getMonthlyPartitionName(table.tableName(), targetDate);
+
+ if (partitionExists(table.schema(), partitionName)) {
+ skipped.add(partitionName);
+ } else {
+ createMonthlyPartition(table, targetDate, partitionName);
+ created.add(partitionName);
+ }
+ }
+
+ log.info("[{}] Monthly 파티션 - 생성: {}, 스킵: {}",
+ table.tableName(), created.size(), skipped.size());
+ if (!created.isEmpty()) {
+ log.info("[{}] 생성된 파티션: {}", table.tableName(), created);
+ }
+ }
+
+ /**
+ * Daily 파티션 이름 생성 (table_YYYY_MM_DD)
+ */
+ private String getDailyPartitionName(String tableName, LocalDate date) {
+ return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM_dd"));
+ }
+
+ /**
+ * Monthly 파티션 이름 생성 (table_YYYY_MM)
+ */
+ private String getMonthlyPartitionName(String tableName, LocalDate date) {
+ return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM"));
+ }
+
+ /**
+ * 파티션 존재 여부 확인
+ */
+ private boolean partitionExists(String schema, String partitionName) {
+ Boolean exists = jdbcTemplate.queryForObject(PARTITION_EXISTS_SQL, Boolean.class, schema, partitionName);
+ return Boolean.TRUE.equals(exists);
+ }
+
+ /**
+ * Daily 파티션 생성
+ */
+ private void createDailyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) {
+ LocalDate endDate = targetDate.plusDays(1);
+
+ String sql = String.format("""
+ CREATE TABLE %s.%s PARTITION OF %s
+ FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
+ """,
+ table.schema(), partitionName, table.getFullTableName(),
+ targetDate, endDate);
+
+ jdbcTemplate.execute(sql);
+ log.debug("Daily 파티션 생성: {}", partitionName);
+ }
+
+ /**
+ * Monthly 파티션 생성
+ */
+ private void createMonthlyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) {
+ LocalDate startDate = targetDate.withDayOfMonth(1);
+ LocalDate endDate = startDate.plusMonths(1);
+
+ String sql = String.format("""
+ CREATE TABLE %s.%s PARTITION OF %s
+ FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
+ """,
+ table.schema(), partitionName, table.getFullTableName(),
+ startDate, endDate);
+
+ jdbcTemplate.execute(sql);
+ log.debug("Monthly 파티션 생성: {}", partitionName);
+ }
+}
diff --git a/src/main/java/com/snp/batch/global/repository/TimelineRepository.java b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java
index 2c28809..7b7b7f5 100644
--- a/src/main/java/com/snp/batch/global/repository/TimelineRepository.java
+++ b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java
@@ -1,6 +1,6 @@
package com.snp.batch.global.repository;
-import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@@ -13,10 +13,25 @@ import java.util.Map;
* Step Context 등 불필요한 데이터를 조회하지 않고 필요한 정보만 가져옴
*/
@Repository
-@RequiredArgsConstructor
public class TimelineRepository {
private final JdbcTemplate jdbcTemplate;
+ private final String tablePrefix;
+
+ public TimelineRepository(
+ JdbcTemplate jdbcTemplate,
+ @Value("${spring.batch.jdbc.table-prefix:BATCH_}") String tablePrefix) {
+ this.jdbcTemplate = jdbcTemplate;
+ this.tablePrefix = tablePrefix;
+ }
+
+ private String getJobExecutionTable() {
+ return tablePrefix + "JOB_EXECUTION";
+ }
+
+ private String getJobInstanceTable() {
+ return tablePrefix + "JOB_INSTANCE";
+ }
/**
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
@@ -27,19 +42,19 @@ public class TimelineRepository {
LocalDateTime startTime,
LocalDateTime endTime) {
- String sql = """
+ String sql = String.format("""
SELECT
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime
- FROM BATCH_JOB_EXECUTION je
- INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
+ FROM %s je
+ INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE ji.JOB_NAME = ?
AND je.START_TIME >= ?
AND je.START_TIME < ?
ORDER BY je.START_TIME DESC
- """;
+ """, getJobExecutionTable(), getJobInstanceTable());
return jdbcTemplate.queryForList(sql, jobName, startTime, endTime);
}
@@ -51,19 +66,19 @@ public class TimelineRepository {
LocalDateTime startTime,
LocalDateTime endTime) {
- String sql = """
+ String sql = String.format("""
SELECT
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime
- FROM BATCH_JOB_EXECUTION je
- INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
+ FROM %s je
+ INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE je.START_TIME >= ?
AND je.START_TIME < ?
ORDER BY ji.JOB_NAME, je.START_TIME DESC
- """;
+ """, getJobExecutionTable(), getJobInstanceTable());
return jdbcTemplate.queryForList(sql, startTime, endTime);
}
@@ -72,17 +87,17 @@ public class TimelineRepository {
* 현재 실행 중인 Job 조회 (STARTED, STARTING 상태)
*/
public List