generated from gc/template-java-maven
feat(phase6): 안정화 - 파티셔닝 배치, 데이터 정리, 에러 핸들링 보완
파티셔닝: - 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) <noreply@anthropic.com>
This commit is contained in:
부모
126e632f5b
커밋
1aec67cee1
69
docs/schema/partition_migration.sql
Normal file
69
docs/schema/partition_migration.sql
Normal file
@ -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;
|
||||||
@ -2,8 +2,10 @@ package com.gcsc.connection.common.exception;
|
|||||||
|
|
||||||
import com.gcsc.connection.common.dto.ApiResponse;
|
import com.gcsc.connection.common.dto.ApiResponse;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
@ -52,6 +54,39 @@ public class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.error("접근 권한이 없습니다"));
|
.body(ApiResponse.error("접근 권한이 없습니다"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 오류 처리
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(DataAccessException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
|
||||||
|
log.warn("Message not readable: {}", e.getMessage());
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body(ApiResponse.error("요청 본문을 읽을 수 없습니다"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 처리되지 않은 예외 처리
|
* 처리되지 않은 예외 처리
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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("데이터 정리 완료");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,3 +64,9 @@ app:
|
|||||||
refresh-token-expiration: 604800000
|
refresh-token-expiration: 604800000
|
||||||
apikey:
|
apikey:
|
||||||
aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c=
|
aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c=
|
||||||
|
retention:
|
||||||
|
request-log-days: 90
|
||||||
|
health-log-days: 90
|
||||||
|
partition:
|
||||||
|
enabled: true
|
||||||
|
advance-months: 2
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user