feat: 웹사이트 표준화 검사 도구 구현

- 4개 검사 엔진: HTML/CSS, 접근성(WCAG), SEO, 성능/보안 (총 50개 항목)
- FastAPI 백엔드 (9개 API, SSE 실시간 진행, PDF/JSON 리포트)
- Next.js 15 프론트엔드 (6개 페이지, 29개 컴포넌트, 반원 게이지 차트)
- Docker Compose 배포 (Backend:8011, Frontend:3011, MongoDB:27022, Redis:6392)
- 전체 테스트 32/32 PASS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 13:57:27 +09:00
parent c37cda5b13
commit b5fa5d96b9
93 changed files with 18735 additions and 22 deletions

148
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,148 @@
import type {
StartInspectionResponse,
InspectionResult,
IssueListResponse,
IssueFilters,
PaginatedResponse,
HistoryParams,
TrendResponse,
} from "@/types/inspection";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8011";
/** API 에러 클래스 */
export class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.name = "ApiError";
this.status = status;
this.detail = detail;
}
}
/** API 클라이언트 */
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
let detail = "요청 처리 중 오류가 발생했습니다";
try {
const error = await response.json();
detail = error.detail || detail;
} catch {
// JSON 파싱 실패 시 기본 메시지 사용
}
throw new ApiError(response.status, detail);
}
return response.json();
}
/** 검사 시작 */
async startInspection(url: string): Promise<StartInspectionResponse> {
return this.request("/api/inspections", {
method: "POST",
body: JSON.stringify({ url }),
});
}
/** 검사 결과 조회 */
async getInspection(id: string): Promise<InspectionResult> {
return this.request(`/api/inspections/${id}`);
}
/** 이슈 목록 조회 */
async getIssues(
id: string,
filters?: IssueFilters
): Promise<IssueListResponse> {
const params = new URLSearchParams();
if (filters?.category && filters.category !== "all") {
params.set("category", filters.category);
}
if (filters?.severity && filters.severity !== "all") {
params.set("severity", filters.severity);
}
const qs = params.toString();
return this.request(`/api/inspections/${id}/issues${qs ? `?${qs}` : ""}`);
}
/** 이력 목록 조회 (페이지네이션) */
async getInspections(params: HistoryParams): Promise<PaginatedResponse> {
const qs = new URLSearchParams();
qs.set("page", String(params.page || 1));
qs.set("limit", String(params.limit || 20));
if (params.url) qs.set("url", params.url);
if (params.sort) qs.set("sort", params.sort);
return this.request(`/api/inspections?${qs}`);
}
/** 트렌드 데이터 조회 */
async getTrend(url: string, limit = 10): Promise<TrendResponse> {
const qs = new URLSearchParams({ url, limit: String(limit) });
return this.request(`/api/inspections/trend?${qs}`);
}
/** PDF 다운로드 */
async downloadPdf(id: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/api/inspections/${id}/report/pdf`
);
if (!response.ok) {
throw new ApiError(response.status, "PDF 다운로드에 실패했습니다");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
response.headers.get("content-disposition")?.split("filename=")[1] ||
`web-inspector-report.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** JSON 다운로드 */
async downloadJson(id: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/api/inspections/${id}/report/json`
);
if (!response.ok) {
throw new ApiError(response.status, "JSON 다운로드에 실패했습니다");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
response.headers.get("content-disposition")?.split("filename=")[1] ||
`web-inspector-report.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** SSE 스트림 URL 반환 */
getStreamUrl(inspectionId: string): string {
return `${this.baseUrl}/api/inspections/${inspectionId}/stream`;
}
}
export const api = new ApiClient(API_BASE_URL);

View File

@ -0,0 +1,141 @@
import type { CategoryKey, Grade, Severity } from "@/types/inspection";
/** 카테고리 한국어 라벨 */
export const CATEGORY_LABELS: Record<CategoryKey, string> = {
html_css: "HTML/CSS 표준",
accessibility: "접근성 (WCAG)",
seo: "SEO 최적화",
performance_security: "성능/보안",
};
/** 카테고리 짧은 라벨 */
export const CATEGORY_SHORT_LABELS: Record<CategoryKey, string> = {
html_css: "HTML/CSS",
accessibility: "접근성",
seo: "SEO",
performance_security: "성능/보안",
};
/** 카테고리 키 배열 */
export const CATEGORY_KEYS: CategoryKey[] = [
"html_css",
"accessibility",
"seo",
"performance_security",
];
/** 심각도 한국어 라벨 */
export const SEVERITY_LABELS: Record<Severity, string> = {
critical: "Critical",
major: "Major",
minor: "Minor",
info: "Info",
};
/** 심각도 색상 */
export const SEVERITY_COLORS: Record<Severity, string> = {
critical: "bg-red-500 text-white",
major: "bg-orange-500 text-white",
minor: "bg-yellow-500 text-white",
info: "bg-blue-500 text-white",
};
/** 심각도 배경 색상 (연한 버전) */
export const SEVERITY_BG_COLORS: Record<Severity, string> = {
critical: "bg-red-50 border-red-200",
major: "bg-orange-50 border-orange-200",
minor: "bg-yellow-50 border-yellow-200",
info: "bg-blue-50 border-blue-200",
};
/** 등급 색상 */
export const GRADE_COLORS: Record<Grade, string> = {
"A+": "text-green-600",
A: "text-green-600",
B: "text-blue-600",
C: "text-yellow-600",
D: "text-orange-600",
F: "text-red-600",
};
/** 등급 배경 색상 */
export const GRADE_BG_COLORS: Record<Grade, string> = {
"A+": "bg-green-100 text-green-700",
A: "bg-green-100 text-green-700",
B: "bg-blue-100 text-blue-700",
C: "bg-yellow-100 text-yellow-700",
D: "bg-orange-100 text-orange-700",
F: "bg-red-100 text-red-700",
};
/** 점수에 따른 색상 반환 */
export function getScoreColor(score: number): string {
if (score >= 80) return "#22C55E"; // 초록
if (score >= 50) return "#F59E0B"; // 주황
return "#EF4444"; // 빨강
}
/** 점수에 따른 Tailwind 색상 클래스 */
export function getScoreTailwindColor(score: number): string {
if (score >= 80) return "text-green-500";
if (score >= 50) return "text-yellow-500";
return "text-red-500";
}
/** 차트 카테고리 색상 */
export const CHART_COLORS: Record<string, string> = {
overall: "#6366F1",
html_css: "#3B82F6",
accessibility: "#22C55E",
seo: "#F59E0B",
performance_security: "#EF4444",
};
/** 차트 범례 라벨 */
export const CHART_LEGEND_LABELS: Record<string, string> = {
overall_score: "종합",
html_css: "HTML/CSS",
accessibility: "접근성",
seo: "SEO",
performance_security: "성능/보안",
};
/** 날짜 포맷 (로컬) */
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/** 날짜+시간 포맷 (로컬) */
export function formatDateTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/** 짧은 날짜 포맷 (MM-DD) */
export function formatShortDate(dateString: string): string {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${month}-${day}`;
}
/** URL 유효성 검사 */
export function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}

View File

@ -0,0 +1,23 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -0,0 +1,57 @@
import {
useQuery,
keepPreviousData,
} from "@tanstack/react-query";
import { api } from "@/lib/api";
/** 검사 결과 조회 */
export function useInspectionResult(inspectionId: string | undefined) {
return useQuery({
queryKey: ["inspection", inspectionId],
queryFn: () => api.getInspection(inspectionId!),
enabled: !!inspectionId,
staleTime: 5 * 60 * 1000,
});
}
/** 이슈 목록 조회 */
export function useInspectionIssues(
inspectionId: string | undefined,
category?: string,
severity?: string
) {
return useQuery({
queryKey: ["inspection-issues", inspectionId, category, severity],
queryFn: () =>
api.getIssues(inspectionId!, { category, severity }),
enabled: !!inspectionId,
});
}
/** 이력 목록 조회 (페이지네이션) */
export function useInspectionHistory(page: number, url?: string) {
return useQuery({
queryKey: ["inspection-history", page, url],
queryFn: () => api.getInspections({ page, limit: 20, url }),
placeholderData: keepPreviousData,
});
}
/** 트렌드 데이터 조회 */
export function useInspectionTrend(url: string | undefined) {
return useQuery({
queryKey: ["inspection-trend", url],
queryFn: () => api.getTrend(url!),
enabled: !!url,
staleTime: 10 * 60 * 1000,
});
}
/** 최근 검사 이력 (메인 페이지용, 5건) */
export function useRecentInspections() {
return useQuery({
queryKey: ["recent-inspections"],
queryFn: () => api.getInspections({ page: 1, limit: 5 }),
staleTime: 60 * 1000,
});
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}