feat: sam_stat 최적화 및 안정화 (Phase 5)

- StatBackfillCommand: 과거 데이터 일괄 백필 (일간+월간, 프로그레스바, 에러 리포트)
- StatVerifyCommand: 원본 DB vs sam_stat 정합성 교차 검증 (--fix 자동 재집계)
- 파티셔닝 준비: 7개 일간 테이블 RANGE COLUMNS(stat_date) 마이그레이션
- Redis 캐싱: StatQueryService Cache::remember TTL 5분 + invalidateCache()
- StatMonitorService: 집계 실패/누락/불일치 시 stat_alerts 알림 기록
- StatAggregatorService: 모니터링 알림 + 캐시 무효화 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 22:17:11 +09:00
parent 3793e95662
commit ca51867cc2
6 changed files with 738 additions and 28 deletions

View File

@@ -0,0 +1,138 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 일간 통계 테이블 RANGE 파티셔닝 준비
*
* 실행 방법: php artisan migrate --path=database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php
*
* 주의: MySQL에서 파티셔닝 적용 시 UNIQUE KEY에 파티션 컬럼(stat_date)이 포함되어야 합니다.
* 이 마이그레이션은 기존 데이터를 보존하며 파티셔닝을 적용합니다.
*/
return new class extends Migration
{
protected $connection = 'sam_stat';
/**
* 파티셔닝 대상 테이블과 고유 키 정의
*/
private function getTargetTables(): array
{
return [
'stat_sales_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_finance_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_production_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_inventory_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_quote_pipeline_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_hr_attendance_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_system_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
];
}
/**
* 연도별 파티션 정의 생성 (2024~2028 + MAXVALUE)
*/
private function getPartitionDefinitions(): string
{
$partitions = [];
for ($year = 2024; $year <= 2028; $year++) {
$partitions[] = "PARTITION p{$year} VALUES LESS THAN ('{$year}-01-01')";
}
$partitions[] = 'PARTITION p_future VALUES LESS THAN MAXVALUE';
return implode(",\n ", $partitions);
}
public function up(): void
{
$tables = $this->getTargetTables();
$partitionDefs = $this->getPartitionDefinitions();
foreach ($tables as $table => $config) {
// 테이블 존재 여부 확인
$exists = DB::connection('sam_stat')
->select("SHOW TABLES LIKE '{$table}'");
if (empty($exists)) {
continue;
}
// 이미 파티셔닝되어 있는지 확인
$partitionInfo = DB::connection('sam_stat')
->select('SELECT PARTITION_NAME FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND PARTITION_NAME IS NOT NULL',
[$table]);
if (! empty($partitionInfo)) {
continue; // 이미 파티셔닝됨
}
// AUTO_INCREMENT PK를 일반 PK로 변경 (파티션 키 포함)
// MySQL 파티셔닝 제약: UNIQUE KEY에 파티션 컬럼 포함 필수
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}`
DROP PRIMARY KEY,
ADD PRIMARY KEY (`id`, `stat_date`),
DROP INDEX `{$config['unique_name']}`,
ADD UNIQUE KEY `{$config['unique_name']}` ({$config['unique_columns']})
");
// RANGE 파티셔닝 적용
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}`
PARTITION BY RANGE COLUMNS(`stat_date`) (
{$partitionDefs}
)
");
}
}
public function down(): void
{
$tables = $this->getTargetTables();
foreach ($tables as $table => $config) {
$exists = DB::connection('sam_stat')
->select("SHOW TABLES LIKE '{$table}'");
if (empty($exists)) {
continue;
}
// 파티션 제거
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}` REMOVE PARTITIONING
");
// PK 원복
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}`
DROP PRIMARY KEY,
ADD PRIMARY KEY (`id`)
");
}
}
};