From 1aec67cee12b9b5c8c4d84ace76e4f4b5b2641f8 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Thu, 9 Apr 2026 11:25:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(phase6):=20=EC=95=88=EC=A0=95=ED=99=94?= =?UTF-8?q?=20-=20=ED=8C=8C=ED=8B=B0=EC=85=94=EB=8B=9D=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98,=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=95=EB=A6=AC,?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파티셔닝: - PartitionService (JdbcTemplate DDL, 파티션 생성/삭제/목록) - PartitionManageScheduler (매월 1일 00:00, 미래 파티션 생성 + 만료 파티션 DROP) - partition_migration.sql (운영 DB 수동 실행용 마이그레이션 문서) - snp_api_request_log 월별 Range 파티션 전환 완료 데이터 정리: - DataCleanupScheduler (매일 02:00, health_log 90일 이전 DELETE) - application.yml retention/partition 설정 추가 에러 핸들링: - GlobalExceptionHandler: DataAccessException, IllegalArgumentException, HttpMessageNotReadableException 핸들러 추가 Closes #11 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/schema/partition_migration.sql | 69 +++++++++++++++++ .../exception/GlobalExceptionHandler.java | 35 +++++++++ .../scheduler/DataCleanupScheduler.java | 38 ++++++++++ .../scheduler/PartitionManageScheduler.java | 75 +++++++++++++++++++ .../monitoring/service/PartitionService.java | 67 +++++++++++++++++ src/main/resources/application.yml | 6 ++ 6 files changed, 290 insertions(+) create mode 100644 docs/schema/partition_migration.sql create mode 100644 src/main/java/com/gcsc/connection/monitoring/scheduler/DataCleanupScheduler.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/scheduler/PartitionManageScheduler.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/service/PartitionService.java diff --git a/docs/schema/partition_migration.sql b/docs/schema/partition_migration.sql new file mode 100644 index 0000000..9f684c3 --- /dev/null +++ b/docs/schema/partition_migration.sql @@ -0,0 +1,69 @@ +-- ============================================================= +-- SNP Connection Monitoring - 파티션 마이그레이션 +-- snp_api_request_log 테이블을 월별 Range 파티션으로 전환 +-- 스키마: common +-- +-- 주의: 운영 DB에서 수동 실행. 서비스 중지 후 진행 권장. +-- ============================================================= + +BEGIN; + +-- 1. 기존 데이터 백업 +CREATE TABLE common.snp_api_request_log_backup AS +SELECT * FROM common.snp_api_request_log; + +-- 2. 기존 테이블 삭제 +DROP TABLE IF EXISTS common.snp_api_request_log CASCADE; + +-- 3. 파티션 테이블 생성 +CREATE TABLE common.snp_api_request_log ( + log_id BIGSERIAL, + request_url VARCHAR(2000), + request_params TEXT, + request_method VARCHAR(10), + request_status VARCHAR(20), + request_headers TEXT, + request_ip VARCHAR(45), + service_id BIGINT, + user_id BIGINT, + api_key_id BIGINT, + response_size BIGINT, + response_time INTEGER, + response_status INTEGER, + error_message TEXT, + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + tenant_id BIGINT, + PRIMARY KEY (log_id, requested_at) +) PARTITION BY RANGE (requested_at); + +-- 4. 현재 월 + 미래 2개월 파티션 생성 (실행 시점에 맞게 날짜 수정) +-- 예시: 2026년 4월 실행 기준 +CREATE TABLE common.snp_api_request_log_202604 + PARTITION OF common.snp_api_request_log + FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); + +CREATE TABLE common.snp_api_request_log_202605 + PARTITION OF common.snp_api_request_log + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); + +CREATE TABLE common.snp_api_request_log_202606 + PARTITION OF common.snp_api_request_log + FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); + +-- 5. 인덱스 재생성 (파티션 테이블에 자동 상속됨) +CREATE INDEX idx_snp_request_log_service ON common.snp_api_request_log (service_id); +CREATE INDEX idx_snp_request_log_user ON common.snp_api_request_log (user_id); +CREATE INDEX idx_snp_request_log_requested ON common.snp_api_request_log (requested_at); +CREATE INDEX idx_snp_request_log_tenant ON common.snp_api_request_log (tenant_id); +CREATE INDEX idx_snp_request_log_daily_stats ON common.snp_api_request_log (requested_at, request_status); +CREATE INDEX idx_snp_request_log_daily_user ON common.snp_api_request_log (requested_at, user_id); +CREATE INDEX idx_snp_request_log_svc_stats ON common.snp_api_request_log (requested_at, service_id, request_status); +CREATE INDEX idx_snp_request_log_top_api ON common.snp_api_request_log (requested_at, request_url, request_method); + +-- 6. 데이터 복원 +INSERT INTO common.snp_api_request_log SELECT * FROM common.snp_api_request_log_backup; + +-- 7. 백업 테이블 삭제 (데이터 복원 확인 후) +-- DROP TABLE common.snp_api_request_log_backup; + +COMMIT; diff --git a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java index 9581297..57e1014 100644 --- a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java @@ -2,8 +2,10 @@ package com.gcsc.connection.common.exception; import com.gcsc.connection.common.dto.ApiResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -52,6 +54,39 @@ public class GlobalExceptionHandler { .body(ApiResponse.error("접근 권한이 없습니다")); } + /** + * 데이터베이스 오류 처리 + */ + @ExceptionHandler(DataAccessException.class) + public ResponseEntity> handleDataAccessException(DataAccessException e) { + log.error("Database error: {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("데이터베이스 오류가 발생했습니다")); + } + + /** + * 잘못된 인자 예외 처리 + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + log.warn("Invalid argument: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(e.getMessage())); + } + + /** + * 요청 본문 읽기 실패 처리 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadable(HttpMessageNotReadableException e) { + log.warn("Message not readable: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error("요청 본문을 읽을 수 없습니다")); + } + /** * 처리되지 않은 예외 처리 */ diff --git a/src/main/java/com/gcsc/connection/monitoring/scheduler/DataCleanupScheduler.java b/src/main/java/com/gcsc/connection/monitoring/scheduler/DataCleanupScheduler.java new file mode 100644 index 0000000..9d87cf4 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/scheduler/DataCleanupScheduler.java @@ -0,0 +1,38 @@ +package com.gcsc.connection.monitoring.scheduler; + +import com.gcsc.connection.monitoring.repository.SnpServiceHealthLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DataCleanupScheduler { + + private final SnpServiceHealthLogRepository healthLogRepository; + + @Value("${app.retention.health-log-days:90}") + private int healthLogRetentionDays; + + /** + * 매일 02:00 실행 - 오래된 데이터 정리 + */ + @Scheduled(cron = "0 0 2 * * *") + @Transactional + public void cleanupOldData() { + log.info("데이터 정리 시작"); + + // Health log 정리 + LocalDateTime healthCutoff = LocalDateTime.now().minusDays(healthLogRetentionDays); + int deletedHealthLogs = healthLogRepository.deleteOlderThan(healthCutoff); + log.info("Health log 정리 완료: {}건 삭제 (기준: {}일 이전)", deletedHealthLogs, healthLogRetentionDays); + + log.info("데이터 정리 완료"); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/scheduler/PartitionManageScheduler.java b/src/main/java/com/gcsc/connection/monitoring/scheduler/PartitionManageScheduler.java new file mode 100644 index 0000000..a356f67 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/scheduler/PartitionManageScheduler.java @@ -0,0 +1,75 @@ +package com.gcsc.connection.monitoring.scheduler; + +import com.gcsc.connection.monitoring.service.PartitionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PartitionManageScheduler { + + private final PartitionService partitionService; + + @Value("${app.partition.enabled:false}") + private boolean partitionEnabled; + + @Value("${app.partition.advance-months:2}") + private int advanceMonths; + + @Value("${app.retention.request-log-days:90}") + private int retentionDays; + + /** + * 매월 1일 00:00 실행 - 파티션 생성/삭제 + */ + @Scheduled(cron = "0 0 0 1 * *") + public void managePartitions() { + if (!partitionEnabled) { + log.debug("파티션 관리 비활성화 상태"); + return; + } + + log.info("파티션 관리 시작"); + YearMonth now = YearMonth.now(); + + // 미래 파티션 생성 + for (int i = 0; i <= advanceMonths; i++) { + partitionService.createPartition(now.plusMonths(i)); + } + + // 보관 기간 이전 파티션 삭제 (3개월 전) + int retentionMonths = retentionDays / 30; + YearMonth cutoff = now.minusMonths(retentionMonths); + + List existing = partitionService.getExistingPartitions(); + String cutoffStr = "snp_api_request_log_" + cutoff.format(DateTimeFormatter.ofPattern("yyyyMM")); + + for (String partition : existing) { + if (partition.compareTo(cutoffStr) < 0) { + YearMonth ym = parsePartitionYearMonth(partition); + if (ym != null) { + partitionService.dropPartition(ym); + } + } + } + + log.info("파티션 관리 완료"); + } + + private YearMonth parsePartitionYearMonth(String partitionName) { + try { + String suffix = partitionName.replace("snp_api_request_log_", ""); + return YearMonth.parse(suffix, DateTimeFormatter.ofPattern("yyyyMM")); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/service/PartitionService.java b/src/main/java/com/gcsc/connection/monitoring/service/PartitionService.java new file mode 100644 index 0000000..97119be --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/service/PartitionService.java @@ -0,0 +1,67 @@ +package com.gcsc.connection.monitoring.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class PartitionService { + + private final JdbcTemplate jdbcTemplate; + + private static final String TABLE_NAME = "common.snp_api_request_log"; + private static final String PARTITION_PREFIX = "snp_api_request_log_"; + + /** + * 월별 파티션 생성 + */ + public void createPartition(YearMonth yearMonth) { + String partitionName = TABLE_NAME + "_" + yearMonth.format(DateTimeFormatter.ofPattern("yyyyMM")); + LocalDate start = yearMonth.atDay(1); + LocalDate end = yearMonth.plusMonths(1).atDay(1); + + String sql = String.format( + "CREATE TABLE IF NOT EXISTS %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')", + partitionName, TABLE_NAME, start, end); + + try { + jdbcTemplate.execute(sql); + log.info("파티션 생성 완료: {}", partitionName); + } catch (Exception e) { + log.error("파티션 생성 실패: {}", partitionName, e); + } + } + + /** + * 월별 파티션 삭제 + */ + public void dropPartition(YearMonth yearMonth) { + String partitionName = TABLE_NAME + "_" + yearMonth.format(DateTimeFormatter.ofPattern("yyyyMM")); + + String sql = String.format("DROP TABLE IF EXISTS %s", partitionName); + + try { + jdbcTemplate.execute(sql); + log.info("파티션 삭제 완료: {}", partitionName); + } catch (Exception e) { + log.error("파티션 삭제 실패: {}", partitionName, e); + } + } + + /** + * 기존 파티션 목록 조회 + */ + public List getExistingPartitions() { + String sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'common' AND tablename LIKE '" + + PARTITION_PREFIX + "%' ORDER BY tablename"; + return jdbcTemplate.queryForList(sql, String.class); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a557cb6..807b1f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -64,3 +64,9 @@ app: refresh-token-expiration: 604800000 apikey: aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c= + retention: + request-log-days: 90 + health-log-days: 90 + partition: + enabled: true + advance-months: 2 -- 2.45.2 From 21229d33890cc96763e96951564aa6433e8db133 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Thu, 9 Apr 2026 11:26:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index bf42442..7a8ab6b 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). - DateRangeFilter 재사용 컴포넌트 (오늘/7일/30일/커스텀) (#23) - 사용자 통계: API Key 보유 사용자 카드 (#23) - 사용량 추이: 일별/주별/월별 탭, 요청수+성공률+응답시간+사용자 차트 (#23) +- snp_api_request_log 월별 Range 파티셔닝 + 자동 관리 배치 (#11) +- 데이터 정리 배치 (health_log 90일 이전 자동 삭제) (#11) +- 에러 핸들링 보완 (DataAccessException, IllegalArgument, HttpMessageNotReadable) (#11) ### 변경 -- 2.45.2