diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index c13973c..3903267 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). - API Key 신청 필드 (사용기간, 서비스IP, 서비스용도, 하루 예상 요청량) (#8) - API Key Permission 관리 API (#8) - API Key 관리 프론트엔드 (신청/검토/키관리/권한편집) (#8) +- API Gateway 프록시 (ANY /gateway/{serviceCode}/**) (#9) +- API Key 인증 필터 (prefix 매칭 + AES 복호화 + 권한 확인) (#9) +- 요청/응답 비동기 로깅 (@Async) (#9) +- 요청 로그 검색 API (JPA Specification 동적 쿼리) (#9) +- 요청 로그 검색/상세 프론트엔드 페이지 (#9) ## [2026-04-07] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e985552..8360226 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import ProtectedRoute from './components/ProtectedRoute'; import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; import RequestLogsPage from './pages/monitoring/RequestLogsPage'; +import RequestLogDetailPage from './pages/monitoring/RequestLogDetailPage'; import MyKeysPage from './pages/apikeys/MyKeysPage'; import KeyRequestPage from './pages/apikeys/KeyRequestPage'; import KeyAdminPage from './pages/apikeys/KeyAdminPage'; @@ -30,6 +31,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/monitoring/RequestLogDetailPage.tsx b/frontend/src/pages/monitoring/RequestLogDetailPage.tsx new file mode 100644 index 0000000..c808455 --- /dev/null +++ b/frontend/src/pages/monitoring/RequestLogDetailPage.tsx @@ -0,0 +1,210 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import type { RequestLog } from '../../types/monitoring'; +import { getLogDetail } from '../../services/monitoringService'; + +const METHOD_COLOR: Record = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-orange-100 text-orange-800', + DELETE: 'bg-red-100 text-red-800', +}; + +const STATUS_BADGE: Record = { + SUCCESS: 'bg-green-100 text-green-800', + DENIED: 'bg-red-100 text-red-800', + EXPIRED: 'bg-orange-100 text-orange-800', + INVALID_KEY: 'bg-red-100 text-red-800', + FAILED: 'bg-gray-100 text-gray-800', +}; + +const formatDateTime = (dateStr: string): string => { + const d = new Date(dateStr); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +}; + +const formatJson = (str: string | null): string | null => { + if (!str) return null; + try { + return JSON.stringify(JSON.parse(str), null, 2); + } catch { + return str; + } +}; + +const RequestLogDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [log, setLog] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + + const fetchDetail = async () => { + setLoading(true); + setError(null); + try { + const res = await getLogDetail(Number(id)); + if (res.success && res.data) { + setLog(res.data); + } else { + setError(res.message || '로그 상세 조회에 실패했습니다.'); + } + } catch { + setError('로그 상세 조회에 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + fetchDetail(); + }, [id]); + + if (loading) { + return
로딩 중...
; + } + + if (error) { + return ( +
+ +
{error}
+
+ ); + } + + if (!log) return null; + + const formattedHeaders = formatJson(log.requestHeaders); + const formattedParams = formatJson(log.requestParams); + + return ( +
+ + + {/* 기본 정보 */} +
+

기본 정보

+
+
+ 요청 시간 + {formatDateTime(log.requestedAt)} +
+
+ 요청 + + + {log.requestMethod} + + {log.requestUrl} + +
+
+ 응답 코드 + + {log.responseStatus != null ? log.responseStatus : '-'} + +
+
+ 응답시간(ms) + + {log.responseTime != null ? log.responseTime : '-'} + +
+
+ 응답크기(bytes) + + {log.responseSize != null ? log.responseSize : '-'} + +
+
+ 상태 + + {log.requestStatus} + +
+
+
+
+ 서비스 + {log.serviceName || '-'} +
+
+ API Key + + {log.apiKeyPrefix || '-'} + +
+
+ 사용자 + {log.userName || '-'} +
+
+ IP + {log.requestIp} +
+
+
+ + {/* 요청 정보 */} + {(formattedHeaders || formattedParams) && ( +
+

요청 정보

+ {formattedHeaders && ( +
+ Request Headers +
+                {formattedHeaders}
+              
+
+ )} + {formattedParams && ( +
+ Request Params +
+                {formattedParams}
+              
+
+ )} +
+ )} + + {/* 에러 정보 */} + {log.errorMessage && ( +
+

에러 정보

+

{log.errorMessage}

+
+ )} +
+ ); +}; + +export default RequestLogDetailPage; diff --git a/frontend/src/pages/monitoring/RequestLogsPage.tsx b/frontend/src/pages/monitoring/RequestLogsPage.tsx index ac6fc43..2a77939 100644 --- a/frontend/src/pages/monitoring/RequestLogsPage.tsx +++ b/frontend/src/pages/monitoring/RequestLogsPage.tsx @@ -1,8 +1,360 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { RequestLog, PageResponse } from '../../types/monitoring'; +import type { ServiceInfo } from '../../types/service'; +import { searchLogs } from '../../services/monitoringService'; +import { getServices } from '../../services/serviceService'; + +const METHOD_COLOR: Record = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-orange-100 text-orange-800', + DELETE: 'bg-red-100 text-red-800', +}; + +const STATUS_BADGE: Record = { + SUCCESS: 'bg-green-100 text-green-800', + DENIED: 'bg-red-100 text-red-800', + EXPIRED: 'bg-orange-100 text-orange-800', + INVALID_KEY: 'bg-red-100 text-red-800', + FAILED: 'bg-gray-100 text-gray-800', +}; + +const REQUEST_STATUSES = ['SUCCESS', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'FAILED']; +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE']; +const DEFAULT_PAGE_SIZE = 20; + +const getTodayString = (): string => { + const d = new Date(); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const formatDateTime = (dateStr: string): string => { + const d = new Date(dateStr); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +}; + const RequestLogsPage = () => { + const navigate = useNavigate(); + + const [startDate, setStartDate] = useState(getTodayString()); + const [endDate, setEndDate] = useState(getTodayString()); + const [serviceId, setServiceId] = useState(''); + const [requestStatus, setRequestStatus] = useState(''); + const [requestMethod, setRequestMethod] = useState(''); + const [requestIp, setRequestIp] = useState(''); + + const [services, setServices] = useState([]); + const [result, setResult] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + + const fetchServices = async () => { + try { + const res = await getServices(); + if (res.success && res.data) { + setServices(res.data); + } + } catch { + // 서비스 목록 로딩 실패는 무시 + } + }; + + const handleSearch = async (page: number) => { + setLoading(true); + setError(null); + setCurrentPage(page); + + try { + const params: Record = { + startDate: startDate || undefined, + endDate: endDate || undefined, + serviceId: serviceId ? Number(serviceId) : undefined, + requestStatus: requestStatus || undefined, + requestMethod: requestMethod || undefined, + requestIp: requestIp || undefined, + page, + size: DEFAULT_PAGE_SIZE, + }; + const res = await searchLogs(params); + if (res.success && res.data) { + setResult(res.data); + } else { + setError(res.message || '로그 조회에 실패했습니다.'); + } + } catch { + setError('로그 조회에 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + const handleReset = () => { + setStartDate(getTodayString()); + setEndDate(getTodayString()); + setServiceId(''); + setRequestStatus(''); + setRequestMethod(''); + setRequestIp(''); + setCurrentPage(0); + }; + + useEffect(() => { + fetchServices(); + handleSearch(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleResetAndSearch = () => { + handleReset(); + // 초기화 후 기본값으로 검색 (setState는 비동기이므로 직접 호출) + setLoading(true); + setError(null); + setCurrentPage(0); + const today = getTodayString(); + const params: Record = { + startDate: today, + endDate: today, + page: 0, + size: DEFAULT_PAGE_SIZE, + }; + searchLogs(params) + .then((res) => { + if (res.success && res.data) { + setResult(res.data); + } else { + setError(res.message || '로그 조회에 실패했습니다.'); + } + }) + .catch(() => { + setError('로그 조회에 실패했습니다.'); + }) + .finally(() => { + setLoading(false); + }); + }; + + const handleRowClick = (logId: number) => { + navigate(`/monitoring/request-logs/${logId}`); + }; + + const handlePrev = () => { + if (currentPage > 0) { + handleSearch(currentPage - 1); + } + }; + + const handleNext = () => { + if (result && currentPage < result.totalPages - 1) { + handleSearch(currentPage + 1); + } + }; + return (
-

Request Logs

-

Coming soon

+

Request Logs

+ + {/* Search Form */} +
+
+
+ +
+ setStartDate(e.target.value)} + className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> + ~ + setEndDate(e.target.value)} + className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + setRequestIp(e.target.value)} + placeholder="IP 주소" + className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + +
+
+
+ + {error && ( +
{error}
+ )} + + {/* Results Table */} +
+ {loading ? ( +
로딩 중...
+ ) : ( + + + + + + + + + + + + + + + {result && result.content.length > 0 ? ( + result.content.map((log) => ( + handleRowClick(log.logId)} + className="cursor-pointer hover:bg-gray-50" + > + + + + + + + + + + )) + ) : ( + + + + )} + +
시간서비스MethodURLStatus Code응답시간(ms)상태IP
+ {formatDateTime(log.requestedAt)} + {log.serviceName || '-'} + + {log.requestMethod} + + + {log.requestUrl} + + {log.responseStatus != null ? log.responseStatus : '-'} + + {log.responseTime != null ? log.responseTime : '-'} + + + {log.requestStatus} + + {log.requestIp}
+ 검색 결과가 없습니다 +
+ )} +
+ + {/* Pagination */} + {result && result.totalElements > 0 && ( +
+ + 총 {result.totalElements}건 / {result.page + 1} / {result.totalPages} 페이지 + +
+ + +
+
+ )}
); }; diff --git a/frontend/src/services/monitoringService.ts b/frontend/src/services/monitoringService.ts new file mode 100644 index 0000000..264cb59 --- /dev/null +++ b/frontend/src/services/monitoringService.ts @@ -0,0 +1,12 @@ +import { get } from './apiClient'; +import type { RequestLog, PageResponse } from '../types/monitoring'; + +export const searchLogs = (params: Record) => { + const query = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`) + .join('&'); + return get>(`/monitoring/logs${query ? '?' + query : ''}`); +}; + +export const getLogDetail = (id: number) => get(`/monitoring/logs/${id}`); diff --git a/frontend/src/types/monitoring.ts b/frontend/src/types/monitoring.ts new file mode 100644 index 0000000..8d8f3ef --- /dev/null +++ b/frontend/src/types/monitoring.ts @@ -0,0 +1,37 @@ +export interface RequestLog { + logId: number; + requestUrl: string; + requestMethod: string; + requestStatus: string; + requestIp: string; + responseStatus: number | null; + responseTime: number | null; + responseSize: number | null; + errorMessage: string | null; + requestedAt: string; + serviceId: number | null; + serviceName: string | null; + apiKeyPrefix: string | null; + userName: string | null; + requestHeaders: string | null; + requestParams: string | null; +} + +export interface RequestLogSearch { + startDate?: string; + endDate?: string; + serviceId?: number; + requestStatus?: string; + requestMethod?: string; + requestIp?: string; + page: number; + size: number; +} + +export interface PageResponse { + content: T[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java index 4648174..56b7860 100644 --- a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java +++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java @@ -11,4 +11,6 @@ public interface SnpApiKeyRepository extends JpaRepository { Optional findByApiKey(String apiKey); List findByUserUserId(Long userId); + + List findByApiKeyPrefix(String apiKeyPrefix); } diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java index 2c96730..0bdd917 100644 --- a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java +++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java @@ -6,9 +6,12 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import java.util.List; +import java.util.Optional; public interface SnpApiPermissionRepository extends JpaRepository { + Optional findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(Long apiKeyId, Long apiId); + List findByApiKeyApiKeyId(Long apiKeyId); @Modifying diff --git a/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java index 1211eec..31496a5 100644 --- a/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java +++ b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java @@ -25,6 +25,12 @@ public enum ErrorCode { API_KEY_REQUEST_NOT_FOUND(404, "KEY003", "API Key 신청을 찾을 수 없습니다"), API_KEY_REQUEST_ALREADY_PROCESSED(409, "KEY004", "이미 처리된 신청입니다"), ENCRYPTION_ERROR(500, "KEY005", "암호화 처리 중 오류가 발생했습니다"), + GATEWAY_API_KEY_MISSING(401, "GW001", "API Key가 필요합니다"), + GATEWAY_API_KEY_INVALID(401, "GW002", "유효하지 않은 API Key입니다"), + GATEWAY_API_KEY_EXPIRED(403, "GW003", "만료된 API Key입니다"), + GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"), + GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"), + GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"), INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다"); private final int status; diff --git a/src/main/java/com/gcsc/connection/config/AsyncConfig.java b/src/main/java/com/gcsc/connection/config/AsyncConfig.java new file mode 100644 index 0000000..ca567a6 --- /dev/null +++ b/src/main/java/com/gcsc/connection/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.gcsc.connection.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean("taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-log-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/gcsc/connection/config/SecurityConfig.java b/src/main/java/com/gcsc/connection/config/SecurityConfig.java index 8e388c1..dcc4423 100644 --- a/src/main/java/com/gcsc/connection/config/SecurityConfig.java +++ b/src/main/java/com/gcsc/connection/config/SecurityConfig.java @@ -41,6 +41,7 @@ public class SecurityConfig { .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/actuator/**").permitAll() .requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll() + .requestMatchers("/gateway/**").permitAll() .requestMatchers("/api/**").authenticated() .anyRequest().permitAll()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/gcsc/connection/gateway/controller/GatewayController.java b/src/main/java/com/gcsc/connection/gateway/controller/GatewayController.java new file mode 100644 index 0000000..3f03b7b --- /dev/null +++ b/src/main/java/com/gcsc/connection/gateway/controller/GatewayController.java @@ -0,0 +1,83 @@ +package com.gcsc.connection.gateway.controller; + +import com.gcsc.connection.common.exception.BusinessException; +import com.gcsc.connection.gateway.service.GatewayService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +/** + * API Gateway 프록시 엔드포인트 + */ +@RestController +@RequestMapping("/gateway") +@RequiredArgsConstructor +@Slf4j +public class GatewayController { + + private final GatewayService gatewayService; + + /** + * 모든 HTTP 메서드에 대한 프록시 요청 처리 + */ + @RequestMapping( + value = "/{serviceCode}/**", + method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, + RequestMethod.DELETE, RequestMethod.PATCH} + ) + public ResponseEntity proxy(@PathVariable String serviceCode, + HttpServletRequest request) { + try { + String remainingPath = extractRemainingPath(serviceCode, request); + return gatewayService.proxyRequest(serviceCode, remainingPath, request); + } catch (BusinessException e) { + return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage()); + } + } + + /** + * /gateway/{serviceCode}/ 이후의 경로 추출 + */ + private String extractRemainingPath(String serviceCode, HttpServletRequest request) { + String fullPath = request.getRequestURI(); + String contextPath = request.getContextPath(); + String prefix = contextPath + "/gateway/" + serviceCode; + String remainingPath = fullPath.substring(prefix.length()); + + if (remainingPath.isEmpty()) { + remainingPath = "/"; + } + + return remainingPath; + } + + /** + * Gateway 소비자용 JSON 에러 응답 생성 + */ + private ResponseEntity buildErrorResponse(int status, String message) { + String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}"; + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_JSON) + .body(json.getBytes()); + } + + /** + * JSON 문자열 이스케이프 처리 + */ + private String escapeJson(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java new file mode 100644 index 0000000..8de1b8c --- /dev/null +++ b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java @@ -0,0 +1,309 @@ +package com.gcsc.connection.gateway.service; + +import com.gcsc.connection.apikey.entity.ApiKeyStatus; +import com.gcsc.connection.apikey.entity.SnpApiKey; +import com.gcsc.connection.apikey.repository.SnpApiKeyRepository; +import com.gcsc.connection.apikey.repository.SnpApiPermissionRepository; +import com.gcsc.connection.common.exception.BusinessException; +import com.gcsc.connection.common.exception.ErrorCode; +import com.gcsc.connection.common.util.AesEncryptor; +import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; +import com.gcsc.connection.monitoring.service.RequestLogService; +import com.gcsc.connection.service.entity.SnpService; +import com.gcsc.connection.service.entity.SnpServiceApi; +import com.gcsc.connection.service.repository.SnpServiceApiRepository; +import com.gcsc.connection.service.repository.SnpServiceRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.LocalDateTime; +import java.util.Enumeration; +import java.util.List; +import java.util.Set; + +@Service +@Slf4j +@RequiredArgsConstructor +public class GatewayService { + + private static final int API_KEY_PREFIX_LENGTH = 8; + private static final Set EXCLUDED_HEADERS = Set.of( + "host", "x-api-key", "connection", "content-length" + ); + + private final SnpApiKeyRepository snpApiKeyRepository; + private final SnpApiPermissionRepository snpApiPermissionRepository; + private final SnpServiceRepository snpServiceRepository; + private final SnpServiceApiRepository snpServiceApiRepository; + private final AesEncryptor aesEncryptor; + private final WebClient webClient; + private final RequestLogService requestLogService; + + /** + * API Gateway 프록시 요청 처리 + */ + public ResponseEntity proxyRequest(String serviceCode, String remainingPath, + HttpServletRequest request) { + LocalDateTime requestedAt = LocalDateTime.now(); + long startTime = System.currentTimeMillis(); + SnpApiKey apiKey = null; + SnpService service = null; + String gatewayPath = "/gateway/" + serviceCode + remainingPath + + (request.getQueryString() != null ? "?" + request.getQueryString() : ""); + String targetUrl = null; + + try { + // 1. API Key 추출 + String rawKey = request.getHeader("X-API-KEY"); + if (rawKey == null || rawKey.isBlank()) { + throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING); + } + + // 2. API Key 검증 (prefix 매칭 후 복호화 비교) + apiKey = findApiKeyByRawKey(rawKey); + + // 3. Key 상태/만료 검증 + validateApiKey(apiKey); + + // 4. 서비스 조회 + service = snpServiceRepository.findByServiceCode(serviceCode) + .orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND)); + + if (!Boolean.TRUE.equals(service.getIsActive())) { + throw new BusinessException(ErrorCode.GATEWAY_SERVICE_INACTIVE); + } + + // 5. 대상 URL 조합 (실패 로그에도 사용) + targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request); + + // 6. ServiceApi 조회 (경로 + 메서드 매칭) + String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath; + SnpServiceApi serviceApi = snpServiceApiRepository + .findByServiceServiceIdAndApiPathAndApiMethod( + service.getServiceId(), apiPath, request.getMethod()) + .orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED)); + + // 6. 권한 확인 + snpApiPermissionRepository + .findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(apiKey.getApiKeyId(), serviceApi.getApiId()) + .orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED)); + + // 7. 프록시 요청 전송 + ResponseEntity response = forwardRequest(targetUrl, request); + + // 9. 마지막 사용 시간 갱신 + apiKey.updateLastUsedAt(); + snpApiKeyRepository.save(apiKey); + + // 10. 성공 로그 저장 + int responseTime = (int) (System.currentTimeMillis() - startTime); + saveLog(request, service, apiKey, targetUrl, gatewayPath, "SUCCESS", + response.getStatusCode().value(), responseTime, + response.getBody() != null ? (long) response.getBody().length : 0L, + null, requestedAt); + + return response; + + } catch (BusinessException e) { + int responseTime = (int) (System.currentTimeMillis() - startTime); + saveLog(request, service, apiKey, targetUrl, gatewayPath, "FAIL", + e.getErrorCode().getStatus(), responseTime, 0L, + e.getErrorCode().getMessage(), requestedAt); + throw e; + + } catch (Exception e) { + int responseTime = (int) (System.currentTimeMillis() - startTime); + log.error("Gateway 프록시 요청 실패: {}", e.getMessage(), e); + saveLog(request, service, apiKey, targetUrl, gatewayPath, "ERROR", + 502, responseTime, 0L, e.getMessage(), requestedAt); + throw new BusinessException(ErrorCode.GATEWAY_PROXY_FAILED); + } + } + + /** + * prefix 매칭 후 복호화하여 API Key 찾기 + */ + private SnpApiKey findApiKeyByRawKey(String rawKey) { + if (rawKey.length() < API_KEY_PREFIX_LENGTH) { + throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID); + } + + String prefix = rawKey.substring(0, API_KEY_PREFIX_LENGTH); + List candidates = snpApiKeyRepository.findByApiKeyPrefix(prefix); + + for (SnpApiKey candidate : candidates) { + try { + String decryptedKey = aesEncryptor.decrypt(candidate.getApiKey()); + if (rawKey.equals(decryptedKey)) { + return candidate; + } + } catch (Exception e) { + log.debug("API Key 복호화 비교 실패 (apiKeyId={})", candidate.getApiKeyId()); + } + } + + throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID); + } + + /** + * API Key 상태 및 만료 검증 + */ + private void validateApiKey(SnpApiKey apiKey) { + if (apiKey.getStatus() != ApiKeyStatus.ACTIVE) { + throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID); + } + + if (apiKey.getExpiresAt() != null && apiKey.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.GATEWAY_API_KEY_EXPIRED); + } + } + + /** + * 대상 URL 구성 + */ + private String buildTargetUrl(String serviceUrl, String remainingPath, HttpServletRequest request) { + StringBuilder url = new StringBuilder(serviceUrl); + if (!remainingPath.isEmpty() && !"/".equals(remainingPath)) { + if (!serviceUrl.endsWith("/") && !remainingPath.startsWith("/")) { + url.append("/"); + } + url.append(remainingPath); + } + + String queryString = request.getQueryString(); + if (queryString != null && !queryString.isEmpty()) { + url.append("?").append(queryString); + } + + return url.toString(); + } + + /** + * WebClient를 통한 프록시 요청 전송 + */ + private ResponseEntity forwardRequest(String targetUrl, HttpServletRequest request) { + HttpMethod method = HttpMethod.valueOf(request.getMethod()); + + WebClient.RequestBodySpec requestSpec = webClient.method(method) + .uri(targetUrl) + .headers(headers -> copyHeaders(request, headers)); + + WebClient.RequestHeadersSpec headersSpec; + if (hasRequestBody(method)) { + headersSpec = requestSpec.bodyValue(readRequestBody(request)); + } else { + headersSpec = requestSpec; + } + + try { + return headersSpec.retrieve() + .toEntity(byte[].class) + .block(); + } catch (WebClientResponseException e) { + // 대상 서버에서 에러 응답이 와도 그대로 전달 + return ResponseEntity + .status(e.getStatusCode()) + .headers(e.getHeaders()) + .body(e.getResponseBodyAsByteArray()); + } + } + + /** + * 요청 헤더 복사 (제외 대상 헤더 필터링) + */ + private void copyHeaders(HttpServletRequest request, HttpHeaders headers) { + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + if (!EXCLUDED_HEADERS.contains(headerName.toLowerCase())) { + headers.set(headerName, request.getHeader(headerName)); + } + } + } + + /** + * POST/PUT/PATCH 메서드 여부 확인 + */ + private boolean hasRequestBody(HttpMethod method) { + return HttpMethod.POST.equals(method) + || HttpMethod.PUT.equals(method) + || HttpMethod.PATCH.equals(method); + } + + /** + * 요청 본문 읽기 + */ + private byte[] readRequestBody(HttpServletRequest request) { + try { + return request.getInputStream().readAllBytes(); + } catch (Exception e) { + log.warn("요청 본문 읽기 실패", e); + return new byte[0]; + } + } + + /** + * 비동기 요청 로그 저장 + */ + private void saveLog(HttpServletRequest request, SnpService service, SnpApiKey apiKey, + String targetUrl, String gatewayPath, String status, Integer responseStatus, + Integer responseTime, Long responseSize, String errorMessage, + LocalDateTime requestedAt) { + try { + SnpApiRequestLog logEntry = SnpApiRequestLog.builder() + .requestUrl(gatewayPath) + .requestMethod(request.getMethod()) + .requestStatus(status) + .requestIp(getClientIp(request)) + .requestHeaders(extractHeaders(request)) + .requestParams(request.getQueryString()) + .service(service) + .user(apiKey != null ? apiKey.getUser() : null) + .apiKey(apiKey) + .tenant(apiKey != null && apiKey.getUser() != null ? apiKey.getUser().getTenant() : null) + .responseStatus(responseStatus) + .responseTime(responseTime) + .responseSize(responseSize) + .errorMessage(errorMessage) + .requestedAt(requestedAt) + .build(); + + requestLogService.saveLogAsync(logEntry); + } catch (Exception e) { + log.error("로그 엔트리 생성 실패", e); + } + } + + /** + * 클라이언트 IP 추출 + */ + private String getClientIp(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isBlank()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + /** + * 요청 헤더를 문자열로 변환 + */ + private String extractHeaders(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String name = headerNames.nextElement(); + if (!"x-api-key".equalsIgnoreCase(name)) { + sb.append(name).append(": ").append(request.getHeader(name)).append("\n"); + } + } + return sb.toString(); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/controller/RequestLogController.java b/src/main/java/com/gcsc/connection/monitoring/controller/RequestLogController.java new file mode 100644 index 0000000..ffb3008 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/controller/RequestLogController.java @@ -0,0 +1,62 @@ +package com.gcsc.connection.monitoring.controller; + +import com.gcsc.connection.common.dto.ApiResponse; +import com.gcsc.connection.common.dto.PageResponse; +import com.gcsc.connection.monitoring.dto.RequestLogResponse; +import com.gcsc.connection.monitoring.dto.RequestLogSearchDto; +import com.gcsc.connection.monitoring.service.RequestLogService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 요청 로그 조회 API + */ +@RestController +@RequestMapping("/api/monitoring") +@RequiredArgsConstructor +public class RequestLogController { + + private final RequestLogService requestLogService; + + /** + * 요청 로그 검색 + */ + @GetMapping("/logs") + public ResponseEntity>> searchLogs( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + @RequestParam(required = false) Long serviceId, + @RequestParam(required = false) String requestStatus, + @RequestParam(required = false) String requestMethod, + @RequestParam(required = false) String requestIp, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + LocalDateTime startDateTime = startDate != null ? startDate.atStartOfDay() : null; + LocalDateTime endDateTime = endDate != null ? endDate.plusDays(1).atStartOfDay() : null; + RequestLogSearchDto searchDto = new RequestLogSearchDto( + startDateTime, endDateTime, serviceId, requestStatus, requestMethod, requestIp, page, size); + Page result = requestLogService.searchLogs(searchDto); + return ResponseEntity.ok(ApiResponse.ok(PageResponse.from(result))); + } + + /** + * 요청 로그 상세 조회 + */ + @GetMapping("/logs/{id}") + public ResponseEntity> getLogDetail(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok(requestLogService.getLogDetail(id))); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/RequestLogResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/RequestLogResponse.java new file mode 100644 index 0000000..0813185 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/RequestLogResponse.java @@ -0,0 +1,73 @@ +package com.gcsc.connection.monitoring.dto; + +import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; + +import java.time.LocalDateTime; + +public record RequestLogResponse( + Long logId, + String requestUrl, + String requestMethod, + String requestStatus, + String requestIp, + Integer responseStatus, + Integer responseTime, + Long responseSize, + String errorMessage, + LocalDateTime requestedAt, + Long serviceId, + String serviceName, + String apiKeyPrefix, + String userName, + String requestHeaders, + String requestParams +) { + + /** + * 목록 조회용 변환 (헤더/파라미터 제외) + */ + public static RequestLogResponse from(SnpApiRequestLog log) { + return new RequestLogResponse( + log.getLogId(), + log.getRequestUrl(), + log.getRequestMethod(), + log.getRequestStatus(), + log.getRequestIp(), + log.getResponseStatus(), + log.getResponseTime(), + log.getResponseSize(), + log.getErrorMessage(), + log.getRequestedAt(), + log.getService() != null ? log.getService().getServiceId() : null, + log.getService() != null ? log.getService().getServiceName() : null, + log.getApiKey() != null ? log.getApiKey().getApiKeyPrefix() : null, + log.getUser() != null ? log.getUser().getUserName() : null, + null, + null + ); + } + + /** + * 상세 조회용 변환 (모든 필드 포함) + */ + public static RequestLogResponse fromDetail(SnpApiRequestLog log) { + return new RequestLogResponse( + log.getLogId(), + log.getRequestUrl(), + log.getRequestMethod(), + log.getRequestStatus(), + log.getRequestIp(), + log.getResponseStatus(), + log.getResponseTime(), + log.getResponseSize(), + log.getErrorMessage(), + log.getRequestedAt(), + log.getService() != null ? log.getService().getServiceId() : null, + log.getService() != null ? log.getService().getServiceName() : null, + log.getApiKey() != null ? log.getApiKey().getApiKeyPrefix() : null, + log.getUser() != null ? log.getUser().getUserName() : null, + log.getRequestHeaders(), + log.getRequestParams() + ); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/RequestLogSearchDto.java b/src/main/java/com/gcsc/connection/monitoring/dto/RequestLogSearchDto.java new file mode 100644 index 0000000..3a2474c --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/RequestLogSearchDto.java @@ -0,0 +1,20 @@ +package com.gcsc.connection.monitoring.dto; + +import java.time.LocalDateTime; + +public record RequestLogSearchDto( + LocalDateTime startDate, + LocalDateTime endDate, + Long serviceId, + String requestStatus, + String requestMethod, + String requestIp, + int page, + int size +) { + + public RequestLogSearchDto { + if (page < 0) page = 0; + if (size <= 0) size = 20; + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java index 8edf50a..4159cf7 100644 --- a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java +++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java @@ -2,6 +2,8 @@ package com.gcsc.connection.monitoring.repository; import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -public interface SnpApiRequestLogRepository extends JpaRepository { +public interface SnpApiRequestLogRepository extends JpaRepository, + JpaSpecificationExecutor { } diff --git a/src/main/java/com/gcsc/connection/monitoring/service/RequestLogService.java b/src/main/java/com/gcsc/connection/monitoring/service/RequestLogService.java new file mode 100644 index 0000000..0bccaf9 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/service/RequestLogService.java @@ -0,0 +1,59 @@ +package com.gcsc.connection.monitoring.service; + +import com.gcsc.connection.common.exception.BusinessException; +import com.gcsc.connection.common.exception.ErrorCode; +import com.gcsc.connection.monitoring.dto.RequestLogResponse; +import com.gcsc.connection.monitoring.dto.RequestLogSearchDto; +import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; +import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository; +import com.gcsc.connection.monitoring.spec.RequestLogSpecification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RequestLogService { + + private final SnpApiRequestLogRepository snpApiRequestLogRepository; + + /** + * 비동기 요청 로그 저장 + */ + @Async("taskExecutor") + @Transactional + public void saveLogAsync(SnpApiRequestLog logEntry) { + try { + snpApiRequestLogRepository.save(logEntry); + } catch (Exception e) { + log.error("요청 로그 저장 실패", e); + } + } + + /** + * 요청 로그 검색 + */ + @Transactional(readOnly = true) + public Page searchLogs(RequestLogSearchDto searchDto) { + Specification spec = RequestLogSpecification.withFilters(searchDto); + Pageable pageable = PageRequest.of(searchDto.page(), searchDto.size()); + return snpApiRequestLogRepository.findAll(spec, pageable).map(RequestLogResponse::from); + } + + /** + * 요청 로그 상세 조회 + */ + @Transactional(readOnly = true) + public RequestLogResponse getLogDetail(Long logId) { + return snpApiRequestLogRepository.findById(logId) + .map(RequestLogResponse::fromDetail) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/spec/RequestLogSpecification.java b/src/main/java/com/gcsc/connection/monitoring/spec/RequestLogSpecification.java new file mode 100644 index 0000000..a7826ae --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/spec/RequestLogSpecification.java @@ -0,0 +1,46 @@ +package com.gcsc.connection.monitoring.spec; + +import com.gcsc.connection.monitoring.dto.RequestLogSearchDto; +import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; +import jakarta.persistence.criteria.Predicate; +import org.springframework.data.jpa.domain.Specification; + +import java.util.ArrayList; +import java.util.List; + +public class RequestLogSpecification { + + private RequestLogSpecification() { + } + + /** + * 검색 조건 기반 Specification 생성 + */ + public static Specification withFilters(RequestLogSearchDto dto) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (dto.startDate() != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("requestedAt"), dto.startDate())); + } + if (dto.endDate() != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("requestedAt"), dto.endDate())); + } + if (dto.serviceId() != null) { + predicates.add(cb.equal(root.get("service").get("serviceId"), dto.serviceId())); + } + if (dto.requestStatus() != null && !dto.requestStatus().isBlank()) { + predicates.add(cb.equal(root.get("requestStatus"), dto.requestStatus())); + } + if (dto.requestMethod() != null && !dto.requestMethod().isBlank()) { + predicates.add(cb.equal(root.get("requestMethod"), dto.requestMethod())); + } + if (dto.requestIp() != null && !dto.requestIp().isBlank()) { + predicates.add(cb.like(root.get("requestIp"), dto.requestIp() + "%")); + } + + query.orderBy(cb.desc(root.get("requestedAt"))); + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java index 1b3322e..f4f0025 100644 --- a/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java +++ b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java @@ -5,10 +5,14 @@ import com.gcsc.connection.service.entity.SnpServiceApi; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface SnpServiceApiRepository extends JpaRepository { List findByService(SnpService service); List findByServiceServiceId(Long serviceId); + + Optional findByServiceServiceIdAndApiPathAndApiMethod( + Long serviceId, String apiPath, String apiMethod); }