Files
tasteby/scripts/persona_review.py
joungmin c78f928a2d 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
2026-06-15 10:20:50 +09:00

587 lines
22 KiB
Python
Executable File

#!/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()