- 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>
2388 lines
74 KiB
Markdown
2388 lines
74 KiB
Markdown
# Web Inspector - 시스템 아키텍처 설계서
|
|
|
|
> 작성: senior-system-architect | 최종 수정: 2026-02-12
|
|
> 기반 문서: PLAN.md, FEATURE_SPEC.md, SCREEN_DESIGN.md
|
|
|
|
---
|
|
|
|
## 1. 시스템 아키텍처 개요
|
|
|
|
### 1.1 아키텍처 다이어그램
|
|
|
|
```
|
|
+----------------------------------------------------------+
|
|
| Docker Compose |
|
|
| |
|
|
| +-------------------+ +-------------------------+ |
|
|
| | Frontend | | Backend | |
|
|
| | (Next.js 15) |----->| (FastAPI) | |
|
|
| | Port: 3011 | HTTP | Port: 8011 | |
|
|
| | |<-----| | |
|
|
| | - App Router | SSE | +-------------------+ | |
|
|
| | - TypeScript | | | Inspection Router | | |
|
|
| | - Tailwind CSS | | +-------------------+ | |
|
|
| | - shadcn/ui | | | History Router | | |
|
|
| | - Zustand | | +-------------------+ | |
|
|
| | - React Query | | | Report Router | | |
|
|
| | - Recharts | | +-------------------+ | |
|
|
| +-------------------+ | | | |
|
|
| | +-------------------+ | |
|
|
| | | Inspection Engine | | |
|
|
| | | +-------------+ | | |
|
|
| | | | HTML/CSS | | | |
|
|
| | | | Checker | | | |
|
|
| | | +-------------+ | | |
|
|
| | | | WCAG | | | |
|
|
| | | | Checker | | | |
|
|
| | | +-------------+ | | |
|
|
| | | | SEO | | | |
|
|
| | | | Checker | | | |
|
|
| | | +-------------+ | | |
|
|
| | | | Perf/Sec | | | |
|
|
| | | | Checker | | | |
|
|
| | | +-------------+ | | |
|
|
| | +-------------------+ | |
|
|
| | | | |
|
|
| +-----------|--+-----------+ |
|
|
| | | |
|
|
| +-------------------+ +------------|--+-----------+ |
|
|
| | Redis 7 |<-----| Progress | | Results | |
|
|
| | Port: 6392 | Pub | State | | Store | |
|
|
| | - 진행 상태 캐시 | Sub +------------|--+-----------+ |
|
|
| | - 결과 캐시 | | v | |
|
|
| +-------------------+ | +-------------------+ | |
|
|
| | | MongoDB 7 | | |
|
|
| | | Port: 27022 | | |
|
|
| | | - inspections | | |
|
|
| | | - (결과 영구저장) | | |
|
|
| | +-------------------+ | |
|
|
| +---------------------------+ |
|
|
+----------------------------------------------------------+
|
|
```
|
|
|
|
### 1.2 데이터 흐름 개요
|
|
|
|
```
|
|
[사용자] --URL 입력--> [Frontend] --POST /api/inspections--> [Backend]
|
|
|
|
|
[Frontend] <--SSE stream------------- |
|
|
(진행 바 업데이트) |
|
|
v
|
|
[Inspection Engine]
|
|
(4개 카테고리 병렬 실행)
|
|
| |
|
|
[Redis] [MongoDB]
|
|
(진행상태) (결과저장)
|
|
|
|
|
[Frontend] <--SSE complete--- |
|
|
(결과 페이지로 리디렉트)
|
|
```
|
|
|
|
### 1.3 설계 원칙
|
|
|
|
| 원칙 | 적용 방식 |
|
|
|------|----------|
|
|
| 단순성 우선 (Simplicity) | 모놀리식 백엔드 + 모놀리식 프론트엔드, 마이크로서비스 불필요 |
|
|
| 비동기 처리 (Async-first) | FastAPI async, Motor async, httpx async, asyncio.gather 병렬 검사 |
|
|
| 관심사 분리 (Separation of Concerns) | Router / Service / Engine / Model 4계층 분리 |
|
|
| 실시간 피드백 (Real-time Feedback) | SSE 단방향 스트리밍으로 검사 진행 상태 전달 |
|
|
| 영속성 분리 (Persistence Split) | Redis = 임시 상태/캐시, MongoDB = 영구 결과 저장 |
|
|
|
|
---
|
|
|
|
## 2. 기술 스택 상세
|
|
|
|
### 2.1 Frontend
|
|
|
|
| 기술 | 버전 | 선택 이유 |
|
|
|------|------|----------|
|
|
| Next.js | 15.x (App Router) | 프로젝트 표준. App Router로 서버 컴포넌트 + 클라이언트 컴포넌트 분리, 파일 기반 라우팅 |
|
|
| TypeScript | 5.x | 타입 안전성, 검사 결과 데이터 모델의 복잡한 타입을 명시적으로 관리 |
|
|
| Tailwind CSS | 3.x | 유틸리티 퍼스트 CSS, shadcn/ui와 네이티브 통합 |
|
|
| shadcn/ui | latest | 복사-붙여넣기 방식 UI 컴포넌트, 커스터마이징 자유도 높음. Card, Table, Button, Badge, Progress 활용 |
|
|
| Zustand | 5.x | 경량 상태 관리, SSE 실시간 진행 상태 + 검사 결과 상태 관리 |
|
|
| TanStack Query (React Query) | 5.x | 서버 상태 관리, 검사 이력 목록/트렌드 데이터 캐싱 및 페이지네이션 |
|
|
| Recharts | 2.x | React 네이티브 차트 라이브러리, 라인 차트(트렌드) + 원형 게이지(점수) 구현 |
|
|
|
|
### 2.2 Backend
|
|
|
|
| 기술 | 버전 | 선택 이유 |
|
|
|------|------|----------|
|
|
| Python | 3.11 | 프로젝트 표준. async/await 성능 개선, ExceptionGroup 등 최신 기능 |
|
|
| FastAPI | 0.115.x | 프로젝트 표준. 자동 OpenAPI 문서, Pydantic v2 네이티브 통합, async 라우터 |
|
|
| Pydantic | 2.x | 요청/응답 데이터 검증 및 직렬화. V2의 성능 향상 (5~50배) |
|
|
| Motor | 3.6.x | MongoDB async 드라이버. PyMongo의 비동기 래퍼 |
|
|
| redis (redis-py) | 5.x | Redis async 클라이언트. aioredis가 redis-py에 통합됨 |
|
|
| sse-starlette | 2.x | FastAPI/Starlette 네이티브 SSE 지원. W3C SSE 규격 준수 |
|
|
| httpx | 0.27.x | 비동기 HTTP 클라이언트. requests 대체, async 네이티브 |
|
|
| BeautifulSoup4 | 4.12.x | HTML 파싱/분석. html5lib 파서와 조합하여 HTML5 표준 파싱 |
|
|
| html5lib | 1.1 | HTML5 사양 준수 파서. BeautifulSoup4의 파서로 사용 |
|
|
| Playwright | 1.49.x | 헤드리스 브라우저. 접근성(axe-core) 검사 실행 환경 |
|
|
| WeasyPrint | 62.x | HTML/CSS -> PDF 변환. Jinja2 템플릿 기반 리포트 생성 |
|
|
| Jinja2 | 3.1.x | PDF 리포트용 HTML 템플릿 엔진 |
|
|
|
|
### 2.3 Infrastructure
|
|
|
|
| 기술 | 버전 | 선택 이유 |
|
|
|------|------|----------|
|
|
| MongoDB | 7.0 | 유연한 스키마로 다양한 검사 결과 구조 저장에 최적. 문서 기반 쿼리 |
|
|
| Redis | 7 (Alpine) | 검사 진행 상태 임시 저장 + 결과 캐싱. Pub/Sub로 SSE 백프레셔 관리 |
|
|
| Docker Compose | 3.x | 4개 서비스(frontend, backend, mongodb, redis) 통합 오케스트레이션 |
|
|
|
|
---
|
|
|
|
## 3. 백엔드 설계
|
|
|
|
### 3.1 계층 구조 (Layered Architecture)
|
|
|
|
```
|
|
+------------------+
|
|
| Routers | -- HTTP 요청/응답 처리, 데이터 검증
|
|
+------------------+
|
|
|
|
|
+------------------+
|
|
| Services | -- 비즈니스 로직 오케스트레이션
|
|
+------------------+
|
|
|
|
|
+------------------+
|
|
| Engines | -- 검사 실행 로직 (4개 카테고리)
|
|
+------------------+
|
|
|
|
|
+------------------+
|
|
| Models / Schemas | -- Pydantic 모델 (요청/응답/DB)
|
|
+------------------+
|
|
|
|
|
+------------------+
|
|
| Database | -- MongoDB(Motor) + Redis 접근 계층
|
|
+------------------+
|
|
```
|
|
|
|
### 3.2 FastAPI 라우터 구조
|
|
|
|
#### 3.2.1 Inspection Router (`/api/inspections`)
|
|
|
|
| Method | Path | 기능 | 설명 |
|
|
|--------|------|------|------|
|
|
| POST | `/api/inspections` | 검사 시작 | URL 검증 -> 검사 ID 생성 -> 비동기 검사 시작 -> 202 반환 |
|
|
| GET | `/api/inspections/{id}/stream` | SSE 스트림 | 검사 진행 상태 실시간 스트리밍 |
|
|
| GET | `/api/inspections/{id}` | 결과 조회 | 검사 결과 전체 데이터 반환 |
|
|
| GET | `/api/inspections/{id}/issues` | 이슈 목록 | 필터링된 이슈 목록 반환 |
|
|
|
|
#### 3.2.2 History Router (`/api/inspections` - 목록/트렌드)
|
|
|
|
| Method | Path | 기능 | 설명 |
|
|
|--------|------|------|------|
|
|
| GET | `/api/inspections` | 이력 목록 | 페이지네이션 + URL 필터 + 정렬 |
|
|
| GET | `/api/inspections/trend` | 트렌드 데이터 | 동일 URL의 시계열 점수 데이터 |
|
|
|
|
#### 3.2.3 Report Router (`/api/inspections/{id}/report`)
|
|
|
|
| Method | Path | 기능 | 설명 |
|
|
|--------|------|------|------|
|
|
| GET | `/api/inspections/{id}/report/pdf` | PDF 다운로드 | WeasyPrint 기반 PDF 생성 |
|
|
| GET | `/api/inspections/{id}/report/json` | JSON 다운로드 | 전체 결과 JSON 파일 다운로드 |
|
|
|
|
#### 3.2.4 Health Router
|
|
|
|
| Method | Path | 기능 | 설명 |
|
|
|--------|------|------|------|
|
|
| GET | `/api/health` | 헬스체크 | MongoDB/Redis 연결 상태 포함 |
|
|
|
|
#### 라우터 등록 코드 (main.py)
|
|
|
|
```python
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from contextlib import asynccontextmanager
|
|
from app.routers import inspections, reports, health
|
|
from app.core.database import connect_db, close_db
|
|
from app.core.redis import connect_redis, close_redis
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Startup
|
|
await connect_db()
|
|
await connect_redis()
|
|
yield
|
|
# Shutdown
|
|
await close_db()
|
|
await close_redis()
|
|
|
|
app = FastAPI(
|
|
title="Web Inspector API",
|
|
version="1.0.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(health.router, prefix="/api", tags=["Health"])
|
|
app.include_router(inspections.router, prefix="/api", tags=["Inspections"])
|
|
app.include_router(reports.router, prefix="/api", tags=["Reports"])
|
|
```
|
|
|
|
### 3.3 검사 엔진 설계
|
|
|
|
#### 3.3.1 엔진 아키텍처
|
|
|
|
```
|
|
InspectionOrchestrator (서비스 계층)
|
|
|
|
|
+-- asyncio.gather() --- 4개 동시 실행
|
|
| |
|
|
| +---+---+---+---+
|
|
| | | | | |
|
|
| v v v v |
|
|
| HTML WCAG SEO Perf|
|
|
| CSS A11y Sec |
|
|
| | | | | |
|
|
| +---+---+---+---+
|
|
| |
|
|
+-- Redis (진행 상태 업데이트)
|
|
+-- MongoDB (결과 저장)
|
|
```
|
|
|
|
#### 3.3.2 BaseChecker 추상 클래스
|
|
|
|
```python
|
|
from abc import ABC, abstractmethod
|
|
from typing import Callable, Optional
|
|
from app.models.schemas import CategoryResult, Issue
|
|
|
|
class BaseChecker(ABC):
|
|
"""모든 검사 엔진의 기본 클래스"""
|
|
|
|
def __init__(self, progress_callback: Optional[Callable] = None):
|
|
self.progress_callback = progress_callback
|
|
|
|
async def update_progress(self, progress: int, current_step: str):
|
|
"""Redis를 통해 진행 상태를 업데이트한다."""
|
|
if self.progress_callback:
|
|
await self.progress_callback(
|
|
category=self.category_name,
|
|
progress=progress,
|
|
current_step=current_step
|
|
)
|
|
|
|
@property
|
|
@abstractmethod
|
|
def category_name(self) -> str:
|
|
"""카테고리 식별자 (예: 'html_css')"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
|
"""검사를 실행하고 결과를 반환한다."""
|
|
pass
|
|
```
|
|
|
|
#### 3.3.3 HTML/CSS Checker
|
|
|
|
```python
|
|
class HtmlCssChecker(BaseChecker):
|
|
"""HTML/CSS 표준 검사 엔진 (F-002)"""
|
|
|
|
category_name = "html_css"
|
|
|
|
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
|
soup = BeautifulSoup(html_content, "html5lib")
|
|
issues = []
|
|
|
|
await self.update_progress(10, "DOCTYPE 검사 중...")
|
|
issues += self._check_doctype(html_content)
|
|
|
|
await self.update_progress(20, "문자 인코딩 검사 중...")
|
|
issues += self._check_charset(soup)
|
|
|
|
await self.update_progress(30, "언어 속성 검사 중...")
|
|
issues += self._check_lang(soup)
|
|
|
|
await self.update_progress(40, "title 태그 검사 중...")
|
|
issues += self._check_title(soup)
|
|
|
|
await self.update_progress(50, "시맨틱 태그 검사 중...")
|
|
issues += self._check_semantic_tags(soup)
|
|
|
|
await self.update_progress(60, "이미지 alt 속성 검사 중...")
|
|
issues += self._check_img_alt(soup)
|
|
|
|
await self.update_progress(70, "중복 ID 검사 중...")
|
|
issues += self._check_duplicate_ids(soup)
|
|
|
|
await self.update_progress(80, "링크 및 스타일 검사 중...")
|
|
issues += self._check_empty_links(soup)
|
|
issues += self._check_inline_styles(soup)
|
|
issues += self._check_deprecated_tags(soup)
|
|
|
|
await self.update_progress(90, "heading 구조 검사 중...")
|
|
issues += self._check_heading_hierarchy(soup)
|
|
issues += self._check_viewport_meta(soup)
|
|
|
|
score = self._calculate_score(issues)
|
|
await self.update_progress(100, "완료")
|
|
|
|
return CategoryResult(
|
|
category="html_css",
|
|
score=score,
|
|
total_issues=len(issues),
|
|
issues=issues
|
|
)
|
|
|
|
def _calculate_score(self, issues: list[Issue]) -> int:
|
|
"""점수 = 100 - (Critical*15 + Major*8 + Minor*3 + Info*1), 최소 0"""
|
|
deduction = sum(
|
|
{"critical": 15, "major": 8, "minor": 3, "info": 1}[i.severity]
|
|
for i in issues
|
|
)
|
|
return max(0, 100 - deduction)
|
|
```
|
|
|
|
**검사 항목 매핑 (H-01 ~ H-12):**
|
|
|
|
| 메서드 | 검사 코드 | 분석 도구 | 심각도 |
|
|
|--------|----------|----------|--------|
|
|
| `_check_doctype()` | H-01 | 문자열 매칭 (`<!DOCTYPE html>`) | Major |
|
|
| `_check_charset()` | H-02 | BS4 `soup.find("meta", charset=True)` | Major |
|
|
| `_check_lang()` | H-03 | BS4 `soup.find("html")["lang"]` | Minor |
|
|
| `_check_title()` | H-04 | BS4 `soup.find("title")` | Major |
|
|
| `_check_semantic_tags()` | H-05 | BS4 `soup.find_all(["header","nav","main","footer","section","article"])` | Minor |
|
|
| `_check_img_alt()` | H-06 | BS4 `soup.find_all("img")` alt 속성 확인 | Major |
|
|
| `_check_duplicate_ids()` | H-07 | BS4 `soup.find_all(id=True)` 중복 검사 | Critical |
|
|
| `_check_empty_links()` | H-08 | BS4 `soup.find_all("a")` href 빈값/"#" 검사 | Minor |
|
|
| `_check_inline_styles()` | H-09 | BS4 `soup.find_all(style=True)` | Info |
|
|
| `_check_deprecated_tags()` | H-10 | BS4 `soup.find_all(["font","center","marquee","blink","strike","big","tt"])` | Major |
|
|
| `_check_heading_hierarchy()` | H-11 | BS4 `soup.find_all(["h1","h2","h3","h4","h5","h6"])` 순서 검사 | Minor |
|
|
| `_check_viewport_meta()` | H-12 | BS4 `soup.find("meta", attrs={"name": "viewport"})` | Major |
|
|
|
|
#### 3.3.4 Accessibility (WCAG) Checker
|
|
|
|
```python
|
|
class AccessibilityChecker(BaseChecker):
|
|
"""접근성(WCAG 2.1 AA) 검사 엔진 (F-003). Playwright + axe-core 기반."""
|
|
|
|
category_name = "accessibility"
|
|
|
|
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
|
await self.update_progress(10, "브라우저 시작 중...")
|
|
|
|
async with async_playwright() as p:
|
|
browser = await p.chromium.launch(headless=True)
|
|
page = await browser.new_page()
|
|
|
|
await self.update_progress(20, "페이지 로드 중...")
|
|
await page.goto(url, wait_until="networkidle", timeout=30000)
|
|
|
|
await self.update_progress(40, "axe-core 주입 중...")
|
|
# axe-core minified JS를 페이지에 주입
|
|
await page.evaluate(AXE_CORE_JS)
|
|
|
|
await self.update_progress(60, "접근성 검사 실행 중...")
|
|
axe_results = await page.evaluate("() => axe.run()")
|
|
|
|
await self.update_progress(80, "결과 분석 중...")
|
|
issues = self._parse_axe_results(axe_results)
|
|
score = self._calculate_score(axe_results)
|
|
|
|
await browser.close()
|
|
|
|
await self.update_progress(100, "완료")
|
|
return CategoryResult(
|
|
category="accessibility",
|
|
score=score,
|
|
total_issues=len(issues),
|
|
wcag_level="AA",
|
|
issues=issues
|
|
)
|
|
|
|
def _calculate_score(self, axe_results: dict) -> int:
|
|
"""axe-core violations 기반 감점: critical=-20, serious=-10, moderate=-5, minor=-2"""
|
|
deduction = 0
|
|
severity_map = {"critical": 20, "serious": 10, "moderate": 5, "minor": 2}
|
|
for violation in axe_results.get("violations", []):
|
|
impact = violation.get("impact", "minor")
|
|
deduction += severity_map.get(impact, 2)
|
|
return max(0, 100 - deduction)
|
|
|
|
def _parse_axe_results(self, axe_results: dict) -> list[Issue]:
|
|
"""axe-core violations -> Issue 리스트 변환 (한국어 메시지)"""
|
|
# axe-core impact를 FEATURE_SPEC 심각도로 매핑
|
|
impact_to_severity = {
|
|
"critical": "critical",
|
|
"serious": "major",
|
|
"moderate": "minor",
|
|
"minor": "info"
|
|
}
|
|
# 한국어 메시지 매핑 딕셔너리 사용
|
|
...
|
|
```
|
|
|
|
**axe-core 주입 방식:**
|
|
- `axe-core@4.10.x` minified JS를 `app/engines/axe_core/axe.min.js`에 번들
|
|
- Playwright `page.evaluate()`로 런타임 주입
|
|
- Docker 이미지에 Playwright + Chromium 사전 설치
|
|
|
|
#### 3.3.5 SEO Checker
|
|
|
|
```python
|
|
class SeoChecker(BaseChecker):
|
|
"""SEO 최적화 검사 엔진 (F-004)"""
|
|
|
|
category_name = "seo"
|
|
|
|
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
|
soup = BeautifulSoup(html_content, "html5lib")
|
|
issues = []
|
|
meta_info = {}
|
|
|
|
await self.update_progress(10, "title 태그 검사 중...")
|
|
issues += self._check_title(soup, meta_info)
|
|
|
|
await self.update_progress(20, "meta description 검사 중...")
|
|
issues += self._check_meta_description(soup, meta_info)
|
|
|
|
await self.update_progress(30, "OG 태그 검사 중...")
|
|
issues += self._check_og_tags(soup)
|
|
issues += self._check_twitter_card(soup)
|
|
|
|
await self.update_progress(40, "canonical URL 검사 중...")
|
|
issues += self._check_canonical(soup)
|
|
|
|
await self.update_progress(50, "robots.txt 확인 중...")
|
|
issues += await self._check_robots_txt(url, meta_info)
|
|
|
|
await self.update_progress(60, "sitemap.xml 확인 중...")
|
|
issues += await self._check_sitemap(url, meta_info)
|
|
|
|
await self.update_progress(70, "H1 태그 검사 중...")
|
|
issues += self._check_h1(soup)
|
|
|
|
await self.update_progress(80, "구조화 데이터 검사 중...")
|
|
issues += self._check_structured_data(soup, html_content, meta_info)
|
|
|
|
await self.update_progress(90, "기타 항목 검사 중...")
|
|
issues += self._check_favicon(soup)
|
|
issues += self._check_viewport(soup)
|
|
issues += self._check_url_structure(url)
|
|
issues += self._check_img_alt_seo(soup)
|
|
|
|
score = self._calculate_score(issues)
|
|
await self.update_progress(100, "완료")
|
|
|
|
return CategoryResult(
|
|
category="seo",
|
|
score=score,
|
|
total_issues=len(issues),
|
|
issues=issues,
|
|
meta_info=meta_info
|
|
)
|
|
```
|
|
|
|
**검사 항목 매핑 (S-01 ~ S-14):**
|
|
|
|
| 메서드 | 검사 코드 | 기법 | 심각도 |
|
|
|--------|----------|------|--------|
|
|
| `_check_title()` | S-01 | BS4 파싱, len() 길이 검사 (10-60자) | Critical |
|
|
| `_check_meta_description()` | S-02 | BS4 meta[name=description] (50-160자) | Major |
|
|
| `_check_meta_keywords()` | S-03 | BS4 meta[name=keywords] | Info |
|
|
| `_check_og_tags()` | S-04 | BS4 meta[property^=og:] | Major |
|
|
| `_check_twitter_card()` | S-05 | BS4 meta[name^=twitter:] | Minor |
|
|
| `_check_canonical()` | S-06 | BS4 link[rel=canonical] | Major |
|
|
| `_check_robots_txt()` | S-07 | httpx GET {base_url}/robots.txt | Major |
|
|
| `_check_sitemap()` | S-08 | httpx GET {base_url}/sitemap.xml | Major |
|
|
| `_check_h1()` | S-09 | BS4 soup.find_all("h1") 개수 검사 | Critical |
|
|
| `_check_structured_data()` | S-10 | JSON-LD script[type=application/ld+json], Microdata | Minor |
|
|
| `_check_favicon()` | S-11 | BS4 link[rel~=icon] | Minor |
|
|
| `_check_viewport()` | S-12 | BS4 meta[name=viewport] | Major |
|
|
| `_check_url_structure()` | S-13 | URL 파싱, 특수문자 비율 | Minor |
|
|
| `_check_img_alt_seo()` | S-14 | BS4 img alt 속성 존재 여부 | Major |
|
|
|
|
#### 3.3.6 Performance/Security Checker
|
|
|
|
```python
|
|
class PerformanceSecurityChecker(BaseChecker):
|
|
"""성능/보안 검사 엔진 (F-005)"""
|
|
|
|
category_name = "performance_security"
|
|
|
|
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
|
issues = []
|
|
metrics = {}
|
|
|
|
await self.update_progress(10, "HTTPS 검사 중...")
|
|
issues += self._check_https(url, metrics)
|
|
|
|
await self.update_progress(20, "SSL 인증서 검사 중...")
|
|
issues += await self._check_ssl(url, metrics)
|
|
|
|
await self.update_progress(35, "보안 헤더 검사 중...")
|
|
issues += self._check_hsts(headers)
|
|
issues += self._check_csp(headers)
|
|
issues += self._check_x_content_type(headers)
|
|
issues += self._check_x_frame_options(headers)
|
|
issues += self._check_x_xss_protection(headers)
|
|
issues += self._check_referrer_policy(headers)
|
|
issues += self._check_permissions_policy(headers)
|
|
|
|
await self.update_progress(60, "응답 시간 측정 중...")
|
|
issues += await self._check_ttfb(url, metrics)
|
|
|
|
await self.update_progress(70, "페이지 크기 분석 중...")
|
|
issues += self._check_page_size(html_content, metrics)
|
|
|
|
await self.update_progress(80, "리다이렉트 검사 중...")
|
|
issues += await self._check_redirects(url, metrics)
|
|
|
|
await self.update_progress(85, "압축 검사 중...")
|
|
issues += self._check_compression(headers, metrics)
|
|
|
|
await self.update_progress(90, "혼합 콘텐츠 검사 중...")
|
|
issues += self._check_mixed_content(url, html_content)
|
|
|
|
score, sub_scores = self._calculate_score(issues, metrics)
|
|
await self.update_progress(100, "완료")
|
|
|
|
return CategoryResult(
|
|
category="performance_security",
|
|
score=score,
|
|
total_issues=len(issues),
|
|
sub_scores=sub_scores,
|
|
issues=issues,
|
|
metrics=metrics
|
|
)
|
|
|
|
def _calculate_score(self, issues, metrics):
|
|
"""
|
|
보안 점수 (70% 가중치) = HTTPS/SSL(30%) + 보안 헤더(40%)
|
|
성능 점수 (30% 가중치) = 응답 시간(40%) + 페이지 크기(30%) + 압축(30%)
|
|
종합 = 보안 * 0.7 + 성능 * 0.3
|
|
"""
|
|
...
|
|
```
|
|
|
|
**검사 항목 매핑 (P-01 ~ P-14):**
|
|
|
|
| 메서드 | 검사 코드 | 기법 | 심각도 |
|
|
|--------|----------|------|--------|
|
|
| `_check_https()` | P-01 | URL scheme 확인 | Critical |
|
|
| `_check_ssl()` | P-02 | ssl 모듈로 인증서 확인, 만료일 추출 | Critical |
|
|
| `_check_hsts()` | P-03 | headers["Strict-Transport-Security"] | Major |
|
|
| `_check_csp()` | P-04 | headers["Content-Security-Policy"] | Major |
|
|
| `_check_x_content_type()` | P-05 | headers["X-Content-Type-Options"] == "nosniff" | Minor |
|
|
| `_check_x_frame_options()` | P-06 | headers["X-Frame-Options"] | Minor |
|
|
| `_check_x_xss_protection()` | P-07 | headers["X-XSS-Protection"] (deprecated 알림) | Info |
|
|
| `_check_referrer_policy()` | P-08 | headers["Referrer-Policy"] | Minor |
|
|
| `_check_permissions_policy()` | P-09 | headers["Permissions-Policy"] | Minor |
|
|
| `_check_ttfb()` | P-10 | httpx 응답 시간 측정 (>1s: Major) | Major |
|
|
| `_check_page_size()` | P-11 | len(html_content) (>3MB: Minor) | Minor |
|
|
| `_check_redirects()` | P-12 | httpx follow_redirects, history 길이 (>3: Minor) | Minor |
|
|
| `_check_compression()` | P-13 | headers["Content-Encoding"] gzip/br | Minor |
|
|
| `_check_mixed_content()` | P-14 | BS4로 http:// 리소스 참조 검출 | Major |
|
|
|
|
#### 3.3.7 Inspection Orchestrator (서비스 계층)
|
|
|
|
```python
|
|
class InspectionService:
|
|
"""검사 오케스트레이션 서비스. 4개 검사 엔진을 병렬 실행하고 결과를 통합한다."""
|
|
|
|
def __init__(self, db: Database, redis: Redis):
|
|
self.db = db
|
|
self.redis = redis
|
|
|
|
async def start_inspection(self, url: str) -> str:
|
|
"""검사를 시작하고 inspection_id를 반환한다."""
|
|
# 1. URL 접근 가능 확인 (타임아웃 10초)
|
|
response = await self._fetch_url(url)
|
|
|
|
# 2. inspection_id 생성 (UUID v4)
|
|
inspection_id = str(uuid.uuid4())
|
|
|
|
# 3. Redis에 초기 상태 저장
|
|
await self._init_progress(inspection_id, url)
|
|
|
|
# 4. 백그라운드 태스크로 검사 실행
|
|
asyncio.create_task(self._run_inspection(inspection_id, url, response))
|
|
|
|
return inspection_id
|
|
|
|
async def _run_inspection(self, inspection_id: str, url: str, response):
|
|
"""4개 카테고리를 비동기 병렬로 실행한다."""
|
|
html_content = response.text
|
|
headers = dict(response.headers)
|
|
start_time = time.time()
|
|
|
|
# 진행 상태 콜백 생성
|
|
async def progress_callback(category: str, progress: int, current_step: str):
|
|
await self._update_progress(inspection_id, category, progress, current_step)
|
|
|
|
# 4개 검사 엔진 생성
|
|
checkers = [
|
|
HtmlCssChecker(progress_callback=progress_callback),
|
|
AccessibilityChecker(progress_callback=progress_callback),
|
|
SeoChecker(progress_callback=progress_callback),
|
|
PerformanceSecurityChecker(progress_callback=progress_callback),
|
|
]
|
|
|
|
# 병렬 실행 (각 카테고리 타임아웃 60초)
|
|
results = await asyncio.gather(
|
|
*[
|
|
asyncio.wait_for(checker.check(url, html_content, headers), timeout=60)
|
|
for checker in checkers
|
|
],
|
|
return_exceptions=True
|
|
)
|
|
|
|
# 결과 통합
|
|
duration = time.time() - start_time
|
|
inspection_result = self._aggregate_results(
|
|
inspection_id, url, results, duration
|
|
)
|
|
|
|
# MongoDB에 저장
|
|
await self.db.inspections.insert_one(inspection_result.model_dump())
|
|
|
|
# Redis 상태 -> completed
|
|
await self._mark_completed(inspection_id, inspection_result.overall_score)
|
|
|
|
async def _fetch_url(self, url: str):
|
|
"""URL에 접근하여 HTML을 가져온다. 타임아웃 10초."""
|
|
async with httpx.AsyncClient(
|
|
follow_redirects=True,
|
|
timeout=httpx.Timeout(10.0),
|
|
verify=True
|
|
) as client:
|
|
response = await client.get(url, headers={
|
|
"User-Agent": "WebInspector/1.0 (Inspection Bot)"
|
|
})
|
|
response.raise_for_status()
|
|
return response
|
|
```
|
|
|
|
### 3.4 SSE 실시간 진행 상태 구현
|
|
|
|
#### 3.4.1 진행 상태 데이터 흐름
|
|
|
|
```
|
|
[Checker Engine]
|
|
|
|
|
| progress_callback(category, progress, current_step)
|
|
v
|
|
[InspectionService._update_progress()]
|
|
|
|
|
| SET + PUBLISH
|
|
v
|
|
[Redis]
|
|
- Key: inspection:{id}:progress (Hash)
|
|
- Channel: inspection:{id}:events (Pub/Sub)
|
|
|
|
|
| SUBSCRIBE
|
|
v
|
|
[SSE Router - EventSourceResponse]
|
|
|
|
|
| text/event-stream
|
|
v
|
|
[Frontend - EventSource API]
|
|
```
|
|
|
|
#### 3.4.2 Redis 키 설계
|
|
|
|
| Redis Key | Type | TTL | 용도 |
|
|
|-----------|------|-----|------|
|
|
| `inspection:{id}:status` | String | 300s | 검사 상태 (running/completed/error) |
|
|
| `inspection:{id}:progress` | Hash | 300s | 카테고리별 진행률 |
|
|
| `inspection:{id}:events` | Pub/Sub Channel | - | SSE 이벤트 발행 채널 |
|
|
| `inspection:result:{id}` | String (JSON) | 3600s | 결과 캐시 (1시간) |
|
|
|
|
#### 3.4.3 SSE 엔드포인트 구현
|
|
|
|
```python
|
|
from sse_starlette.sse import EventSourceResponse
|
|
|
|
@router.get("/inspections/{inspection_id}/stream")
|
|
async def stream_progress(inspection_id: str):
|
|
"""검사 진행 상태를 SSE로 스트리밍한다."""
|
|
|
|
async def event_generator():
|
|
pubsub = redis.pubsub()
|
|
await pubsub.subscribe(f"inspection:{inspection_id}:events")
|
|
|
|
try:
|
|
# 현재 상태 즉시 전송 (이미 진행 중일 수 있음)
|
|
current = await get_current_progress(inspection_id)
|
|
if current:
|
|
yield {
|
|
"event": "progress",
|
|
"data": json.dumps(current, ensure_ascii=False)
|
|
}
|
|
|
|
# Pub/Sub 메시지 수신 루프
|
|
async for message in pubsub.listen():
|
|
if message["type"] == "message":
|
|
event_data = json.loads(message["data"])
|
|
event_type = event_data.pop("event_type", "progress")
|
|
|
|
yield {
|
|
"event": event_type,
|
|
"data": json.dumps(event_data, ensure_ascii=False)
|
|
}
|
|
|
|
# complete 또는 error 이벤트면 스트림 종료
|
|
if event_type in ("complete", "error"):
|
|
break
|
|
finally:
|
|
await pubsub.unsubscribe(f"inspection:{inspection_id}:events")
|
|
await pubsub.close()
|
|
|
|
return EventSourceResponse(
|
|
event_generator(),
|
|
media_type="text/event-stream"
|
|
)
|
|
```
|
|
|
|
#### 3.4.4 SSE 이벤트 타입
|
|
|
|
| 이벤트명 | 발행 시점 | 데이터 |
|
|
|---------|----------|--------|
|
|
| `progress` | 검사 단계 변경시 (약 10% 단위) | `{ inspection_id, status, overall_progress, categories: {...} }` |
|
|
| `category_complete` | 카테고리 1개 완료시 | `{ inspection_id, category, score, total_issues }` |
|
|
| `complete` | 전체 검사 완료시 | `{ inspection_id, status: "completed", overall_score, redirect_url }` |
|
|
| `error` | 오류 발생시 | `{ inspection_id, status: "error", message }` |
|
|
|
|
### 3.5 MongoDB 스키마 설계
|
|
|
|
#### 3.5.1 inspections 컬렉션
|
|
|
|
```javascript
|
|
// Collection: inspections
|
|
{
|
|
"_id": ObjectId, // MongoDB 자동 생성
|
|
"inspection_id": "uuid-v4", // 외부 식별자 (인덱스)
|
|
"url": "https://example.com", // 검사 대상 URL (인덱스)
|
|
"status": "completed", // running | completed | error
|
|
"created_at": ISODate("2026-02-12T10:00:00Z"), // 검사 시작 시각 (인덱스)
|
|
"completed_at": ISODate("2026-02-12T10:00:35Z"), // 검사 완료 시각
|
|
"duration_seconds": 35, // 소요 시간 (초)
|
|
"overall_score": 76, // 종합 점수 (0-100)
|
|
"grade": "B", // 등급 (A+/A/B/C/D/F)
|
|
|
|
// 이슈 요약
|
|
"summary": {
|
|
"total_issues": 23,
|
|
"critical": 2,
|
|
"major": 9,
|
|
"minor": 8,
|
|
"info": 4
|
|
},
|
|
|
|
// 카테고리별 결과
|
|
"categories": {
|
|
"html_css": {
|
|
"score": 85,
|
|
"grade": "A",
|
|
"total_issues": 5,
|
|
"critical": 0,
|
|
"major": 2,
|
|
"minor": 2,
|
|
"info": 1,
|
|
"issues": [
|
|
{
|
|
"code": "H-07",
|
|
"severity": "critical", // critical | major | minor | info
|
|
"message": "중복 ID 발견: 'main-content'",
|
|
"element": "<div id=\"main-content\">",
|
|
"line": 45,
|
|
"suggestion": "각 요소에 고유한 ID를 부여하세요"
|
|
}
|
|
]
|
|
},
|
|
"accessibility": {
|
|
"score": 72,
|
|
"grade": "B",
|
|
"total_issues": 8,
|
|
"critical": 1,
|
|
"major": 3,
|
|
"minor": 3,
|
|
"info": 1,
|
|
"wcag_level": "AA",
|
|
"issues": [
|
|
{
|
|
"code": "A-02",
|
|
"severity": "major",
|
|
"wcag_criterion": "1.4.3",
|
|
"message": "텍스트와 배경의 색상 대비가 부족합니다 (2.3:1, 최소 4.5:1 필요)",
|
|
"element": "<p class=\"light-text\">",
|
|
"suggestion": "텍스트 색상을 더 어둡게 하거나 배경을 더 밝게 조정하세요"
|
|
}
|
|
]
|
|
},
|
|
"seo": {
|
|
"score": 68,
|
|
"grade": "C",
|
|
"total_issues": 6,
|
|
"critical": 1,
|
|
"major": 2,
|
|
"minor": 2,
|
|
"info": 1,
|
|
"meta_info": {
|
|
"title": "사이트 제목",
|
|
"title_length": 25,
|
|
"description": "사이트 설명...",
|
|
"description_length": 120,
|
|
"has_robots_txt": true,
|
|
"has_sitemap": false,
|
|
"structured_data_types": ["JSON-LD"]
|
|
},
|
|
"issues": [...]
|
|
},
|
|
"performance_security": {
|
|
"score": 78,
|
|
"grade": "B",
|
|
"total_issues": 4,
|
|
"critical": 0,
|
|
"major": 2,
|
|
"minor": 1,
|
|
"info": 1,
|
|
"sub_scores": {
|
|
"security": 82,
|
|
"performance": 70
|
|
},
|
|
"metrics": {
|
|
"ttfb_ms": 450,
|
|
"page_size_bytes": 1250000,
|
|
"redirect_count": 1,
|
|
"compression": "gzip",
|
|
"https": true,
|
|
"ssl_valid": true,
|
|
"ssl_expiry_days": 89
|
|
},
|
|
"issues": [...]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 3.5.2 인덱스 설계
|
|
|
|
```javascript
|
|
// 검사 ID로 조회 (단일 결과 조회)
|
|
db.inspections.createIndex({ "inspection_id": 1 }, { unique: true })
|
|
|
|
// URL + 생성일로 조회 (이력 목록, 트렌드)
|
|
db.inspections.createIndex({ "url": 1, "created_at": -1 })
|
|
|
|
// 생성일 내림차순 (이력 목록 기본 정렬)
|
|
db.inspections.createIndex({ "created_at": -1 })
|
|
|
|
// URL 텍스트 검색 (부분 일치 검색)
|
|
db.inspections.createIndex({ "url": "text" })
|
|
```
|
|
|
|
#### 3.5.3 등급 계산 로직
|
|
|
|
```python
|
|
def calculate_grade(score: int) -> str:
|
|
if score >= 90: return "A+"
|
|
if score >= 80: return "A"
|
|
if score >= 70: return "B"
|
|
if score >= 60: return "C"
|
|
if score >= 50: return "D"
|
|
return "F"
|
|
|
|
def calculate_overall_score(categories: dict) -> int:
|
|
"""4개 카테고리 점수의 단순 평균"""
|
|
scores = [cat["score"] for cat in categories.values()]
|
|
return round(sum(scores) / len(scores))
|
|
```
|
|
|
|
### 3.6 Redis 캐시 전략
|
|
|
|
| 용도 | Key 패턴 | TTL | 설명 |
|
|
|------|----------|-----|------|
|
|
| 검사 진행 상태 | `inspection:{id}:status` | 5분 | 검사 완료 후 자동 만료 |
|
|
| 카테고리별 진행률 | `inspection:{id}:progress` | 5분 | Hash: `html_css=100, seo=60...` |
|
|
| SSE 이벤트 발행 | `inspection:{id}:events` | - | Pub/Sub 채널, 자동 정리 |
|
|
| 결과 캐시 | `inspection:result:{id}` | 1시간 | 자주 조회되는 결과 캐시 |
|
|
| 최근 검사 목록 캐시 | `inspections:recent` | 5분 | 메인 페이지 최근 이력 캐시 |
|
|
|
|
### 3.7 PDF 리포트 생성
|
|
|
|
```python
|
|
class ReportService:
|
|
"""PDF/JSON 리포트 생성 서비스"""
|
|
|
|
def __init__(self, template_dir: str):
|
|
self.env = Environment(loader=FileSystemLoader(template_dir))
|
|
|
|
async def generate_pdf(self, inspection: InspectionResult) -> bytes:
|
|
"""Jinja2 HTML 템플릿 -> WeasyPrint PDF 변환"""
|
|
template = self.env.get_template("report.html")
|
|
html_string = template.render(
|
|
inspection=inspection,
|
|
generated_at=datetime.now().isoformat()
|
|
)
|
|
pdf_bytes = HTML(string=html_string).write_pdf()
|
|
return pdf_bytes
|
|
```
|
|
|
|
**PDF 템플릿 구조 (`templates/report.html`):**
|
|
- 표지: 로고, 검사 URL, 검사 일시
|
|
- 종합 점수 섹션: 원형 점수 + 등급
|
|
- 카테고리별 점수 요약 테이블
|
|
- 카테고리별 이슈 목록 (심각도 색상 코딩)
|
|
- 개선 제안 요약
|
|
- Tailwind CSS 인라인 스타일 (WeasyPrint 호환)
|
|
|
|
---
|
|
|
|
## 4. 프론트엔드 설계
|
|
|
|
### 4.1 Next.js App Router 페이지 구조
|
|
|
|
```
|
|
app/
|
|
layout.tsx -- 루트 레이아웃 (Header 포함)
|
|
page.tsx -- P-001: 메인 페이지 (URL 입력)
|
|
inspections/
|
|
[id]/
|
|
page.tsx -- P-003: 검사 결과 대시보드
|
|
progress/
|
|
page.tsx -- P-002: 검사 진행 페이지
|
|
issues/
|
|
page.tsx -- P-004: 상세 이슈 페이지
|
|
history/
|
|
page.tsx -- P-005: 검사 이력 페이지
|
|
trend/
|
|
page.tsx -- P-006: 트렌드 비교 페이지
|
|
```
|
|
|
|
**라우팅 매핑:**
|
|
|
|
| 페이지 | 경로 | 파일 | 렌더링 |
|
|
|--------|------|------|--------|
|
|
| P-001 메인 | `/` | `app/page.tsx` | Client Component |
|
|
| P-002 진행 | `/inspections/[id]/progress` | `app/inspections/[id]/progress/page.tsx` | Client Component |
|
|
| P-003 대시보드 | `/inspections/[id]` | `app/inspections/[id]/page.tsx` | Server Component + Client 하이드레이션 |
|
|
| P-004 이슈 | `/inspections/[id]/issues` | `app/inspections/[id]/issues/page.tsx` | Client Component |
|
|
| P-005 이력 | `/history` | `app/history/page.tsx` | Client Component |
|
|
| P-006 트렌드 | `/history/trend` | `app/history/trend/page.tsx` | Client Component |
|
|
|
|
### 4.2 컴포넌트 계층 구조
|
|
|
|
```
|
|
components/
|
|
layout/
|
|
Header.tsx -- 글로벌 헤더 (로고 + 네비게이션)
|
|
Footer.tsx -- 글로벌 푸터 (선택적)
|
|
|
|
inspection/
|
|
UrlInputForm.tsx -- URL 입력 폼 + 검사 시작 버튼
|
|
OverallProgressBar.tsx -- 전체 진행률 바
|
|
CategoryProgressCard.tsx -- 카테고리별 진행 카드 (상태, 진행률, 단계 텍스트)
|
|
InspectionProgress.tsx -- 진행 페이지 컨테이너 (SSE 연결 관리)
|
|
|
|
dashboard/
|
|
OverallScoreGauge.tsx -- 종합 점수 원형 게이지 (Recharts PieChart)
|
|
CategoryScoreCard.tsx -- 카테고리별 점수 카드
|
|
IssueSummaryBar.tsx -- 심각도별 이슈 수 요약 바
|
|
InspectionMeta.tsx -- 검사 메타 정보 (URL, 일시, 소요시간)
|
|
ActionButtons.tsx -- 이슈 상세, PDF, JSON 버튼
|
|
|
|
issues/
|
|
FilterBar.tsx -- 카테고리/심각도 필터 바
|
|
IssueCard.tsx -- 개별 이슈 카드 (코드, 심각도 배지, 메시지, 요소, 제안)
|
|
IssueList.tsx -- 이슈 목록 컨테이너 (필터링 + 정렬)
|
|
|
|
history/
|
|
SearchBar.tsx -- URL 검색 입력
|
|
InspectionHistoryTable.tsx -- 이력 테이블
|
|
Pagination.tsx -- 페이지네이션
|
|
|
|
trend/
|
|
TrendChart.tsx -- 시계열 라인 차트 (Recharts LineChart)
|
|
ChartLegend.tsx -- 차트 범례 (토글 기능)
|
|
ComparisonSummary.tsx -- 최근 vs 이전 비교 요약
|
|
|
|
common/
|
|
ScoreBadge.tsx -- 점수 등급 배지 (A+/A/B/C/D/F, 색상 코딩)
|
|
SeverityBadge.tsx -- 심각도 배지 (Critical=빨강, Major=주황, Minor=노랑, Info=파랑)
|
|
LoadingSpinner.tsx -- 로딩 스피너
|
|
EmptyState.tsx -- 빈 상태 표시
|
|
ErrorState.tsx -- 에러 상태 표시
|
|
```
|
|
|
|
### 4.3 상태 관리 (Zustand Store 설계)
|
|
|
|
#### 4.3.1 Inspection Progress Store
|
|
|
|
```typescript
|
|
// stores/useInspectionStore.ts
|
|
interface CategoryProgress {
|
|
status: "pending" | "running" | "completed" | "error";
|
|
progress: number; // 0-100
|
|
currentStep?: string; // "색상 대비 검사 중..."
|
|
score?: number; // 완료 시 점수
|
|
totalIssues?: number; // 완료 시 이슈 수
|
|
}
|
|
|
|
interface InspectionProgressState {
|
|
inspectionId: string | null;
|
|
url: string | null;
|
|
status: "idle" | "connecting" | "running" | "completed" | "error";
|
|
overallProgress: number;
|
|
categories: {
|
|
html_css: CategoryProgress;
|
|
accessibility: CategoryProgress;
|
|
seo: CategoryProgress;
|
|
performance_security: CategoryProgress;
|
|
};
|
|
errorMessage: string | null;
|
|
|
|
// Actions
|
|
setInspection: (id: string, url: string) => void;
|
|
updateProgress: (data: SSEProgressEvent) => void;
|
|
setCategoryComplete: (data: SSECategoryCompleteEvent) => void;
|
|
setCompleted: (data: SSECompleteEvent) => void;
|
|
setError: (message: string) => void;
|
|
reset: () => void;
|
|
}
|
|
```
|
|
|
|
#### 4.3.2 Inspection Result Store
|
|
|
|
```typescript
|
|
// stores/useResultStore.ts
|
|
interface InspectionResultState {
|
|
result: InspectionResult | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
fetchResult: (inspectionId: string) => Promise<void>;
|
|
clearResult: () => void;
|
|
}
|
|
```
|
|
|
|
**참고: 이력 목록과 트렌드 데이터는 React Query(TanStack Query)로 관리한다. Zustand는 실시간 SSE 상태처럼 서버 상태와 클라이언트 상태가 혼합된 데이터에만 사용한다.**
|
|
|
|
### 4.4 React Query 설정
|
|
|
|
```typescript
|
|
// lib/queries.ts
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
// 검사 결과 조회
|
|
export function useInspectionResult(inspectionId: string) {
|
|
return useQuery({
|
|
queryKey: ["inspection", inspectionId],
|
|
queryFn: () => api.getInspection(inspectionId),
|
|
enabled: !!inspectionId,
|
|
staleTime: 5 * 60 * 1000, // 5분 캐시
|
|
});
|
|
}
|
|
|
|
// 이슈 목록 조회
|
|
export function useInspectionIssues(
|
|
inspectionId: string,
|
|
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) {
|
|
return useQuery({
|
|
queryKey: ["inspection-trend", url],
|
|
queryFn: () => api.getTrend(url),
|
|
enabled: !!url,
|
|
staleTime: 10 * 60 * 1000, // 10분 캐시
|
|
});
|
|
}
|
|
```
|
|
|
|
### 4.5 SSE 수신 및 진행 바 업데이트 방식
|
|
|
|
#### 4.5.1 SSE 연결 커스텀 훅
|
|
|
|
```typescript
|
|
// hooks/useSSE.ts
|
|
export function useInspectionSSE(inspectionId: string | null) {
|
|
const { updateProgress, setCategoryComplete, setCompleted, setError } =
|
|
useInspectionStore();
|
|
const router = useRouter();
|
|
|
|
useEffect(() => {
|
|
if (!inspectionId) return;
|
|
|
|
const eventSource = new EventSource(
|
|
`${API_BASE_URL}/api/inspections/${inspectionId}/stream`
|
|
);
|
|
|
|
eventSource.addEventListener("progress", (e) => {
|
|
const data = JSON.parse(e.data);
|
|
updateProgress(data);
|
|
});
|
|
|
|
eventSource.addEventListener("category_complete", (e) => {
|
|
const data = JSON.parse(e.data);
|
|
setCategoryComplete(data);
|
|
});
|
|
|
|
eventSource.addEventListener("complete", (e) => {
|
|
const data = JSON.parse(e.data);
|
|
setCompleted(data);
|
|
eventSource.close();
|
|
// 결과 페이지로 자동 이동
|
|
router.push(`/inspections/${inspectionId}`);
|
|
});
|
|
|
|
eventSource.addEventListener("error", (e) => {
|
|
// SSE 프로토콜 에러 vs 검사 에러 구분
|
|
if (e instanceof MessageEvent) {
|
|
const data = JSON.parse(e.data);
|
|
setError(data.message);
|
|
}
|
|
eventSource.close();
|
|
});
|
|
|
|
// SSE 연결 타임아웃 (120초)
|
|
const timeout = setTimeout(() => {
|
|
eventSource.close();
|
|
setError("검사 시간이 초과되었습니다");
|
|
}, 120000);
|
|
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
eventSource.close();
|
|
};
|
|
}, [inspectionId]);
|
|
}
|
|
```
|
|
|
|
#### 4.5.2 진행 페이지 컴포넌트 (P-002)
|
|
|
|
```typescript
|
|
// app/inspections/[id]/progress/page.tsx
|
|
"use client";
|
|
|
|
export default function ProgressPage({ params }: { params: { id: string } }) {
|
|
const { status, overallProgress, categories, url, errorMessage } =
|
|
useInspectionStore();
|
|
|
|
// SSE 연결
|
|
useInspectionSSE(params.id);
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto p-6">
|
|
<h1>검사 중: {url}</h1>
|
|
<OverallProgressBar progress={overallProgress} />
|
|
|
|
<div className="space-y-4 mt-6">
|
|
{Object.entries(categories).map(([key, cat]) => (
|
|
<CategoryProgressCard
|
|
key={key}
|
|
categoryName={CATEGORY_LABELS[key]}
|
|
status={cat.status}
|
|
progress={cat.progress}
|
|
currentStep={cat.currentStep}
|
|
score={cat.score}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{status === "error" && (
|
|
<ErrorState message={errorMessage} onRetry={handleRetry} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 4.6 API 클라이언트 설정
|
|
|
|
```typescript
|
|
// lib/api.ts
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8011";
|
|
|
|
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) {
|
|
const error = await response.json();
|
|
throw new ApiError(response.status, error.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) params.set("category", filters.category);
|
|
if (filters?.severity) params.set("severity", filters.severity);
|
|
return this.request(`/api/inspections/${id}/issues?${params}`);
|
|
}
|
|
|
|
// 이력 목록 조회
|
|
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);
|
|
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<Blob> {
|
|
const response = await fetch(`${this.baseUrl}/api/inspections/${id}/report/pdf`);
|
|
return response.blob();
|
|
}
|
|
|
|
// JSON 다운로드
|
|
async downloadJson(id: string): Promise<Blob> {
|
|
const response = await fetch(`${this.baseUrl}/api/inspections/${id}/report/json`);
|
|
return response.blob();
|
|
}
|
|
}
|
|
|
|
export const api = new ApiClient(API_BASE_URL);
|
|
```
|
|
|
|
### 4.7 차트 컴포넌트 설계
|
|
|
|
#### 종합 점수 게이지 (OverallScoreGauge)
|
|
|
|
```typescript
|
|
// Recharts PieChart 기반 반원형 게이지
|
|
// 점수에 따른 색상: 0-49 빨강(#EF4444), 50-79 주황(#F59E0B), 80-100 초록(#22C55E)
|
|
<PieChart width={200} height={120}>
|
|
<Pie
|
|
data={[
|
|
{ value: score, fill: getScoreColor(score) },
|
|
{ value: 100 - score, fill: "#E5E7EB" }
|
|
]}
|
|
startAngle={180}
|
|
endAngle={0}
|
|
innerRadius={60}
|
|
outerRadius={80}
|
|
/>
|
|
<text x="50%" y="80%" textAnchor="middle" fontSize={36} fontWeight="bold">
|
|
{score}
|
|
</text>
|
|
</PieChart>
|
|
```
|
|
|
|
#### 트렌드 라인 차트 (TrendChart)
|
|
|
|
```typescript
|
|
// Recharts LineChart 기반 시계열 차트
|
|
// 5개 라인: 종합, HTML/CSS, 접근성, SEO, 성능/보안
|
|
const CATEGORY_COLORS = {
|
|
overall: "#6366F1", // 인디고 (종합)
|
|
html_css: "#3B82F6", // 파랑
|
|
accessibility: "#22C55E", // 초록
|
|
seo: "#F59E0B", // 주황
|
|
performance_security: "#EF4444" // 빨강
|
|
};
|
|
|
|
<LineChart data={dataPoints}>
|
|
<XAxis dataKey="created_at" tickFormatter={formatDate} />
|
|
<YAxis domain={[0, 100]} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Legend />
|
|
{visibleLines.map(key => (
|
|
<Line key={key} dataKey={key} stroke={CATEGORY_COLORS[key]} />
|
|
))}
|
|
</LineChart>
|
|
```
|
|
|
|
---
|
|
|
|
## 5. API 스펙
|
|
|
|
### 5.1 POST /api/inspections -- 검사 시작
|
|
|
|
**Request:**
|
|
```
|
|
POST /api/inspections
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"url": "https://example.com"
|
|
}
|
|
```
|
|
|
|
**Response (202 Accepted):**
|
|
```json
|
|
{
|
|
"inspection_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"status": "running",
|
|
"url": "https://example.com",
|
|
"stream_url": "/api/inspections/550e8400-e29b-41d4-a716-446655440000/stream"
|
|
}
|
|
```
|
|
|
|
**Error (422 Validation Error):**
|
|
```json
|
|
{
|
|
"detail": "유효한 URL을 입력해주세요 (http:// 또는 https://로 시작해야 합니다)"
|
|
}
|
|
```
|
|
|
|
**Error (400 Bad Request):**
|
|
```json
|
|
{
|
|
"detail": "해당 URL에 접근할 수 없습니다"
|
|
}
|
|
```
|
|
|
|
### 5.2 GET /api/inspections/{id}/stream -- SSE 스트림
|
|
|
|
**Request:**
|
|
```
|
|
GET /api/inspections/{id}/stream
|
|
Accept: text/event-stream
|
|
```
|
|
|
|
**Response (text/event-stream):**
|
|
```
|
|
event: progress
|
|
data: {"inspection_id":"uuid","status":"running","overall_progress":45,"categories":{"html_css":{"status":"completed","progress":100},"accessibility":{"status":"running","progress":60,"current_step":"색상 대비 검사 중..."},"seo":{"status":"running","progress":30,"current_step":"robots.txt 확인 중..."},"performance_security":{"status":"pending","progress":0}}}
|
|
|
|
event: category_complete
|
|
data: {"inspection_id":"uuid","category":"html_css","score":85,"total_issues":5}
|
|
|
|
event: complete
|
|
data: {"inspection_id":"uuid","status":"completed","overall_score":76,"redirect_url":"/inspections/uuid"}
|
|
```
|
|
|
|
### 5.3 GET /api/inspections/{id} -- 검사 결과 조회
|
|
|
|
**Request:**
|
|
```
|
|
GET /api/inspections/{id}
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
```json
|
|
{
|
|
"inspection_id": "uuid",
|
|
"url": "https://example.com",
|
|
"status": "completed",
|
|
"created_at": "2026-02-12T10:00:00Z",
|
|
"completed_at": "2026-02-12T10:00:35Z",
|
|
"duration_seconds": 35,
|
|
"overall_score": 76,
|
|
"grade": "B",
|
|
"categories": {
|
|
"html_css": {
|
|
"score": 85,
|
|
"grade": "A",
|
|
"total_issues": 5,
|
|
"critical": 0,
|
|
"major": 2,
|
|
"minor": 2,
|
|
"info": 1
|
|
},
|
|
"accessibility": {
|
|
"score": 72,
|
|
"grade": "B",
|
|
"total_issues": 8,
|
|
"critical": 1,
|
|
"major": 3,
|
|
"minor": 3,
|
|
"info": 1
|
|
},
|
|
"seo": {
|
|
"score": 68,
|
|
"grade": "C",
|
|
"total_issues": 6,
|
|
"critical": 1,
|
|
"major": 2,
|
|
"minor": 2,
|
|
"info": 1
|
|
},
|
|
"performance_security": {
|
|
"score": 78,
|
|
"grade": "B",
|
|
"total_issues": 4,
|
|
"critical": 0,
|
|
"major": 2,
|
|
"minor": 1,
|
|
"info": 1
|
|
}
|
|
},
|
|
"summary": {
|
|
"total_issues": 23,
|
|
"critical": 2,
|
|
"major": 9,
|
|
"minor": 8,
|
|
"info": 4
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error (404 Not Found):**
|
|
```json
|
|
{
|
|
"detail": "검사 결과를 찾을 수 없습니다"
|
|
}
|
|
```
|
|
|
|
### 5.4 GET /api/inspections/{id}/issues -- 이슈 목록 조회
|
|
|
|
**Request:**
|
|
```
|
|
GET /api/inspections/{id}/issues?category=html_css&severity=critical
|
|
```
|
|
|
|
**Query Parameters:**
|
|
|
|
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|
|
|---------|------|------|--------|------|
|
|
| category | string | N | all | html_css, accessibility, seo, performance_security |
|
|
| severity | string | N | all | critical, major, minor, info |
|
|
|
|
**Response (200 OK):**
|
|
```json
|
|
{
|
|
"inspection_id": "uuid",
|
|
"total": 5,
|
|
"filters": {
|
|
"category": "html_css",
|
|
"severity": "all"
|
|
},
|
|
"issues": [
|
|
{
|
|
"code": "H-07",
|
|
"category": "html_css",
|
|
"severity": "critical",
|
|
"message": "중복 ID 발견: 'main-content'",
|
|
"element": "<div id=\"main-content\">",
|
|
"line": 45,
|
|
"suggestion": "각 요소에 고유한 ID를 부여하세요"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### 5.5 GET /api/inspections -- 이력 목록 조회
|
|
|
|
**Request:**
|
|
```
|
|
GET /api/inspections?page=1&limit=20&url=example.com&sort=-created_at
|
|
```
|
|
|
|
**Query Parameters:**
|
|
|
|
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|
|
|---------|------|------|--------|------|
|
|
| page | int | N | 1 | 페이지 번호 |
|
|
| limit | int | N | 20 | 페이지당 항목 수 (최대 100) |
|
|
| url | string | N | - | URL 필터 (부분 일치) |
|
|
| sort | string | N | -created_at | 정렬 기준 (- prefix = 내림차순) |
|
|
|
|
**Response (200 OK):**
|
|
```json
|
|
{
|
|
"items": [
|
|
{
|
|
"inspection_id": "uuid",
|
|
"url": "https://example.com",
|
|
"created_at": "2026-02-12T10:00:00Z",
|
|
"overall_score": 76,
|
|
"grade": "B",
|
|
"total_issues": 23
|
|
}
|
|
],
|
|
"total": 150,
|
|
"page": 1,
|
|
"limit": 20,
|
|
"total_pages": 8
|
|
}
|
|
```
|
|
|
|
### 5.6 GET /api/inspections/trend -- 트렌드 데이터
|
|
|
|
**Request:**
|
|
```
|
|
GET /api/inspections/trend?url=https://example.com&limit=10
|
|
```
|
|
|
|
**Query Parameters:**
|
|
|
|
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|
|
|---------|------|------|--------|------|
|
|
| url | string | Y | - | 정확한 URL |
|
|
| limit | int | N | 10 | 최대 데이터 포인트 수 |
|
|
|
|
**Response (200 OK):**
|
|
```json
|
|
{
|
|
"url": "https://example.com",
|
|
"data_points": [
|
|
{
|
|
"inspection_id": "uuid-1",
|
|
"created_at": "2026-02-01T10:00:00Z",
|
|
"overall_score": 72,
|
|
"html_css": 80,
|
|
"accessibility": 65,
|
|
"seo": 70,
|
|
"performance_security": 73
|
|
},
|
|
{
|
|
"inspection_id": "uuid-2",
|
|
"created_at": "2026-02-10T10:00:00Z",
|
|
"overall_score": 78,
|
|
"html_css": 85,
|
|
"accessibility": 72,
|
|
"seo": 68,
|
|
"performance_security": 86
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### 5.7 GET /api/inspections/{id}/report/pdf -- PDF 다운로드
|
|
|
|
**Request:**
|
|
```
|
|
GET /api/inspections/{id}/report/pdf
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
```
|
|
Content-Type: application/pdf
|
|
Content-Disposition: attachment; filename="web-inspector-example-com-2026-02-12.pdf"
|
|
```
|
|
|
|
### 5.8 GET /api/inspections/{id}/report/json -- JSON 다운로드
|
|
|
|
**Request:**
|
|
```
|
|
GET /api/inspections/{id}/report/json
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
```
|
|
Content-Type: application/json
|
|
Content-Disposition: attachment; filename="web-inspector-example-com-2026-02-12.json"
|
|
```
|
|
|
|
### 5.9 GET /api/health -- 헬스체크
|
|
|
|
**Response (200 OK):**
|
|
```json
|
|
{
|
|
"status": "healthy",
|
|
"timestamp": "2026-02-12T10:00:00Z",
|
|
"services": {
|
|
"mongodb": "connected",
|
|
"redis": "connected"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Docker 구성
|
|
|
|
### 6.1 서비스 구성
|
|
|
|
| 서비스 | 이미지 | 포트 (호스트:컨테이너) | 역할 |
|
|
|--------|--------|---------------------|------|
|
|
| frontend | 커스텀 (Node 20) | 3011:3000 | Next.js 프론트엔드 |
|
|
| backend | 커스텀 (Python 3.11 + Playwright) | 8011:8000 | FastAPI 백엔드 |
|
|
| mongodb | mongo:7.0 | 27022:27017 | 데이터베이스 |
|
|
| redis | redis:7-alpine | 6392:6379 | 캐시/Pub-Sub |
|
|
|
|
### 6.2 docker-compose.yml
|
|
|
|
```yaml
|
|
services:
|
|
# ===================
|
|
# Infrastructure
|
|
# ===================
|
|
mongodb:
|
|
image: mongo:7.0
|
|
container_name: web-inspector-mongodb
|
|
restart: unless-stopped
|
|
environment:
|
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
|
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
|
|
ports:
|
|
- "${MONGO_PORT:-27022}:27017"
|
|
volumes:
|
|
- mongodb_data:/data/db
|
|
networks:
|
|
- app-network
|
|
healthcheck:
|
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
container_name: web-inspector-redis
|
|
restart: unless-stopped
|
|
ports:
|
|
- "${REDIS_PORT:-6392}:6379"
|
|
volumes:
|
|
- redis_data:/data
|
|
networks:
|
|
- app-network
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
|
|
# ===================
|
|
# Backend
|
|
# ===================
|
|
backend:
|
|
build:
|
|
context: ./backend
|
|
dockerfile: Dockerfile
|
|
container_name: web-inspector-backend
|
|
restart: unless-stopped
|
|
ports:
|
|
- "${BACKEND_PORT:-8011}:8000"
|
|
environment:
|
|
- MONGODB_URL=mongodb://${MONGO_USER:-admin}:${MONGO_PASSWORD:-password123}@mongodb:27017/
|
|
- DB_NAME=${DB_NAME:-web_inspector}
|
|
- REDIS_URL=redis://redis:6379
|
|
depends_on:
|
|
mongodb:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
networks:
|
|
- app-network
|
|
|
|
# ===================
|
|
# Frontend
|
|
# ===================
|
|
frontend:
|
|
build:
|
|
context: ./frontend
|
|
dockerfile: Dockerfile
|
|
container_name: web-inspector-frontend
|
|
restart: unless-stopped
|
|
ports:
|
|
- "${FRONTEND_PORT:-3011}:3000"
|
|
environment:
|
|
- NEXT_PUBLIC_API_URL=http://backend:8000
|
|
depends_on:
|
|
- backend
|
|
networks:
|
|
- app-network
|
|
|
|
volumes:
|
|
mongodb_data:
|
|
redis_data:
|
|
|
|
networks:
|
|
app-network:
|
|
driver: bridge
|
|
```
|
|
|
|
### 6.3 Backend Dockerfile
|
|
|
|
```dockerfile
|
|
FROM python:3.11-slim
|
|
|
|
WORKDIR /app
|
|
|
|
# 시스템 의존성 설치 (WeasyPrint + Playwright)
|
|
RUN apt-get update && apt-get install -y \
|
|
curl \
|
|
# WeasyPrint 의존성
|
|
libpango-1.0-0 \
|
|
libpangocairo-1.0-0 \
|
|
libgdk-pixbuf2.0-0 \
|
|
libffi-dev \
|
|
shared-mime-info \
|
|
# Playwright 의존성
|
|
libnss3 \
|
|
libnspr4 \
|
|
libatk1.0-0 \
|
|
libatk-bridge2.0-0 \
|
|
libdrm2 \
|
|
libxcomposite1 \
|
|
libxdamage1 \
|
|
libxrandr2 \
|
|
libgbm1 \
|
|
libpango-1.0-0 \
|
|
libasound2 \
|
|
libxshmfence1 \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Python 의존성
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
# Playwright + Chromium 설치
|
|
RUN playwright install chromium
|
|
RUN playwright install-deps chromium
|
|
|
|
# 애플리케이션 코드
|
|
COPY app/ ./app/
|
|
|
|
EXPOSE 8000
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
|
```
|
|
|
|
### 6.4 Backend requirements.txt
|
|
|
|
```
|
|
# Core
|
|
fastapi>=0.115.0
|
|
uvicorn[standard]>=0.32.0
|
|
pydantic>=2.10.0
|
|
python-dotenv>=1.0.0
|
|
|
|
# Database
|
|
motor>=3.6.0
|
|
redis>=5.2.0
|
|
|
|
# SSE
|
|
sse-starlette>=2.1.0
|
|
|
|
# HTTP Client
|
|
httpx>=0.27.0
|
|
|
|
# HTML Parsing
|
|
beautifulsoup4>=4.12.0
|
|
html5lib>=1.1
|
|
lxml>=5.3.0
|
|
|
|
# Accessibility (Playwright)
|
|
playwright>=1.49.0
|
|
|
|
# PDF Report
|
|
weasyprint>=62.0
|
|
Jinja2>=3.1.0
|
|
|
|
# Utilities
|
|
python-slugify>=8.0.0
|
|
```
|
|
|
|
### 6.5 Frontend Dockerfile
|
|
|
|
```dockerfile
|
|
FROM node:20-alpine AS base
|
|
|
|
# Dependencies
|
|
FROM base AS deps
|
|
WORKDIR /app
|
|
COPY package.json package-lock.json* ./
|
|
RUN npm ci
|
|
|
|
# Build
|
|
FROM base AS builder
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
# Production
|
|
FROM base AS runner
|
|
WORKDIR /app
|
|
ENV NODE_ENV=production
|
|
|
|
RUN addgroup --system --gid 1001 nodejs
|
|
RUN adduser --system --uid 1001 nextjs
|
|
|
|
COPY --from=builder /app/public ./public
|
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
|
|
USER nextjs
|
|
EXPOSE 3000
|
|
ENV PORT=3000
|
|
CMD ["node", "server.js"]
|
|
```
|
|
|
|
### 6.6 .env 파일 (프로젝트 포트 설정)
|
|
|
|
```env
|
|
PROJECT_NAME=web-inspector
|
|
MONGO_USER=admin
|
|
MONGO_PASSWORD=password123
|
|
MONGO_PORT=27022
|
|
REDIS_PORT=6392
|
|
DB_NAME=web_inspector
|
|
BACKEND_PORT=8011
|
|
FRONTEND_PORT=3011
|
|
```
|
|
|
|
---
|
|
|
|
## 7. 디렉토리 구조
|
|
|
|
### 7.1 전체 프로젝트 구조
|
|
|
|
```
|
|
web-inspector/
|
|
docker-compose.yml
|
|
.env
|
|
.gitignore
|
|
CLAUDE.md
|
|
README.md
|
|
PLAN.md
|
|
FEATURE_SPEC.md
|
|
SCREEN_DESIGN.pptx
|
|
SCREEN_DESIGN.md
|
|
ARCHITECTURE.md <-- 이 문서
|
|
|
|
backend/
|
|
Dockerfile
|
|
requirements.txt
|
|
app/
|
|
__init__.py
|
|
main.py -- FastAPI 앱 엔트리포인트
|
|
core/
|
|
__init__.py
|
|
config.py -- 환경변수 설정 (Settings)
|
|
database.py -- MongoDB 연결 관리 (Motor)
|
|
redis.py -- Redis 연결 관리
|
|
models/
|
|
__init__.py
|
|
schemas.py -- Pydantic 모델 (요청/응답)
|
|
database.py -- MongoDB 문서 모델
|
|
routers/
|
|
__init__.py
|
|
health.py -- GET /api/health
|
|
inspections.py -- 검사 시작, SSE, 결과/이슈 조회, 이력, 트렌드
|
|
reports.py -- PDF/JSON 리포트 다운로드
|
|
services/
|
|
__init__.py
|
|
inspection_service.py -- 검사 오케스트레이션 (시작, 진행, 결과 통합)
|
|
report_service.py -- PDF/JSON 리포트 생성
|
|
engines/
|
|
__init__.py
|
|
base.py -- BaseChecker 추상 클래스
|
|
html_css.py -- HTML/CSS 표준 검사 엔진
|
|
accessibility.py -- 접근성(WCAG) 검사 엔진
|
|
seo.py -- SEO 최적화 검사 엔진
|
|
performance_security.py -- 성능/보안 검사 엔진
|
|
axe_core/
|
|
axe.min.js -- axe-core 라이브러리 번들
|
|
templates/
|
|
report.html -- PDF 리포트 Jinja2 템플릿
|
|
report.css -- PDF 리포트 스타일
|
|
|
|
frontend/
|
|
Dockerfile
|
|
package.json
|
|
tsconfig.json
|
|
tailwind.config.ts
|
|
next.config.ts
|
|
postcss.config.js
|
|
components.json -- shadcn/ui 설정
|
|
public/
|
|
favicon.ico
|
|
logo.svg
|
|
src/
|
|
app/
|
|
layout.tsx -- 루트 레이아웃
|
|
page.tsx -- 메인 페이지 (P-001)
|
|
globals.css -- 전역 스타일
|
|
inspections/
|
|
[id]/
|
|
page.tsx -- 결과 대시보드 (P-003)
|
|
progress/
|
|
page.tsx -- 진행 페이지 (P-002)
|
|
issues/
|
|
page.tsx -- 이슈 페이지 (P-004)
|
|
history/
|
|
page.tsx -- 이력 페이지 (P-005)
|
|
trend/
|
|
page.tsx -- 트렌드 페이지 (P-006)
|
|
components/
|
|
layout/
|
|
Header.tsx
|
|
inspection/
|
|
UrlInputForm.tsx
|
|
InspectionProgress.tsx
|
|
OverallProgressBar.tsx
|
|
CategoryProgressCard.tsx
|
|
dashboard/
|
|
OverallScoreGauge.tsx
|
|
CategoryScoreCard.tsx
|
|
IssueSummaryBar.tsx
|
|
InspectionMeta.tsx
|
|
ActionButtons.tsx
|
|
issues/
|
|
FilterBar.tsx
|
|
IssueCard.tsx
|
|
IssueList.tsx
|
|
history/
|
|
SearchBar.tsx
|
|
InspectionHistoryTable.tsx
|
|
Pagination.tsx
|
|
trend/
|
|
TrendChart.tsx
|
|
ChartLegend.tsx
|
|
ComparisonSummary.tsx
|
|
common/
|
|
ScoreBadge.tsx
|
|
SeverityBadge.tsx
|
|
LoadingSpinner.tsx
|
|
EmptyState.tsx
|
|
ErrorState.tsx
|
|
ui/ -- shadcn/ui 컴포넌트
|
|
button.tsx
|
|
card.tsx
|
|
input.tsx
|
|
badge.tsx
|
|
progress.tsx
|
|
table.tsx
|
|
tabs.tsx
|
|
hooks/
|
|
useSSE.ts -- SSE 연결 커스텀 훅
|
|
stores/
|
|
useInspectionStore.ts -- 검사 진행 상태 (Zustand)
|
|
useResultStore.ts -- 검사 결과 상태 (Zustand)
|
|
lib/
|
|
api.ts -- API 클라이언트
|
|
queries.ts -- React Query 훅
|
|
utils.ts -- 유틸리티 함수 (점수 색상, 등급 계산 등)
|
|
constants.ts -- 상수 (카테고리명, 심각도 색상 등)
|
|
types/
|
|
inspection.ts -- TypeScript 타입 정의
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Pydantic 모델 (요청/응답 스키마)
|
|
|
|
### 8.1 Backend 모델 (`app/models/schemas.py`)
|
|
|
|
```python
|
|
from pydantic import BaseModel, Field, HttpUrl
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
|
|
# --- Enums ---
|
|
|
|
class InspectionStatus(str, Enum):
|
|
RUNNING = "running"
|
|
COMPLETED = "completed"
|
|
ERROR = "error"
|
|
|
|
class Severity(str, Enum):
|
|
CRITICAL = "critical"
|
|
MAJOR = "major"
|
|
MINOR = "minor"
|
|
INFO = "info"
|
|
|
|
class CategoryName(str, Enum):
|
|
HTML_CSS = "html_css"
|
|
ACCESSIBILITY = "accessibility"
|
|
SEO = "seo"
|
|
PERFORMANCE_SECURITY = "performance_security"
|
|
|
|
# --- Request ---
|
|
|
|
class StartInspectionRequest(BaseModel):
|
|
url: HttpUrl
|
|
|
|
# --- Response ---
|
|
|
|
class StartInspectionResponse(BaseModel):
|
|
inspection_id: str
|
|
status: str = "running"
|
|
url: str
|
|
stream_url: str
|
|
|
|
class Issue(BaseModel):
|
|
code: str # "H-07", "A-02", etc.
|
|
category: CategoryName
|
|
severity: Severity
|
|
message: str
|
|
element: Optional[str] = None # HTML 요소 문자열
|
|
line: Optional[int] = None # 라인 번호
|
|
suggestion: str
|
|
wcag_criterion: Optional[str] = None # 접근성 전용
|
|
|
|
class CategoryResult(BaseModel):
|
|
score: int = Field(ge=0, le=100)
|
|
grade: str
|
|
total_issues: int
|
|
critical: int = 0
|
|
major: int = 0
|
|
minor: int = 0
|
|
info: int = 0
|
|
issues: list[Issue] = []
|
|
# 카테고리별 추가 필드
|
|
wcag_level: Optional[str] = None # accessibility
|
|
meta_info: Optional[dict] = None # seo
|
|
sub_scores: Optional[dict] = None # performance_security
|
|
metrics: Optional[dict] = None # performance_security
|
|
|
|
class IssueSummary(BaseModel):
|
|
total_issues: int
|
|
critical: int
|
|
major: int
|
|
minor: int
|
|
info: int
|
|
|
|
class InspectionResult(BaseModel):
|
|
inspection_id: str
|
|
url: str
|
|
status: InspectionStatus
|
|
created_at: datetime
|
|
completed_at: Optional[datetime] = None
|
|
duration_seconds: Optional[float] = None
|
|
overall_score: int = Field(ge=0, le=100)
|
|
grade: str
|
|
categories: dict[str, CategoryResult]
|
|
summary: IssueSummary
|
|
|
|
class IssueListResponse(BaseModel):
|
|
inspection_id: str
|
|
total: int
|
|
filters: dict
|
|
issues: list[Issue]
|
|
|
|
class InspectionListItem(BaseModel):
|
|
inspection_id: str
|
|
url: str
|
|
created_at: datetime
|
|
overall_score: int
|
|
grade: str
|
|
total_issues: int
|
|
|
|
class PaginatedResponse(BaseModel):
|
|
items: list[InspectionListItem]
|
|
total: int
|
|
page: int
|
|
limit: int
|
|
total_pages: int
|
|
|
|
class TrendDataPoint(BaseModel):
|
|
inspection_id: str
|
|
created_at: datetime
|
|
overall_score: int
|
|
html_css: int
|
|
accessibility: int
|
|
seo: int
|
|
performance_security: int
|
|
|
|
class TrendResponse(BaseModel):
|
|
url: str
|
|
data_points: list[TrendDataPoint]
|
|
```
|
|
|
|
### 8.2 Frontend 타입 (`src/types/inspection.ts`)
|
|
|
|
```typescript
|
|
export type Severity = "critical" | "major" | "minor" | "info";
|
|
export type CategoryName = "html_css" | "accessibility" | "seo" | "performance_security";
|
|
export type InspectionStatus = "running" | "completed" | "error";
|
|
export type Grade = "A+" | "A" | "B" | "C" | "D" | "F";
|
|
|
|
export interface Issue {
|
|
code: string;
|
|
category: CategoryName;
|
|
severity: Severity;
|
|
message: string;
|
|
element?: string;
|
|
line?: number;
|
|
suggestion: string;
|
|
wcag_criterion?: string;
|
|
}
|
|
|
|
export interface CategoryResult {
|
|
score: number;
|
|
grade: Grade;
|
|
total_issues: number;
|
|
critical: number;
|
|
major: number;
|
|
minor: number;
|
|
info: number;
|
|
issues?: Issue[];
|
|
wcag_level?: string;
|
|
meta_info?: Record<string, unknown>;
|
|
sub_scores?: { security: number; performance: number };
|
|
metrics?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface IssueSummary {
|
|
total_issues: number;
|
|
critical: number;
|
|
major: number;
|
|
minor: number;
|
|
info: number;
|
|
}
|
|
|
|
export interface InspectionResult {
|
|
inspection_id: string;
|
|
url: string;
|
|
status: InspectionStatus;
|
|
created_at: string;
|
|
completed_at?: string;
|
|
duration_seconds?: number;
|
|
overall_score: number;
|
|
grade: Grade;
|
|
categories: Record<CategoryName, CategoryResult>;
|
|
summary: IssueSummary;
|
|
}
|
|
|
|
export interface InspectionListItem {
|
|
inspection_id: string;
|
|
url: string;
|
|
created_at: string;
|
|
overall_score: number;
|
|
grade: Grade;
|
|
total_issues: number;
|
|
}
|
|
|
|
export interface PaginatedResponse {
|
|
items: InspectionListItem[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
total_pages: number;
|
|
}
|
|
|
|
export interface TrendDataPoint {
|
|
inspection_id: string;
|
|
created_at: string;
|
|
overall_score: number;
|
|
html_css: number;
|
|
accessibility: number;
|
|
seo: number;
|
|
performance_security: number;
|
|
}
|
|
|
|
export interface TrendResponse {
|
|
url: string;
|
|
data_points: TrendDataPoint[];
|
|
}
|
|
|
|
// SSE Event Types
|
|
export interface SSEProgressEvent {
|
|
inspection_id: string;
|
|
status: string;
|
|
overall_progress: number;
|
|
categories: Record<CategoryName, {
|
|
status: "pending" | "running" | "completed" | "error";
|
|
progress: number;
|
|
current_step?: string;
|
|
}>;
|
|
}
|
|
|
|
export interface SSECategoryCompleteEvent {
|
|
inspection_id: string;
|
|
category: CategoryName;
|
|
score: number;
|
|
total_issues: number;
|
|
}
|
|
|
|
export interface SSECompleteEvent {
|
|
inspection_id: string;
|
|
status: "completed";
|
|
overall_score: number;
|
|
redirect_url: string;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. 비기능 요구사항 대응
|
|
|
|
### 9.1 성능
|
|
|
|
| 항목 | 목표 | 구현 방식 |
|
|
|------|------|----------|
|
|
| 검사 시작 API | < 2초 | URL 접근 확인(10초 타임아웃) 후 비동기 태스크 즉시 반환 |
|
|
| 전체 검사 시간 | < 120초 | asyncio.gather 병렬 실행, 카테고리당 60초 타임아웃 |
|
|
| SSE 이벤트 지연 | < 500ms | Redis Pub/Sub 직접 연결, 네트워크 홉 최소화 |
|
|
| 프론트엔드 로드 | < 3초 | Next.js 정적 빌드, standalone 출력 |
|
|
| PDF 생성 | < 10초 | WeasyPrint 로컬 렌더링, 템플릿 캐싱 |
|
|
|
|
### 9.2 확장성
|
|
|
|
| 전략 | 설명 |
|
|
|------|------|
|
|
| 수평 확장 | 백엔드 컨테이너 복제 (Docker Compose replicas), Redis Pub/Sub가 인스턴스 간 이벤트 공유 |
|
|
| DB 인덱스 | inspection_id(unique), url+created_at(복합), created_at(정렬) |
|
|
| 캐싱 | Redis 결과 캐시(1시간), React Query 클라이언트 캐시(5분) |
|
|
| 이력 제한 | URL당 최대 100건, 초과 시 oldest 삭제 |
|
|
|
|
### 9.3 장애 대응
|
|
|
|
| 장애 시나리오 | 대응 전략 |
|
|
|-------------|----------|
|
|
| 대상 URL 접근 불가 | 10초 타임아웃 -> 400 에러 반환 (즉시 실패) |
|
|
| 카테고리 검사 타임아웃 | 60초 타임아웃 -> 해당 카테고리만 에러 처리, 나머지는 정상 반환 |
|
|
| Playwright 크래시 | try/except로 접근성 검사 에러 격리, 점수 0 반환 |
|
|
| MongoDB 연결 실패 | healthcheck 실패 시 컨테이너 재시작, 결과 Redis에 임시 보관 |
|
|
| Redis 연결 실패 | SSE 대신 폴링 모드 폴백 (GET /api/inspections/{id} 주기적 호출) |
|
|
| 대형 HTML (>10MB) | 페이지 크기 제한 검사, 초과 시 경고 후 제한된 검사 실행 |
|
|
|
|
---
|
|
|
|
## 10. 트레이드오프 분석
|
|
|
|
### 10.1 주요 결정 및 대안
|
|
|
|
| 결정 | 채택 | 대안 | 근거 |
|
|
|------|------|------|------|
|
|
| 실시간 통신 | SSE | WebSocket | SSE는 단방향 스트리밍에 충분, HTTP 호환성 우수, 구현 단순 |
|
|
| 접근성 검사 | Playwright + axe-core JS 주입 | axe-selenium-python | Playwright가 async 네이티브, 더 빠르고 Docker 친화적 |
|
|
| HTML 파싱 | BS4 + html5lib (로컬) | W3C Validator API (외부) | 외부 API 의존 제거, 속도 제한 없음, 오프라인 동작 |
|
|
| PDF 생성 | WeasyPrint | Puppeteer/Playwright PDF | WeasyPrint는 Python 네이티브, 추가 브라우저 인스턴스 불필요 |
|
|
| 상태 관리 | Zustand(SSE) + React Query(서버) | Redux Toolkit | SSE 실시간 상태는 Zustand, 서버 캐시는 React Query로 역할 분리 |
|
|
| DB | MongoDB | PostgreSQL | 검사 결과의 중첩 구조(카테고리 > 이슈)가 문서 DB에 자연스러움 |
|
|
| 검사 병렬 실행 | asyncio.gather | Celery 태스크 큐 | MVP 단계에서 인프라 단순화, 단일 프로세스 내 병렬 처리로 충분 |
|
|
| Redis 용도 | 상태 캐시 + Pub/Sub | 별도 메시지 브로커 (RabbitMQ) | 단일 Redis로 캐시/Pub-Sub 겸용, 인프라 최소화 |
|
|
|
|
### 10.2 향후 확장 시 재평가 필요
|
|
|
|
- 동시 검사 수가 50건 이상이면 Celery + Redis Broker로 전환 검토
|
|
- 접근성 검사 외 성능 메트릭(CWV)도 Playwright로 측정하려면 Playwright 인스턴스 풀 관리 필요
|
|
- 다국어 지원 시 i18n 프레임워크(next-intl) 도입 검토
|
|
|
|
---
|
|
|
|
## 11. 구현 순서 가이드
|
|
|
|
### Phase 3 (백엔드 구현) 권장 순서
|
|
|
|
1. `core/config.py`, `core/database.py`, `core/redis.py` -- 인프라 연결
|
|
2. `models/schemas.py` -- Pydantic 모델 전체 정의
|
|
3. `engines/base.py` -- BaseChecker 추상 클래스
|
|
4. `engines/html_css.py` -- HTML/CSS 검사 엔진 (가장 단순, 외부 의존 없음)
|
|
5. `engines/seo.py` -- SEO 검사 엔진 (httpx 외부 요청 포함)
|
|
6. `engines/performance_security.py` -- 성능/보안 검사 엔진
|
|
7. `engines/accessibility.py` -- 접근성 검사 엔진 (Playwright 의존, 가장 복잡)
|
|
8. `services/inspection_service.py` -- 오케스트레이션 서비스
|
|
9. `routers/inspections.py` -- 검사 API + SSE 엔드포인트
|
|
10. `routers/health.py` -- 헬스체크
|
|
11. `services/report_service.py` + `templates/` -- PDF/JSON 리포트
|
|
12. `routers/reports.py` -- 리포트 API
|
|
|
|
### Phase 3 (프론트엔드 구현) 권장 순서
|
|
|
|
1. Next.js 프로젝트 초기화 + shadcn/ui + Tailwind 설정
|
|
2. `types/inspection.ts` -- 타입 정의
|
|
3. `lib/api.ts` -- API 클라이언트
|
|
4. `lib/constants.ts`, `lib/utils.ts` -- 상수/유틸리티
|
|
5. `components/layout/Header.tsx` -- 공통 레이아웃
|
|
6. `components/common/*` -- 공통 컴포넌트 (Badge, Spinner 등)
|
|
7. `app/page.tsx` + `UrlInputForm.tsx` -- 메인 페이지 (P-001)
|
|
8. `stores/useInspectionStore.ts` + `hooks/useSSE.ts` -- SSE 상태 관리
|
|
9. `app/inspections/[id]/progress/page.tsx` -- 진행 페이지 (P-002)
|
|
10. `app/inspections/[id]/page.tsx` + 대시보드 컴포넌트 -- 결과 페이지 (P-003)
|
|
11. `app/inspections/[id]/issues/page.tsx` + 이슈 컴포넌트 -- 이슈 페이지 (P-004)
|
|
12. `lib/queries.ts` -- React Query 훅
|
|
13. `app/history/page.tsx` -- 이력 페이지 (P-005)
|
|
14. `app/history/trend/page.tsx` + 차트 컴포넌트 -- 트렌드 페이지 (P-006)
|
|
|
|
---
|
|
|
|
## 부록 A: 카테고리 라벨 상수
|
|
|
|
```python
|
|
# Backend
|
|
CATEGORY_LABELS = {
|
|
"html_css": "HTML/CSS 표준",
|
|
"accessibility": "접근성 (WCAG)",
|
|
"seo": "SEO 최적화",
|
|
"performance_security": "성능/보안",
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// Frontend
|
|
export const CATEGORY_LABELS: Record<string, string> = {
|
|
html_css: "HTML/CSS 표준",
|
|
accessibility: "접근성 (WCAG)",
|
|
seo: "SEO 최적화",
|
|
performance_security: "성능/보안",
|
|
};
|
|
|
|
export const SEVERITY_COLORS: Record<string, string> = {
|
|
critical: "#EF4444", // red-500
|
|
major: "#F97316", // orange-500
|
|
minor: "#EAB308", // yellow-500
|
|
info: "#3B82F6", // blue-500
|
|
};
|
|
|
|
export const SCORE_COLORS = {
|
|
good: "#22C55E", // green-500 (80-100)
|
|
average: "#F59E0B", // amber-500 (50-79)
|
|
poor: "#EF4444", // red-500 (0-49)
|
|
};
|
|
```
|
|
|
|
## 부록 B: 환경변수 정리
|
|
|
|
### Backend 환경변수
|
|
|
|
| 변수명 | 기본값 | 설명 |
|
|
|--------|--------|------|
|
|
| `MONGODB_URL` | `mongodb://admin:password123@mongodb:27017/` | MongoDB 연결 문자열 |
|
|
| `DB_NAME` | `web_inspector` | MongoDB 데이터베이스명 |
|
|
| `REDIS_URL` | `redis://redis:6379` | Redis 연결 문자열 |
|
|
| `URL_FETCH_TIMEOUT` | `10` | URL 접근 타임아웃 (초) |
|
|
| `CATEGORY_TIMEOUT` | `60` | 카테고리별 검사 타임아웃 (초) |
|
|
| `MAX_HTML_SIZE` | `10485760` | 최대 HTML 크기 (10MB) |
|
|
|
|
### Frontend 환경변수
|
|
|
|
| 변수명 | 기본값 | 설명 |
|
|
|--------|--------|------|
|
|
| `NEXT_PUBLIC_API_URL` | `http://localhost:8011` | 백엔드 API URL |
|