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:
2025-12-15 15:16:38 +09:00
parent 23fd59dc88
commit ba528b5a13
6 changed files with 316 additions and 0 deletions

View 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'],
]);
}
}