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

2388 lines
74 KiB
Markdown

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