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:
HYOJIN 2026-04-09 11:25:43 +09:00
부모 126e632f5b
커밋 1aec67cee1
6개의 변경된 파일290개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -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 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<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
apikey:
aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c=
retention:
request-log-days: 90
health-log-days: 90
partition:
enabled: true
advance-months: 2