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); // 사용자/테넌트 정보 가져오기 (ApiKeyMiddleware에서 설정된 값 우선) // api_user는 이미 user_id 값, tenant_id도 마찬가지 $userId = app()->bound('api_user') ? app('api_user') : $request->user()?->id; $tenantId = app()->bound('tenant_id') ? app('tenant_id') : $request->user()?->current_tenant_id; // 그룹 ID 생성 (동일 tenant+user의 연속 요청 묶기) $groupId = $this->getOrCreateGroupId($tenantId, $userId); $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' => $userId, 'tenant_id' => $tenantId, 'group_id' => $groupId, ]; // 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'], ]); } /** * 그룹 ID 생성 또는 기존 그룹 ID 반환 * 동일 tenant+user의 5초 내 요청은 같은 그룹으로 묶음 */ protected function getOrCreateGroupId(?int $tenantId, ?int $userId): string { $cacheKey = "api_log_group:{$tenantId}:{$userId}"; $groupTtl = 5; // 5초 내 요청은 같은 그룹 // 캐시에서 기존 그룹 ID 확인 $existingGroupId = cache()->get($cacheKey); if ($existingGroupId) { // TTL 갱신 (연속 요청 시 그룹 유지) cache()->put($cacheKey, $existingGroupId, $groupTtl); return $existingGroupId; } // 새 그룹 ID 생성 $newGroupId = now()->format('Ymd_His_').substr(md5(uniqid((string) mt_rand(), true)), 0, 8); cache()->put($cacheKey, $newGroupId, $groupTtl); return $newGroupId; } }