feat: DB 백업 시스템 구축 (Phase 1,2,4)

- Phase 1: backup.conf.example + sam-db-backup.sh 백업 스크립트
- Phase 2: BackupCheckCommand + StatMonitorService.recordBackupFailure()
- Phase 2: routes/console.php에 db:backup-check 05:00 스케줄 등록
- Phase 4: SlackNotificationService 생성 (웹훅 알림)
- Phase 4: BackupCheckCommand/StatMonitorService에 Slack 알림 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 08:33:19 +09:00
parent 57d9ac2d7f
commit fb0155624f
6 changed files with 530 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use App\Services\SlackNotificationService;
use App\Services\Stats\StatMonitorService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class BackupCheckCommand extends Command
{
protected $signature = 'db:backup-check
{--path= : 백업 경로 오버라이드}';
protected $description = 'DB 백업 상태를 확인하고 이상 시 알림을 생성합니다';
public function handle(StatMonitorService $monitorService): int
{
$this->info('DB 백업 상태 확인 시작...');
$statusFile = $this->option('path')
? rtrim($this->option('path'), '/') . '/.backup_status'
: env('BACKUP_STATUS_FILE', '/data/backup/mysql/.backup_status');
$errors = [];
// 1. 상태 파일 존재 여부
if (! file_exists($statusFile)) {
$errors[] = '백업 상태 파일 없음: ' . $statusFile;
$this->reportErrors($monitorService, $errors);
return self::FAILURE;
}
$status = json_decode(file_get_contents($statusFile), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$errors[] = '상태 파일 JSON 파싱 실패: ' . json_last_error_msg();
$this->reportErrors($monitorService, $errors);
return self::FAILURE;
}
// 2. last_run이 25시간 이내인지
$lastRun = strtotime($status['last_run'] ?? '');
if (! $lastRun || (time() - $lastRun) > 25 * 3600) {
$lastRunStr = $status['last_run'] ?? 'unknown';
$errors[] = "마지막 백업이 25시간 초과: {$lastRunStr}";
}
// 3. status가 success인지
if (($status['status'] ?? '') !== 'success') {
$errors[] = '백업 상태 실패: ' . ($status['status'] ?? 'unknown');
}
// 4. 각 DB 백업 파일 크기 검증
$minSizes = [
'sam' => (int) env('BACKUP_MIN_SIZE_SAM', 1048576),
'sam_stat' => (int) env('BACKUP_MIN_SIZE_STAT', 102400),
];
$databases = $status['databases'] ?? [];
foreach ($minSizes as $dbName => $minSize) {
if (! isset($databases[$dbName])) {
$errors[] = "{$dbName} DB 백업 정보 없음";
continue;
}
$sizeBytes = $databases[$dbName]['size_bytes'] ?? 0;
if ($sizeBytes < $minSize) {
$errors[] = "{$dbName} 백업 파일 크기 부족: {$sizeBytes} bytes (최소 {$minSize})";
}
}
// 결과 처리
if (! empty($errors)) {
$this->reportErrors($monitorService, $errors);
return self::FAILURE;
}
$this->info('✅ DB 백업 상태 정상');
Log::info('db:backup-check 정상', [
'last_run' => $status['last_run'],
'databases' => array_keys($databases),
]);
return self::SUCCESS;
}
private function reportErrors(StatMonitorService $monitorService, array $errors): void
{
$errorMessage = implode("\n", $errors);
$this->error('❌ DB 백업 이상 감지:');
foreach ($errors as $error) {
$this->error(" - {$error}");
}
// stat_alerts에 기록
$monitorService->recordBackupFailure(
'[backup] DB 백업 이상 감지',
$errorMessage
);
// Slack 알림 전송
app(SlackNotificationService::class)->sendBackupAlert(
'DB 백업 이상 감지',
$errorMessage
);
Log::error('db:backup-check 실패', ['errors' => $errors]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SlackNotificationService
{
private ?string $webhookUrl;
private string $serverName;
private bool $enabled;
public function __construct()
{
$this->webhookUrl = env('SLACK_ALERT_WEBHOOK_URL') ?: env('LOG_SLACK_WEBHOOK_URL');
$this->serverName = env('SLACK_ALERT_SERVER_NAME', config('app.env', 'unknown'));
$this->enabled = (bool) env('SLACK_ALERT_ENABLED', false);
}
/**
* 일반 알림 전송
*/
public function sendAlert(string $title, string $message, string $severity = 'critical'): void
{
$color = match ($severity) {
'critical' => '#FF0000',
'warning' => '#FFA500',
default => '#3498DB',
};
$emoji = match ($severity) {
'critical' => ':rotating_light:',
'warning' => ':warning:',
default => ':information_source:',
};
$this->send([
'attachments' => [
[
'color' => $color,
'blocks' => [
[
'type' => 'header',
'text' => [
'type' => 'plain_text',
'text' => "{$emoji} {$title}",
'emoji' => true,
],
],
[
'type' => 'section',
'fields' => [
[
'type' => 'mrkdwn',
'text' => "*서버:*\n{$this->serverName}",
],
[
'type' => 'mrkdwn',
'text' => '*시간:*\n' . now()->format('Y-m-d H:i:s'),
],
],
],
[
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => "*상세:*\n{$message}",
],
],
[
'type' => 'context',
'elements' => [
[
'type' => 'mrkdwn',
'text' => "환경: `" . config('app.env') . "` | 심각도: `{$severity}`",
],
],
],
],
],
],
]);
}
/**
* 백업 실패 알림
*/
public function sendBackupAlert(string $title, string $message): void
{
$this->sendAlert("[SAM 백업 실패] {$title}", $message, 'critical');
}
/**
* 통계/모니터링 알림
*/
public function sendStatAlert(string $title, string $message, string $domain): void
{
$this->sendAlert("[SAM {$domain}] {$title}", $message, 'critical');
}
/**
* Slack 웹훅으로 메시지 전송
*/
private function send(array $payload): void
{
if (! $this->enabled) {
Log::debug('Slack 알림 비활성화 상태 (SLACK_ALERT_ENABLED=false)');
return;
}
if (empty($this->webhookUrl)) {
Log::warning('Slack 웹훅 URL 미설정');
return;
}
try {
$response = Http::timeout(10)->post($this->webhookUrl, $payload);
if (! $response->successful()) {
Log::error('Slack 알림 전송 실패', [
'status' => $response->status(),
'body' => $response->body(),
]);
}
} catch (\Throwable $e) {
Log::error('Slack 알림 전송 예외', [
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Services\Stats; namespace App\Services\Stats;
use App\Models\Stats\StatAlert; use App\Models\Stats\StatAlert;
use App\Services\SlackNotificationService;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class StatMonitorService class StatMonitorService
@@ -26,6 +27,13 @@ public function recordAggregationFailure(int $tenantId, string $domain, string $
'is_resolved' => false, 'is_resolved' => false,
'created_at' => now(), 'created_at' => now(),
]); ]);
// critical 알림 Slack 전송
app(SlackNotificationService::class)->sendStatAlert(
"[{$jobType}] 집계 실패",
mb_substr($errorMessage, 0, 300),
$domain
);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('stat_alert 기록 실패', [ Log::error('stat_alert 기록 실패', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
@@ -94,6 +102,13 @@ public function recordMismatch(int $tenantId, string $domain, string $label, flo
'is_resolved' => false, 'is_resolved' => false,
'created_at' => now(), 'created_at' => now(),
]); ]);
// critical 알림 Slack 전송
app(SlackNotificationService::class)->sendStatAlert(
"[{$label}] 정합성 불일치",
"원본={$expected}, 통계={$actual}, 차이=" . ($actual - $expected),
$domain
);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('stat_alert 기록 실패 (mismatch)', [ Log::error('stat_alert 기록 실패 (mismatch)', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
@@ -103,6 +118,33 @@ public function recordMismatch(int $tenantId, string $domain, string $label, flo
} }
} }
/**
* 백업 실패 알림 기록 (시스템 레벨, tenantId=0)
*/
public function recordBackupFailure(string $title, string $message): void
{
try {
StatAlert::create([
'tenant_id' => 0,
'alert_type' => 'backup_failure',
'domain' => 'backup',
'severity' => 'critical',
'title' => $title,
'message' => mb_substr($message, 0, 500),
'current_value' => 0,
'threshold_value' => 0,
'is_read' => false,
'is_resolved' => false,
'created_at' => now(),
]);
} catch (\Throwable $e) {
Log::error('stat_alert 기록 실패 (backup_failure)', [
'title' => $title,
'error' => $e->getMessage(),
]);
}
}
/** /**
* 알림 해결 처리 * 알림 해결 처리
*/ */

View File

@@ -120,6 +120,19 @@
\Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]); \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]);
}); });
// ─── DB 백업 모니터링 ───
// 매일 새벽 05:00에 DB 백업 상태 확인 (04:30 백업 완료 후 점검)
Schedule::command('db:backup-check')
->dailyAt('05:00')
->appendOutputTo(storage_path('logs/scheduler.log'))
->onSuccess(function () {
\Illuminate\Support\Facades\Log::info('✅ db:backup-check 스케줄러 실행 성공', ['time' => now()]);
})
->onFailure(function () {
\Illuminate\Support\Facades\Log::error('❌ db:backup-check 스케줄러 실행 실패', ['time' => now()]);
});
// 매일 오전 09:00에 KPI 목표 대비 알림 체크 // 매일 오전 09:00에 KPI 목표 대비 알림 체크
Schedule::command('stat:check-kpi-alerts') Schedule::command('stat:check-kpi-alerts')
->dailyAt('09:00') ->dailyAt('09:00')

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# =============================================================================
# SAM DB Backup Configuration
# =============================================================================
# 사용법: 이 파일을 backup.conf로 복사 후 환경에 맞게 수정
# cp backup.conf.example backup.conf
# chmod 600 backup.conf
# =============================================================================
# DB 접속 정보
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=codebridge
DB_PASS="code**bridge"
# 백업 대상 DB (공백 구분)
DATABASES="sam sam_stat"
# 백업 저장 경로
BACKUP_BASE_DIR=/data/backup/mysql
# 보관 정책
DAILY_RETENTION_DAYS=7
WEEKLY_RETENTION_DAYS=28
# 로그
LOG_FILE=/data/backup/mysql/logs/backup.log
# 상태 파일 (Laravel 모니터링용)
STATUS_FILE=/data/backup/mysql/.backup_status
# 최소 백업 파일 크기 (bytes) — 이보다 작으면 실패로 간주
MIN_SIZE_SAM=1048576 # 1MB
MIN_SIZE_SAM_STAT=102400 # 100KB

