navigate(`/api-hub/services/${svc.serviceId}`)}
- >
-
-
-
- {svc.serviceName}
-
-
{svc.serviceCode}
+
+ {recentTop3.length > 0 ? (
+
+ {recentTop3.map((api) => {
+ const palette = api.apiDomain ? getDomainColorByHash(api.apiDomain) : DOMAIN_COLOR_PALETTE[4];
+ return (
+
navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
+ >
+
+ {api.apiDomain && (
+
+ {formatDomain(api.apiDomain)}
+
+ )}
-
-
-
- {HEALTH_LABEL[svc.healthStatus] ?? svc.healthStatus}
-
-
-
- {svc.description && (
-
- {svc.description}
+
+ {api.apiName}
- )}
-
-
API {svc.apiCount}개
-
도메인 {svc.domains.length}개
+ {api.description && (
+
+ {truncate(api.description, 80)}
+
+ )}
+
+
+ {formatDate(api.createdAt)}
+
-
- ))}
+ );
+ })}
) : (
-
- {searchQuery ? '검색 결과가 없습니다' : '등록된 서비스가 없습니다'}
+
+ 등록된 API가 없습니다
)}
+ {/* 서비스 도메인 섹션 */}
+ {domainList.length > 0 && (
+
+
+
+ {domainList.map((item) => {
+ const palette = getDomainColorByHash(item.domain);
+ const iconPaths = parseIconPaths(item.iconPath);
+ const imgSrc = `${import.meta.env.BASE_URL}images/domains/${item.domain.toLowerCase()}.jpg`;
+ return (
+
navigate(`/api-hub/domains/${encodeURIComponent(item.domain)}`)}
+ className={`group relative overflow-hidden rounded-xl border bg-white dark:bg-gray-800 ${palette.border} cursor-pointer transition-all duration-200 hover:-translate-y-1 hover:shadow-xl`}
+ >
+
+

{ (e.target as HTMLImageElement).style.display = 'none'; }}
+ />
+
+
+
+
+
+
+ {formatDomain(item.domain)}
+
+
+
+ {item.apiCount} APIs
+
+
+
+
+ );
+ })}
+
+
+ )}
);
};
diff --git a/frontend/src/pages/apihub/ApiHubDomainPage.tsx b/frontend/src/pages/apihub/ApiHubDomainPage.tsx
new file mode 100644
index 0000000..321892e
--- /dev/null
+++ b/frontend/src/pages/apihub/ApiHubDomainPage.tsx
@@ -0,0 +1,238 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import type { ServiceCatalog } from '../../types/apihub';
+import { getCatalog } from '../../services/apiHubService';
+
+const DEFAULT_ICON_PATHS = [
+ 'M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776',
+];
+
+const DOMAIN_COLOR_PALETTE = [
+ { color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
+ { color: 'text-rose-400', bg: 'bg-rose-500/10', border: 'border-rose-500/30', line: 'from-rose-500' },
+ { color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30', line: 'from-blue-500' },
+ { color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', line: 'from-amber-500' },
+ { color: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30', line: 'from-violet-500' },
+ { color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', line: 'from-cyan-500' },
+ { color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', line: 'from-orange-500' },
+ { color: 'text-pink-400', bg: 'bg-pink-500/10', border: 'border-pink-500/30', line: 'from-pink-500' },
+ { color: 'text-lime-400', bg: 'bg-lime-500/10', border: 'border-lime-500/30', line: 'from-lime-500' },
+ { color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', line: 'from-indigo-500' },
+ { color: 'text-teal-400', bg: 'bg-teal-500/10', border: 'border-teal-500/30', line: 'from-teal-500' },
+ { color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', border: 'border-fuchsia-500/30', line: 'from-fuchsia-500' },
+];
+
+const domainColorCache = new Map
();
+let nextColorIdx = 0;
+
+const getDomainColorByHash = (domain: string) => {
+ const key = domain.toUpperCase();
+ const cached = domainColorCache.get(key);
+ if (cached) return cached;
+ const color = DOMAIN_COLOR_PALETTE[nextColorIdx % DOMAIN_COLOR_PALETTE.length];
+ nextColorIdx++;
+ domainColorCache.set(key, color);
+ return color;
+};
+
+/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
+const parseIconPaths = (iconPath: string | null): string[] => {
+ if (!iconPath) return DEFAULT_ICON_PATHS;
+ const pathRegex = /d="([^"]+)"/g;
+ const matches: string[] = [];
+ let m;
+ while ((m = pathRegex.exec(iconPath)) !== null) {
+ matches.push(m[1]);
+ }
+ return matches.length > 0 ? matches : [iconPath];
+};
+
+const formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
+
+interface FlatApi {
+ serviceId: number;
+ apiId: number;
+ apiName: string;
+ apiPath: string;
+ apiMethod: string;
+ description: string | null;
+}
+
+interface DomainInfo {
+ domain: string;
+ iconPath: string | null;
+ apis: FlatApi[];
+}
+
+const ApiHubDomainPage = () => {
+ const { domainName } = useParams<{ domainName: string }>();
+ const navigate = useNavigate();
+ const [catalog, setCatalog] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ useEffect(() => {
+ getCatalog()
+ .then((res) => {
+ setCatalog(res.data ?? []);
+ })
+ .finally(() => setIsLoading(false));
+ }, []);
+
+ const domainInfo = useMemo(() => {
+ if (!domainName) return null;
+ const targetKey = decodeURIComponent(domainName).toUpperCase();
+ const apis: FlatApi[] = [];
+ let iconPath: string | null = null;
+ let foundDomain = '';
+
+ for (const svc of catalog) {
+ for (const dg of svc.domains) {
+ if (dg.domain.toUpperCase() === targetKey) {
+ if (!foundDomain) foundDomain = dg.domain;
+ if (iconPath === null && dg.iconPath) iconPath = dg.iconPath;
+ for (const api of dg.apis) {
+ apis.push({
+ serviceId: svc.serviceId,
+ apiId: api.apiId,
+ apiName: api.apiName,
+ apiPath: api.apiPath,
+ apiMethod: api.apiMethod,
+ description: api.description ?? null,
+ });
+ }
+ }
+ }
+ }
+
+ if (!foundDomain) return null;
+ return { domain: foundDomain, iconPath, apis };
+ }, [catalog, domainName]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!domainInfo) {
+ return (
+
+
+
도메인을 찾을 수 없습니다.
+
+
+
+ );
+ }
+
+ const palette = getDomainColorByHash(domainInfo.domain);
+ const iconPaths = parseIconPaths(domainInfo.iconPath);
+
+ return (
+
+ {/* 헤더 카드 */}
+
+ {/* 상단 컬러 라인 */}
+
+
+
+ {/* 도메인 아이콘 */}
+
+
+
+
+ {/* 도메인명 + API 개수 */}
+
+
+ {formatDomain(domainInfo.domain)}
+
+
+ {domainInfo.apis.length}개 API
+
+
+
+
+
+ {/* API 목록 */}
+
+ {/* 검색 헤더 */}
+
+
+ API 목록
+ {domainInfo.apis.length}건
+
+
+
+
setSearchQuery(e.target.value)}
+ placeholder="API 검색..."
+ className="w-52 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
+ />
+
+
+
+ {/* 리스트 */}
+ {(() => {
+ const filtered = domainInfo.apis.filter((api) => {
+ if (!searchQuery.trim()) return true;
+ const q = searchQuery.toLowerCase();
+ return api.apiName.toLowerCase().includes(q) || (api.description ?? '').toLowerCase().includes(q);
+ });
+
+ if (filtered.length === 0) {
+ return (
+
+ {searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
+
+ );
+ }
+
+ return (
+
+ {filtered.map((api) => (
+
navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
+ className="flex items-center gap-4 px-5 py-3.5 cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
+ >
+
+
+
+ ))}
+
+ );
+ })()}
+
+
+ );
+};
+
+export default ApiHubDomainPage;
diff --git a/frontend/src/services/apiHubService.ts b/frontend/src/services/apiHubService.ts
index 20bfc5c..71e9e23 100644
--- a/frontend/src/services/apiHubService.ts
+++ b/frontend/src/services/apiHubService.ts
@@ -1,9 +1,10 @@
import { get } from './apiClient';
-import type { ServiceCatalog, RecentApi } from '../types/apihub';
+import type { ServiceCatalog, RecentApi, PopularApi } from '../types/apihub';
import type { ApiDetailInfo } from '../types/service';
export const getCatalog = () => get('/api-hub/catalog');
export const getRecentApis = () => get('/api-hub/recent-apis');
+export const getPopularApis = () => get('/api-hub/popular-apis');
export const getServiceCatalog = (serviceId: number) =>
get(`/api-hub/services/${serviceId}`);
export const getApiHubApiDetail = (serviceId: number, apiId: number) =>
diff --git a/frontend/src/types/apihub.ts b/frontend/src/types/apihub.ts
index a4985d3..efb9ac7 100644
--- a/frontend/src/types/apihub.ts
+++ b/frontend/src/types/apihub.ts
@@ -39,6 +39,14 @@ export interface ServiceCatalog {
domains: DomainGroup[];
}
+export interface PopularApi {
+ domain: string;
+ apiName: string;
+ apiId: number | null;
+ serviceId: number | null;
+ count: number;
+}
+
export interface RecentApi {
apiId: number;
apiName: string;
diff --git a/src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java b/src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java
index c784111..2b1f757 100644
--- a/src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java
+++ b/src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java
@@ -1,5 +1,6 @@
package com.gcsc.connection.apihub.controller;
+import com.gcsc.connection.apihub.dto.PopularApiResponse;
import com.gcsc.connection.apihub.dto.RecentApiResponse;
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
import com.gcsc.connection.apihub.service.ApiHubService;
@@ -44,6 +45,15 @@ public class ApiHubController {
return ResponseEntity.ok(ApiResponse.ok(recentApis));
}
+ /**
+ * 인기 API (최근 1주일 기준 호출 수 Top N)
+ */
+ @GetMapping("/popular-apis")
+ public ResponseEntity>> getPopularApis() {
+ List popularApis = apiHubService.getPopularApis(3);
+ return ResponseEntity.ok(ApiResponse.ok(popularApis));
+ }
+
/**
* 서비스 단건 카탈로그 조회
*/
diff --git a/src/main/java/com/gcsc/connection/apihub/dto/PopularApiResponse.java b/src/main/java/com/gcsc/connection/apihub/dto/PopularApiResponse.java
new file mode 100644
index 0000000..2414bde
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apihub/dto/PopularApiResponse.java
@@ -0,0 +1,10 @@
+package com.gcsc.connection.apihub.dto;
+
+public record PopularApiResponse(
+ String domain,
+ String apiName,
+ Long apiId,
+ Long serviceId,
+ long count
+) {
+}
diff --git a/src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java b/src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java
index 75e1e0d..eae215c 100644
--- a/src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java
+++ b/src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java
@@ -15,6 +15,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
+
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -28,6 +31,7 @@ public class ApiHubService {
private final SnpServiceRepository snpServiceRepository;
private final SnpServiceApiRepository snpServiceApiRepository;
private final SnpApiDomainRepository snpApiDomainRepository;
+ private final SnpApiRequestLogRepository snpApiRequestLogRepository;
/**
* 활성 서비스와 각 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
@@ -72,6 +76,23 @@ public class ApiHubService {
.toList();
}
+ /**
+ * 최근 1주일 기준 인기 API 상위 N건
+ */
+ @Transactional(readOnly = true)
+ public List getPopularApis(int limit) {
+ LocalDateTime since = LocalDateTime.now().minusDays(7);
+ return snpApiRequestLogRepository.findTopApisForHub(since, limit).stream()
+ .map(row -> new com.gcsc.connection.apihub.dto.PopularApiResponse(
+ (String) row[0],
+ (String) row[1],
+ row[2] != null ? ((Number) row[2]).longValue() : null,
+ row[3] != null ? ((Number) row[3]).longValue() : null,
+ ((Number) row[4]).longValue()
+ ))
+ .toList();
+ }
+
private Map buildDomainMap() {
return snpApiDomainRepository.findAllByOrderBySortOrderAscDomainNameAsc().stream()
.collect(Collectors.toMap(SnpApiDomain::getDomainName, Function.identity()));
diff --git a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java
index bb25fa5..2f8e952 100644
--- a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java
@@ -87,6 +87,15 @@ public class GlobalExceptionHandler {
.body(ApiResponse.error("요청 본문을 읽을 수 없습니다"));
}
+ /**
+ * 정적 리소스 미발견 (이미지 등 404)
+ */
+ @ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
+ public ResponseEntity handleNoResourceFound(
+ org.springframework.web.servlet.resource.NoResourceFoundException e) {
+ return ResponseEntity.notFound().build();
+ }
+
/**
* 처리되지 않은 예외 처리
*/
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 9d54bae..4531504 100644
--- a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java
+++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java
@@ -12,6 +12,20 @@ import java.util.List;
public interface SnpApiRequestLogRepository extends JpaRepository,
JpaSpecificationExecutor {
+ /** API HUB 인기 API (최근 1주일, 도메인 포함) */
+ @Query(value = "SELECT COALESCE(a.api_domain, '') as domain, " +
+ "COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)) as apiName, " +
+ "a.api_id, a.service_id, COUNT(*) as cnt " +
+ "FROM common.snp_api_request_log l " +
+ "LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
+ "LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
+ "AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
+ "AND a.api_method = l.request_method " +
+ "WHERE l.requested_at >= :since AND a.api_id IS NOT NULL " +
+ "GROUP BY a.api_domain, a.api_name, a.api_id, a.service_id, SPLIT_PART(l.request_url, '?', 1) " +
+ "ORDER BY cnt DESC LIMIT :limit", nativeQuery = true)
+ List