feat: API 요청/응답 로깅 시스템 추가
- api_request_logs 테이블 생성 (하루치만 보관) - LogApiRequest 미들웨어로 DB + 로그 파일 이중 저장 - 날짜별 로그 파일: storage/logs/api/api-YYYY-MM-DD.log - 민감 데이터 자동 마스킹 (password, token 등) - api-log:prune 스케줄러로 매일 03:00 자동 정리
This commit is contained in:
166
app/Http/Middleware/LogApiRequest.php
Normal file
166
app/Http/Middleware/LogApiRequest.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiRequestLog;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* API 요청/응답 로깅 미들웨어
|
||||
*
|
||||
* DB와 로그 파일에 이중으로 저장
|
||||
*/
|
||||
class LogApiRequest
|
||||
{
|
||||
/**
|
||||
* 로깅에서 제외할 경로 패턴
|
||||
*/
|
||||
protected array $exceptPaths = [
|
||||
'api/health',
|
||||
'api-docs*',
|
||||
'docs*',
|
||||
];
|
||||
|
||||
/**
|
||||
* 민감한 헤더 (마스킹 처리)
|
||||
*/
|
||||
protected array $sensitiveHeaders = [
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'cookie',
|
||||
];
|
||||
|
||||
/**
|
||||
* 민감한 필드 (마스킹 처리)
|
||||
*/
|
||||
protected array $sensitiveFields = [
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
'new_password',
|
||||
'token',
|
||||
'secret',
|
||||
'api_key',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 제외 경로 체크
|
||||
if ($this->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
64
app/Models/ApiRequestLog.php
Normal file
64
app/Models/ApiRequestLog.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* API 요청/응답 로그 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $method
|
||||
* @property string $url
|
||||
* @property string|null $route_name
|
||||
* @property array|null $request_headers
|
||||
* @property array|null $request_body
|
||||
* @property array|null $request_query
|
||||
* @property int $response_status
|
||||
* @property string|null $response_body
|
||||
* @property int $duration_ms
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property int|null $user_id
|
||||
* @property int|null $tenant_id
|
||||
* @property \Carbon\Carbon $created_at
|
||||
*/
|
||||
class ApiRequestLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'method',
|
||||
'url',
|
||||
'route_name',
|
||||
'request_headers',
|
||||
'request_body',
|
||||
'request_query',
|
||||
'response_status',
|
||||
'response_body',
|
||||
'duration_ms',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'user_id',
|
||||
'tenant_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'request_headers' => '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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_request_logs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiRequestLog;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
@@ -8,7 +9,25 @@
|
||||
$this->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')
|
||||
|
||||
Reference in New Issue
Block a user