Merge pull request 'feat(phase4): API Gateway 프록시 + 요청 로깅' (#16) from feature/ISSUE-9-phase4-gateway-logging into develop

This commit is contained in:
HYOJIN 2026-04-08 11:21:37 +09:00
커밋 b5a29bfdfd
20개의 변경된 파일1315개의 추가작업 그리고 3개의 파일을 삭제

파일 보기

@ -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]

파일 보기

@ -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 = () => {
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
<Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
<Route path="/apikeys/my-keys" element={<MyKeysPage />} />
<Route path="/apikeys/request" element={<KeyRequestPage />} />
<Route path="/apikeys/admin" element={<KeyAdminPage />} />

파일 보기

@ -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<string, string> = {
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<string, string> = {
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<RequestLog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="text-center py-10 text-gray-500"> ...</div>;
}
if (error) {
return (
<div>
<button
onClick={() => navigate(-1)}
className="text-blue-600 hover:text-blue-800 font-medium mb-4"
>
&larr;
</button>
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
</div>
);
}
if (!log) return null;
const formattedHeaders = formatJson(log.requestHeaders);
const formattedParams = formatJson(log.requestParams);
return (
<div>
<button
onClick={() => navigate(-1)}
className="text-blue-600 hover:text-blue-800 font-medium mb-6"
>
&larr;
</button>
{/* 기본 정보 */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<div>
<span className="block text-sm font-medium text-gray-500"> </span>
<span className="text-sm text-gray-900">{formatDateTime(log.requestedAt)}</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500"></span>
<span className="text-sm text-gray-900">
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold mr-2 ${
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
}`}
>
{log.requestMethod}
</span>
{log.requestUrl}
</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500"> </span>
<span className="text-sm text-gray-900">
{log.responseStatus != null ? log.responseStatus : '-'}
</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500">(ms)</span>
<span className="text-sm text-gray-900">
{log.responseTime != null ? log.responseTime : '-'}
</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500">(bytes)</span>
<span className="text-sm text-gray-900">
{log.responseSize != null ? log.responseSize : '-'}
</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500"></span>
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
}`}
>
{log.requestStatus}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<span className="block text-sm font-medium text-gray-500"></span>
<span className="text-sm text-gray-900">{log.serviceName || '-'}</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500">API Key</span>
<span className="text-sm text-gray-900 font-mono">
{log.apiKeyPrefix || '-'}
</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500"></span>
<span className="text-sm text-gray-900">{log.userName || '-'}</span>
</div>
<div>
<span className="block text-sm font-medium text-gray-500">IP</span>
<span className="text-sm text-gray-900 font-mono">{log.requestIp}</span>
</div>
</div>
</div>
{/* 요청 정보 */}
{(formattedHeaders || formattedParams) && (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> </h2>
{formattedHeaders && (
<div className="mb-4">
<span className="block text-sm font-medium text-gray-500 mb-1">Request Headers</span>
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto">
{formattedHeaders}
</pre>
</div>
)}
{formattedParams && (
<div>
<span className="block text-sm font-medium text-gray-500 mb-1">Request Params</span>
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto">
{formattedParams}
</pre>
</div>
)}
</div>
)}
{/* 에러 정보 */}
{log.errorMessage && (
<div className="bg-red-50 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-red-900 mb-2"> </h2>
<p className="text-sm text-red-800">{log.errorMessage}</p>
</div>
)}
</div>
);
};
export default RequestLogDetailPage;

파일 보기

@ -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<string, string> = {
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<string, string> = {
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<ServiceInfo[]>([]);
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, string | number | undefined> = {
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<string, string | number | undefined> = {
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 (
<div>
<h1 className="text-2xl font-bold text-gray-900">Request Logs</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Request Logs</h1>
{/* Search Form */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => 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"
/>
<span className="text-gray-500">~</span>
<input
type="date"
value={endDate}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value=""></option>
{services.map((s) => (
<option key={s.serviceId} value={s.serviceId}>
{s.serviceName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
value={requestStatus}
onChange={(e) => setRequestStatus(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value=""></option>
{REQUEST_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">HTTP Method</label>
<select
value={requestMethod}
onChange={(e) => setRequestMethod(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value=""></option>
{HTTP_METHODS.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">IP</label>
<input
type="text"
value={requestIp}
onChange={(e) => 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"
/>
</div>
<div className="flex items-end gap-2">
<button
onClick={() => handleSearch(0)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
<button
onClick={handleResetAndSearch}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
{/* Results Table */}
<div className="overflow-x-auto bg-white rounded-lg shadow mb-6">
{loading ? (
<div className="text-center py-10 text-gray-500"> ...</div>
) : (
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Method</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">URL</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Status Code</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">(ms)</th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{result && result.content.length > 0 ? (
result.content.map((log) => (
<tr
key={log.logId}
onClick={() => handleRowClick(log.logId)}
className="cursor-pointer hover:bg-gray-50"
>
<td className="px-4 py-3 whitespace-nowrap">
{formatDateTime(log.requestedAt)}
</td>
<td className="px-4 py-3">{log.serviceName || '-'}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
}`}
>
{log.requestMethod}
</span>
</td>
<td className="px-4 py-3 text-gray-500 truncate max-w-[250px]" title={log.requestUrl}>
{log.requestUrl}
</td>
<td className="px-4 py-3">
{log.responseStatus != null ? log.responseStatus : '-'}
</td>
<td className="px-4 py-3">
{log.responseTime != null ? log.responseTime : '-'}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
}`}
>
{log.requestStatus}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs">{log.requestIp}</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{result && result.totalElements > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
{result.totalElements} / {result.page + 1} / {result.totalPages}
</span>
<div className="flex gap-2">
<button
onClick={handlePrev}
disabled={currentPage === 0}
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
<button
onClick={handleNext}
disabled={!result || currentPage >= result.totalPages - 1}
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
</div>
</div>
)}
</div>
);
};

파일 보기

@ -0,0 +1,12 @@
import { get } from './apiClient';
import type { RequestLog, PageResponse } from '../types/monitoring';
export const searchLogs = (params: Record<string, string | number | undefined>) => {
const query = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
.join('&');
return get<PageResponse<RequestLog>>(`/monitoring/logs${query ? '?' + query : ''}`);
};
export const getLogDetail = (id: number) => get<RequestLog>(`/monitoring/logs/${id}`);

파일 보기

@ -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<T> {
content: T[];
page: number;
size: number;
totalElements: number;
totalPages: number;
}

파일 보기

@ -11,4 +11,6 @@ public interface SnpApiKeyRepository extends JpaRepository<SnpApiKey, Long> {
Optional<SnpApiKey> findByApiKey(String apiKey);
List<SnpApiKey> findByUserUserId(Long userId);
List<SnpApiKey> findByApiKeyPrefix(String apiKeyPrefix);
}

파일 보기

@ -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<SnpApiPermission, Long> {
Optional<SnpApiPermission> findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(Long apiKeyId, Long apiId);
List<SnpApiPermission> findByApiKeyApiKeyId(Long apiKeyId);
@Modifying

파일 보기

@ -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;

파일 보기

@ -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;
}
}

파일 보기

@ -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);

파일 보기

@ -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<byte[]> 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<byte[]> 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");
}
}

파일 보기

@ -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<String> 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<byte[]> 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<byte[]> 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<SnpApiKey> 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<byte[]> 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<String> 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<String> 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();
}
}

파일 보기

@ -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<ApiResponse<PageResponse<RequestLogResponse>>> 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<RequestLogResponse> result = requestLogService.searchLogs(searchDto);
return ResponseEntity.ok(ApiResponse.ok(PageResponse.from(result)));
}
/**
* 요청 로그 상세 조회
*/
@GetMapping("/logs/{id}")
public ResponseEntity<ApiResponse<RequestLogResponse>> getLogDetail(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(requestLogService.getLogDetail(id)));
}
}

파일 보기

@ -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()
);
}
}

파일 보기

@ -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;
}
}

파일 보기

@ -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<SnpApiRequestLog, Long> {
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
JpaSpecificationExecutor<SnpApiRequestLog> {
}

파일 보기

@ -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<RequestLogResponse> searchLogs(RequestLogSearchDto searchDto) {
Specification<SnpApiRequestLog> 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));
}
}

파일 보기

@ -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<SnpApiRequestLog> withFilters(RequestLogSearchDto dto) {
return (root, query, cb) -> {
List<Predicate> 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]));
};
}
}

파일 보기

@ -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<SnpServiceApi, Long> {
List<SnpServiceApi> findByService(SnpService service);
List<SnpServiceApi> findByServiceServiceId(Long serviceId);
Optional<SnpServiceApi> findByServiceServiceIdAndApiPathAndApiMethod(
Long serviceId, String apiPath, String apiMethod);
}