Initial commit: 프로젝트 초기 구성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-10 06:53:16 +09:00
commit b54811ad8d
21 changed files with 3168 additions and 0 deletions

47
.claude/skills/README.md Normal file
View File

@ -0,0 +1,47 @@
# Prototype Workspace 공통 Skills
이 워크스페이스의 모든 프로젝트에 적용되는 개발 표준 가이드입니다.
개별 프로젝트의 `.claude/skills/`에 동일 파일이 있으면 프로젝트별 설정이 우선합니다.
## 중요도 1 (핵심)
| Skill | 설명 | 파일 |
|-------|------|------|
| deployment-standards | Docker 배포 규칙 | [deployment-standards.md](deployment-standards.md) |
| project-stack | React+Next.js+FastAPI 기본 구조 | [project-stack.md](project-stack.md) |
| korean-dev-conventions | 한국어 주석, 에러 처리 등 | [korean-dev-conventions.md](korean-dev-conventions.md) |
## 중요도 2 (주요)
| Skill | 설명 | 파일 |
|-------|------|------|
| ai-api-integration | AI 모델 FastAPI 통합 패턴 | [ai-api-integration.md](ai-api-integration.md) |
| api-design-standards | RESTful API 설계 규칙 | [api-design-standards.md](api-design-standards.md) |
| frontend-component-patterns | React 컴포넌트 패턴 | [frontend-component-patterns.md](frontend-component-patterns.md) |
| database-patterns | MongoDB 설계 패턴 | [database-patterns.md](database-patterns.md) |
## 프로젝트 생성 도구
| Skill | 설명 | 파일 |
|-------|------|------|
| prj-archetypes | 프로젝트 아키타입 정의 (6종) | [prj-archetypes.md](prj-archetypes.md) |
> `/prj` 커맨드: `.claude/commands/prj.md` — 프로젝트 생성 에이전트
## 중요도 3 (참고)
| Skill | 설명 | 파일 |
|-------|------|------|
| infrastructure-setup | MAAS, K8s, Rancher 인프라 구축 | [infrastructure-setup.md](infrastructure-setup.md) |
| testing-standards | Jest/pytest 테스트 작성 | [testing-standards.md](testing-standards.md) |
| monitoring-logging | Prometheus/Grafana 모니터링 | [monitoring-logging.md](monitoring-logging.md) |
| gitea-workflow | Gitea 리포지토리 워크플로우 | [gitea-workflow.md](gitea-workflow.md) |
## 기술 스택 요약
- **Frontend**: Next.js 16 + React 19 + Tailwind CSS 4 + shadcn/ui
- **Backend**: FastAPI + Python 3.11
- **Database**: MongoDB 7.0 + Redis 7
- **Containerization**: Docker + Docker Compose
- **AI**: Claude API + OpenAI API
- **Git**: Gitea (http://gitea.yakenator.io/yakenator/)

View File

@ -0,0 +1,213 @@
# AI API 통합 패턴 (AI API Integration)
이 프로젝트의 AI 모델 API 통합 패턴입니다.
## Claude API 통합
### 클라이언트 초기화
```python
from anthropic import AsyncAnthropic
class AIArticleGeneratorWorker:
def __init__(self):
self.claude_api_key = os.getenv("CLAUDE_API_KEY")
self.claude_client = None
async def start(self):
if self.claude_api_key:
self.claude_client = AsyncAnthropic(api_key=self.claude_api_key)
else:
logger.error("Claude API key not configured")
return
```
### API 호출 패턴
```python
async def _call_claude_api(self, prompt: str) -> str:
"""Claude API 호출"""
try:
response = await self.claude_client.messages.create(
model="claude-sonnet-4-20250514", # 또는 claude-3-5-sonnet-latest
max_tokens=8192,
messages=[
{
"role": "user",
"content": prompt
}
]
)
return response.content[0].text
except Exception as e:
logger.error(f"Claude API error: {e}")
raise
```
### JSON 응답 파싱
```python
async def _generate_article(self, prompt: str) -> Dict[str, Any]:
"""기사 생성 및 JSON 파싱"""
response_text = await self._call_claude_api(prompt)
# JSON 블록 추출
json_match = re.search(r'```json\s*(.*?)\s*```', response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
else:
json_str = response_text
return json.loads(json_str)
```
## 프롬프트 관리
### MongoDB 기반 동적 프롬프트
```python
class AIArticleGeneratorWorker:
def __init__(self):
self._cached_prompt = None
self._prompt_cache_time = None
self._prompt_cache_ttl = 300 # 5분 캐시
self._default_prompt = """..."""
async def _get_prompt_template(self) -> str:
"""MongoDB에서 프롬프트 템플릿을 가져옴 (캐시 적용)"""
import time
current_time = time.time()
# 캐시가 유효하면 캐시된 프롬프트 반환
if (self._cached_prompt and self._prompt_cache_time and
current_time - self._prompt_cache_time < self._prompt_cache_ttl):
return self._cached_prompt
try:
prompts_collection = self.db.prompts
custom_prompt = await prompts_collection.find_one({"service": "article_generator"})
if custom_prompt and custom_prompt.get("content"):
self._cached_prompt = custom_prompt["content"]
logger.info("Using custom prompt from database")
else:
self._cached_prompt = self._default_prompt
logger.info("Using default prompt")
self._prompt_cache_time = current_time
return self._cached_prompt
except Exception as e:
logger.warning(f"Error fetching prompt from database: {e}, using default")
return self._default_prompt
```
### 프롬프트 템플릿 형식
```python
prompt_template = """Write a comprehensive article based on the following news information.
Keyword: {keyword}
News Information:
Title: {title}
Summary: {summary}
Link: {link}
{search_text}
Please write in the following JSON format:
{{
"title": "Article title",
"summary": "One-line summary",
"subtopics": [
{{
"title": "Subtopic 1",
"content": ["Paragraph 1", "Paragraph 2", ...]
}}
],
"categories": ["Category1", "Category2"],
"entities": {{
"people": [{{"name": "Name", "context": ["role", "company"]}}],
"organizations": [{{"name": "Name", "context": ["industry", "type"]}}]
}}
}}
Requirements:
- Structure with 2-5 subtopics
- Professional and objective tone
- Write in English
"""
```
## OpenAI API 통합 (참고)
### 클라이언트 초기화
```python
from openai import AsyncOpenAI
class OpenAIService:
def __init__(self):
self.api_key = os.getenv("OPENAI_API_KEY")
self.client = AsyncOpenAI(api_key=self.api_key)
async def generate(self, prompt: str) -> str:
response = await self.client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
max_tokens=4096
)
return response.choices[0].message.content
```
## 에러 처리 및 재시도
### 재시도 패턴
```python
import asyncio
from typing import Optional
async def _call_with_retry(
self,
func,
max_retries: int = 3,
initial_delay: float = 1.0
) -> Optional[Any]:
"""지수 백오프 재시도"""
delay = initial_delay
for attempt in range(max_retries):
try:
return await func()
except Exception as e:
if attempt == max_retries - 1:
logger.error(f"All {max_retries} attempts failed: {e}")
raise
logger.warning(f"Attempt {attempt + 1} failed: {e}, retrying in {delay}s")
await asyncio.sleep(delay)
delay *= 2 # 지수 백오프
```
## 환경 변수
```bash
# .env 파일
CLAUDE_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
# docker-compose.yml
environment:
- CLAUDE_API_KEY=${CLAUDE_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
```
## 비용 최적화
### 토큰 제한
```python
response = await self.claude_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192, # 출력 토큰 제한
messages=[...]
)
```
### 캐싱 전략
- MongoDB에 응답 캐시 저장
- TTL 기반 캐시 만료
- 동일 입력에 대한 중복 호출 방지

View File

@ -0,0 +1,269 @@
# RESTful API 설계 규칙 (API Design Standards)
이 프로젝트의 RESTful API 설계 패턴입니다.
## FastAPI 기본 구조
### 앱 초기화
```python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
app = FastAPI(
title="Biocode API",
description="바이오코드 성격 분석 및 유명인 등록 API",
version="1.0.0"
)
```
### Pydantic 모델 정의
```python
class AddFamousPersonRequest(BaseModel):
year: int
month: int
day: int
name: str
description: Optional[str] = ""
entity_type: Optional[str] = "person" # "person" or "organization"
class BiocodeResponse(BaseModel):
biocode: str
g_code: str
s_code: str
personality: dict
```
## 엔드포인트 패턴
### Health Check
```python
@app.get("/health")
async def health_check():
"""헬스체크 엔드포인트"""
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
```
### GET - 조회
```python
@app.get("/biocode/{year}/{month}/{day}")
async def get_biocode(year: int, month: int, day: int):
"""생년월일로 바이오코드 조회"""
try:
biocode = calculate_biocode(year, month, day)
g_code = f"g{biocode[:2]}"
s_code = biocode[2:]
if g_code not in biocode_data:
raise HTTPException(status_code=404, detail=f"G code {g_code} not found")
personality = biocode_data[g_code].get("codes", {}).get(biocode, {})
return {
"biocode": biocode,
"g_code": g_code,
"s_code": s_code,
"personality": personality
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
### POST - 생성
```python
@app.post("/add_famous_person")
async def add_famous_person(request: AddFamousPersonRequest):
"""유명인/조직 등록"""
try:
biocode = calculate_biocode(request.year, request.month, request.day)
g_code = f"g{biocode[:2]}"
if g_code not in biocode_data:
raise HTTPException(status_code=404, detail=f"G code {g_code} not found")
# 엔티티 타입에 따라 저장 위치 결정
is_organization = request.entity_type == "organization"
target_field = "famousOrganizations" if is_organization else "famousPeople"
# 데이터 추가
new_entry = {
"name": request.name,
"code": biocode,
"description": request.description or ""
}
if target_field not in biocode_data[g_code]["codes"][biocode]:
biocode_data[g_code]["codes"][biocode][target_field] = []
biocode_data[g_code]["codes"][biocode][target_field].append(new_entry)
# JSON 파일 저장
save_biocode_data(g_code)
return {
"success": True,
"biocode": biocode,
"entity_type": request.entity_type,
"name": request.name
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
## 라우터 분리 패턴
### 메인 앱 (main.py)
```python
from fastapi import FastAPI
from app.routers import users, articles
app = FastAPI(title="News Engine API")
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
@app.get("/health")
async def health():
return {"status": "healthy"}
```
### 라우터 (routers/users.py)
```python
from fastapi import APIRouter, HTTPException, Depends
from app.models.user import UserCreate, UserResponse
from app.database import get_database
router = APIRouter()
@router.post("/", response_model=UserResponse)
async def create_user(user: UserCreate, db = Depends(get_database)):
"""새 사용자 생성"""
existing = await db.users.find_one({"email": user.email})
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
result = await db.users.insert_one(user.dict())
return UserResponse(id=str(result.inserted_id), **user.dict())
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: str, db = Depends(get_database)):
"""사용자 조회"""
user = await db.users.find_one({"_id": ObjectId(user_id)})
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserResponse(**user)
```
## 에러 처리
### HTTPException 사용
```python
from fastapi import HTTPException
# 404 Not Found
raise HTTPException(status_code=404, detail="Resource not found")
# 400 Bad Request
raise HTTPException(status_code=400, detail="Invalid input data")
# 500 Internal Server Error
raise HTTPException(status_code=500, detail=str(e))
```
### 전역 예외 처리
```python
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "error": str(exc)}
)
```
## 응답 형식
### 성공 응답
```json
{
"success": true,
"data": { ... },
"message": "Operation completed successfully"
}
```
### 에러 응답
```json
{
"detail": "Error message here"
}
```
### 목록 응답
```json
{
"items": [...],
"total": 100,
"page": 1,
"page_size": 20
}
```
## CORS 설정
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://yourdomain.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
## API 버저닝
```python
# URL 기반 버저닝
app.include_router(v1_router, prefix="/api/v1")
app.include_router(v2_router, prefix="/api/v2")
# 또는 헤더 기반
@app.get("/api/resource")
async def get_resource(api_version: str = Header(default="v1")):
if api_version == "v2":
return v2_response()
return v1_response()
```
## 인증
### JWT 토큰 검증
```python
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
security = HTTPBearer()
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
try:
payload = jwt.decode(
credentials.credentials,
os.getenv("JWT_SECRET_KEY"),
algorithms=["HS256"]
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@router.get("/protected")
async def protected_route(user = Depends(verify_token)):
return {"user": user}
```

View File

@ -0,0 +1,384 @@
# MongoDB 설계 패턴 (Database Patterns)
이 프로젝트의 MongoDB 설계 및 사용 패턴입니다.
## 연결 설정
### Motor (async driver)
```python
from motor.motor_asyncio import AsyncIOMotorClient
class WikipediaEnrichmentWorker:
def __init__(self):
self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
self.db_name = os.getenv("DB_NAME", "ai_writer_db")
self.db = None
async def start(self):
client = AsyncIOMotorClient(self.mongodb_url)
self.db = client[self.db_name]
```
### PyMongo (sync driver)
```python
from pymongo import MongoClient
from pymongo.database import Database
client: MongoClient = None
db: Database = None
def connect_to_mongo():
global client, db
try:
client = MongoClient(MONGODB_URL)
db = client[DATABASE_NAME]
client.admin.command('ping') # 연결 테스트
print(f"Successfully connected to MongoDB: {DATABASE_NAME}")
except Exception as e:
print(f"Error connecting to MongoDB: {e}")
raise e
```
## 컬렉션 설계
### 기사 컬렉션 (articles_en)
```json
{
"_id": "ObjectId",
"news_id": "unique_id",
"title": "Article Title",
"summary": "One-line summary",
"subtopics": [
{
"title": "Subtopic 1",
"content": ["Paragraph 1", "Paragraph 2"]
}
],
"categories": ["Category1", "Category2"],
"entities": {
"people": [
{
"name": "Person Name",
"context": ["role", "company"],
"birth_date": "1990-01-15",
"wikipedia_url": "https://...",
"image_urls": ["https://..."],
"verified": true
}
],
"organizations": [
{
"name": "Organization Name",
"context": ["industry", "type"],
"founding_date": "2004-02-04",
"wikipedia_url": "https://...",
"image_urls": ["https://..."],
"verified": true
}
]
},
"wikipedia_enriched": true,
"wikipedia_enriched_at": "2024-01-15T10:30:00",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:30:00"
}
```
### 엔티티 캐시 컬렉션 (entity_people)
```json
{
"_id": "ObjectId",
"name": "Elon Musk",
"context": ["Tesla", "SpaceX", "CEO"],
"birth_date": "1971-06-28",
"wikipedia_url": "https://en.wikipedia.org/wiki/Elon_Musk",
"image_urls": ["https://..."],
"verified": true,
"created_at": "2024-01-10T00:00:00",
"updated_at": "2024-01-15T10:30:00"
}
```
## 인덱스 설계
### 인덱스 생성 패턴
```python
class EntityCache:
async def ensure_indexes(self):
"""인덱스 생성 (이미 존재하면 무시)"""
try:
# wikipedia_url이 unique key (동명이인 구분)
try:
await self.people_collection.create_index(
"wikipedia_url", unique=True, sparse=True
)
except Exception:
pass # 이미 존재
# 이름으로 검색용 (동명이인 가능)
try:
await self.people_collection.create_index("name")
except Exception:
pass
# context 검색용
try:
await self.people_collection.create_index("context")
except Exception:
pass
# TTL 정책용
try:
await self.people_collection.create_index("updated_at")
except Exception:
pass
logger.info("Entity cache indexes ensured")
except Exception as e:
logger.warning(f"Error ensuring indexes: {e}")
```
## CRUD 패턴
### Create (삽입)
```python
async def save_person(self, data: Dict[str, Any]) -> bool:
"""인물 정보 저장/갱신 (wikipedia_url 기준)"""
now = datetime.now()
update_doc = {
"name": data.get("name"),
"context": data.get("context", []),
"birth_date": data.get("birth_date"),
"wikipedia_url": data.get("wikipedia_url"),
"image_urls": data.get("image_urls", []),
"verified": data.get("verified", False),
"updated_at": now
}
if data.get("wikipedia_url"):
# upsert: 있으면 업데이트, 없으면 삽입
result = await self.people_collection.update_one(
{"wikipedia_url": data["wikipedia_url"]},
{
"$set": update_doc,
"$setOnInsert": {"created_at": now}
},
upsert=True
)
return result.modified_count > 0 or result.upserted_id is not None
```
### Read (조회)
```python
async def get_person(self, name: str, context: List[str] = None) -> Tuple[Optional[Dict], bool]:
"""
인물 정보 조회 (context 기반 최적 매칭)
Returns:
Tuple of (cached_data, needs_refresh)
"""
# 이름으로 모든 후보 검색
cursor = self.people_collection.find({"name": {"$regex": f"^{name}$", "$options": "i"}})
candidates = await cursor.to_list(length=10)
if not candidates:
return None, True
# context가 있으면 최적 후보 선택
if context:
best_match = None
best_score = -1
for candidate in candidates:
score = self._calculate_context_match_score(
candidate.get("context", []), context
)
if score > best_score:
best_score = score
best_match = candidate
if best_match and best_score >= MIN_CONTEXT_MATCH:
needs_refresh = not self._is_cache_fresh(best_match)
return best_match, needs_refresh
# context 없으면 첫 번째 후보 반환
candidate = candidates[0]
needs_refresh = not self._is_cache_fresh(candidate)
return candidate, needs_refresh
```
### Update (수정)
```python
async def update_article(self, mongodb_id: str, update_data: Dict[str, Any]):
"""기사 정보 업데이트"""
result = await self.collection.update_one(
{"_id": ObjectId(mongodb_id)},
{
"$set": {
"entities.people": update_data.get("people", []),
"entities.organizations": update_data.get("organizations", []),
"wikipedia_enriched": True,
"wikipedia_enriched_at": datetime.now().isoformat()
}
}
)
return result.modified_count > 0
```
### Delete (삭제)
```python
async def delete_old_cache(self, days: int = 30):
"""오래된 캐시 데이터 삭제"""
cutoff_date = datetime.now() - timedelta(days=days)
result = await self.people_collection.delete_many({
"updated_at": {"$lt": cutoff_date}
})
return result.deleted_count
```
## 캐싱 전략
### TTL 기반 캐시
```python
# 캐시 유효 기간 (7일)
CACHE_TTL_DAYS = 7
def _is_cache_fresh(self, cached_data: Dict[str, Any]) -> bool:
"""캐시 데이터가 신선한지 확인"""
if not cached_data:
return False
updated_at = cached_data.get("updated_at")
if not updated_at:
return False
if isinstance(updated_at, str):
updated_at = datetime.fromisoformat(updated_at)
expiry_date = updated_at + timedelta(days=CACHE_TTL_DAYS)
return datetime.now() < expiry_date
```
### 갱신 정책
```python
# 정책:
# - 7일이 지나면 갱신 시도 (삭제 아님)
# - API 호출 실패 시 기존 데이터 유지
# - 데이터 동일 시 확인 일자만 갱신
async def save_person(self, new_data: Dict, existing_data: Dict = None):
"""기존 데이터와 비교하여 적절히 처리"""
if existing_data and existing_data.get("verified"):
# 기존에 검증된 데이터가 있음
if not new_data.get("birth_date") and existing_data.get("birth_date"):
# 새 데이터가 덜 완전하면 기존 데이터 유지, 시간만 갱신
await self.people_collection.update_one(
{"wikipedia_url": existing_data["wikipedia_url"]},
{"$set": {"updated_at": datetime.now()}}
)
return
# 새 데이터로 갱신
await self._upsert_person(new_data)
```
## GridFS (대용량 파일)
### 오디오 파일 저장
```python
from motor.motor_asyncio import AsyncIOMotorGridFSBucket
class AudioStorage:
def __init__(self, db):
self.fs = AsyncIOMotorGridFSBucket(db, bucket_name="audio")
async def save_audio(self, audio_data: bytes, filename: str) -> str:
"""오디오 파일 저장"""
file_id = await self.fs.upload_from_stream(
filename,
audio_data,
metadata={"content_type": "audio/mpeg"}
)
return str(file_id)
async def get_audio(self, file_id: str) -> bytes:
"""오디오 파일 조회"""
grid_out = await self.fs.open_download_stream(ObjectId(file_id))
return await grid_out.read()
```
## 백업 정책
### 규칙
- **주기**: 하루에 한 번 (daily)
- **보관 기간**: 최소 7일
- **백업 위치**: 프로젝트 루트의 `./backups/` 디렉토리
### MongoDB 백업
```bash
# 백업 실행
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
docker exec {프로젝트}-mongodb mongodump \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
--out="/tmp/$BACKUP_NAME"
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
echo "백업 완료: ./backups/$BACKUP_NAME"
```
### MongoDB 복원
```bash
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
docker exec {프로젝트}-mongodb mongorestore \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
"/tmp/$BACKUP_NAME"
```
### 자동화 스크립트 (backup-mongodb.sh)
```bash
#!/bin/bash
PROJECT_NAME="{프로젝트명}"
BACKUP_DIR="{프로젝트경로}/backups"
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
# 백업 실행
docker exec ${PROJECT_NAME}-mongodb mongodump \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
--out="/tmp/$BACKUP_NAME"
docker cp ${PROJECT_NAME}-mongodb:/tmp/$BACKUP_NAME $BACKUP_DIR/
# 7일 이상 된 백업 삭제
find $BACKUP_DIR -type d -name "mongodb_backup_*" -mtime +7 -exec rm -rf {} \;
echo "$(date): Backup completed - $BACKUP_NAME" >> $BACKUP_DIR/backup.log
```
### cron 설정
```bash
# crontab -e
0 2 * * * /path/to/backup-mongodb.sh # 매일 새벽 2시
```
### 주의사항
- 새 프로젝트 생성 시 반드시 백업 스크립트 설정
- 백업 디렉토리는 .gitignore에 추가하여 커밋 제외
- 중요 데이터는 외부 스토리지에 추가 백업 권장
---
## 환경 변수
```bash
# .env
MONGODB_URL=mongodb://admin:password123@mongodb:27017/
DB_NAME=ai_writer_db
TARGET_COLLECTION=articles_en
# docker-compose.yml
environment:
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
- DB_NAME=ai_writer_db
```

View File

@ -0,0 +1,140 @@
# Docker 배포 규칙 (Deployment Standards)
Docker 배포 표준입니다.
## Dockerfile 패턴
### Python 마이크로서비스 (Worker)
```dockerfile
FROM python:3.11-slim
WORKDIR /app
# 필요한 시스템 패키지 설치 (git은 공통 라이브러리 설치에 필요)
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
# 의존성 설치 (공통 라이브러리 포함 시)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir git+http://gitea.yakenator.io/yakenator/{공통라이브러리}.git
# 애플리케이션 코드 복사
COPY *.py .
# 워커 실행
CMD ["python", "worker.py"]
```
### Python API 서비스
```dockerfile
FROM python:3.11-slim
WORKDIR /app
# curl은 healthcheck에 필요
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
## docker-compose.yml 패턴
### 서비스 구조
```yaml
services:
# ===================
# Infrastructure (독립 포트 사용)
# ===================
mongodb:
image: mongo:7.0
container_name: {프로젝트}-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
ports:
- "{호스트포트}:27017" # 호스트 포트는 충돌 방지를 위해 변경
volumes:
- {프로젝트}_mongodb_data:/data/db
networks:
- {프로젝트}-network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
```
### Worker 서비스 패턴
```yaml
{서비스명}:
build:
context: ./repos/{서비스명}
dockerfile: Dockerfile
container_name: {프로젝트}-{서비스명}
restart: unless-stopped
environment:
- REDIS_URL=redis://redis:6379
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
- DB_NAME={데이터베이스명}
- TARGET_COLLECTION={컬렉션명}
depends_on:
redis:
condition: service_healthy
mongodb:
condition: service_healthy
networks:
- {프로젝트}-network
```
## 컨테이너 네이밍 규칙
- 컨테이너명: `{프로젝트}-{서비스명}`
- 볼륨명: `{프로젝트}_{데이터유형}_data`
- 네트워크: `{프로젝트}-network`
## 환경 변수 관리
- `.env` 파일로 민감 정보 관리
- docker-compose.yml에서 `${VAR:-default}` 형태로 기본값 제공
- 공통 변수: `MONGO_USER`, `MONGO_PASSWORD`, `REDIS_URL`, `JWT_SECRET_KEY`
## 헬스체크 패턴
### Redis
```yaml
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
```
### MongoDB
```yaml
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
```
### FastAPI 서비스
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
```
## 참고 리포지토리
- Gitea: http://gitea.yakenator.io/yakenator/

View File

@ -0,0 +1,325 @@
# React 컴포넌트 패턴 (Frontend Component Patterns)
이 프로젝트의 React/Next.js 컴포넌트 패턴입니다.
## shadcn/ui 기반 컴포넌트
### 설치 및 초기화
```bash
# shadcn/ui 초기화
npx shadcn@latest init
# 컴포넌트 추가
npx shadcn@latest add button card dialog tabs table form
```
### Button 컴포넌트 (CVA 패턴)
```tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-6",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
```
## Next.js App Router 구조
### 레이아웃 (layout.tsx)
```tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "News Engine Admin",
description: "Admin dashboard for News Pipeline management",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body className={`${geistSans.variable} antialiased`}>
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html>
);
}
```
### Provider 패턴
```tsx
// components/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}
```
## 유틸리티 함수
### cn() 함수 (lib/utils.ts)
```tsx
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
## 커스텀 훅 패턴
### 데이터 페칭 훅
```tsx
// hooks/use-articles.ts
import { useState, useEffect } from "react"
interface Article {
id: string
title: string
summary: string
}
export function useArticles() {
const [articles, setArticles] = useState<Article[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function fetchArticles() {
try {
const res = await fetch("/api/articles")
if (!res.ok) throw new Error("Failed to fetch")
const data = await res.json()
setArticles(data.items)
} catch (e) {
setError(e as Error)
} finally {
setIsLoading(false)
}
}
fetchArticles()
}, [])
return { articles, isLoading, error }
}
```
### 토글 훅
```tsx
// hooks/use-toggle.ts
import { useState, useCallback } from "react"
export function useToggle(initialState = false) {
const [state, setState] = useState(initialState)
const toggle = useCallback(() => setState((s) => !s), [])
const setTrue = useCallback(() => setState(true), [])
const setFalse = useCallback(() => setState(false), [])
return { state, toggle, setTrue, setFalse }
}
```
## 폼 패턴 (react-hook-form + zod)
```tsx
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
const formSchema = z.object({
title: z.string().min(1, "제목을 입력하세요"),
content: z.string().min(10, "내용은 10자 이상이어야 합니다"),
})
type FormValues = z.infer<typeof formSchema>
export function ArticleForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
content: "",
},
})
async function onSubmit(values: FormValues) {
try {
const res = await fetch("/api/articles", {
method: "POST",
body: JSON.stringify(values),
})
if (!res.ok) throw new Error("Failed to create")
// 성공 처리
} catch (error) {
console.error(error)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>제목</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">저장</Button>
</form>
</Form>
)
}
```
## 다크모드 지원
### 테마 토글 버튼
```tsx
"use client"
import { useTheme } from "next-themes"
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
)
}
```
## 토스트 알림 (sonner)
```tsx
import { toast } from "sonner"
// 성공 알림
toast.success("저장되었습니다")
// 에러 알림
toast.error("오류가 발생했습니다")
// 로딩 알림
const toastId = toast.loading("처리 중...")
// 완료 후
toast.success("완료!", { id: toastId })
```
## 파일 구조
```
src/
├── app/ # Next.js App Router
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # 메인 페이지
│ └── dashboard/
│ └── page.tsx
├── components/
│ ├── ui/ # shadcn/ui 기본 컴포넌트
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── ...
│ ├── providers.tsx # Context Providers
│ └── app-sidebar.tsx # 앱 전용 컴포넌트
├── hooks/ # 커스텀 훅
│ └── use-articles.ts
├── lib/ # 유틸리티
│ └── utils.ts
└── types/ # TypeScript 타입
└── index.ts
```

View File

@ -0,0 +1,163 @@
# Gitea 워크플로우 (Gitea Workflow)
이 프로젝트의 Gitea 리포지토리 관리 워크플로우입니다.
## Gitea 서버 정보
- **URL**: http://gitea.yakenator.io
- **조직**: yakenator
- **사용자**: yakenator
- **비밀번호**: asdfg23we
## 새 리포지토리 생성
### 1. Gitea CLI (tea) 사용
```bash
# tea 설치 (macOS)
brew install tea
# 로그인
tea login add --url http://gitea.yakenator.io --user yakenator --password asdfg23we
# 리포지토리 생성
tea repo create --name {repo-name} --org yakenator --private=false
```
### 2. API 사용
```bash
# 리포지토리 생성
curl -X POST "http://gitea.yakenator.io/api/v1/orgs/yakenator/repos" \
-H "Content-Type: application/json" \
-u "yakenator:asdfg23we" \
-d '{
"name": "{repo-name}",
"description": "서비스 설명",
"private": false,
"auto_init": false
}'
```
### 3. 웹 UI 사용
1. http://gitea.yakenator.io 접속
2. yakenator / asdfg23we 로 로그인
3. yakenator 조직 선택
4. "New Repository" 클릭
5. 리포지토리 정보 입력 후 생성
## 새 프로젝트 초기화 및 푸시
### 전체 워크플로우
```bash
# 1. 프로젝트 디렉토리 생성
mkdir ./repos/{new-service}
cd ./repos/{new-service}
# 2. Git 초기화
git init
# 3. 파일 생성 (Dockerfile, requirements.txt, worker.py 등)
# 4. 첫 커밋
git add -A
git commit -m "Initial commit: {service-name} 서비스 초기 구성
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
# 5. Gitea에 리포지토리 생성 (API)
curl -X POST "http://gitea.yakenator.io/api/v1/orgs/yakenator/repos" \
-H "Content-Type: application/json" \
-u "yakenator:asdfg23we" \
-d '{"name": "{new-service}", "private": false}'
# 6. Remote 추가 및 푸시
git remote add origin http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{new-service}.git
git branch -M main
git push -u origin main
```
## 기존 리포지토리 클론
```bash
# HTTP 클론 (인증 포함)
git clone http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{repo-name}.git
# 또는 클론 후 credential 설정
git clone http://gitea.yakenator.io/yakenator/{repo-name}.git
cd {repo-name}
git config credential.helper store
```
## 리포지토리 URL 패턴
```
http://gitea.yakenator.io/yakenator/{repo-name}.git
```
### 현재 등록된 리포지토리
| 서비스 | URL |
|--------|-----|
| news-commons | http://gitea.yakenator.io/yakenator/news-commons.git |
| news-article-generator | http://gitea.yakenator.io/yakenator/news-article-generator.git |
| news-article-translator | http://gitea.yakenator.io/yakenator/news-article-translator.git |
| news-biocode-service | http://gitea.yakenator.io/yakenator/news-biocode-service.git |
| news-google-search | http://gitea.yakenator.io/yakenator/news-google-search.git |
| news-image-generator | http://gitea.yakenator.io/yakenator/news-image-generator.git |
| news-rss-collector | http://gitea.yakenator.io/yakenator/news-rss-collector.git |
| news-tts-generator | http://gitea.yakenator.io/yakenator/news-tts-generator.git |
| news-wikipedia-enrichment | http://gitea.yakenator.io/yakenator/news-wikipedia-enrichment.git |
| news-user-service | http://gitea.yakenator.io/yakenator/news-user-service.git |
| mcp_biocode | http://gitea.yakenator.io/yakenator/mcp_biocode.git |
| base-auth | http://gitea.yakenator.io/yakenator/base-auth.git |
| base-image | http://gitea.yakenator.io/yakenator/base-image.git |
## 커밋 및 푸시
```bash
# 변경사항 확인
git status
# 스테이징 및 커밋
git add -A
git commit -m "$(cat <<'EOF'
feat: 기능 설명
- 변경사항 1
- 변경사항 2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
# 푸시
git push
```
## 브랜치 전략
```bash
# 기본 브랜치: main
# 기능 브랜치: feature/{기능명}
# 버그 수정: fix/{버그설명}
# 브랜치 생성 및 전환
git checkout -b feature/new-feature
# 작업 후 푸시
git push -u origin feature/new-feature
# PR 생성 (웹 UI 또는 API)
```
## 주의사항
- 모든 서비스는 `yakenator` 조직 아래에 생성
- 리포지토리 이름은 `news-{서비스명}` 패턴 사용
- 민감 정보(.env, credentials)는 절대 커밋하지 않음
- .gitignore에 다음 항목 포함:
```
.env
__pycache__/
*.pyc
node_modules/
.DS_Store
```

View File

@ -0,0 +1,268 @@
# 인프라 구축 가이드 (Infrastructure Setup)
인프라 설정 패턴입니다.
## MAAS (Metal as a Service)
### 개요
- 베어메탈 서버 프로비저닝 도구
- Ubuntu 기반 자동 배포
- 네트워크 자동 설정
### 기본 설정
```yaml
# MAAS 설정 예시
machines:
- hostname: k8s-master-01
architecture: amd64
cpu_count: 8
memory: 32768
storage: 500GB
tags:
- kubernetes
- master
- hostname: k8s-worker-01
architecture: amd64
cpu_count: 16
memory: 65536
storage: 1TB
tags:
- kubernetes
- worker
```
### 네트워크 설정
```yaml
# 서브넷 설정
subnets:
- cidr: 10.10.0.0/16
gateway_ip: 10.10.0.1
dns_servers:
- 8.8.8.8
- 8.8.4.4
```
## Kubernetes 클러스터
### 클러스터 구성
```yaml
# 마스터 노드: 3대 (HA 구성)
# 워커 노드: 3대 이상
# etcd: 마스터 노드에 내장
```
### kubeadm 초기화
```bash
# 마스터 노드 초기화
kubeadm init --pod-network-cidr=10.244.0.0/16 \
--control-plane-endpoint="k8s-api.example.com:6443" \
--upload-certs
# 워커 노드 조인
kubeadm join k8s-api.example.com:6443 \
--token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
```
### CNI 설치 (Flannel)
```bash
kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
```
## Rancher 설치
### Docker 기반 설치
```bash
docker run -d --restart=unless-stopped \
-p 80:80 -p 443:443 \
--privileged \
-v /opt/rancher:/var/lib/rancher \
rancher/rancher:latest
```
### Helm 기반 설치
```bash
# Rancher Helm 레포 추가
helm repo add rancher-latest https://releases.rancher.com/server-charts/latest
helm repo update
# 네임스페이스 생성
kubectl create namespace cattle-system
# cert-manager 설치 (Let's Encrypt 사용 시)
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
# Rancher 설치
helm install rancher rancher-latest/rancher \
--namespace cattle-system \
--set hostname=rancher.example.com \
--set replicas=3 \
--set ingress.tls.source=letsEncrypt \
--set letsEncrypt.email=admin@example.com
```
## Docker Compose 배포
### docker-compose.yml 구조
```yaml
services:
# ===================
# Infrastructure
# ===================
mongodb:
image: mongo:7.0
container_name: {프로젝트}-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
ports:
- "{호스트포트}:27017"
volumes:
- {프로젝트}_mongodb_data:/data/db
networks:
- {프로젝트}-network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
container_name: {프로젝트}-redis
restart: unless-stopped
ports:
- "{호스트포트}:6379"
volumes:
- {프로젝트}_redis_data:/data
networks:
- {프로젝트}-network
volumes:
{프로젝트}_mongodb_data:
{프로젝트}_redis_data:
networks:
{프로젝트}-network:
driver: bridge
```
### 배포 명령어
```bash
# 전체 서비스 시작
docker-compose up -d
# 특정 서비스만 재빌드
docker-compose up -d --build {서비스명}
# 로그 확인
docker-compose logs -f {서비스명}
# 서비스 상태 확인
docker-compose ps
```
## 환경 변수 관리
### .env 파일
```bash
# Infrastructure
MONGO_USER=admin
MONGO_PASSWORD={비밀번호}
REDIS_URL=redis://redis:6379
# API Keys
CLAUDE_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
JWT_SECRET_KEY={시크릿키}
# Database
DB_NAME={데이터베이스명}
TARGET_COLLECTION={컬렉션명}
```
### 환경별 설정
```bash
# 개발 환경
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# 프로덕션 환경
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
## 백업 전략
### MongoDB 백업
```bash
# 백업
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
docker exec {프로젝트}-mongodb mongodump \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
--out="/tmp/$BACKUP_NAME"
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
# 복원
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
docker exec {프로젝트}-mongodb mongorestore \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
"/tmp/$BACKUP_NAME"
```
### 볼륨 백업
```bash
# 볼륨 백업
docker run --rm \
-v {프로젝트}_mongodb_data:/data \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/mongodb_volume.tar.gz -C /data .
```
## 네트워크 구성
### 포트 매핑 규칙
```yaml
# Infrastructure (기본 포트 + 오프셋)
MongoDB: {오프셋}+27017:27017
Redis: {오프셋}+6379:6379
# Application Services
api-service: 8000:8000
admin-frontend: 3000:3000
```
### 내부 통신
```yaml
# 컨테이너 간 통신은 서비스명 사용
mongodb: mongodb://{user}:{password}@mongodb:27017/
redis: redis://redis:6379
```
## 모니터링 접근점
### 헬스체크 엔드포인트
```bash
# MongoDB
docker exec {프로젝트}-mongodb mongosh --eval "db.adminCommand('ping')"
# Redis
docker exec {프로젝트}-redis redis-cli ping
# FastAPI 서비스
curl http://localhost:{포트}/health
```
### 로그 수집
```bash
# 전체 로그
docker-compose logs -f
# 특정 서비스 로그
docker-compose logs -f {서비스명}
# 최근 100줄만
docker-compose logs --tail=100 {서비스명}
```