190
scripts/backup/sam-db-backup.sh Executable file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# =============================================================================
# SAM DB Backup Script
# =============================================================================
# 용도: MySQL 데이터베이스 백업 (mysqldump + gzip)
# 실행: crontab에서 매일 04:30 실행
# 30 4 * * * /home/webservice/api/scripts/backup/sam-db-backup.sh
# =============================================================================
set -euo pipefail
# 스크립트 경로 기준으로 설정 파일 로드
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF_FILE="${SCRIPT_DIR}/backup.conf"
if [[ ! -f "$CONF_FILE" ]]; then
echo "[FATAL] 설정 파일 없음: $CONF_FILE" >&2
exit 1
fi
# shellcheck source=backup.conf.example
source "$CONF_FILE"
# =============================================================================
# 변수 초기화
# =============================================================================
TODAY=$(date +%Y-%m-%d)
TIMESTAMP=$(date +%Y%m%d_%H%M)
DAY_OF_WEEK=$(date +%u) # 1=월 ~ 7=일
DAILY_DIR="${BACKUP_BASE_DIR}/daily/${TODAY}"
WEEKLY_DIR="${BACKUP_BASE_DIR}/weekly"
LOG_DIR=$(dirname "$LOG_FILE")
ERRORS=()
DB_RESULTS=()
# =============================================================================
# 함수 정의
# =============================================================================
log() {
local level="$1"
shift
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
}
ensure_dirs() {
mkdir -p "$DAILY_DIR" "$WEEKLY_DIR" "$LOG_DIR"
}
backup_database() {
local db_name="$1"
local output_file="${DAILY_DIR}/${db_name}_${TIMESTAMP}.sql.gz"
log "INFO" "백업 시작: ${db_name}"
if mysqldump \
--host="$DB_HOST" \
--port="$DB_PORT" \
--user="$DB_USER" \
--password="$DB_PASS" \
--single-transaction \
--routines \
--triggers \
--quick \
--lock-tables=false \
"$db_name" 2>>"$LOG_FILE" | gzip > "$output_file"; then
local file_size
file_size=$(stat -f%z "$output_file" 2>/dev/null || stat -c%s "$output_file" 2>/dev/null || echo 0)
# 최소 크기 검증
local min_size_var="MIN_SIZE_$(echo "$db_name" | tr '[:lower:]' '[:upper:]')"
local min_size="${!min_size_var:-0}"
if [[ "$file_size" -lt "$min_size" ]]; then
log "ERROR" "백업 파일 크기 부족: ${db_name} (${file_size} bytes < ${min_size} bytes)"
ERRORS+=("${db_name}: 파일 크기 부족 (${file_size} < ${min_size})")
DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"$(basename "$output_file")\",\"size_bytes\":${file_size},\"status\":\"size_error\"}")
return 1
fi
log "INFO" "백업 완료: ${db_name} (${file_size} bytes)"
DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"$(basename "$output_file")\",\"size_bytes\":${file_size},\"status\":\"success\"}")
# 일요일이면 weekly 복사
if [[ "$DAY_OF_WEEK" -eq 7 ]]; then
local weekly_file="${WEEKLY_DIR}/${db_name}_${TIMESTAMP}_week.sql.gz"
cp "$output_file" "$weekly_file"
log "INFO" "주간 백업 복사: ${db_name}"
fi
return 0
else
log "ERROR" "mysqldump 실패: ${db_name}"
ERRORS+=("${db_name}: mysqldump 실패")
DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"\",\"size_bytes\":0,\"status\":\"dump_error\"}")
rm -f "$output_file"
return 1
fi
}
cleanup_old_backups() {
log "INFO" "오래된 백업 정리 시작"
# daily: DAILY_RETENTION_DAYS일 초과 디렉토리 삭제
if [[ -d "${BACKUP_BASE_DIR}/daily" ]]; then
find "${BACKUP_BASE_DIR}/daily" -mindepth 1 -maxdepth 1 -type d -mtime +"$DAILY_RETENTION_DAYS" -exec rm -rf {} \; 2>>"$LOG_FILE"
local daily_deleted=$?
log "INFO" "일간 백업 정리 완료 (${DAILY_RETENTION_DAYS}일 초과 삭제)"
fi
# weekly: WEEKLY_RETENTION_DAYS일 초과 파일 삭제
if [[ -d "$WEEKLY_DIR" ]]; then
find "$WEEKLY_DIR" -type f -name "*.sql.gz" -mtime +"$WEEKLY_RETENTION_DAYS" -delete 2>>"$LOG_FILE"
log "INFO" "주간 백업 정리 완료 (${WEEKLY_RETENTION_DAYS}일 초과 삭제)"
fi
}
write_status_file() {
local status="success"
local errors_json="[]"
if [[ ${#ERRORS[@]} -gt 0 ]]; then
status="failure"
# 에러 배열을 JSON 배열로 변환
errors_json="["
for i in "${!ERRORS[@]}"; do
[[ $i -gt 0 ]] && errors_json+=","
errors_json+="\"${ERRORS[$i]}\""
done
errors_json+="]"
fi
# databases 객체 구성
local databases_json="{"
for i in "${!DB_RESULTS[@]}"; do
[[ $i -gt 0 ]] && databases_json+=","
local result="${DB_RESULTS[$i]}"
local db_name
db_name=$(echo "$result" | sed 's/.*"db":"\([^"]*\)".*/\1/')
local file_name
file_name=$(echo "$result" | sed 's/.*"file":"\([^"]*\)".*/\1/')
local size
size=$(echo "$result" | sed 's/.*"size_bytes":\([0-9]*\).*/\1/')
databases_json+="\"${db_name}\":{\"file\":\"${file_name}\",\"size_bytes\":${size}}"
done
databases_json+="}"
cat > "$STATUS_FILE" <<EOF
{
"last_run": "$(date '+%Y-%m-%dT%H:%M:%S%z' | sed 's/\(..\)$/:\1/')",
"status": "${status}",
"databases": ${databases_json},
"errors": ${errors_json}
}
EOF
log "INFO" "상태 파일 기록: ${status}"
}
# =============================================================================
# 메인 실행
# =============================================================================
main() {
log "INFO" "========== SAM DB 백업 시작 =========="
ensure_dirs
local has_error=0
for db in $DATABASES; do
if ! backup_database "$db"; then
has_error=1
fi
done
cleanup_old_backups
write_status_file
if [[ $has_error -eq 1 ]]; then
log "ERROR" "========== SAM DB 백업 완료 (일부 실패) =========="
exit 1
else
log "INFO" "========== SAM DB 백업 완료 (성공) =========="
exit 0
fi
}
main "$@"