From 55d4dd58860010485134c5869b0d80e04c8a1bac Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Thu, 4 Dec 2025 13:05:00 +0900 Subject: [PATCH] =?UTF-8?q?[=EC=88=98=EC=A0=95]=20-=20=ED=8C=8C=ED=8B=B0?= =?UTF-8?q?=EC=85=98=20=EA=B4=80=EB=A6=AC=20job=20=EC=B6=94=EA=B0=80=20(+3?= =?UTF-8?q?=EC=9D=BC=20=EB=AF=B8=EB=A6=AC=20=EC=83=9D=EC=84=B1,=2014?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=EC=A0=84=20=ED=8C=8C=ED=8B=B0=EC=85=98=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99drop=20=EC=84=A4=EC=A0=95)=20-=20(=EC=9E=84?= =?UTF-8?q?=EC=8B=9C)=20GPU=20=EC=9A=B4=EC=98=81=20=ED=8F=AC=ED=8A=B8=2090?= =?UTF-8?q?00=EB=B2=88=20=EB=B3=80=EA=B2=BD=20-=20ais=5Ftarget=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=9D=BC=EC=9D=BC=20=ED=8C=8C=ED=8B=B0?= =?UTF-8?q?=EC=85=98=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(1?= =?UTF-8?q?=EC=9D=BC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=95=BD=2020GB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/global/config/SwaggerConfig.java | 16 +- .../global/partition/PartitionConfig.java | 129 ++++++-- .../partition/PartitionManagerTasklet.java | 296 +++++++++++++++--- .../repository/AisTargetRepositoryImpl.java | 6 +- src/main/resources/application-prod.yml | 24 +- src/main/resources/application.yml | 22 +- src/main/resources/sql/ais_target_ddl.sql | 153 ++++----- 7 files changed, 459 insertions(+), 187 deletions(-) diff --git a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java index 5867db9..8d4e911 100644 --- a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -30,23 +30,29 @@ public class SwaggerConfig { @Value("${server.port:8081}") private int serverPort; + @Value("${server.servlet.context-path:}") + private String contextPath; + @Bean public OpenAPI openAPI() { return new OpenAPI() .info(apiInfo()) .servers(List.of( new Server() - .url("http://localhost:" + serverPort) + .url("http://localhost:" + serverPort + contextPath) .description("로컬 개발 서버"), new Server() - .url("http://10.26.252.39:" + serverPort) + .url("http://10.26.252.39:" + serverPort + contextPath) .description("로컬 개발 서버"), new Server() - .url("http://211.208.115.83:" + serverPort) + .url("http://211.208.115.83:" + serverPort + contextPath) .description("중계 서버"), new Server() - .url("http://10.187.58.58:" + serverPort) - .description("운영 서버") + .url("http://10.187.58.58:" + serverPort + contextPath) + .description("운영 서버"), + new Server() + .url("https://mda.kcg.go.kr" + contextPath) + .description("운영 서버 프록시") )); } diff --git a/src/main/java/com/snp/batch/global/partition/PartitionConfig.java b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java index 60bc58f..33fc195 100644 --- a/src/main/java/com/snp/batch/global/partition/PartitionConfig.java +++ b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java @@ -1,50 +1,133 @@ package com.snp.batch.global.partition; import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; /** - * 파티션 관리 대상 테이블 설정 + * 파티션 관리 설정 (application.yml 기반) * - * Daily 파티션: 매일 실행 - * Monthly 파티션: 매월 말일에만 실행 + * 설정 예시: + * app.batch.partition: + * daily-tables: + * - schema: snp_data + * table-name: ais_target + * partition-column: message_timestamp + * periods-ahead: 3 + * monthly-tables: + * - schema: snp_data + * table-name: some_table + * partition-column: created_at + * periods-ahead: 2 + * retention: + * daily-default-days: 14 + * monthly-default-months: 1 + * custom: + * - table-name: ais_target + * retention-days: 30 */ @Getter +@Setter @Component +@ConfigurationProperties(prefix = "app.batch.partition") public class PartitionConfig { /** - * Daily 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM_DD) + * 일별 파티션 테이블 목록 (파티션 네이밍: {table}_YYMMDD) */ - private final List dailyPartitionTables = List.of( - // 추후 daily 파티션 테이블 추가 - ); + private List dailyTables = new ArrayList<>(); /** - * Monthly 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM) + * 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM) */ - private final List monthlyPartitionTables = List.of( - new PartitionTableInfo( - "snp_data", - "ais_target", - "message_timestamp", - 2 // 미리 생성할 개월 수 - ) - ); + private List monthlyTables = new ArrayList<>(); /** - * 파티션 테이블 정보 + * 보관기간 설정 */ - public record PartitionTableInfo( - String schema, - String tableName, - String partitionColumn, - int periodsAhead // 미리 생성할 기간 수 (daily: 일, monthly: 월) - ) { + private RetentionConfig retention = new RetentionConfig(); + + /** + * 파티션 테이블 설정 + */ + @Getter + @Setter + public static class PartitionTableConfig { + private String schema = "snp_data"; + private String tableName; + private String partitionColumn; + private int periodsAhead = 3; // 미리 생성할 기간 수 (daily: 일, monthly: 월) + public String getFullTableName() { return schema + "." + tableName; } } + + /** + * 보관기간 설정 + */ + @Getter + @Setter + public static class RetentionConfig { + /** + * 일별 파티션 기본 보관기간 (일) + */ + private int dailyDefaultDays = 14; + + /** + * 월별 파티션 기본 보관기간 (개월) + */ + private int monthlyDefaultMonths = 1; + + /** + * 개별 테이블 보관기간 설정 + */ + private List custom = new ArrayList<>(); + } + + /** + * 개별 테이블 보관기간 설정 + */ + @Getter + @Setter + public static class CustomRetention { + private String tableName; + private Integer retentionDays; // 일 단위 보관기간 (일별 파티션용) + private Integer retentionMonths; // 월 단위 보관기간 (월별 파티션용) + } + + /** + * 일별 파티션 테이블의 보관기간 조회 (일 단위) + */ + public int getDailyRetentionDays(String tableName) { + return getCustomRetention(tableName) + .map(c -> c.getRetentionDays() != null ? c.getRetentionDays() : retention.getDailyDefaultDays()) + .orElse(retention.getDailyDefaultDays()); + } + + /** + * 월별 파티션 테이블의 보관기간 조회 (월 단위) + */ + public int getMonthlyRetentionMonths(String tableName) { + return getCustomRetention(tableName) + .map(c -> c.getRetentionMonths() != null ? c.getRetentionMonths() : retention.getMonthlyDefaultMonths()) + .orElse(retention.getMonthlyDefaultMonths()); + } + + /** + * 개별 테이블 보관기간 설정 조회 + */ + private Optional getCustomRetention(String tableName) { + if (retention.getCustom() == null) { + return Optional.empty(); + } + return retention.getCustom().stream() + .filter(c -> tableName.equals(c.getTableName())) + .findFirst(); + } } diff --git a/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java index e904116..a6918ed 100644 --- a/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java +++ b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java @@ -1,6 +1,6 @@ package com.snp.batch.global.partition; -import com.snp.batch.global.partition.PartitionConfig.PartitionTableInfo; +import com.snp.batch.global.partition.PartitionConfig.PartitionTableConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.StepContribution; @@ -20,8 +20,12 @@ import java.util.List; * 파티션 관리 Tasklet * * 스케줄: 매일 실행 - * - Daily 파티션: 매일 생성 - * - Monthly 파티션: 매월 말일에만 생성 + * - Daily 파티션: 매일 생성/삭제 (네이밍: {table}_YYMMDD) + * - Monthly 파티션: 매월 말일에만 생성/삭제 (네이밍: {table}_YYYY_MM) + * + * 보관기간: + * - 기본값: 일별 14일, 월별 1개월 + * - 개별 테이블별 보관기간 설정 가능 (application.yml) */ @Slf4j @Component @@ -31,6 +35,9 @@ public class PartitionManagerTasklet implements Tasklet { private final JdbcTemplate jdbcTemplate; private final PartitionConfig partitionConfig; + private static final DateTimeFormatter DAILY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyMMdd"); + private static final DateTimeFormatter MONTHLY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyyy_MM"); + private static final String PARTITION_EXISTS_SQL = """ SELECT EXISTS ( SELECT 1 FROM pg_class c @@ -41,6 +48,17 @@ public class PartitionManagerTasklet implements Tasklet { ) """; + private static final String FIND_PARTITIONS_SQL = """ + SELECT c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_inherits i ON i.inhrelid = c.oid + WHERE n.nspname = ? + AND c.relname LIKE ? + AND c.relkind = 'r' + ORDER BY c.relname + """; + @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { LocalDate today = LocalDate.now(); @@ -52,14 +70,24 @@ public class PartitionManagerTasklet implements Tasklet { log.info("월 말일 여부: {}", isLastDayOfMonth); log.info("========================================"); - // Daily 파티션 처리 (매일) - processDailyPartitions(today); + // 1. Daily 파티션 생성 (매일) + createDailyPartitions(today); - // Monthly 파티션 처리 (매월 말일만) + // 2. Daily 파티션 삭제 (보관기간 초과분) + deleteDailyPartitions(today); + + // 3. Monthly 파티션 생성 (매월 말일만) if (isLastDayOfMonth) { - processMonthlyPartitions(today); + createMonthlyPartitions(today); } else { - log.info("Monthly 파티션: 말일이 아니므로 스킵"); + log.info("Monthly 파티션 생성: 말일이 아니므로 스킵"); + } + + // 4. Monthly 파티션 삭제 (매월 1일에만, 보관기간 초과분) + if (today.getDayOfMonth() == 1) { + deleteMonthlyPartitions(today); + } else { + log.info("Monthly 파티션 삭제: 1일이 아니므로 스킵"); } log.info("========================================"); @@ -76,36 +104,38 @@ public class PartitionManagerTasklet implements Tasklet { return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth(); } - /** - * Daily 파티션 처리 - */ - private void processDailyPartitions(LocalDate today) { - List tables = partitionConfig.getDailyPartitionTables(); + // ==================== Daily 파티션 생성 ==================== - if (tables.isEmpty()) { - log.info("Daily 파티션: 대상 테이블 없음"); + /** + * Daily 파티션 생성 + */ + private void createDailyPartitions(LocalDate today) { + List tables = partitionConfig.getDailyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Daily 파티션 생성: 대상 테이블 없음"); return; } - log.info("Daily 파티션 처리 시작: {} 개 테이블", tables.size()); + log.info("Daily 파티션 생성 시작: {} 개 테이블", tables.size()); - for (PartitionTableInfo table : tables) { - processDailyPartition(table, today); + for (PartitionTableConfig table : tables) { + createDailyPartitionsForTable(table, today); } } /** - * 개별 Daily 파티션 생성 + * 개별 테이블 Daily 파티션 생성 */ - private void processDailyPartition(PartitionTableInfo table, LocalDate today) { + private void createDailyPartitionsForTable(PartitionTableConfig table, LocalDate today) { List created = new ArrayList<>(); List skipped = new ArrayList<>(); - for (int i = 0; i <= table.periodsAhead(); i++) { + for (int i = 0; i <= table.getPeriodsAhead(); i++) { LocalDate targetDate = today.plusDays(i); - String partitionName = getDailyPartitionName(table.tableName(), targetDate); + String partitionName = getDailyPartitionName(table.getTableName(), targetDate); - if (partitionExists(table.schema(), partitionName)) { + if (partitionExists(table.getSchema(), partitionName)) { skipped.add(partitionName); } else { createDailyPartition(table, targetDate, partitionName); @@ -113,40 +143,97 @@ public class PartitionManagerTasklet implements Tasklet { } } - log.info("[{}] Daily 파티션 - 생성: {}, 스킵: {}", - table.tableName(), created.size(), skipped.size()); + log.info("[{}] Daily 파티션 생성 - 생성: {}, 스킵: {}", + table.getTableName(), created.size(), skipped.size()); + if (!created.isEmpty()) { + log.info("[{}] 생성된 파티션: {}", table.getTableName(), created); + } } - /** - * Monthly 파티션 처리 - */ - private void processMonthlyPartitions(LocalDate today) { - List tables = partitionConfig.getMonthlyPartitionTables(); + // ==================== Daily 파티션 삭제 ==================== - if (tables.isEmpty()) { - log.info("Monthly 파티션: 대상 테이블 없음"); + /** + * Daily 파티션 삭제 (보관기간 초과분) + */ + private void deleteDailyPartitions(LocalDate today) { + List tables = partitionConfig.getDailyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Daily 파티션 삭제: 대상 테이블 없음"); return; } - log.info("Monthly 파티션 처리 시작: {} 개 테이블", tables.size()); + log.info("Daily 파티션 삭제 시작: {} 개 테이블", tables.size()); - for (PartitionTableInfo table : tables) { - processMonthlyPartition(table, today); + for (PartitionTableConfig table : tables) { + int retentionDays = partitionConfig.getDailyRetentionDays(table.getTableName()); + deleteDailyPartitionsForTable(table, today, retentionDays); } } /** - * 개별 Monthly 파티션 생성 + * 개별 테이블 Daily 파티션 삭제 */ - private void processMonthlyPartition(PartitionTableInfo table, LocalDate today) { + private void deleteDailyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionDays) { + LocalDate cutoffDate = today.minusDays(retentionDays); + String likePattern = table.getTableName() + "_%"; + + List partitions = jdbcTemplate.queryForList( + FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern); + + List deleted = new ArrayList<>(); + + for (String partitionName : partitions) { + // 파티션 이름에서 날짜 추출 (table_YYMMDD) + LocalDate partitionDate = parseDailyPartitionDate(table.getTableName(), partitionName); + if (partitionDate != null && partitionDate.isBefore(cutoffDate)) { + dropPartition(table.getSchema(), partitionName); + deleted.add(partitionName); + } + } + + if (!deleted.isEmpty()) { + log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제: {} 개", + table.getTableName(), retentionDays, deleted.size()); + log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted); + } else { + log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제할 파티션 없음", + table.getTableName(), retentionDays); + } + } + + // ==================== Monthly 파티션 생성 ==================== + + /** + * Monthly 파티션 생성 + */ + private void createMonthlyPartitions(LocalDate today) { + List tables = partitionConfig.getMonthlyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Monthly 파티션 생성: 대상 테이블 없음"); + return; + } + + log.info("Monthly 파티션 생성 시작: {} 개 테이블", tables.size()); + + for (PartitionTableConfig table : tables) { + createMonthlyPartitionsForTable(table, today); + } + } + + /** + * 개별 테이블 Monthly 파티션 생성 + */ + private void createMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today) { List created = new ArrayList<>(); List skipped = new ArrayList<>(); - for (int i = 0; i <= table.periodsAhead(); i++) { + for (int i = 0; i <= table.getPeriodsAhead(); i++) { LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1); - String partitionName = getMonthlyPartitionName(table.tableName(), targetDate); + String partitionName = getMonthlyPartitionName(table.getTableName(), targetDate); - if (partitionExists(table.schema(), partitionName)) { + if (partitionExists(table.getSchema(), partitionName)) { skipped.add(partitionName); } else { createMonthlyPartition(table, targetDate, partitionName); @@ -154,27 +241,127 @@ public class PartitionManagerTasklet implements Tasklet { } } - log.info("[{}] Monthly 파티션 - 생성: {}, 스킵: {}", - table.tableName(), created.size(), skipped.size()); + log.info("[{}] Monthly 파티션 생성 - 생성: {}, 스킵: {}", + table.getTableName(), created.size(), skipped.size()); if (!created.isEmpty()) { - log.info("[{}] 생성된 파티션: {}", table.tableName(), created); + log.info("[{}] 생성된 파티션: {}", table.getTableName(), created); + } + } + + // ==================== Monthly 파티션 삭제 ==================== + + /** + * Monthly 파티션 삭제 (보관기간 초과분) + */ + private void deleteMonthlyPartitions(LocalDate today) { + List tables = partitionConfig.getMonthlyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Monthly 파티션 삭제: 대상 테이블 없음"); + return; + } + + log.info("Monthly 파티션 삭제 시작: {} 개 테이블", tables.size()); + + for (PartitionTableConfig table : tables) { + int retentionMonths = partitionConfig.getMonthlyRetentionMonths(table.getTableName()); + deleteMonthlyPartitionsForTable(table, today, retentionMonths); } } /** - * Daily 파티션 이름 생성 (table_YYYY_MM_DD) + * 개별 테이블 Monthly 파티션 삭제 + */ + private void deleteMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionMonths) { + LocalDate cutoffDate = today.minusMonths(retentionMonths).withDayOfMonth(1); + String likePattern = table.getTableName() + "_%"; + + List partitions = jdbcTemplate.queryForList( + FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern); + + List deleted = new ArrayList<>(); + + for (String partitionName : partitions) { + // 파티션 이름에서 날짜 추출 (table_YYYY_MM) + LocalDate partitionDate = parseMonthlyPartitionDate(table.getTableName(), partitionName); + if (partitionDate != null && partitionDate.isBefore(cutoffDate)) { + dropPartition(table.getSchema(), partitionName); + deleted.add(partitionName); + } + } + + if (!deleted.isEmpty()) { + log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제: {} 개", + table.getTableName(), retentionMonths, deleted.size()); + log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted); + } else { + log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제할 파티션 없음", + table.getTableName(), retentionMonths); + } + } + + // ==================== 파티션 이름 생성 ==================== + + /** + * Daily 파티션 이름 생성 (table_YYMMDD) */ private String getDailyPartitionName(String tableName, LocalDate date) { - return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM_dd")); + return tableName + "_" + date.format(DAILY_PARTITION_FORMAT); } /** * Monthly 파티션 이름 생성 (table_YYYY_MM) */ private String getMonthlyPartitionName(String tableName, LocalDate date) { - return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM")); + return tableName + "_" + date.format(MONTHLY_PARTITION_FORMAT); } + // ==================== 파티션 이름에서 날짜 추출 ==================== + + /** + * Daily 파티션 이름에서 날짜 추출 (table_YYMMDD -> LocalDate) + */ + private LocalDate parseDailyPartitionDate(String tableName, String partitionName) { + try { + String prefix = tableName + "_"; + if (!partitionName.startsWith(prefix)) { + return null; + } + String dateStr = partitionName.substring(prefix.length()); + // YYMMDD 형식 (6자리) + if (dateStr.length() == 6 && dateStr.matches("\\d{6}")) { + return LocalDate.parse(dateStr, DAILY_PARTITION_FORMAT); + } + return null; + } catch (Exception e) { + log.trace("파티션 날짜 파싱 실패: {}", partitionName); + return null; + } + } + + /** + * Monthly 파티션 이름에서 날짜 추출 (table_YYYY_MM -> LocalDate) + */ + private LocalDate parseMonthlyPartitionDate(String tableName, String partitionName) { + try { + String prefix = tableName + "_"; + if (!partitionName.startsWith(prefix)) { + return null; + } + String dateStr = partitionName.substring(prefix.length()); + // YYYY_MM 형식 (7자리) + if (dateStr.length() == 7 && dateStr.matches("\\d{4}_\\d{2}")) { + return LocalDate.parse(dateStr + "_01", DateTimeFormatter.ofPattern("yyyy_MM_dd")); + } + return null; + } catch (Exception e) { + log.trace("파티션 날짜 파싱 실패: {}", partitionName); + return null; + } + } + + // ==================== DB 작업 ==================== + /** * 파티션 존재 여부 확인 */ @@ -186,14 +373,14 @@ public class PartitionManagerTasklet implements Tasklet { /** * Daily 파티션 생성 */ - private void createDailyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) { + private void createDailyPartition(PartitionTableConfig 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(), + table.getSchema(), partitionName, table.getFullTableName(), targetDate, endDate); jdbcTemplate.execute(sql); @@ -203,7 +390,7 @@ public class PartitionManagerTasklet implements Tasklet { /** * Monthly 파티션 생성 */ - private void createMonthlyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) { + private void createMonthlyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) { LocalDate startDate = targetDate.withDayOfMonth(1); LocalDate endDate = startDate.plusMonths(1); @@ -211,10 +398,19 @@ public class PartitionManagerTasklet implements Tasklet { 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(), + table.getSchema(), partitionName, table.getFullTableName(), startDate, endDate); jdbcTemplate.execute(sql); log.debug("Monthly 파티션 생성: {}", partitionName); } + + /** + * 파티션 삭제 + */ + private void dropPartition(String schema, String partitionName) { + String sql = String.format("DROP TABLE IF EXISTS %s.%s", schema, partitionName); + jdbcTemplate.execute(sql); + log.debug("파티션 삭제: {}", partitionName); + } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java index d70e02b..98204b3 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java @@ -46,7 +46,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { received_date, collected_at, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, - ?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326), + ?, ?, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -203,9 +203,9 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { SELECT DISTINCT ON (mmsi) * FROM %s WHERE message_timestamp BETWEEN ? AND ? - AND ST_DWithin( + AND public.ST_DWithin( geom::geography, - ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, + public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326)::geography, ? ) ORDER BY mmsi, message_timestamp DESC diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 0c1d440..36d6937 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -4,7 +4,7 @@ spring: # PostgreSQL Database Configuration datasource: - url: jdbc:postgresql://10.187.58.58:5432/mdadb?currentSchema=snp_data + url: jdbc:postgresql://10.187.58.58:5432/mdadb?currentSchema=snp_data,public username: mda password: mda#8932 driver-class-name: org.postgresql.Driver @@ -28,7 +28,7 @@ spring: batch: jdbc: table-prefix: "snp_data.batch_" - initialize-schema: always # Changed to 'never' as tables already exist + initialize-schema: never # Changed to 'never' as tables already exist job: enabled: false # Prevent auto-run on startup @@ -42,7 +42,7 @@ spring: quartz: job-store-type: jdbc # JDBC store for schedule persistence jdbc: - initialize-schema: always # Create Quartz tables if not exist + initialize-schema: never # Create Quartz tables if not exist properties: org.quartz.scheduler.instanceName: SNPBatchScheduler org.quartz.scheduler.instanceId: AUTO @@ -55,9 +55,9 @@ spring: # Server Configuration server: - port: 8041 + port: 9000 servlet: - context-path: / + context-path: /snp-api # Actuator Configuration management: @@ -69,18 +69,10 @@ management: health: show-details: always -# Logging Configuration + +# Logging Configuration (logback-spring.xml에서 상세 설정) logging: - level: - root: INFO - com.snp.batch: DEBUG - org.springframework.batch: DEBUG - org.springframework.jdbc: DEBUG - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" - file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" - file: - name: logs/snp-batch.log + config: classpath:logback-spring.xml # Custom Application Properties app: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e57e57e..daaab5f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -91,15 +91,33 @@ app: schedule: enabled: true cron: "0 0 * * * ?" # Every hour + # AIS Target 배치 설정 ais-target: since-seconds: 60 # API 조회 범위 (초) chunk-size: 5000 # 배치 청크 크기 schedule: cron: "15 * * * * ?" # 매 분 15초 실행 - partition: - months-ahead: 2 # 미리 생성할 파티션 개월 수 # AIS Target 캐시 설정 ais-target-cache: ttl-minutes: 120 # 캐시 TTL (분) - 2시간 max-size: 300000 # 최대 캐시 크기 - 30만 건 + + # 파티션 관리 설정 + partition: + # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) + daily-tables: + - schema: snp_data + table-name: ais_target + partition-column: message_timestamp + periods-ahead: 3 # 미리 생성할 일수 + # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM) + monthly-tables: [] # 현재 없음 + # 기본 보관기간 + retention: + daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일) + monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월) + # 개별 테이블 보관기간 설정 (옵션) + custom: + # - table-name: ais_target + # retention-days: 30 # ais_target만 30일 보관 diff --git a/src/main/resources/sql/ais_target_ddl.sql b/src/main/resources/sql/ais_target_ddl.sql index 8e34732..a56ee09 100644 --- a/src/main/resources/sql/ais_target_ddl.sql +++ b/src/main/resources/sql/ais_target_ddl.sql @@ -3,8 +3,8 @@ -- ============================================ -- 용도: 선박 AIS 위치 정보 저장 (항적 분석용) -- 수집 주기: 매 분 15초 --- 예상 데이터량: 약 33,000건/분 --- 파티셔닝: 월별 파티션 (ais_target_YYYY_MM) +-- 예상 데이터량: 약 33,000건/분, 일 20GB (인덱스 포함) +-- 파티셔닝: 일별 파티션 (ais_target_YYMMDD) -- ============================================ -- PostGIS 확장 활성화 (이미 설치되어 있다면 생략) @@ -77,18 +77,26 @@ CREATE TABLE IF NOT EXISTS snp_data.ais_target ( ) PARTITION BY RANGE (message_timestamp); -- ============================================ --- 2. 초기 파티션 생성 (현재 월 + 다음 월) +-- 2. 초기 파티션 생성 (현재 일 + 다음 3일) -- ============================================ --- 예: 2025년 12월과 2026년 1월 파티션 --- 실제 운영 시 create_ais_target_partition 함수로 자동 생성 +-- 파티션 네이밍: ais_target_YYMMDD +-- 실제 운영 시 partitionManagerJob에서 자동 생성 --- 2025년 12월 파티션 -CREATE TABLE IF NOT EXISTS snp_data.ais_target_2025_12 PARTITION OF snp_data.ais_target - FOR VALUES FROM ('2025-12-01 00:00:00+00') TO ('2026-01-01 00:00:00+00'); +-- 2024년 12월 4일 파티션 (예시) +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241204 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-04 00:00:00+00') TO ('2024-12-05 00:00:00+00'); --- 2026년 1월 파티션 -CREATE TABLE IF NOT EXISTS snp_data.ais_target_2026_01 PARTITION OF snp_data.ais_target - FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00'); +-- 2024년 12월 5일 파티션 +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241205 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-05 00:00:00+00') TO ('2024-12-06 00:00:00+00'); + +-- 2024년 12월 6일 파티션 +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241206 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-06 00:00:00+00') TO ('2024-12-07 00:00:00+00'); + +-- 2024년 12월 7일 파티션 +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241207 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-07 00:00:00+00') TO ('2024-12-08 00:00:00+00'); -- ============================================ -- 3. 인덱스 생성 (각 파티션에 자동 상속) @@ -120,7 +128,7 @@ CREATE INDEX IF NOT EXISTS idx_ais_target_collected_at ON snp_data.ais_target (collected_at DESC); -- ============================================ --- 4. 파티션 자동 생성 함수 +-- 4. 파티션 자동 생성 함수 (일별) -- ============================================ -- 파티션 존재 여부 확인 함수 @@ -137,8 +145,8 @@ BEGIN END; $$ LANGUAGE plpgsql; --- 특정 월의 파티션 생성 함수 -CREATE OR REPLACE FUNCTION snp_data.create_ais_target_partition(target_date DATE) +-- 특정 일의 파티션 생성 함수 +CREATE OR REPLACE FUNCTION snp_data.create_ais_target_daily_partition(target_date DATE) RETURNS TEXT AS $$ DECLARE partition_name TEXT; @@ -146,12 +154,12 @@ DECLARE end_date DATE; create_sql TEXT; BEGIN - -- 파티션 이름 생성: ais_target_YYYY_MM - partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); + -- 파티션 이름 생성: ais_target_YYMMDD + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); -- 시작/종료 날짜 계산 - start_date := DATE_TRUNC('month', target_date)::DATE; - end_date := (DATE_TRUNC('month', target_date) + INTERVAL '1 month')::DATE; + start_date := target_date; + end_date := target_date + INTERVAL '1 day'; -- 이미 존재하면 스킵 IF snp_data.partition_exists(partition_name) THEN @@ -175,18 +183,18 @@ BEGIN END; $$ LANGUAGE plpgsql; --- 다음 N개월 파티션 사전 생성 함수 -CREATE OR REPLACE FUNCTION snp_data.create_future_ais_target_partitions(months_ahead INTEGER DEFAULT 2) +-- 다음 N일 파티션 사전 생성 함수 +CREATE OR REPLACE FUNCTION snp_data.create_future_ais_target_daily_partitions(days_ahead INTEGER DEFAULT 3) RETURNS TABLE (partition_name TEXT, status TEXT) AS $$ DECLARE i INTEGER; target_date DATE; result TEXT; BEGIN - FOR i IN 0..months_ahead LOOP - target_date := DATE_TRUNC('month', CURRENT_DATE + (i || ' months')::INTERVAL)::DATE; - result := snp_data.create_ais_target_partition(target_date); - partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); + FOR i IN 0..days_ahead LOOP + target_date := CURRENT_DATE + i; + result := snp_data.create_ais_target_daily_partition(target_date); + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); status := result; RETURN NEXT; END LOOP; @@ -194,17 +202,17 @@ END; $$ LANGUAGE plpgsql; -- ============================================ --- 5. 오래된 파티션 삭제 함수 +-- 5. 오래된 파티션 삭제 함수 (일별) -- ============================================ --- 특정 월의 파티션 삭제 함수 -CREATE OR REPLACE FUNCTION snp_data.drop_ais_target_partition(target_date DATE) +-- 특정 일의 파티션 삭제 함수 +CREATE OR REPLACE FUNCTION snp_data.drop_ais_target_daily_partition(target_date DATE) RETURNS TEXT AS $$ DECLARE partition_name TEXT; drop_sql TEXT; BEGIN - partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); -- 존재하지 않으면 스킵 IF NOT snp_data.partition_exists(partition_name) THEN @@ -221,17 +229,17 @@ BEGIN END; $$ LANGUAGE plpgsql; --- N개월 이전 파티션 정리 함수 -CREATE OR REPLACE FUNCTION snp_data.cleanup_old_ais_target_partitions(retention_months INTEGER DEFAULT 3) +-- N일 이전 파티션 정리 함수 +CREATE OR REPLACE FUNCTION snp_data.cleanup_old_ais_target_daily_partitions(retention_days INTEGER DEFAULT 14) RETURNS TABLE (partition_name TEXT, status TEXT) AS $$ DECLARE rec RECORD; partition_date DATE; cutoff_date DATE; BEGIN - cutoff_date := DATE_TRUNC('month', CURRENT_DATE - (retention_months || ' months')::INTERVAL)::DATE; + cutoff_date := CURRENT_DATE - retention_days; - -- ais_target_YYYY_MM 패턴의 파티션 조회 + -- ais_target_YYMMDD 패턴의 파티션 조회 FOR rec IN SELECT c.relname FROM pg_class c @@ -239,12 +247,13 @@ BEGIN JOIN pg_inherits i ON i.inhrelid = c.oid WHERE n.nspname = 'snp_data' AND c.relname LIKE 'ais_target_%' + AND LENGTH(c.relname) = 17 -- ais_target_YYMMDD = 17자 AND c.relkind = 'r' ORDER BY c.relname LOOP - -- 파티션 이름에서 날짜 추출 (ais_target_YYYY_MM) + -- 파티션 이름에서 날짜 추출 (ais_target_YYMMDD) BEGIN - partition_date := TO_DATE(SUBSTRING(rec.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM'); + partition_date := TO_DATE(SUBSTRING(rec.relname FROM 'ais_target_(\d{6})'), 'YYMMDD'); IF partition_date < cutoff_date THEN EXECUTE format('DROP TABLE snp_data.%I', rec.relname); @@ -261,8 +270,8 @@ BEGIN END; $$ LANGUAGE plpgsql; --- 파티션별 통계 조회 함수 -CREATE OR REPLACE FUNCTION snp_data.ais_target_partition_stats() +-- 파티션별 통계 조회 함수 (일별) +CREATE OR REPLACE FUNCTION snp_data.ais_target_daily_partition_stats() RETURNS TABLE ( partition_name TEXT, row_count BIGINT, @@ -273,11 +282,7 @@ BEGIN RETURN QUERY SELECT c.relname::TEXT as partition_name, - (SELECT COUNT(*)::BIGINT FROM snp_data.ais_target WHERE message_timestamp >= - TO_DATE(SUBSTRING(c.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM') - AND message_timestamp < - TO_DATE(SUBSTRING(c.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM') + INTERVAL '1 month' - ) as row_count, + (pg_stat_get_live_tuples(c.oid))::BIGINT as row_count, pg_relation_size(c.oid) as size_bytes, pg_size_pretty(pg_relation_size(c.oid)) as size_pretty FROM pg_class c @@ -294,7 +299,7 @@ $$ LANGUAGE plpgsql; -- 6. 코멘트 -- ============================================ -COMMENT ON TABLE snp_data.ais_target IS 'AIS 선박 위치 정보 (매 분 15초 수집, 월별 파티션)'; +COMMENT ON TABLE snp_data.ais_target IS 'AIS 선박 위치 정보 (매 분 15초 수집, 일별 파티션 - ais_target_YYMMDD)'; COMMENT ON COLUMN snp_data.ais_target.mmsi IS 'Maritime Mobile Service Identity (복합 PK)'; COMMENT ON COLUMN snp_data.ais_target.message_timestamp IS 'AIS 메시지 발생 시간 (복합 PK, 파티션 키)'; @@ -308,33 +313,6 @@ COMMENT ON COLUMN snp_data.ais_target.draught IS '흘수 (meters)'; COMMENT ON COLUMN snp_data.ais_target.collected_at IS '배치 수집 시점'; COMMENT ON COLUMN snp_data.ais_target.received_date IS 'API 수신 시간'; --- ============================================ --- 유지보수용 함수: 오래된 데이터 정리 --- ============================================ - --- 오래된 데이터 삭제 함수 (기본: 7일 이전) -CREATE OR REPLACE FUNCTION snp_data.cleanup_ais_target(retention_days INTEGER DEFAULT 7) -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM snp_data.ais_target - WHERE message_timestamp < NOW() - (retention_days || ' days')::INTERVAL; - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - RAISE NOTICE 'Deleted % rows older than % days', deleted_count, retention_days; - - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION snp_data.create_ais_target_partition IS '특정 월의 AIS Target 파티션 생성'; -COMMENT ON FUNCTION snp_data.create_future_ais_target_partitions IS '향후 N개월 파티션 사전 생성'; -COMMENT ON FUNCTION snp_data.drop_ais_target_partition IS '특정 월의 파티션 삭제'; -COMMENT ON FUNCTION snp_data.cleanup_old_ais_target_partitions IS 'N개월 이전 파티션 정리'; -COMMENT ON FUNCTION snp_data.ais_target_partition_stats IS '파티션별 통계 조회'; - -- ============================================ -- 7. 유지보수용 함수: 통계 조회 -- ============================================ @@ -362,6 +340,11 @@ END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 조회'; +COMMENT ON FUNCTION snp_data.create_ais_target_daily_partition IS '특정 일의 AIS Target 파티션 생성'; +COMMENT ON FUNCTION snp_data.create_future_ais_target_daily_partitions IS '향후 N일 파티션 사전 생성'; +COMMENT ON FUNCTION snp_data.drop_ais_target_daily_partition IS '특정 일의 파티션 삭제'; +COMMENT ON FUNCTION snp_data.cleanup_old_ais_target_daily_partitions IS 'N일 이전 파티션 정리'; +COMMENT ON FUNCTION snp_data.ais_target_daily_partition_stats IS '파티션별 통계 조회'; -- ============================================ -- 예시 쿼리 @@ -373,7 +356,7 @@ COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 -- 2. 특정 시간 범위의 항적 조회 -- SELECT * FROM snp_data.ais_target -- WHERE mmsi = 123456789 --- AND message_timestamp BETWEEN '2025-12-01 00:00:00+00' AND '2025-12-01 01:00:00+00' +-- AND message_timestamp BETWEEN '2024-12-04 00:00:00+00' AND '2024-12-04 01:00:00+00' -- ORDER BY message_timestamp; -- 3. 특정 구역(원형) 내 선박 조회 @@ -387,26 +370,19 @@ COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 -- ) -- ORDER BY mmsi, message_timestamp DESC; --- 4. LineString 항적 생성 --- SELECT mmsi, ST_MakeLine(geom ORDER BY message_timestamp) as track --- FROM snp_data.ais_target --- WHERE mmsi = 123456789 --- AND message_timestamp BETWEEN '2025-12-01 00:00:00+00' AND '2025-12-01 01:00:00+00' --- GROUP BY mmsi; +-- 4. 다음 7일 파티션 미리 생성 +-- SELECT * FROM snp_data.create_future_ais_target_daily_partitions(7); --- 5. 다음 3개월 파티션 미리 생성 --- SELECT * FROM snp_data.create_future_ais_target_partitions(3); +-- 5. 특정 일 파티션 생성 +-- SELECT snp_data.create_ais_target_daily_partition('2024-12-10'); --- 6. 특정 월 파티션 생성 --- SELECT snp_data.create_ais_target_partition('2026-03-01'); +-- 6. 14일 이전 파티션 정리 +-- SELECT * FROM snp_data.cleanup_old_ais_target_daily_partitions(14); --- 7. 3개월 이전 파티션 정리 --- SELECT * FROM snp_data.cleanup_old_ais_target_partitions(3); +-- 7. 파티션별 통계 조회 +-- SELECT * FROM snp_data.ais_target_daily_partition_stats(); --- 8. 파티션별 통계 조회 --- SELECT * FROM snp_data.ais_target_partition_stats(); - --- 9. 전체 통계 조회 +-- 8. 전체 통계 조회 -- SELECT * FROM snp_data.ais_target_stats(); -- ============================================ @@ -431,12 +407,13 @@ VALUES ( updated_at = NOW(); -- 2. partitionManagerJob: 매일 00:10에 실행 --- Daily 파티션: 매일 생성, Monthly 파티션: 말일에만 생성 (Job 내부에서 분기) +-- Daily 파티션: 매일 생성/삭제 (ais_target_YYMMDD) +-- Monthly 파티션: 말일 생성, 1일 삭제 (table_YYYY_MM) INSERT INTO public.job_schedule (job_name, cron_expression, description, active, created_at, updated_at, created_by, updated_by) VALUES ( 'partitionManagerJob', '0 10 0 * * ?', - '파티션 관리 - 매일 00:10 실행 (Daily: 매일, Monthly: 말일만)', + '파티션 관리 - 매일 00:10 실행 (Daily: 생성/삭제, Monthly: 말일 생성/1일 삭제)', true, NOW(), NOW(),