View File

@ -0,0 +1,161 @@
# 한국어 개발 컨벤션 (Korean Dev Conventions)
이 프로젝트의 한국어 개발 규칙입니다.
## 주석 규칙
### Python Docstring
```python
async def get_organization_info(self, name: str, context: List[str] = None) -> OrganizationInfo:
"""
조직/단체 정보 조회 (context 기반 후보 선택)
Args:
name: 조직 이름
context: 기사에서 추출한 컨텍스트 키워드 (산업, 유형 등)
Returns:
OrganizationInfo with founding_year, wikipedia_url, and image_url if found
"""
```
### 섹션 구분 주석
```python
# ===================
# Infrastructure (독립 포트 사용)
# ===================
```
### 인라인 주석
```python
# 캐시에서 조회 (context 기반 매칭)
cached_data, needs_refresh = await self.entity_cache.get_person(name, context=context)
# P154 = logo image
"property": "P154",
```
## 로깅 메시지
### 한글 + 영문 혼용 패턴
```python
logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)")
logger.info(f"Article {news_id} enriched with Wikipedia data in {processing_time:.2f}s")
logger.warning(f"Biocode registration failed (non-critical): {e}")
```
### 워커 로그 패턴
```python
logger.info("Starting Wikipedia Enrichment Worker")
logger.info(f"Processing job {job.job_id} for Wikipedia enrichment")
logger.info(f"Job {job.job_id} forwarded to image_generation")
```
## 에러 처리
### Try-Except 패턴
```python
try:
info = await self.wikipedia_service.get_person_info(name, context=context)
except Exception as e:
logger.error(f"Error getting person info for '{name}': {e}")
```
### 비치명적 에러 처리
```python
try:
stats = await self.biocode_client.register_entities(people, organizations)
except Exception as e:
# Biocode 등록 실패는 전체 파이프라인을 중단시키지 않음
logger.warning(f"Biocode registration failed (non-critical): {e}")
```
## 변수/함수 네이밍
### Python (snake_case)
```python
# 변수
birth_date = "1990-01-15"
founding_year = 2004
image_urls = []
existing_names = set()
# 함수
def get_existing_names(biocode_data: dict) -> set:
async def _enrich_organizations(self, entities: List[Dict[str, Any]]):
```
### TypeScript (camelCase)
```typescript
// 변수
const articleCount = 100;
const isLoading = false;
// 함수
function getArticleById(id: string): Article
async function fetchDashboardStats(): Promise<Stats>
```
## 상수 정의
```python
# 영문 상수명 + 한글 주석
API_URL = "https://en.wikipedia.org/api/rest_v1/page/summary/{title}"
SEARCH_URL = "https://en.wikipedia.org/w/api.php"
# 기본값
DEFAULT_TIMEOUT = 10 # seconds
MAX_RETRIES = 3
```
## 타입 힌트
```python
from typing import Optional, Dict, Any, List
async def enrich_entities(
self,
people: List[Dict[str, Any]],
organizations: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""엔티티 목록을 Wikipedia 정보로 보강 (context 지원)"""
```
## 커밋 메시지
### 형식
```
<type>: <description>
- <detail 1>
- <detail 2>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
### 타입
- `feat`: 새로운 기능
- `fix`: 버그 수정
- `chore`: 설정, 문서 등 잡다한 작업
- `refactor`: 리팩토링
- `docs`: 문서 수정
### 예시
```
feat: Pass entity_type to biocode API
- biocode_worker: Forward entity_type (person/organization) to API
- Enables proper storage in famousPeople or famousOrganizations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
## 파일 인코딩
- 모든 파일: UTF-8
- JSON 파일: `ensure_ascii=False` 사용
```python
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(content, f, ensure_ascii=False, indent=2)
```

