# 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 | 문자열 매칭 (``) | 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": "
", "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": "

", "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; 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 (

검사 중: {url}

{Object.entries(categories).map(([key, cat]) => ( ))}
{status === "error" && ( )}
); } ``` ### 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(path: string, options?: RequestInit): Promise { 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 { return this.request("/api/inspections", { method: "POST", body: JSON.stringify({ url }), }); } // 검사 결과 조회 async getInspection(id: string): Promise { return this.request(`/api/inspections/${id}`); } // 이슈 목록 조회 async getIssues(id: string, filters?: IssueFilters): Promise { 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 { 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 { const qs = new URLSearchParams({ url, limit: String(limit) }); return this.request(`/api/inspections/trend?${qs}`); } // PDF 다운로드 async downloadPdf(id: string): Promise { const response = await fetch(`${this.baseUrl}/api/inspections/${id}/report/pdf`); return response.blob(); } // JSON 다운로드 async downloadJson(id: string): Promise { 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) {score} ``` #### 트렌드 라인 차트 (TrendChart) ```typescript // Recharts LineChart 기반 시계열 차트 // 5개 라인: 종합, HTML/CSS, 접근성, SEO, 성능/보안 const CATEGORY_COLORS = { overall: "#6366F1", // 인디고 (종합) html_css: "#3B82F6", // 파랑 accessibility: "#22C55E", // 초록 seo: "#F59E0B", // 주황 performance_security: "#EF4444" // 빨강 }; } /> {visibleLines.map(key => ( ))} ``` --- ## 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": "
", "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; sub_scores?: { security: number; performance: number }; metrics?: Record; } 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; 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; } 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 = { html_css: "HTML/CSS 표준", accessibility: "접근성 (WCAG)", seo: "SEO 최적화", performance_security: "성능/보안", }; export const SEVERITY_COLORS: Record = { 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 |