Files
web-inspector/ARCHITECTURE.md
jungwoo choi b5fa5d96b9 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>
2026-02-13 13:57:27 +09:00

74 KiB

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)

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 추상 클래스

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

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

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

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

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 (서비스 계층)

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 엔드포인트 구현

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 컬렉션

// 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 인덱스 설계

// 검사 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 등급 계산 로직

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 리포트 생성

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

// 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

// 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 설정

// 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 연결 커스텀 훅

// 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)

// 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 클라이언트 설정

// 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)

// 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)

// 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):

{
  "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):

{
  "detail": "유효한 URL을 입력해주세요 (http:// 또는 https://로 시작해야 합니다)"
}

Error (400 Bad Request):

{
  "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):

{
  "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):

{
  "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):

{
  "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):

{
  "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):

{
  "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):

{
  "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

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

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

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 파일 (프로젝트 포트 설정)

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)

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)

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: 카테고리 라벨 상수

# Backend
CATEGORY_LABELS = {
    "html_css": "HTML/CSS 표준",
    "accessibility": "접근성 (WCAG)",
    "seo": "SEO 최적화",
    "performance_security": "성능/보안",
}
// 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