View File

@ -0,0 +1,361 @@
# 모니터링 및 로깅 (Monitoring & Logging)
이 프로젝트의 모니터링 및 로깅 패턴입니다.
## Python 로깅
### 기본 설정
```python
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
```
### 로깅 패턴
```python
# 정보성 로그
logger.info(f"Starting Wikipedia Enrichment Worker")
logger.info(f"Processing job {job.job_id} for Wikipedia enrichment")
logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)")
# 경고 로그 (비치명적 오류)
logger.warning(f"Biocode registration failed (non-critical): {e}")
logger.warning(f"Failed to get logo for '{title}': {e}")
# 에러 로그
logger.error(f"Error processing job {job.job_id}: {e}")
logger.error(f"Claude API key not configured")
# 디버그 로그
logger.debug(f"Selected candidate '{candidate.get('title')}' with score: {best_score}")
```
### 구조화된 로깅
```python
import json
def log_structured(level: str, message: str, **kwargs):
"""구조화된 JSON 로깅"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"level": level,
"message": message,
**kwargs
}
print(json.dumps(log_entry))
# 사용 예시
log_structured("INFO", "Article processed",
job_id=job.job_id,
processing_time=processing_time,
people_count=len(enriched_people),
orgs_count=len(enriched_orgs)
)
```
## Docker 로그
### 로그 확인
```bash
# 전체 로그
docker-compose logs -f
# 특정 서비스 로그
docker-compose logs -f news-wikipedia-enrichment
# 최근 100줄만
docker-compose logs --tail=100 news-article-generator
# 시간 범위 지정
docker-compose logs --since 2024-01-15T10:00:00 news-wikipedia-enrichment
```
### 로그 드라이버 설정
```yaml
# docker-compose.yml
services:
news-wikipedia-enrichment:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
## Prometheus 설정
### docker-compose.yml
```yaml
services:
prometheus:
image: prom/prometheus:latest
container_name: {프로젝트}-prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- {프로젝트}_prometheus_data:/prometheus
networks:
- {프로젝트}-network
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
```
### prometheus.yml
```yaml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'fastapi-services'
static_configs:
- targets:
- 'base-auth:8000'
- 'base-image:8000'
- 'news-user-service:8000'
metrics_path: '/metrics'
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
- job_name: 'mongodb'
static_configs:
- targets: ['mongodb-exporter:9216']
```
### FastAPI 메트릭 노출
```python
from prometheus_client import Counter, Histogram, generate_latest
from fastapi import Response
# 메트릭 정의
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
)
REQUEST_LATENCY = Histogram(
'http_request_duration_seconds',
'HTTP request latency',
['method', 'endpoint']
)
@app.get("/metrics")
async def metrics():
return Response(
content=generate_latest(),
media_type="text/plain"
)
@app.middleware("http")
async def track_metrics(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
REQUEST_COUNT.labels(
method=request.method,
endpoint=request.url.path,
status=response.status_code
).inc()
REQUEST_LATENCY.labels(
method=request.method,
endpoint=request.url.path
).observe(duration)
return response
```
## Grafana 설정
### docker-compose.yml
```yaml
services:
grafana:
image: grafana/grafana:latest
container_name: {프로젝트}-grafana
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- {프로젝트}_grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin123
- GF_USERS_ALLOW_SIGN_UP=false
networks:
- {프로젝트}-network
```
### 데이터소스 프로비저닝
```yaml
# grafana/provisioning/datasources/datasources.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
```
### 대시보드 예시 (JSON)
```json
{
"dashboard": {
"title": "News Pipeline Monitoring",
"panels": [
{
"title": "Request Rate",
"type": "graph",
"targets": [
{
"expr": "rate(http_requests_total[5m])",
"legendFormat": "{{method}} {{endpoint}}"
}
]
},
{
"title": "Request Latency (p95)",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))",
"legendFormat": "{{endpoint}}"
}
]
}
]
}
}
```
## 헬스체크
### FastAPI 헬스체크 엔드포인트
```python
@app.get("/health")
async def health_check():
"""헬스체크 엔드포인트"""
checks = {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"checks": {}
}
# MongoDB 체크
try:
await db.command("ping")
checks["checks"]["mongodb"] = "healthy"
except Exception as e:
checks["checks"]["mongodb"] = f"unhealthy: {e}"
checks["status"] = "unhealthy"
# Redis 체크
try:
await redis.ping()
checks["checks"]["redis"] = "healthy"
except Exception as e:
checks["checks"]["redis"] = f"unhealthy: {e}"
checks["status"] = "unhealthy"
status_code = 200 if checks["status"] == "healthy" else 503
return JSONResponse(content=checks, status_code=status_code)
```
### Docker 헬스체크
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
## 워커 하트비트
### Redis 기반 하트비트
```python
class QueueManager:
async def start_heartbeat(self, worker_name: str):
"""워커 하트비트 시작"""
async def heartbeat_loop():
while True:
try:
await self.redis.setex(
f"worker:heartbeat:{worker_name}",
60, # 60초 TTL
datetime.now().isoformat()
)
await asyncio.sleep(30) # 30초마다 갱신
except Exception as e:
logger.error(f"Heartbeat error: {e}")
asyncio.create_task(heartbeat_loop())
async def get_active_workers(self) -> List[str]:
"""활성 워커 목록 조회"""
keys = await self.redis.keys("worker:heartbeat:*")
return [key.decode().split(":")[-1] for key in keys]
```
## 알림 설정 (Alertmanager)
### alertmanager.yml
```yaml
global:
slack_api_url: 'https://hooks.slack.com/services/xxx'
route:
receiver: 'slack-notifications'
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts'
send_resolved: true
title: '{{ .GroupLabels.alertname }}'
text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
```
### 알림 규칙
```yaml
# prometheus/rules/alerts.yml
groups:
- name: service-alerts
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1
for: 5m
labels:
severity: critical
annotations:
description: "High error rate detected"
- alert: WorkerDown
expr: absent(up{job="fastapi-services"})
for: 1m
labels:
severity: warning
annotations:
description: "Worker service is down"
```

View File

@ -0,0 +1,179 @@
# Project Archetypes (프로젝트 아키타입)
`/prj` 에이전트가 프로젝트 유형을 판단할 때 참조하는 아키타입 정의입니다.
사용자의 설명에 가장 맞는 아키타입을 선택하고, 해당 참조 프로젝트를 분석하세요.
---
## fullstack-webapp
웹 애플리케이션 (프론트엔드 + 백엔드 + DB)
- **키워드**: 대시보드, 관리자, 서비스, 앱, 플랫폼, CRUD
- **참조 프로젝트**: site20 (Biocode Chat), site22 (Trading), black-ink (소설 작성)
- **Stack**: Next.js 16 + FastAPI + MongoDB 7.0 + Redis 7
- **init 템플릿**: `fullstack`
- **구조**:
```
{name}/
├── frontend/ # Next.js App Router + Tailwind CSS 4
├── backend/ # FastAPI + Motor(async MongoDB)
│ └── app/
│ ├── main.py
│ ├── database.py
│ ├── config.py
│ ├── routers/
│ ├── models/
│ └── services/
├── docker-compose.yml # MongoDB + Redis + backend + frontend
├── .env
└── CLAUDE.md
```
- **적용 스킬**: project-stack, api-design-standards, frontend-component-patterns, database-patterns, deployment-standards
---
## backend-api
API 서버 또는 마이크로서비스 (프론트엔드 없음)
- **키워드**: API, 서버, 서비스, 마이크로서비스, 백엔드, 데이터 처리
- **참조 프로젝트**: site12 (i18n), site13 (파일), site14 (FCM), site15 (이미지), site16 (인증)
- **Stack**: FastAPI + MongoDB 7.0 + Redis 7
- **init 템플릿**: `backend`
- **구조**:
```
{name}/
├── app/
│ ├── main.py
│ ├── database.py
│ ├── config.py
│ ├── routers/
│ ├── models/
│ └── services/
├── Dockerfile
├── requirements.txt
├── docker-compose.yml
├── .env
└── CLAUDE.md
```
- **적용 스킬**: api-design-standards, database-patterns, deployment-standards
- **특이사항**: site12~16 패턴 - 단일 책임, 독립 배포 가능
---
## ai-service
AI/ML 모델을 활용하는 서비스
- **키워드**: AI, 분석, 생성, Claude, GPT, ML, 추론, 예측, 파이프라인
- **참조 프로젝트**: site23 (Hedge System), pipeline (뉴스 파이프라인), black-ink (Claude 도구 실행)
- **Stack**: FastAPI + Claude/OpenAI API + MongoDB + Redis
- **init 템플릿**: `backend`
- **구조**:
```
{name}/
├── app/
│ ├── main.py
│ ├── services/
│ │ ├── claude_service.py # AI API 클라이언트
│ │ ├── prompt_builder.py # 프롬프트 관리
│ │ └── orchestrator.py # 파이프라인 조합
│ ├── routers/
│ └── models/
├── prompts/ # 프롬프트 템플릿 (.md)
├── Dockerfile
└── docker-compose.yml
```
- **적용 스킬**: ai-api-integration, api-design-standards, database-patterns
- **특이사항**: AI API 키 관리, 프롬프트 버전 관리, 토큰 사용량 모니터링 필요
---
## microservice-pipeline
여러 서비스가 순차/병렬로 처리하는 파이프라인
- **키워드**: 파이프라인, 워커, 큐, 배치, ETL, 수집, 처리
- **참조 프로젝트**: pipeline (7개 마이크로서비스), site19 (뉴스 엔진)
- **Stack**: Python workers + Redis 큐 + MongoDB + Docker Compose
- **init 템플릿**: `fullstack` (커스텀 수정)
- **구조**:
```
{name}/
├── services/
│ ├── collector/ # 데이터 수집
│ ├── processor/ # 데이터 처리
│ ├── analyzer/ # 분석
│ └── api/ # 결과 조회 API
├── commons/ # 공유 유틸리티
├── docker-compose.yml # 전체 서비스 오케스트레이션
├── .env
└── CLAUDE.md
```
- **적용 스킬**: deployment-standards, database-patterns, monitoring-logging
- **특이사항**: 서비스 간 Redis 큐 통신, 하트비트 패턴, 독립 스케일링
---
## frontend-only
프론트엔드만 필요한 프로젝트 (정적 사이트, 클라이언트 앱)
- **키워드**: UI, 프론트엔드, 웹사이트, 랜딩, 테마, 빌더
- **참조 프로젝트**: site03 (Dynamic Theme), site04 (UI Builder), site06 (News Theme)
- **Stack**: Next.js 16 + React 19 + Tailwind CSS 4
- **init 템플릿**: `frontend`
- **구조**:
```
{name}/
├── src/
│ ├── app/ # Next.js App Router
│ ├── components/ # 재사용 컴포넌트
│ ├── hooks/ # 커스텀 훅
│ ├── lib/ # 유틸리티
│ └── types/ # TypeScript 타입
├── public/
├── Dockerfile
├── package.json
└── CLAUDE.md
```
- **적용 스킬**: frontend-component-patterns, project-stack
- **특이사항**: 외부 API 연동 시 환경변수로 URL 관리
---
## content-platform
콘텐츠 생성/관리 플랫폼 (AI 기반 창작, CMS)
- **키워드**: 소설, 글쓰기, 콘텐츠, CMS, 에디터, 스토리, 시나리오
- **참조 프로젝트**: black-ink (대화형 소설), site17/inkchain (소설 생성), site18 (소설+이미지)
- **Stack**: Next.js 16 + FastAPI + Claude API + MongoDB + Redis
- **init 템플릿**: `fullstack`
- **구조**:
```
{name}/
├── frontend/
│ └── src/
│ ├── components/
│ │ ├── ChatWindow.tsx # 대화형 UI
│ │ ├── MessageList.tsx
│ │ └── StageProgress.tsx # 단계 표시
│ └── hooks/
│ └── useChat.ts # SSE 스트리밍
├── backend/
│ └── app/
│ └── services/
│ ├── claude_service.py # Claude SSE 스트리밍
│ ├── chat_orchestrator.py # 도구 실행 루프
│ ├── context_manager.py # 컨텍스트 CRUD
│ ├── stage_router.py # 단계 자동 판단
│ └── prompt_builder.py # 프롬프트 조립
└── docker-compose.yml
```
- **적용 스킬**: ai-api-integration, frontend-component-patterns, database-patterns
- **특이사항**: SSE 스트리밍, Claude 도구 실행 루프, 단계 기반 워크플로우 (black-ink 패턴)
---
## 아키타입 선택 가이드
1. 사용자 설명에서 **키워드** 매칭
2. 여러 아키타입이 겹치면 **블렌딩** 가능 (예: AI + fullstack = ai-service 패턴의 백엔드 + fullstack의 프론트엔드)
3. 불확실하면 사용자에게 선택지를 제시
4. "like siteXX" 표현이 있으면 해당 프로젝트의 아키타입을 우선 적용

View File

@ -0,0 +1,117 @@
# 프로젝트 기술 스택 (Project Stack)
이 프로젝트의 기본 기술 스택과 구조입니다.
## Frontend (news-engine-admin)
### 기술 스택
- **Framework**: Next.js 16.x (App Router)
- **Language**: TypeScript 5.x
- **React**: 19.x
- **Styling**: Tailwind CSS 4.x
- **UI Components**: Radix UI + shadcn/ui
- **Form**: react-hook-form + zod
- **Icons**: lucide-react
- **Theme**: next-themes (다크모드 지원)
### 디렉토리 구조
```
src/
├── app/ # Next.js App Router 페이지
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # 메인 페이지
│ └── dashboard/ # 대시보드 페이지들
├── components/ # React 컴포넌트
│ ├── ui/ # shadcn/ui 기본 컴포넌트
│ ├── dashboard/ # 대시보드 전용 컴포넌트
│ └── providers/ # Context Provider
├── hooks/ # 커스텀 훅
├── lib/ # 유틸리티 함수
│ └── utils.ts # cn() 등 공통 유틸
└── types/ # TypeScript 타입 정의
```
### 설치 명령어
```bash
# 프로젝트 생성
npx create-next-app@latest --typescript --tailwind --app
# shadcn/ui 초기화
npx shadcn@latest init
# 컴포넌트 추가
npx shadcn@latest add button card dialog tabs
```
## Backend (FastAPI 마이크로서비스)
### 기술 스택
- **Framework**: FastAPI
- **Language**: Python 3.11
- **Database**: MongoDB (motor - async driver)
- **Queue**: Redis (aioredis)
- **Validation**: Pydantic v2
- **HTTP Client**: aiohttp
### 마이크로서비스 구조
```
service/
├── Dockerfile
├── requirements.txt
├── worker.py # 메인 워커 (큐 처리)
├── *_service.py # 비즈니스 로직
└── *_client.py # 외부 서비스 클라이언트
```
### API 서비스 구조
```
service/
├── Dockerfile
├── requirements.txt
└── app/
├── __init__.py
├── main.py # FastAPI 앱 엔트리포인트
├── database.py # DB 연결 관리
├── models/ # Pydantic 모델
└── routers/ # API 라우터
```
## 공통 라이브러리 (news-commons)
### 제공 기능
- `QueueManager`: Redis 큐 관리 (enqueue, dequeue, heartbeat)
- `PipelineJob`: 파이프라인 작업 데이터 모델
- `PersonEntity`, `OrganizationEntity`: 엔티티 모델
- 로깅, 설정 유틸리티
### 사용 예시
```python
from news_commons import PipelineJob, QueueManager
queue_manager = QueueManager(redis_url="redis://redis:6379")
await queue_manager.connect()
job = await queue_manager.dequeue('wikipedia_enrichment', timeout=5)
```
## 인프라
### 컨테이너
- **MongoDB**: 7.0
- **Redis**: 7-alpine
- **Docker Compose**: 서비스 오케스트레이션
### 외부 서비스
- **Gitea**: 코드 저장소 (http://gitea.yakenator.io/)
- **OpenAI API**: GPT 모델 사용
- **Claude API**: Claude 모델 사용
## 참고 리포지토리
| 서비스 | 설명 | URL |
|--------|------|-----|
| news-commons | 공통 라이브러리 | gitea.yakenator.io/sapiens/news-commons |
| news-article-generator | 기사 생성 | gitea.yakenator.io/sapiens/news-article-generator |
| news-wikipedia-enrichment | 위키피디아 보강 | gitea.yakenator.io/sapiens/news-wikipedia-enrichment |
| news-image-generator | 이미지 생성 | gitea.yakenator.io/sapiens/news-image-generator |
| mcp_biocode | 바이오코드 API | gitea.yakenator.io/sapiens/mcp_biocode |

View File

@ -0,0 +1,360 @@
# 테스트 작성 표준 (Testing Standards)
이 프로젝트의 테스트 작성 패턴입니다.
## Python (pytest)
### 설치
```bash
pip install pytest pytest-asyncio pytest-cov
```
### 디렉토리 구조
```
service/
├── worker.py
├── service.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 공통 fixture
│ ├── test_worker.py
│ └── test_service.py
└── pytest.ini
```
### pytest.ini 설정
```ini
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
```
### Fixture 패턴 (conftest.py)
```python
import pytest
import asyncio
from motor.motor_asyncio import AsyncIOMotorClient
@pytest.fixture(scope="session")
def event_loop():
"""세션 범위의 이벤트 루프"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
async def mongodb():
"""테스트용 MongoDB 연결"""
client = AsyncIOMotorClient("mongodb://localhost:27017")
db = client["test_db"]
yield db
# 테스트 후 정리
await client.drop_database("test_db")
client.close()
@pytest.fixture
def sample_article():
"""테스트용 기사 데이터"""
return {
"title": "Test Article",
"summary": "Test summary",
"entities": {
"people": [{"name": "Elon Musk", "context": ["Tesla", "CEO"]}],
"organizations": [{"name": "Tesla", "context": ["EV", "automotive"]}]
}
}
```
### 단위 테스트 예시
```python
import pytest
from service import calculate_biocode
def test_calculate_biocode_basic():
"""바이오코드 계산 기본 테스트"""
result = calculate_biocode(1990, 5, 15)
assert result is not None
assert len(result) == 4 # g코드 2자리 + s코드 2자리
def test_calculate_biocode_edge_cases():
"""경계값 테스트"""
# 연초
result = calculate_biocode(1990, 1, 1)
assert result.endswith("60") # 대설
# 연말
result = calculate_biocode(1990, 12, 31)
assert result is not None
```
### 비동기 테스트 예시
```python
import pytest
from wikipedia_service import WikipediaService
@pytest.mark.asyncio
async def test_get_person_info():
"""인물 정보 조회 테스트"""
service = WikipediaService()
try:
info = await service.get_person_info(
"Elon Musk",
context=["Tesla", "SpaceX"]
)
assert info is not None
assert info.name == "Elon Musk"
assert info.wikipedia_url is not None
finally:
await service.close()
@pytest.mark.asyncio
async def test_get_organization_info_with_logo():
"""조직 로고 우선 조회 테스트"""
service = WikipediaService()
try:
info = await service.get_organization_info(
"Apple Inc.",
context=["technology", "iPhone"]
)
assert info is not None
assert info.image_urls # 로고 이미지가 있어야 함
finally:
await service.close()
```
### Mock 사용 예시
```python
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
async def test_worker_process_job():
"""워커 작업 처리 테스트 (외부 API 모킹)"""
with patch('worker.WikipediaService') as mock_service:
mock_instance = AsyncMock()
mock_instance.get_person_info.return_value = PersonInfo(
name="Test Person",
birth_date="1990-01-15",
verified=True
)
mock_service.return_value = mock_instance
worker = WikipediaEnrichmentWorker()
# ... 테스트 수행
```
### 테스트 실행
```bash
# 전체 테스트
pytest
# 커버리지 포함
pytest --cov=. --cov-report=html
# 특정 테스트만
pytest tests/test_service.py -v
# 특정 함수만
pytest tests/test_service.py::test_calculate_biocode_basic -v
```
## JavaScript/TypeScript (Jest)
### 설치
```bash
npm install --save-dev jest @types/jest ts-jest
```
### jest.config.js
```javascript
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts', '**/*.spec.ts'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
],
}
```
### 단위 테스트 예시
```typescript
// utils.test.ts
import { cn, formatDate } from './utils'
describe('cn utility', () => {
it('should merge class names', () => {
const result = cn('foo', 'bar')
expect(result).toBe('foo bar')
})
it('should handle conditional classes', () => {
const result = cn('foo', false && 'bar', 'baz')
expect(result).toBe('foo baz')
})
})
describe('formatDate', () => {
it('should format date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('2024-01-15')
})
})
```
### React 컴포넌트 테스트
```typescript
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
it('renders correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick handler', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies variant styles', () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByText('Delete')
expect(button).toHaveClass('bg-destructive')
})
})
```
### API 테스트 (MSW Mock)
```typescript
// api.test.ts
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { fetchArticles } from './api'
const server = setupServer(
rest.get('/api/articles', (req, res, ctx) => {
return res(
ctx.json({
items: [{ id: '1', title: 'Test Article' }],
total: 1
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('fetchArticles', () => {
it('fetches articles successfully', async () => {
const articles = await fetchArticles()
expect(articles.items).toHaveLength(1)
expect(articles.items[0].title).toBe('Test Article')
})
})
```
### 테스트 실행
```bash
# 전체 테스트
npm test
# 감시 모드
npm test -- --watch
# 커버리지
npm test -- --coverage
# 특정 파일만
npm test -- Button.test.tsx
```
## E2E 테스트 (Playwright)
### 설치
```bash
npm install --save-dev @playwright/test
npx playwright install
```
### playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
})
```
### E2E 테스트 예시
```typescript
// e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Dashboard', () => {
test('should display article list', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: 'Articles' })).toBeVisible()
await expect(page.getByRole('table')).toBeVisible()
})
test('should filter articles by keyword', async ({ page }) => {
await page.goto('/dashboard')
await page.fill('[placeholder="Search..."]', 'technology')
await page.click('button:has-text("Search")')
await expect(page.locator('table tbody tr')).toHaveCount(5)
})
})
```
## CI/CD 통합
### GitHub Actions
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
python-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pytest --cov
js-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npm test -- --coverage
```