diff --git a/app/Http/Middleware/LogApiRequest.php b/app/Http/Middleware/LogApiRequest.php new file mode 100644 index 0000000..ff64a4a --- /dev/null +++ b/app/Http/Middleware/LogApiRequest.php @@ -0,0 +1,166 @@ +shouldSkip($request)) { + return $next($request); + } + + $startTime = microtime(true); + + $response = $next($request); + + $this->logRequest($request, $response, $startTime); + + return $response; + } + + protected function shouldSkip(Request $request): bool + { + foreach ($this->exceptPaths as $pattern) { + if ($request->is($pattern)) { + return true; + } + } + return false; + } + + protected function logRequest(Request $request, Response $response, float $startTime): void + { + try { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + $logData = [ + 'method' => $request->method(), + 'url' => $request->fullUrl(), + 'route_name' => $request->route()?->getName(), + 'request_headers' => $this->maskSensitiveHeaders($request->headers->all()), + 'request_body' => $this->maskSensitiveFields($request->all()), + 'request_query' => $request->query(), + 'response_status' => $response->getStatusCode(), + 'response_body' => $this->truncateResponse($response->getContent()), + 'duration_ms' => $durationMs, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'user_id' => $request->user()?->id, + 'tenant_id' => $request->user()?->current_tenant_id ?? null, + ]; + + // DB 저장 + ApiRequestLog::create($logData); + + // 로그 파일 저장 + $this->writeToLogFile($logData); + + } catch (\Throwable $e) { + // 로깅 실패가 비즈니스 로직에 영향을 주지 않도록 조용히 처리 + Log::error('API 로깅 실패', [ + 'error' => $e->getMessage(), + 'url' => $request->fullUrl(), + ]); + } + } + + protected function maskSensitiveHeaders(array $headers): array + { + foreach ($this->sensitiveHeaders as $header) { + if (isset($headers[$header])) { + $headers[$header] = ['***MASKED***']; + } + } + return $headers; + } + + protected function maskSensitiveFields(array $data): array + { + foreach ($data as $key => $value) { + if (in_array(strtolower($key), $this->sensitiveFields)) { + $data[$key] = '***MASKED***'; + } elseif (is_array($value)) { + $data[$key] = $this->maskSensitiveFields($value); + } + } + return $data; + } + + protected function truncateResponse(?string $content): ?string + { + if ($content === null) { + return null; + } + + // 10KB 이상이면 잘라내기 + $maxLength = 10 * 1024; + if (strlen($content) > $maxLength) { + return substr($content, 0, $maxLength) . '...[TRUNCATED]'; + } + + return $content; + } + + protected function writeToLogFile(array $logData): void + { + $logMessage = sprintf( + "[%s] %s %s | Status: %d | Duration: %dms | IP: %s | User: %s", + now()->format('Y-m-d H:i:s'), + $logData['method'], + $logData['url'], + $logData['response_status'], + $logData['duration_ms'], + $logData['ip_address'] ?? 'N/A', + $logData['user_id'] ?? 'guest' + ); + + Log::channel('api')->info($logMessage, [ + 'request_body' => $logData['request_body'], + 'response_status' => $logData['response_status'], + ]); + } +} \ No newline at end of file diff --git a/app/Models/ApiRequestLog.php b/app/Models/ApiRequestLog.php new file mode 100644 index 0000000..151efb3 --- /dev/null +++ b/app/Models/ApiRequestLog.php @@ -0,0 +1,64 @@ + 'array', + 'request_body' => 'array', + 'request_query' => 'array', + 'response_status' => 'integer', + 'duration_ms' => 'integer', + 'user_id' => 'integer', + 'tenant_id' => 'integer', + 'created_at' => 'datetime', + ]; + + /** + * 하루 지난 로그 삭제 + */ + public static function pruneOldLogs(): int + { + return static::where('created_at', '<', now()->subDay())->delete(); + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index 5632204..5319a29 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,7 @@ use App\Http\Middleware\CheckPermission; use App\Http\Middleware\CheckSwaggerAuth; use App\Http\Middleware\CorsMiddleware; +use App\Http\Middleware\LogApiRequest; use App\Http\Middleware\PermMapper; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Application; @@ -25,11 +26,15 @@ $middleware->append(ApiRateLimiter::class); // 1. Rate Limiting 먼저 체크 $middleware->append(ApiKeyMiddleware::class); // 2. API Key 검증 + // API 미들웨어 그룹에 로깅 추가 + $middleware->appendToGroup('api', LogApiRequest::class); + $middleware->alias([ 'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth (alias 유지) 'swagger.auth' => CheckSwaggerAuth::class, 'perm.map' => PermMapper::class, // 전처리: 라우트명 → perm 키 생성/주입 'permission' => CheckPermission::class, // 검사: perm 키로 접근 허용/차단 + 'log.api' => LogApiRequest::class, // API 요청/응답 로깅 (선택적 사용용) ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/config/logging.php b/config/logging.php index 1345f6f..4ed83fc 100644 --- a/config/logging.php +++ b/config/logging.php @@ -127,6 +127,23 @@ 'path' => storage_path('logs/laravel.log'), ], + /* + |-------------------------------------------------------------------------- + | API Request/Response 로그 채널 + |-------------------------------------------------------------------------- + | + | API 통신 전용 로그 채널. 날짜별로 파일 분리. + | 파일 경로: storage/logs/api/api-YYYY-MM-DD.log + | + */ + 'api' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/api/api.log'), + 'level' => 'info', + 'days' => env('API_LOG_DAYS', 14), + 'replace_placeholders' => true, + ], + ], ]; diff --git a/database/migrations/2025_12_15_144707_create_api_request_logs_table.php b/database/migrations/2025_12_15_144707_create_api_request_logs_table.php new file mode 100644 index 0000000..7ae8541 --- /dev/null +++ b/database/migrations/2025_12_15_144707_create_api_request_logs_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('method', 10)->comment('HTTP 메서드'); + $table->string('url', 2048)->comment('요청 URL'); + $table->string('route_name')->nullable()->comment('라우트 이름'); + $table->json('request_headers')->nullable()->comment('요청 헤더'); + $table->json('request_body')->nullable()->comment('요청 바디'); + $table->json('request_query')->nullable()->comment('쿼리 파라미터'); + $table->unsignedSmallInteger('response_status')->comment('응답 상태 코드'); + $table->longText('response_body')->nullable()->comment('응답 바디'); + $table->unsignedInteger('duration_ms')->comment('처리 시간 (ms)'); + $table->string('ip_address', 45)->nullable()->comment('클라이언트 IP'); + $table->string('user_agent', 512)->nullable()->comment('User Agent'); + $table->unsignedBigInteger('user_id')->nullable()->comment('사용자 ID'); + $table->unsignedBigInteger('tenant_id')->nullable()->comment('테넌트 ID'); + $table->timestamp('created_at')->useCurrent()->comment('생성일시'); + + $table->index('created_at'); + $table->index(['method', 'response_status']); + $table->index('user_id'); + $table->index('tenant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_request_logs'); + } +}; diff --git a/routes/console.php b/routes/console.php index de9add0..adc567c 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); +// API 요청 로그 정리 커맨드 +Artisan::command('api-log:prune', function () { + $deleted = ApiRequestLog::pruneOldLogs(); + $this->info("✅ {$deleted}개의 API 요청 로그가 삭제되었습니다."); +})->purpose('하루 지난 API 요청 로그 삭제'); + // 스케줄러 정의 (Laravel 12 표준 방식) + +// 매일 새벽 03:00에 API 요청 로그 정리 (하루치만 보관) +Schedule::command('api-log:prune') + ->dailyAt('03:00') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ api-log:prune 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ api-log:prune 스케줄러 실행 실패', ['time' => now()]); + }); + // 매일 새벽 03:10에 감사 로그 정리 (환경값 기반 보관기간) Schedule::command('audit:prune') ->dailyAt('03:10')