ch-bootstrap: persona pipeline + Design-First + 안전-최대 권한

- Redmine 8단계 페르소나 파이프라인 (.claude/agents, workflows)
- Design-First docs 골격 (docs/design, docs/adr, docs/pipeline)
- 안전-최대 권한 정책 (.claude/settings.json)
- Tasteby 고유 규칙 보존 (CLAUDE.md 병합)
- scripts/enqueue.sh: Redmine 큐 투입

Refs: tasteby bootstrap
This commit is contained in:
joungmin
2026-06-15 10:20:50 +09:00
parent f2861b6b79
commit c78f928a2d
37 changed files with 3633 additions and 0 deletions

316
scripts/code_review.py Executable file
View File

@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
Tasteby 페르소나 기반 코드 리뷰
4개 페르소나(프론트엔드, 백엔드, 보안, 아키텍처)가 git diff를 리뷰하고 최종 권고를 출력.
사용법:
# 커밋되지 않은 변경 리뷰
python3 scripts/code_review.py
# 최근 N개 커밋 리뷰
python3 scripts/code_review.py --commits 3
# 특정 영역만 리뷰 (frontend / backend / all)
python3 scripts/code_review.py --scope frontend
python3 scripts/code_review.py --scope backend
# 특정 커밋 범위 리뷰
python3 scripts/code_review.py --range abc1234..def5678
"""
import subprocess, json, os, sys, argparse
from pathlib import Path
# .env 파일에서 환경변수 로드
def load_dotenv():
env_path = Path(__file__).resolve().parent.parent / ".env"
if env_path.exists():
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
load_dotenv()
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
MODEL = os.environ.get("REVIEW_MODEL", "anthropic/claude-haiku-4-5")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
# --- 페르소나 정의 ---
PERSONAS = {
"frontend": [
{
"name": "프론트엔드 UX 전문가",
"emoji": "\U0001f3a8",
"prompt": """너는 Next.js/React 전문 프론트엔드 엔지니어야.
이 코드 변경에서 UX, 성능, 접근성 문제를 찾아내.
특히:
- 불필요한 리렌더링이나 useEffect 남용은 없는지
- 로딩/에러 상태 처리가 빠져있는지
- 모바일 반응형이 깨질 수 있는 레이아웃 변경은 없는지
- Tailwind CSS 클래스 사용이 적절한지
- 네이버 지도 API 관련 변경이 올바른지
한국어로 3줄 이내로 핵심만 답변."""
},
{
"name": "타입스크립트 품질 검사관",
"emoji": "\U0001f9d0",
"prompt": """너는 TypeScript 타입 안전성과 코드 품질만 보는 엄격한 리뷰어야.
이 코드 변경에서 타입 안전성과 코드 품질 문제를 찾아내.
특히:
- any 타입 사용이나 타입 단언(as) 남용은 없는지
- API 응답 타입이 제대로 정의되어 있는지
- null/undefined 처리가 빠져있는지
- 컴포넌트 props 인터페이스가 명확한지
한국어로 3줄 이내로 핵심만 답변."""
},
],
"backend": [
{
"name": "백엔드 API 설계자",
"emoji": "\u2699\ufe0f",
"prompt": """너는 Spring Boot + MyBatis 전문 백엔드 엔지니어야.
이 코드 변경에서 API 설계, 데이터 접근, 성능 문제를 찾아내.
특히:
- MyBatis SQL에 N+1 쿼리나 비효율적인 조인은 없는지
- Oracle DB 특성(인덱스, CLOB 처리, ROWNUM)을 고려했는지
- REST API 설계가 일관적인지 (네이밍, HTTP 메서드, 상태 코드)
- Redis 캐싱 전략이 적절한지 (TTL, 키 설계, 무효화)
- ClobTypeHandler와 @JsonRawValue 사용이 올바른지
한국어로 3줄 이내로 핵심만 답변."""
},
{
"name": "보안 감사관",
"emoji": "\U0001f6e1\ufe0f",
"prompt": """너는 OWASP Top 10을 기준으로 보안 취약점만 찾는 보안 전문가야.
이 코드 변경에서 보안 위험 요소를 찾아내.
특히:
- SQL Injection 가능성 (MyBatis ${} 사용 여부)
- XSS 취약점 (사용자 입력이 그대로 렌더링되는지)
- 민감 정보 노출 (API 키, DB 비밀번호, 에러 스택트레이스)
- CORS 설정이 너무 개방적이지 않은지
- JWT/인증 토큰 처리가 안전한지
- 입력값 검증이 빠져있는지
한국어로 3줄 이내로 핵심만 답변."""
},
],
"common": [
{
"name": "아키텍처 리뷰어",
"emoji": "\U0001f3d7\ufe0f",
"prompt": """너는 전체 시스템 아키텍처를 보는 테크 리드야.
이 코드 변경이 전체 아키텍처에 미치는 영향을 평가해.
특히:
- 프론트엔드-백엔드 간 API 계약이 맞는지
- 관심사 분리가 제대로 되어 있는지 (컨트롤러/서비스/매퍼)
- 기존 코드 패턴과 일관성이 있는지
- 확장성이나 유지보수성을 해치는 변경은 없는지
한국어로 3줄 이내로 핵심만 답변."""
},
]
}
PROJECT_CONTEXT = """
[프로젝트 컨텍스트 — Tasteby]
- YouTube 맛집 영상에서 식당 정보를 추출하여 지도에 표시하는 서비스
- 스택: Spring Boot 3.3.5 + Java 21 + MyBatis + Oracle 23ai + Redis + Next.js 16 + TypeScript + Tailwind CSS
- 프론트엔드: Next.js App Router, TypeScript, Tailwind CSS, 네이버 지도 API
- 백엔드: Spring Boot REST API, MyBatis XML 매퍼, Oracle ADB, Redis 캐싱
- 주요 패턴: Controller(thin) -> Service(비즈니스 로직) -> Mapper(MyBatis XML)
- 도메인: Lombok @Data/@Builder, Jackson SNAKE_CASE, CLOB은 ClobTypeHandler
- UUID 생성: IdGenerator.newId() (32자 대문자 hex)
- 인증: JWT 토큰 (AuthUtil.requireAdmin())
[리뷰 시 참고]
- MyBatis에서 #{} 는 안전(PreparedStatement), ${} 는 SQL Injection 위험
- Oracle CLOB 필드는 ClobTypeHandler + @JsonRawValue 조합으로 처리
- Oracle 대문자 컬럼명 → JsonUtil.lowerKeys()로 변환
- resultType="map" 쿼리 시 서비스 레이어에서 JsonUtil.lowerKeys() 필수
- UserInfo.isAdmin은 @JsonProperty("is_admin") 필요 (JavaBeans boolean 네이밍)
- CORS 설정: WebConfig.java에서 관리, 새 HTTP 메서드 추가 시 allowedMethods 확인
"""
def get_diff(args):
"""변경사항 diff 가져오기"""
if args.range:
cmd = ["git", "diff", args.range]
elif args.commits:
cmd = ["git", "diff", f"HEAD~{args.commits}", "HEAD"]
else:
diff = subprocess.run(["git", "diff", "HEAD"], capture_output=True, text=True).stdout
if not diff.strip():
diff = subprocess.run(["git", "diff", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout
return diff
return subprocess.run(cmd, capture_output=True, text=True).stdout
def filter_diff_by_scope(diff, scope):
"""scope에 따라 diff 필터링"""
if scope == "all":
return diff
lines = diff.split("\n")
filtered = []
include = False
for line in lines:
if line.startswith("diff --git"):
if scope == "frontend":
include = "frontend/" in line
elif scope == "backend":
include = "backend-java/" in line
if include:
filtered.append(line)
return "\n".join(filtered)
def get_changed_files(args):
"""변경된 파일 목록"""
if args.range:
cmd = ["git", "diff", "--name-only", args.range]
elif args.commits:
cmd = ["git", "diff", "--name-only", f"HEAD~{args.commits}", "HEAD"]
else:
result = subprocess.run(["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True).stdout.strip()
if not result:
result = subprocess.run(["git", "diff", "--name-only", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout.strip()
return result
return subprocess.run(cmd, capture_output=True, text=True).stdout.strip()
def call_llm(system_prompt, user_content):
"""OpenRouter API 호출"""
import urllib.request
body = json.dumps({
"model": MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content}
],
"max_tokens": 500,
}).encode()
req = urllib.request.Request(API_URL, data=body, headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"[API 호출 실패] {e}"
def select_personas(scope, files):
"""변경 파일에 따라 적절한 페르소나 선택"""
personas = list(PERSONAS["common"])
has_frontend = any(f.startswith("frontend/") for f in files.split("\n")) if files else False
has_backend = any(f.startswith("backend-java/") for f in files.split("\n")) if files else False
if scope == "frontend" or (scope == "all" and has_frontend):
personas = PERSONAS["frontend"] + personas
if scope == "backend" or (scope == "all" and has_backend):
personas = PERSONAS["backend"] + personas
if scope == "all" and not has_frontend and not has_backend:
personas = PERSONAS["frontend"] + PERSONAS["backend"] + personas
return personas
def main():
parser = argparse.ArgumentParser(description="Tasteby 페르소나 기반 코드 리뷰")
parser.add_argument("--commits", "-n", type=int, help="최근 N개 커밋 리뷰")
parser.add_argument("--range", "-r", type=str, help="커밋 범위 (예: abc..def)")
parser.add_argument("--scope", "-s", choices=["frontend", "backend", "all"], default="all",
help="리뷰 범위 (frontend/backend/all)")
parser.add_argument("--model", "-m", type=str, help="사용할 모델 (기본: anthropic/claude-haiku-4-5)")
args = parser.parse_args()
if args.model:
global MODEL
MODEL = args.model
if not OPENROUTER_API_KEY:
print("ERROR: OPENROUTER_API_KEY 환경변수가 필요합니다")
print(" .env 파일에 OPENROUTER_API_KEY=sk-or-... 추가하거나")
print(" export OPENROUTER_API_KEY=sk-or-...")
sys.exit(1)
diff = get_diff(args)
if not diff.strip():
print("변경사항이 없습니다.")
sys.exit(0)
diff = filter_diff_by_scope(diff, args.scope)
if not diff.strip():
print(f"'{args.scope}' 범위에 해당하는 변경사항이 없습니다.")
sys.exit(0)
files = get_changed_files(args)
personas = select_personas(args.scope, files)
max_diff = 8000
truncated = len(diff) > max_diff
diff_for_review = diff[:max_diff]
print("=" * 60)
print(f" Tasteby 코드 리뷰 ({args.scope})")
print("=" * 60)
print(f"\n 모델: {MODEL}")
print(f" 변경 파일:\n")
for f in files.split("\n"):
if f.strip():
if f.startswith("frontend/"):
tag = "[FE]"
elif f.startswith("backend-java/"):
tag = "[BE]"
else:
tag = "[--]"
print(f" {tag} {f}")
if truncated:
print(f"\n (diff가 {max_diff}자로 잘림, 원본 {len(diff)}자)")
print()
opinions = []
for p in personas:
print(f" {p['emoji']} [{p['name']}] 리뷰 중...")
user_msg = f"{PROJECT_CONTEXT}\n\n아래 코드 변경을 리뷰해줘:\n\n```diff\n{diff_for_review}\n```"
result = call_llm(p["prompt"], user_msg)
opinions.append({"name": p["name"], "emoji": p["emoji"], "opinion": result})
print(f" {result}\n")
print("=" * 60)
summary_prompt = """너는 프론트엔드/백엔드 리뷰어들의 의견을 종합하는 테크 리드야.
각 의견을 보고 최종 권고를 내려줘:
- PASS: 문제 없음, 배포 가능
- WARN: 사소한 이슈 있지만 배포 가능, 후속 작업 필요
- FAIL: 수정 필요, 배포 전 반드시 고쳐야 함
한국어로 최종 판정 1줄 + 이유/액션 아이템 2-3줄로 답변."""
opinions_text = "\n\n".join(
f"[{o['name']}]\n{o['opinion']}" for o in opinions
)
print(f"\n [최종 권고]")
final = call_llm(
summary_prompt,
f"{PROJECT_CONTEXT}\n\n변경사항 요약:\n{diff_for_review[:3000]}\n\n리뷰 의견:\n{opinions_text}"
)
print(f" {final}")
print("\n" + "=" * 60)
if __name__ == "__main__":
main()

21
scripts/enqueue.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# 새 작업을 파이프라인 큐에 투입 = 01-Planner/신규 Redmine 이슈 생성.
# 사용법: ./scripts/enqueue.sh "제목" ["요구사항"]
set -euo pipefail
cd "$(dirname "$0")/.."
set -a; . ./.env; set +a
SUBJECT="${1:?사용법: enqueue.sh \"제목\" [\"요구\"]}"; BODY="${2:-}"
PLANNER=$(curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
"$REDMINE_URL/projects/$REDMINE_PROJECT/issue_categories.json" \
| python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='01-Planner']")
DESC=$(printf '## [AI] Planner\n\n(요구사항)\n%s\n\n---\nWorking dir: %s' "$BODY" "$(pwd)")
python3 - "$REDMINE_URL" "$REDMINE_API_KEY" "$REDMINE_PROJECT" "$SUBJECT" "$DESC" "$PLANNER" <<'PY'
import sys,json,urllib.request
base,key,proj,subject,desc,cat=sys.argv[1:7]
p={"issue":{"project_id":proj,"tracker_id":2,"subject":subject,"description":desc,
"category_id":int(cat),"status_id":1}}
r=urllib.request.Request(base+"/issues.json",data=json.dumps(p).encode(),
headers={"X-Redmine-API-Key":key,"Content-Type":"application/json"},method="POST")
i=json.load(urllib.request.urlopen(r))["issue"]
print(f"enqueued #{i['id']}: {i['subject']} -> 01-Planner/신규")
PY

586
scripts/persona_review.py Executable file
View File

@@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""
Tasteby 페르소나 기반 UX/기능 리뷰
사용자 페르소나 관점에서 앱 기능을 리뷰하고 개선점을 도출.
결과는 reviews/ 디렉토리에 타임스탬프로 저장.
사용법:
# 전체 페르소나 리뷰
python3 scripts/persona_review.py
# 특정 페르소나만
python3 scripts/persona_review.py --persona foodie
python3 scripts/persona_review.py --persona tourist
python3 scripts/persona_review.py --persona beginner
python3 scripts/persona_review.py --persona mobile
# 특정 영역만 리뷰
python3 scripts/persona_review.py --focus home
python3 scripts/persona_review.py --focus map
python3 scripts/persona_review.py --focus detail
python3 scripts/persona_review.py --focus admin
# 모델 변경
python3 scripts/persona_review.py --model anthropic/claude-sonnet-4
# 결과만 보기 (API 호출 없이 마지막 리뷰)
python3 scripts/persona_review.py --latest
"""
import json, os, sys, argparse, glob
from pathlib import Path
from datetime import datetime, timezone, timedelta
# .env 로드
def load_dotenv():
env_path = Path(__file__).resolve().parent.parent / ".env"
if env_path.exists():
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
load_dotenv()
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
MODEL = os.environ.get("REVIEW_MODEL", "anthropic/claude-haiku-4-5")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
REVIEWS_DIR = PROJECT_ROOT / "reviews"
KST = timezone(timedelta(hours=9))
# --- 페르소나 정의 ---
PERSONAS = {
"foodie": {
"name": "맛집 탐방러",
"emoji": "🍜",
"desc": "유튜브 맛집 영상을 자주 보고, 주말마다 맛집 탐방을 다니는 30대 직장인",
"prompt": """너는 유튜브 먹방/맛집 채널을 매일 보는 30대 직장인이야.
주말마다 새로운 맛집을 찾아가는 게 취미고, 성시경, 김사원세끼 같은 채널을 구독해.
지도 앱으로 맛집 위치를 확인하고, 리뷰를 남기는 걸 좋아해.
이 앱의 현재 상태를 보고, 너 같은 사용자 입장에서 리뷰해줘:
1. 유튜버별로 맛집을 찾고 → 지도에서 위치 확인 → 상세 정보를 보는 흐름이 자연스러운가?
2. 식당 필터링(장르, 지역, 유튜버)이 편리한가?
3. 식당 상세 정보(평가, 메뉴, 가격대, 예약 링크)가 충분한가?
4. 즐겨찾기, 리뷰, 메모 기능이 실용적인가?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"tourist": {
"name": "여행자",
"emoji": "✈️",
"desc": "서울 여행 계획 중인 지방/해외 거주자. 유명 유튜버가 간 맛집을 찾고 싶음",
"prompt": """너는 서울 여행을 계획하고 있는 부산 거주 20대야.
유튜브에서 본 맛집들을 미리 정리해서 여행 코스를 짜고 싶어.
"이 동네에 갔을 때 뭘 먹을지" 지역별로 정리하는 게 중요해.
이 앱의 현재 상태를 보고, 너 같은 사용자 입장에서 리뷰해줘:
1. 특정 지역(예: 강남, 홍대)의 맛집을 한눈에 볼 수 있는가?
2. 지도에서 동선을 짜기 편한가? (근처 맛집 묶어보기)
3. 즐겨찾기로 "가볼 곳 리스트"를 만들기 편한가?
4. 예약 정보(테이블링, 캐치테이블 링크)가 잘 연결되어 있는가?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"beginner": {
"name": "앱 첫 방문자",
"emoji": "🆕",
"desc": "앱에 처음 들어온 사람. 이 앱이 뭔지, 어떻게 쓰는지 모름",
"prompt": """너는 친구가 보내준 링크를 타고 이 앱에 처음 들어온 사람이야.
이 앱이 뭘 하는 건지, 어떻게 쓰는 건지 전혀 모르는 상태야.
이 앱의 현재 상태를 보고, 첫 방문자 입장에서 리뷰해줘:
1. 첫 화면을 보고 3초 안에 "이 앱이 뭔지" 이해할 수 있는가?
2. 어디를 눌러야 하는지 직관적으로 알 수 있는가?
3. 회원가입/로그인 없이도 앱을 체험해볼 수 있는가?
4. 처음 사용자에게 혼란스럽거나 막히는 지점은?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"mobile": {
"name": "모바일 사용자",
"emoji": "📱",
"desc": "스마트폰으로만 앱을 사용. 지하철에서 맛집 검색하거나 현장에서 확인",
"prompt": """너는 항상 스마트폰으로만 이 앱을 쓰는 사용자야.
지하철에서 주말 맛집을 검색하거나, 현장에서 "내 주변 맛집"을 찾아볼 때 사용해.
한 손 조작이 중요하고, 지도 + 리스트를 빠르게 전환해야 해.
이 앱의 현재 프론트엔드 코드를 보고, 모바일 사용자 입장에서 리뷰해줘:
1. 모바일에서 터치 영역이 충분한가? (최소 44x44px)
2. 지도와 리스트 전환이 매끄러운가?
3. "내 주변" 기능이 현장에서 유용한가?
4. 바텀시트, 필터 등 모바일 UI가 네이티브 앱처럼 자연스러운가?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"power": {
"name": "파워 유저",
"emoji": "",
"desc": "매주 맛집 탐방 후 리뷰/메모를 남기는 헤비 유저. 즐겨찾기 50개 이상",
"prompt": """너는 이 앱을 3개월째 매주 쓰는 파워 유저야.
즐겨찾기 50개 이상, 리뷰도 20개 넘게 남겼어. 영상관리 페이지도 잘 알아.
이 앱의 현재 상태를 보고, 파워 유저 입장에서 리뷰해줘:
1. 즐겨찾기가 많아졌을 때 관리가 편한가? (검색, 정렬, 폴더)
2. 내가 남긴 리뷰/메모를 다시 찾아보기 편한가?
3. 새로 추가된 식당이나 영상을 빠르게 확인할 수 있는가?
4. 반복 사용하면서 느끼는 불편함은?
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"frontend_dev": {
"name": "프론트엔드 개발자",
"emoji": "💻",
"desc": "Next.js/React/TypeScript 전문 프론트엔드 개발자. 코드 품질과 성능에 민감",
"prompt": """너는 Next.js + TypeScript 경력 5년차 프론트엔드 개발자야.
코드 품질, 성능 최적화, 컴포넌트 설계에 까다로운 편이야.
이 앱의 프론트엔드 코드를 보고, 개발자 관점에서 리뷰해줘:
1. 컴포넌트 구조가 적절한가? (크기, 책임 분리, 재사용성)
2. 상태 관리가 효율적인가? (불필요한 리렌더링, prop drilling)
3. API 호출 패턴이 적절한가? (로딩/에러 처리, 캐싱, 중복 요청)
4. 성능 문제가 있는가? (큰 컴포넌트, 메모이제이션 부족, 번들 크기)
5. 구체적인 리팩토링/개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"backend_dev": {
"name": "백엔드 개발자",
"emoji": "⚙️",
"desc": "Spring Boot/MyBatis/Oracle 전문 백엔드 개발자. API 설계와 DB 성능에 민감",
"prompt": """너는 Spring Boot + MyBatis + Oracle DB 경력 7년차 백엔드 개발자야.
REST API 설계, DB 쿼리 최적화, 캐싱 전략에 까다로운 편이야.
이 앱의 백엔드 코드를 보고, 개발자 관점에서 리뷰해줘:
1. Controller/Service/Mapper 계층 분리가 적절한가?
2. MyBatis 쿼리에 성능 문제가 있는가? (N+1, 불필요한 JOIN, 인덱스 활용)
3. 에러 처리/예외 전략이 일관적인가?
4. Redis 캐싱 전략이 적절한가? (키 설계, TTL, 무효화)
5. 구체적인 리팩토링/개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
"devops": {
"name": "DevOps 엔지니어",
"emoji": "🚀",
"desc": "CI/CD, 컨테이너, 모니터링 전문. 운영 안정성과 배포 자동화에 민감",
"prompt": """너는 Kubernetes + Docker + CI/CD 전문 DevOps 엔지니어야.
운영 안정성, 배포 자동화, 모니터링, 로깅에 까다로운 편이야.
이 앱의 인프라/배포 관련 코드를 보고, DevOps 관점에서 리뷰해줘:
1. 배포 프로세스가 안전하고 자동화되어 있는가?
2. 환경별 설정(dev/prod) 분리가 적절한가?
3. 로깅/모니터링이 장애 대응에 충분한가?
4. 컨테이너 이미지 빌드가 최적화되어 있는가? (레이어 캐싱, 이미지 크기)
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
},
}
# --- 앱 컨텍스트 수집 ---
FOCUS_PATHS = {
"home": [
"frontend/src/app/page.tsx",
"frontend/src/app/layout.tsx",
],
"map": [
"frontend/src/components/MapView.tsx",
"frontend/src/components/FilterSheet.tsx",
"frontend/src/components/RestaurantList.tsx",
],
"detail": [
"frontend/src/components/RestaurantDetail.tsx",
"frontend/src/components/ReviewSection.tsx",
"frontend/src/components/MemoSection.tsx",
],
"admin": [
"frontend/src/app/admin/page.tsx",
],
"api": [
"frontend/src/lib/api.ts",
],
"backend": [
"backend-java/src/main/java/com/tasteby/controller/RestaurantController.java",
"backend-java/src/main/java/com/tasteby/controller/VideoController.java",
"backend-java/src/main/java/com/tasteby/service/RestaurantService.java",
"backend-java/src/main/java/com/tasteby/service/VideoService.java",
"backend-java/src/main/java/com/tasteby/service/CacheService.java",
"backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml",
],
"infra": [
"ecosystem.config.js",
"deploy.sh",
"backend-java/Dockerfile",
"frontend/Dockerfile",
"k8s/backend-deployment.yaml",
"k8s/frontend-deployment.yaml",
],
}
def collect_app_context(focus, persona_keys=None):
"""프론트엔드/백엔드 파일 읽어서 앱 상태 컨텍스트 생성"""
if focus == "all":
paths = []
for key in ["home", "map", "detail", "api"]:
paths.extend(FOCUS_PATHS[key])
# 개발자 페르소나면 백엔드/인프라도 포함
if persona_keys:
dev_personas = {"frontend_dev", "backend_dev", "devops"}
if dev_personas & set(persona_keys):
paths.extend(FOCUS_PATHS.get("backend", []))
paths.extend(FOCUS_PATHS.get("infra", []))
paths = list(dict.fromkeys(paths))
else:
paths = FOCUS_PATHS.get(focus, [])
context_parts = []
for rel_path in paths:
full_path = PROJECT_ROOT / rel_path
if full_path.exists():
try:
content = full_path.read_text()
if len(content) > 4000:
content = content[:4000] + "\n... (truncated)"
context_parts.append(f"=== {rel_path} ===\n{content}")
except Exception:
pass
# 디자인 가이드 포함
for doc in ["frontend/docs/design-concepts.md", "frontend/docs/brand-guide.md"]:
doc_path = PROJECT_ROOT / doc
if doc_path.exists():
try:
dg = doc_path.read_text()
if len(dg) > 2000:
dg = dg[:2000] + "\n... (truncated)"
context_parts.append(f"=== {doc} ===\n{dg}")
except Exception:
pass
return "\n\n".join(context_parts)
# --- LLM 호출 ---
def call_llm(system_prompt, user_content):
"""OpenRouter API 호출"""
import urllib.request
body = json.dumps({
"model": MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content}
],
"max_tokens": 2000,
}).encode()
req = urllib.request.Request(API_URL, data=body, headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"[API 호출 실패] {e}"
# --- 개발자 토론 ---
DEV_PERSONA = {
"name": "풀스택 개발자",
"emoji": "🧑‍💻",
"prompt": """너는 이 프로젝트의 풀스택 개발자야.
Spring Boot + MyBatis + Oracle 백엔드와 Next.js + TypeScript 프론트엔드를 모두 다룰 수 있어.
현재 코드베이스를 잘 알고 있고, 무엇이 쉽고 어려운지 정확히 판단할 수 있어.
사용자 페르소나들이 아래와 같은 개선 제안을 했어.
각 제안에 대해 개발자 입장에서 대화하듯 응답해줘:
응답 형식 (각 제안마다):
- ✅ "좋은 생각이에요! 이건 OO 파일 수정하면 금방 됩니다" (쉬움, 1일 이내)
- ⚠️ "가능은 한데, OO 때문에 좀 복잡해요. N일 정도 걸릴 듯" (보통)
- 🔴 "이건 현재 구조에서 어려워요. OO를 먼저 바꿔야 합니다" (어려움/큰 작업)
- 💡 "그것보다 이렇게 하면 더 좋을 것 같아요" (대안 제시)
각 제안에 예상 작업량(시간/일)도 함께 알려줘.
한국어로, 실제 동료 개발자에게 말하듯 자연스럽게 답변."""
}
def dev_discussion(user_opinions, app_context):
"""사용자 페르소나 의견을 개발자가 검토하고 토론"""
opinions_text = "\n\n".join(
f"[{o['name']} {o['emoji']}]\n{o['opinion']}" for o in user_opinions
)
user_msg = f"""아래는 앱의 현재 코드 상태야:
{app_context[:5000]}
---
아래는 사용자 페르소나들의 개선 제안이야. 각 제안에 대해 실현 가능성을 판단해줘:
{opinions_text}"""
return call_llm(DEV_PERSONA["prompt"], user_msg)
# --- 종합 판정 ---
def synthesize(user_opinions, dev_response, app_context):
"""사용자 의견 + 개발자 판단을 종합해 최종 태스크 리스트 생성"""
system_prompt = """너는 PM(프로덕트 매니저)이야.
사용자 페르소나들의 요구사항과 개발자의 실현 가능성 판단을 종합해서
최종 실행 태스크 리스트를 만들어줘.
규칙:
- 개발자가 "쉽다"고 한 것 중 여러 페르소나가 원한 것 → [P0] (바로 하자)
- 개발자가 "가능하다"고 한 것 중 임팩트 큰 것 → [P1] (다음 스프린트)
- 개발자가 "어렵다"고 한 것 중 꼭 필요한 것 → [P2] (로드맵에 넣자)
- 개발자가 대안을 제시한 경우 대안으로 태스크 작성
- 각 태스크에 예상 작업량, 수정 대상 파일 포함
- 최대 15개 태스크
- 한국어로 작성"""
opinions_text = "\n\n".join(
f"[{o['name']} {o['emoji']}]\n{o['opinion']}" for o in user_opinions
)
user_msg = f"""사용자 의견:
{opinions_text}
---
개발자 검토:
{dev_response}"""
return call_llm(system_prompt, user_msg)
# --- 결과 저장 ---
def save_result(opinions, dev_response, summary, focus, model):
"""리뷰 결과를 reviews/ 디렉토리에 저장"""
REVIEWS_DIR.mkdir(exist_ok=True)
now = datetime.now(KST)
filename = f"persona-review-{now.strftime('%Y%m%d-%H%M')}.md"
filepath = REVIEWS_DIR / filename
lines = []
lines.append(f"# Tasteby 페르소나 UX 리뷰 — {now.strftime('%Y-%m-%d %H:%M')} KST")
lines.append("")
lines.append(f"- **모델**: {model}")
lines.append(f"- **리뷰 범위**: {focus}")
lines.append(f"- **페르소나**: {', '.join(o['name'] for o in opinions)}")
lines.append("")
lines.append("---")
lines.append("")
# 1단계: 사용자 리뷰
lines.append("# 1단계: 사용자 페르소나 리뷰")
lines.append("")
for o in opinions:
lines.append(f"## {o['emoji']} {o['name']}")
lines.append(f"> {PERSONAS[o['key']]['desc']}")
lines.append("")
lines.append(o["opinion"])
lines.append("")
lines.append("---")
lines.append("")
# 2단계: 개발자 토론
lines.append("# 2단계: 🧑‍💻 개발자 실현 가능성 검토")
lines.append("")
lines.append(dev_response)
lines.append("")
lines.append("---")
lines.append("")
# 3단계: 종합
lines.append("# 3단계: 📋 최종 태스크 리스트")
lines.append("")
lines.append(summary)
lines.append("")
filepath.write_text("\n".join(lines))
return filepath
def show_latest():
"""가장 최근 리뷰 파일 출력"""
if not REVIEWS_DIR.exists():
print("리뷰 기록이 없습니다.")
return
files = sorted(REVIEWS_DIR.glob("persona-review-*.md"))
if not files:
print("리뷰 기록이 없습니다.")
return
latest = files[-1]
print(f"최근 리뷰: {latest}\n")
print(latest.read_text())
# --- 메인 ---
def main():
parser = argparse.ArgumentParser(description="Tasteby 페르소나 기반 UX 리뷰")
parser.add_argument("--persona", "-p", type=str,
help=f"특정 페르소나만 ({', '.join(PERSONAS.keys())})")
parser.add_argument("--focus", "-f", type=str, default="all",
choices=["all", "home", "map", "detail", "admin", "api", "backend", "infra"],
help="리뷰 대상 영역")
parser.add_argument("--model", "-m", type=str, help="사용할 모델")
parser.add_argument("--latest", "-l", action="store_true",
help="마지막 리뷰 결과 보기")
parser.add_argument("--list", action="store_true",
help="리뷰 기록 목록 보기")
args = parser.parse_args()
if args.latest:
show_latest()
return
if args.list:
if not REVIEWS_DIR.exists():
print("리뷰 기록이 없습니다.")
return
files = sorted(REVIEWS_DIR.glob("persona-review-*.md"))
if not files:
print("리뷰 기록이 없습니다.")
return
print(f"리뷰 기록 ({len(files)}건):\n")
for f in files:
print(f" {f.name}")
return
if args.model:
global MODEL
MODEL = args.model
if not OPENROUTER_API_KEY:
print("ERROR: OPENROUTER_API_KEY가 필요합니다")
print(" .env 파일에 OPENROUTER_API_KEY=sk-or-... 추가하거나")
print(" export OPENROUTER_API_KEY=sk-or-...")
sys.exit(1)
# 사용자 페르소나 분류 (개발자 페르소나는 제외 — 자동으로 토론에 참여)
USER_PERSONAS = {k: v for k, v in PERSONAS.items()
if k not in ("frontend_dev", "backend_dev", "devops")}
# 페르소나 선택
if args.persona:
if args.persona not in USER_PERSONAS:
print(f"ERROR: 알 수 없는 페르소나 '{args.persona}'")
print(f" 가능한 값: {', '.join(USER_PERSONAS.keys())}")
sys.exit(1)
selected = {args.persona: USER_PERSONAS[args.persona]}
else:
selected = USER_PERSONAS
# 앱 컨텍스트 수집 (개발자 토론을 위해 백엔드도 포함)
all_keys = list(selected.keys()) + ["backend_dev"]
print("=" * 60)
print(f" Tasteby 페르소나 UX 리뷰")
print("=" * 60)
print(f"\n 모델: {MODEL}")
print(f" 범위: {args.focus}")
print(f" 사용자 페르소나: {', '.join(p['name'] for p in selected.values())}")
print(f" 개발자: {DEV_PERSONA['emoji']} {DEV_PERSONA['name']} (자동 참여)")
print(f"\n 앱 컨텍스트 수집 중...")
app_context = collect_app_context(args.focus, all_keys)
print(f" 컨텍스트 크기: {len(app_context):,}")
# ── 1단계: 사용자 페르소나 리뷰 ──
print(f"\n{'=' * 60}")
print(f" 1단계: 사용자 페르소나 리뷰")
print(f"{'=' * 60}\n")
opinions = []
for key, persona in selected.items():
print(f" {persona['emoji']} [{persona['name']}] 리뷰 중...")
user_msg = f"""아래는 유튜브 맛집 지도 서비스 'Tasteby'의 코드야.
유튜버들이 소개한 식당을 지도에 표시하고, 필터링/검색/즐겨찾기/리뷰를 제공하는 앱이야.
이 코드를 보고 앱의 현재 상태를 파악한 뒤 리뷰해줘.
{app_context}"""
result = call_llm(persona["prompt"], user_msg)
opinions.append({
"key": key,
"name": persona["name"],
"emoji": persona["emoji"],
"opinion": result,
})
print(f" 완료 ✓")
print(f"\n{'' * 50}")
print(f" {persona['emoji']} {persona['name']}:")
print(f"{'' * 50}")
for line in result.split("\n"):
print(f" {line}")
print()
# ── 2단계: 개발자 토론 ──
print(f"{'=' * 60}")
print(f" 2단계: {DEV_PERSONA['emoji']} 개발자 실현 가능성 검토")
print(f"{'=' * 60}\n")
print(f" {DEV_PERSONA['emoji']} [{DEV_PERSONA['name']}] 각 제안 검토 중...")
dev_response = dev_discussion(opinions, app_context)
print(f" 완료 ✓")
print(f"\n{'' * 50}")
print(f" {DEV_PERSONA['emoji']} {DEV_PERSONA['name']}:")
print(f"{'' * 50}")
for line in dev_response.split("\n"):
print(f" {line}")
print()
# ── 3단계: PM 종합 ──
print(f"{'=' * 60}")
print(f" 3단계: 📋 PM 종합 — 최종 태스크 리스트")
print(f"{'=' * 60}\n")
print(f" 📋 사용자 의견 + 개발자 판단 종합 중...")
summary = synthesize(opinions, dev_response, app_context)
print(f"\n{'' * 50}")
print(f" 📋 최종 태스크 리스트:")
print(f"{'' * 50}")
for line in summary.split("\n"):
print(f" {line}")
print()
# 저장
filepath = save_result(opinions, dev_response, summary, args.focus, MODEL)
print("=" * 60)
print(f" 💾 결과 저장: {filepath}")
print("=" * 60)
if __name__ == "__main__":
main()