리소스 허브로
기술 문서 (프로토타입 버전)
E2E QA 보고
저장소의 docs/e2e-qa-report.md 와 동일한 원문입니다. 아래에서 Markdown과 HTML 변환 결과를 각각 복사할 수 있습니다.
공개 문서 원문 (Markdown)
# SaaS Engine — E2E QA 리포트
> 최초 작성: 2026-04-10 | **업데이트: 2026-04-10 (2차)**
> 분석 도구: Playwright v1.59 + 코드베이스 직접 분석
> 테스트: `pnpm test:e2e:new-user` + `pnpm test:e2e:security` (35 tests)
---
## 📊 테스트 실행 결과 요약
| 날짜 | 프로젝트 | 전체 | 통과 | 실패 |
|------|----------|------|------|------|
| 2026-04-10 (초기) | new-user + security | 35 | 25 | **10** |
| **2026-04-11 (수정 후)** | new-user + security | 35 | **35** | **0** |
**Top 5 수정 완료 → 35/35 전부 통과.**
---
## ✅ 완료된 수정 (Top 5)
| # | 파일 | 변경 내용 | 심각도 |
|---|------|-----------|--------|
| 1 | `app/api/conversation/route.ts` | DELETE 전 `conversations.user_id = user.id` 소유권 검증 추가 | 🔴 Critical |
| 2 | `app/api/messages/route.ts` | GET 전 conversation 소유권 검증 추가 | 🔴 Critical |
| 3 | `app/api/webhooks/polar/route.ts` | 모듈 레벨 `Webhooks({secret:""})` 제거 → 요청마다 lazy 초기화 | 🔴 Critical |
| 4 | `app/page.tsx` | 로그인 CTA `aria-label="로그인 페이지로 이동"` 추가 (타임라인 카드 내 텍스트와 구분) | 🟠 High |
| 5 | `app/login/page.tsx` | `sanitizeNextPath()` 진입 시점 적용 → 유효하지 않은 next 파라미터 `router.replace`로 즉시 제거 | 🟡 Medium |
| 5+ | `proxy.ts` | SSR 라우트 보호 이미 구현 확인 (`/dashboard`, `/chat`, `/onboarding`) | — |
---
## 🔧 추가 개선사항
### A. 즉시 수정 권장 (코드 버그)
---
#### A-1. `polar-subscription-sync.ts` — `currentPeriodEnd` 타입 안전성
**파일**: `lib/polar-subscription-sync.ts:95`
**심각도**: 🟠 High
```typescript
// 현재: Date 객체를 가정
current_period_end: sub.currentPeriodEnd.toISOString(),
// 문제: Polar SDK 버전·직렬화 방식에 따라 string으로 올 수 있음
// → string에서 .toISOString() 호출 → TypeError: not a function
```
**영향**: 웹훅 처리 중 `TypeError` → 구독 상태 DB 반영 실패
**수정**:
```typescript
current_period_end: new Date(sub.currentPeriodEnd).toISOString(),
```
---
#### A-2. `engine/usage` — 채팅 요청당 `subscriptions` 테이블 이중 조회
**파일**: `engine/usage/usage.ts:101`, `features/gemini-chat/api/chatHandler.ts:27`
**심각도**: 🟡 Medium — 성능
`handleChatStream`은 이미 `checkUsageLimit()` 내부에서 `resolvePlanLimit()`를 호출해 `subscriptions`를 한 번 조회합니다.
이후 `incrementUsage()`가 또다시 `resolvePlanLimit()`를 호출 → **채팅 요청마다 `subscriptions` 테이블 2회 조회**.
```typescript
// chatHandler.ts
const usageCheck = await checkUsageLimit(supabase, userId); // subscriptions 1회
// ...
await incrementUsage(supabase, userId, "gemini_chat", tokens); // subscriptions 1회 더 (내부)
```
**수정**: `checkUsageLimit`에서 resolve된 `limit`을 `incrementUsage`에 전달하는 시그니처 추가:
```typescript
await incrementUsage(supabase, userId, "gemini_chat", tokens, usageCheck.limit);
// incrementUsage에서 limit가 전달되면 resolvePlanLimit 스킵
```
---
#### A-3. `/api/health` — 운영 환경에서 환경변수 이름 노출
**파일**: `app/api/health/route.ts:20-26`
**심각도**: 🟡 Medium — 정보 노출
테스트에서 실제 확인된 응답:
```json
{
"auth": {
"ready": false,
"missingEnvKeys": ["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]
}
}
```
공격자가 인프라 구성 파악에 활용 가능.
**수정**: `NODE_ENV !== "production"` 조건 추가:
```typescript
auth: {
ready: missingEnvKeys.length === 0 && missingAuthKey.length === 0,
...(process.env.NODE_ENV !== "production" && {
missingEnvKeys: [...missingEnvKeys, ...missingAuthKey],
}),
},
```
---
#### A-4. `app/layout.tsx` — 기본 메타데이터 미변경
**파일**: `app/layout.tsx:24-27`
**심각도**: 🟡 Medium — SEO / 신뢰도
```typescript
export const metadata: Metadata = {
title: "Create Next App", // ← placeholder
description: "Generated by create next app", // ← placeholder
};
```
**영향**: Google 검색 결과, 브라우저 탭, SNS 공유 시 "Create Next App" 노출 → 신뢰도 0
**수정**: 서비스명·설명으로 교체:
```typescript
export const metadata: Metadata = {
title: "SaaS Engine — AI Chat Platform",
description: "Supabase 인증 + Polar 구독 + Gemini AI 채팅 통합 SaaS 플랫폼",
};
```
---
### B. 전환율 개선 (UX)
---
#### B-1. `checkout/success` — 폴링 중 대시보드 링크 이른 활성화
**파일**: `app/checkout/success/checkout-success-client.tsx:143-148`
**심각도**: 🟠 High — 결제 후 이탈
현재: `phase === "polling"` 상태에서도 "대시보드로" 링크가 완전히 활성화 → 구독 미반영 상태로 이동 → 사용자 혼란
```typescript
// 현재: 항상 활성화
<Link href="/dashboard">대시보드로</Link>
// 수정: 폴링 중에는 시각적 비활성화 + 안내
<Link
href="/dashboard"
className={phase === "polling" ? "pointer-events-none opacity-40" : ""}
aria-disabled={phase === "polling"}
>
대시보드로 {phase === "polling" ? "(구독 확인 중…)" : ""}
</Link>
```
---
#### B-2. 채팅 사용량 한도 초과 — 업그레이드 CTA 텍스트만 표시
**파일**: `app/chat/page.tsx:129`
**심각도**: 🟠 High — 무료→유료 전환 핵심 포인트
```typescript
// 현재: 텍스트 에러 메시지 + 텍스트 URL
setError(`Usage limit exceeded. Upgrade: ${data.upgrade_url ?? UPGRADE_URL}`);
```
에러 박스에 URL이 평문으로 표시되어 클릭 불가, 복사-붙여넣기 필요.
**수정**: 에러 상태에 `upgradeUrl`을 별도로 저장하고 클릭 가능한 버튼으로 렌더링:
```typescript
// state 추가
const [upgradeUrl, setUpgradeUrl] = useState<string | null>(null);
// 429 처리
if (res.status === 429) {
const data = await res.json();
setError("일일 사용량 한도를 초과했습니다.");
setUpgradeUrl(data.upgrade_url ?? UPGRADE_URL);
return;
}
// UI
{upgradeUrl && (
<Link href={upgradeUrl} className="mt-2 inline-block rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
플랜 업그레이드
</Link>
)}
```
---
#### B-3. `withAuth()` 공통 유틸 — API 라우트 보일러플레이트 제거
**해당 파일**: `app/api/` 하위 8개 라우트
**심각도**: 🟢 Low-Medium — 유지보수성
현재 모든 API 라우트에 동일한 6줄 패턴 반복:
```typescript
const res = NextResponse.next();
const supabase = createServerSupabase(request, res);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
```
**수정**: `lib/api-auth.ts` 유틸 추출:
```typescript
// lib/api-auth.ts
export async function requireAuth(request: NextRequest) {
const response = NextResponse.next();
const supabase = createServerSupabase(request, response);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { user: null, supabase, response, unauthorized: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
return { user, supabase, response, unauthorized: null };
}
// 사용
const auth = await requireAuth(request);
if (auth.unauthorized) return auth.unauthorized;
const { user, supabase } = auth;
```
---
#### B-4. Analytics 미설치 — 전환 퍼널 측정 불가
**심각도**: 🟠 High — SaaS 운영 관점
현재 상태:
```
방문자 → 신규 유저 → 로그인 → 결제 → 활성 사용자
100% ?% ?% ?% ?%
```
어느 단계에서 이탈하는지 전혀 알 수 없음.
**추가 권장**: Vercel Analytics (설정 1줄) 또는 PostHog (무료, 셀프호스트 가능)
```typescript
// app/layout.tsx
import { Analytics } from "@vercel/analytics/react";
// <body> 안에 추가
<Analytics />
```
핵심 이벤트 추적 포인트:
- 랜딩 → 로그인 클릭
- OAuth 완료 → 온보딩 진입
- 온보딩 → 건너뛰기 vs 완료
- 요금제 페이지 조회
- 체크아웃 클릭
- 결제 완료 (checkout_id 기준)
- 사용량 한도 초과 (업그레이드 CTA 노출)
---
#### B-5. OAuth 에러 메시지 — 기술 코드 그대로 노출
**파일**: `app/login/page.tsx:54-58`
**심각도**: 🟡 Medium — 사용자 경험
```typescript
// 현재: 기술적 오류 코드 그대로
`OAuth 콜백 오류: ${oauthErrorCode}` // "access_denied", "invalid_grant" 등
```
**수정**: 사용자 친화적 메시지 맵핑:
```typescript
const OAUTH_ERROR_MESSAGES: Record<string, string> = {
access_denied: "로그인을 취소했습니다. 다시 시도해 주세요.",
invalid_grant: "인증 세션이 만료됐습니다. 다시 로그인해 주세요.",
oauth_no_session: "로그인 처리에 실패했습니다. 브라우저 쿠키 설정을 확인해 주세요.",
};
const userMessage = OAUTH_ERROR_MESSAGES[oauthErrorCode ?? ""]
?? `로그인에 실패했습니다. (${oauthErrorCode})`;
```
---
### C. 코드 품질 (낮은 우선순위)
| # | 위치 | 문제 | 권장 |
|---|------|------|------|
| C-1 | `lib/supabase.ts` + `lib/supabaseClient.ts` | 동일 내용 re-export 중복 | `lib/supabaseClient.ts` 하나로 통합 |
| C-2 | `features/gemini-chat/types/index.ts:4` | `MessageRole = "assistant"` vs Gemini API `"model"` 불일치 | 명시적 매핑 타입 또는 정렬 |
| C-3 | `engine/billing/index.ts` 외 7개 | `export {}` 빈 스캐폴드 파일 | 구현 전까지 삭제 |
| C-4 | `app/chat/page.tsx:104-176` | `handleSend` 70+ 줄 | `useChatStream()` 커스텀 훅 분리 |
| C-5 | `app/dashboard/page.tsx:43-364` | 단일 컴포넌트 364줄, state 10+ 개 | `useAuth`, `useSubscription` 훅 분리 |
| C-6 | `engine/rate-limit/index.ts` | 스캐폴드만 존재, 미구현 | `/api/chat` 엔드포인트에 rate limit 구현 |
---
## 전체 이탈 포인트 지도
### Flow 1: 신규 유저
```
랜딩 진입
├─ ❌ 이탈 1 (미해결): 서비스 설명 없음 — placeholder 타임라인
│
└─ 로그인 페이지
├─ ✅ 수정됨: 외부 URL next 파라미터 URL 바 노출 → 즉시 제거
├─ ❌ 이탈 2 (미해결): 환경변수 누락 경고 표시되어도 버튼 disabled 아님
│ → signInWithGoogle 호출 → Supabase 오류 → 빈 화면
│
└─ OAuth
├─ ❌ 이탈 3 (B-5): 에러 코드 그대로 노출 ("access_denied")
└─ 콜백 → 온보딩
└─ ❌ 이탈 4 (B-4): Analytics 없음, 이탈률 측정 불가
```
### Flow 2: 결제 유저
```
요금제 페이지
├─ ❌ 이탈 5 (미해결): 하드코딩 가격 $24/$48/$76 — 면책 조항 작은 글씨
├─ ❌ 이탈 6 (미해결): POLAR_PRODUCT_ID 미설정 시 결제 클릭 → 500 오류
│
└─ checkout/success
└─ ❌ 이탈 7 (B-1): 폴링 중 대시보드 링크 활성화
→ 구독 미반영 상태로 이동 → 혼란
```
### Flow 3: 재방문 유저
```
재방문
├─ ❌ 이탈 8 (미해결): useEffect 2개 독립 실행 (getCurrentUser + health check)
├─ ❌ 이탈 9 (미해결): /api/subscription 실패 시 error state 있으나 재시도 버튼 없음
│
└─ 채팅
├─ ❌ 이탈 10 (B-2): 사용량 한도 초과 → URL 텍스트만, 버튼 없음
└─ ❌ 이탈 11 (미해결): 대화 목록 API 실패 시 에러 메시지 없음 (빈 사이드바)
```
---
## 수정 우선순위 로드맵 (업데이트)
```
✅ 완료 (1차 — Top 5):
1. conversation/message 소유권 검증 [CRITICAL]
2. 웹훅 lazy 초기화 [CRITICAL]
3. 랜딩 로그인 CTA aria-label [High]
4. next 파라미터 sanitize (로그인 진입) [Medium]
5. proxy.ts SSR 보호 확인 [High]
✅ 완료 (2차 — 추가 개선):
A-1. polar-subscription-sync 타입 안전성 [High] new Date() 래핑
A-2. subscriptions 이중 DB 조회 제거 [Medium] preResolvedLimit 전달
A-3. health API 운영 환경 정보 노출 차단 [Medium] NODE_ENV !== "production" 조건
A-4. 기본 메타데이터 교체 [Medium] "SaaS Engine" 타이틀·설명
B-1. checkout/success 폴링 중 링크 비활성화 [High] phase === "polling" 시 비활성
B-2. 채팅 한도 초과 → 업그레이드 버튼 [High] error sentinel + 인디고 버튼
B-3. withAuth() 공통 유틸 추출 [Medium] lib/api-auth.ts 생성
B-5. OAuth 에러 메시지 한국어화 [Medium] 에러 코드 → 한국어 맵핑
C-1. lib/supabase.ts 중복 파일 삭제 [Low]
C-3. 빈 스캐폴드 파일 6개 삭제 [Low]
🔜 미완료 (선택적):
B-4. Analytics 설치 (Vercel / PostHog) [High]
C-2. MessageRole "assistant" vs "model" 타입 정렬 [Low]
C-4. handleSend → useChatStream 훅 분리 [Low]
C-5. DashboardPage 컴포넌트 분리 [Low]
C-6. /api/chat rate-limit 구현 [Low]
```
---
## 테스트 실행 방법
```bash
# 개발 서버 실행 후:
pnpm test:e2e:new-user # 신규 유저 시나리오 (22 tests)
pnpm test:e2e:security # 보안 시나리오 (13 tests)
# 인증이 필요한 Flow 2·3 실행 (테스트 계정 필요):
# .env.local에 TEST_USER_EMAIL / TEST_USER_PASSWORD 설정 후
# (playwright.config.ts가 `.env.local` 자동 로드 — `source` 생략 가능)
pnpm exec playwright test tests/e2e/auth.setup.ts --project=setup
pnpm test:e2e
# HTML 리포트 보기
pnpm test:e2e:report
```
---
*이 리포트는 Playwright E2E 자동화 + 코드 직접 분석 기반으로 작성됐습니다.*
*단순 추측 없이 실제 코드 경로와 테스트 실행 결과만 반영했습니다.*
*업데이트 (2차): 2026-04-10 — 추가 개선사항 10개 구현 완료 (A-1~4, B-1~3, B-5, C-1, C-3).*
공개 문서 변환 코드 (HTML)
<h1>SaaS Engine — E2E QA 리포트</h1>
<blockquote>
<p>최초 작성: 2026-04-10 | <strong>업데이트: 2026-04-10 (2차)</strong><br>분석 도구: Playwright v1.59 + 코드베이스 직접 분석<br>테스트: <code>pnpm test:e2e:new-user</code> + <code>pnpm test:e2e:security</code> (35 tests)</p>
</blockquote>
<hr>
<h2>📊 테스트 실행 결과 요약</h2>
<table>
<thead>
<tr>
<th>날짜</th>
<th>프로젝트</th>
<th>전체</th>
<th>통과</th>
<th>실패</th>
</tr>
</thead>
<tbody><tr>
<td>2026-04-10 (초기)</td>
<td>new-user + security</td>
<td>35</td>
<td>25</td>
<td><strong>10</strong></td>
</tr>
<tr>
<td><strong>2026-04-11 (수정 후)</strong></td>
<td>new-user + security</td>
<td>35</td>
<td><strong>35</strong></td>
<td><strong>0</strong></td>
</tr>
</tbody></table>
<p><strong>Top 5 수정 완료 → 35/35 전부 통과.</strong></p>
<hr>
<h2>✅ 완료된 수정 (Top 5)</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>파일</th>
<th>변경 내용</th>
<th>심각도</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>app/api/conversation/route.ts</code></td>
<td>DELETE 전 <code>conversations.user_id = user.id</code> 소유권 검증 추가</td>
<td>🔴 Critical</td>
</tr>
<tr>
<td>2</td>
<td><code>app/api/messages/route.ts</code></td>
<td>GET 전 conversation 소유권 검증 추가</td>
<td>🔴 Critical</td>
</tr>
<tr>
<td>3</td>
<td><code>app/api/webhooks/polar/route.ts</code></td>
<td>모듈 레벨 <code>Webhooks({secret:""})</code> 제거 → 요청마다 lazy 초기화</td>
<td>🔴 Critical</td>
</tr>
<tr>
<td>4</td>
<td><code>app/page.tsx</code></td>
<td>로그인 CTA <code>aria-label="로그인 페이지로 이동"</code> 추가 (타임라인 카드 내 텍스트와 구분)</td>
<td>🟠 High</td>
</tr>
<tr>
<td>5</td>
<td><code>app/login/page.tsx</code></td>
<td><code>sanitizeNextPath()</code> 진입 시점 적용 → 유효하지 않은 next 파라미터 <code>router.replace</code>로 즉시 제거</td>
<td>🟡 Medium</td>
</tr>
<tr>
<td>5+</td>
<td><code>proxy.ts</code></td>
<td>SSR 라우트 보호 이미 구현 확인 (<code>/dashboard</code>, <code>/chat</code>, <code>/onboarding</code>)</td>
<td>—</td>
</tr>
</tbody></table>
<hr>
<h2>🔧 추가 개선사항</h2>
<h3>A. 즉시 수정 권장 (코드 버그)</h3>
<hr>
<h4>A-1. <code>polar-subscription-sync.ts</code> — <code>currentPeriodEnd</code> 타입 안전성</h4>
<p><strong>파일</strong>: <code>lib/polar-subscription-sync.ts:95</code><br><strong>심각도</strong>: 🟠 High</p>
<pre><code class="language-typescript">// 현재: Date 객체를 가정
current_period_end: sub.currentPeriodEnd.toISOString(),
// 문제: Polar SDK 버전·직렬화 방식에 따라 string으로 올 수 있음
// → string에서 .toISOString() 호출 → TypeError: not a function
</code></pre>
<p><strong>영향</strong>: 웹훅 처리 중 <code>TypeError</code> → 구독 상태 DB 반영 실패<br><strong>수정</strong>:</p>
<pre><code class="language-typescript">current_period_end: new Date(sub.currentPeriodEnd).toISOString(),
</code></pre>
<hr>
<h4>A-2. <code>engine/usage</code> — 채팅 요청당 <code>subscriptions</code> 테이블 이중 조회</h4>
<p><strong>파일</strong>: <code>engine/usage/usage.ts:101</code>, <code>features/gemini-chat/api/chatHandler.ts:27</code><br><strong>심각도</strong>: 🟡 Medium — 성능</p>
<p><code>handleChatStream</code>은 이미 <code>checkUsageLimit()</code> 내부에서 <code>resolvePlanLimit()</code>를 호출해 <code>subscriptions</code>를 한 번 조회합니다.<br>이후 <code>incrementUsage()</code>가 또다시 <code>resolvePlanLimit()</code>를 호출 → <strong>채팅 요청마다 <code>subscriptions</code> 테이블 2회 조회</strong>.</p>
<pre><code class="language-typescript">// chatHandler.ts
const usageCheck = await checkUsageLimit(supabase, userId); // subscriptions 1회
// ...
await incrementUsage(supabase, userId, "gemini_chat", tokens); // subscriptions 1회 더 (내부)
</code></pre>
<p><strong>수정</strong>: <code>checkUsageLimit</code>에서 resolve된 <code>limit</code>을 <code>incrementUsage</code>에 전달하는 시그니처 추가:</p>
<pre><code class="language-typescript">await incrementUsage(supabase, userId, "gemini_chat", tokens, usageCheck.limit);
// incrementUsage에서 limit가 전달되면 resolvePlanLimit 스킵
</code></pre>
<hr>
<h4>A-3. <code>/api/health</code> — 운영 환경에서 환경변수 이름 노출</h4>
<p><strong>파일</strong>: <code>app/api/health/route.ts:20-26</code><br><strong>심각도</strong>: 🟡 Medium — 정보 노출</p>
<p>테스트에서 실제 확인된 응답:</p>
<pre><code class="language-json">{
"auth": {
"ready": false,
"missingEnvKeys": ["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]
}
}
</code></pre>
<p>공격자가 인프라 구성 파악에 활용 가능.<br><strong>수정</strong>: <code>NODE_ENV !== "production"</code> 조건 추가:</p>
<pre><code class="language-typescript">auth: {
ready: missingEnvKeys.length === 0 && missingAuthKey.length === 0,
...(process.env.NODE_ENV !== "production" && {
missingEnvKeys: [...missingEnvKeys, ...missingAuthKey],
}),
},
</code></pre>
<hr>
<h4>A-4. <code>app/layout.tsx</code> — 기본 메타데이터 미변경</h4>
<p><strong>파일</strong>: <code>app/layout.tsx:24-27</code><br><strong>심각도</strong>: 🟡 Medium — SEO / 신뢰도</p>
<pre><code class="language-typescript">export const metadata: Metadata = {
title: "Create Next App", // ← placeholder
description: "Generated by create next app", // ← placeholder
};
</code></pre>
<p><strong>영향</strong>: Google 검색 결과, 브라우저 탭, SNS 공유 시 "Create Next App" 노출 → 신뢰도 0<br><strong>수정</strong>: 서비스명·설명으로 교체:</p>
<pre><code class="language-typescript">export const metadata: Metadata = {
title: "SaaS Engine — AI Chat Platform",
description: "Supabase 인증 + Polar 구독 + Gemini AI 채팅 통합 SaaS 플랫폼",
};
</code></pre>
<hr>
<h3>B. 전환율 개선 (UX)</h3>
<hr>
<h4>B-1. <code>checkout/success</code> — 폴링 중 대시보드 링크 이른 활성화</h4>
<p><strong>파일</strong>: <code>app/checkout/success/checkout-success-client.tsx:143-148</code><br><strong>심각도</strong>: 🟠 High — 결제 후 이탈</p>
<p>현재: <code>phase === "polling"</code> 상태에서도 "대시보드로" 링크가 완전히 활성화 → 구독 미반영 상태로 이동 → 사용자 혼란</p>
<pre><code class="language-typescript">// 현재: 항상 활성화
<Link href="/dashboard">대시보드로</Link>
// 수정: 폴링 중에는 시각적 비활성화 + 안내
<Link
href="/dashboard"
className={phase === "polling" ? "pointer-events-none opacity-40" : ""}
aria-disabled={phase === "polling"}
>
대시보드로 {phase === "polling" ? "(구독 확인 중…)" : ""}
</Link>
</code></pre>
<hr>
<h4>B-2. 채팅 사용량 한도 초과 — 업그레이드 CTA 텍스트만 표시</h4>
<p><strong>파일</strong>: <code>app/chat/page.tsx:129</code><br><strong>심각도</strong>: 🟠 High — 무료→유료 전환 핵심 포인트</p>
<pre><code class="language-typescript">// 현재: 텍스트 에러 메시지 + 텍스트 URL
setError(`Usage limit exceeded. Upgrade: ${data.upgrade_url ?? UPGRADE_URL}`);
</code></pre>
<p>에러 박스에 URL이 평문으로 표시되어 클릭 불가, 복사-붙여넣기 필요.</p>
<p><strong>수정</strong>: 에러 상태에 <code>upgradeUrl</code>을 별도로 저장하고 클릭 가능한 버튼으로 렌더링:</p>
<pre><code class="language-typescript">// state 추가
const [upgradeUrl, setUpgradeUrl] = useState<string | null>(null);
// 429 처리
if (res.status === 429) {
const data = await res.json();
setError("일일 사용량 한도를 초과했습니다.");
setUpgradeUrl(data.upgrade_url ?? UPGRADE_URL);
return;
}
// UI
{upgradeUrl && (
<Link href={upgradeUrl} className="mt-2 inline-block rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
플랜 업그레이드
</Link>
)}
</code></pre>
<hr>
<h4>B-3. <code>withAuth()</code> 공통 유틸 — API 라우트 보일러플레이트 제거</h4>
<p><strong>해당 파일</strong>: <code>app/api/</code> 하위 8개 라우트<br><strong>심각도</strong>: 🟢 Low-Medium — 유지보수성</p>
<p>현재 모든 API 라우트에 동일한 6줄 패턴 반복:</p>
<pre><code class="language-typescript">const res = NextResponse.next();
const supabase = createServerSupabase(request, res);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
</code></pre>
<p><strong>수정</strong>: <code>lib/api-auth.ts</code> 유틸 추출:</p>
<pre><code class="language-typescript">// lib/api-auth.ts
export async function requireAuth(request: NextRequest) {
const response = NextResponse.next();
const supabase = createServerSupabase(request, response);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { user: null, supabase, response, unauthorized: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
return { user, supabase, response, unauthorized: null };
}
// 사용
const auth = await requireAuth(request);
if (auth.unauthorized) return auth.unauthorized;
const { user, supabase } = auth;
</code></pre>
<hr>
<h4>B-4. Analytics 미설치 — 전환 퍼널 측정 불가</h4>
<p><strong>심각도</strong>: 🟠 High — SaaS 운영 관점</p>
<p>현재 상태:</p>
<pre><code>방문자 → 신규 유저 → 로그인 → 결제 → 활성 사용자
100% ?% ?% ?% ?%
</code></pre>
<p>어느 단계에서 이탈하는지 전혀 알 수 없음.</p>
<p><strong>추가 권장</strong>: Vercel Analytics (설정 1줄) 또는 PostHog (무료, 셀프호스트 가능)</p>
<pre><code class="language-typescript">// app/layout.tsx
import { Analytics } from "@vercel/analytics/react";
// <body> 안에 추가
<Analytics />
</code></pre>
<p>핵심 이벤트 추적 포인트:</p>
<ul>
<li>랜딩 → 로그인 클릭</li>
<li>OAuth 완료 → 온보딩 진입</li>
<li>온보딩 → 건너뛰기 vs 완료</li>
<li>요금제 페이지 조회</li>
<li>체크아웃 클릭</li>
<li>결제 완료 (checkout_id 기준)</li>
<li>사용량 한도 초과 (업그레이드 CTA 노출)</li>
</ul>
<hr>
<h4>B-5. OAuth 에러 메시지 — 기술 코드 그대로 노출</h4>
<p><strong>파일</strong>: <code>app/login/page.tsx:54-58</code><br><strong>심각도</strong>: 🟡 Medium — 사용자 경험</p>
<pre><code class="language-typescript">// 현재: 기술적 오류 코드 그대로
`OAuth 콜백 오류: ${oauthErrorCode}` // "access_denied", "invalid_grant" 등
</code></pre>
<p><strong>수정</strong>: 사용자 친화적 메시지 맵핑:</p>
<pre><code class="language-typescript">const OAUTH_ERROR_MESSAGES: Record<string, string> = {
access_denied: "로그인을 취소했습니다. 다시 시도해 주세요.",
invalid_grant: "인증 세션이 만료됐습니다. 다시 로그인해 주세요.",
oauth_no_session: "로그인 처리에 실패했습니다. 브라우저 쿠키 설정을 확인해 주세요.",
};
const userMessage = OAUTH_ERROR_MESSAGES[oauthErrorCode ?? ""]
?? `로그인에 실패했습니다. (${oauthErrorCode})`;
</code></pre>
<hr>
<h3>C. 코드 품질 (낮은 우선순위)</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>위치</th>
<th>문제</th>
<th>권장</th>
</tr>
</thead>
<tbody><tr>
<td>C-1</td>
<td><code>lib/supabase.ts</code> + <code>lib/supabaseClient.ts</code></td>
<td>동일 내용 re-export 중복</td>
<td><code>lib/supabaseClient.ts</code> 하나로 통합</td>
</tr>
<tr>
<td>C-2</td>
<td><code>features/gemini-chat/types/index.ts:4</code></td>
<td><code>MessageRole = "assistant"</code> vs Gemini API <code>"model"</code> 불일치</td>
<td>명시적 매핑 타입 또는 정렬</td>
</tr>
<tr>
<td>C-3</td>
<td><code>engine/billing/index.ts</code> 외 7개</td>
<td><code>export {}</code> 빈 스캐폴드 파일</td>
<td>구현 전까지 삭제</td>
</tr>
<tr>
<td>C-4</td>
<td><code>app/chat/page.tsx:104-176</code></td>
<td><code>handleSend</code> 70+ 줄</td>
<td><code>useChatStream()</code> 커스텀 훅 분리</td>
</tr>
<tr>
<td>C-5</td>
<td><code>app/dashboard/page.tsx:43-364</code></td>
<td>단일 컴포넌트 364줄, state 10+ 개</td>
<td><code>useAuth</code>, <code>useSubscription</code> 훅 분리</td>
</tr>
<tr>
<td>C-6</td>
<td><code>engine/rate-limit/index.ts</code></td>
<td>스캐폴드만 존재, 미구현</td>
<td><code>/api/chat</code> 엔드포인트에 rate limit 구현</td>
</tr>
</tbody></table>
<hr>
<h2>전체 이탈 포인트 지도</h2>
<h3>Flow 1: 신규 유저</h3>
<pre><code>랜딩 진입
├─ ❌ 이탈 1 (미해결): 서비스 설명 없음 — placeholder 타임라인
│
└─ 로그인 페이지
├─ ✅ 수정됨: 외부 URL next 파라미터 URL 바 노출 → 즉시 제거
├─ ❌ 이탈 2 (미해결): 환경변수 누락 경고 표시되어도 버튼 disabled 아님
│ → signInWithGoogle 호출 → Supabase 오류 → 빈 화면
│
└─ OAuth
├─ ❌ 이탈 3 (B-5): 에러 코드 그대로 노출 ("access_denied")
└─ 콜백 → 온보딩
└─ ❌ 이탈 4 (B-4): Analytics 없음, 이탈률 측정 불가
</code></pre>
<h3>Flow 2: 결제 유저</h3>
<pre><code>요금제 페이지
├─ ❌ 이탈 5 (미해결): 하드코딩 가격 $24/$48/$76 — 면책 조항 작은 글씨
├─ ❌ 이탈 6 (미해결): POLAR_PRODUCT_ID 미설정 시 결제 클릭 → 500 오류
│
└─ checkout/success
└─ ❌ 이탈 7 (B-1): 폴링 중 대시보드 링크 활성화
→ 구독 미반영 상태로 이동 → 혼란
</code></pre>
<h3>Flow 3: 재방문 유저</h3>
<pre><code>재방문
├─ ❌ 이탈 8 (미해결): useEffect 2개 독립 실행 (getCurrentUser + health check)
├─ ❌ 이탈 9 (미해결): /api/subscription 실패 시 error state 있으나 재시도 버튼 없음
│
└─ 채팅
├─ ❌ 이탈 10 (B-2): 사용량 한도 초과 → URL 텍스트만, 버튼 없음
└─ ❌ 이탈 11 (미해결): 대화 목록 API 실패 시 에러 메시지 없음 (빈 사이드바)
</code></pre>
<hr>
<h2>수정 우선순위 로드맵 (업데이트)</h2>
<pre><code>✅ 완료 (1차 — Top 5):
1. conversation/message 소유권 검증 [CRITICAL]
2. 웹훅 lazy 초기화 [CRITICAL]
3. 랜딩 로그인 CTA aria-label [High]
4. next 파라미터 sanitize (로그인 진입) [Medium]
5. proxy.ts SSR 보호 확인 [High]
✅ 완료 (2차 — 추가 개선):
A-1. polar-subscription-sync 타입 안전성 [High] new Date() 래핑
A-2. subscriptions 이중 DB 조회 제거 [Medium] preResolvedLimit 전달
A-3. health API 운영 환경 정보 노출 차단 [Medium] NODE_ENV !== "production" 조건
A-4. 기본 메타데이터 교체 [Medium] "SaaS Engine" 타이틀·설명
B-1. checkout/success 폴링 중 링크 비활성화 [High] phase === "polling" 시 비활성
B-2. 채팅 한도 초과 → 업그레이드 버튼 [High] error sentinel + 인디고 버튼
B-3. withAuth() 공통 유틸 추출 [Medium] lib/api-auth.ts 생성
B-5. OAuth 에러 메시지 한국어화 [Medium] 에러 코드 → 한국어 맵핑
C-1. lib/supabase.ts 중복 파일 삭제 [Low]
C-3. 빈 스캐폴드 파일 6개 삭제 [Low]
🔜 미완료 (선택적):
B-4. Analytics 설치 (Vercel / PostHog) [High]
C-2. MessageRole "assistant" vs "model" 타입 정렬 [Low]
C-4. handleSend → useChatStream 훅 분리 [Low]
C-5. DashboardPage 컴포넌트 분리 [Low]
C-6. /api/chat rate-limit 구현 [Low]
</code></pre>
<hr>
<h2>테스트 실행 방법</h2>
<pre><code class="language-bash"># 개발 서버 실행 후:
pnpm test:e2e:new-user # 신규 유저 시나리오 (22 tests)
pnpm test:e2e:security # 보안 시나리오 (13 tests)
# 인증이 필요한 Flow 2·3 실행 (테스트 계정 필요):
# .env.local에 TEST_USER_EMAIL / TEST_USER_PASSWORD 설정 후
# (playwright.config.ts가 `.env.local` 자동 로드 — `source` 생략 가능)
pnpm exec playwright test tests/e2e/auth.setup.ts --project=setup
pnpm test:e2e
# HTML 리포트 보기
pnpm test:e2e:report
</code></pre>
<hr>
<p><em>이 리포트는 Playwright E2E 자동화 + 코드 직접 분석 기반으로 작성됐습니다.</em><br><em>단순 추측 없이 실제 코드 경로와 테스트 실행 결과만 반영했습니다.</em><br><em>업데이트 (2차): 2026-04-10 — 추가 개선사항 10개 구현 완료 (A-1<del>4, B-1</del>3, B-5, C-1, C-3).</em></p>