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