리소스 허브로

기술 문서 (프로토타입 버전)

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:&quot;&quot;})</code> 제거 → 요청마다 lazy 초기화</td>
<td>🔴 Critical</td>
</tr>
<tr>
<td>4</td>
<td><code>app/page.tsx</code></td>
<td>로그인 CTA <code>aria-label=&quot;로그인 페이지로 이동&quot;</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, &quot;gemini_chat&quot;, 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, &quot;gemini_chat&quot;, 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">{
  &quot;auth&quot;: {
    &quot;ready&quot;: false,
    &quot;missingEnvKeys&quot;: [&quot;NEXT_PUBLIC_SUPABASE_URL&quot;, &quot;NEXT_PUBLIC_SUPABASE_ANON_KEY&quot;]
  }
}
</code></pre>
<p>공격자가 인프라 구성 파악에 활용 가능.<br><strong>수정</strong>: <code>NODE_ENV !== &quot;production&quot;</code> 조건 추가:</p>
<pre><code class="language-typescript">auth: {
  ready: missingEnvKeys.length === 0 &amp;&amp; missingAuthKey.length === 0,
  ...(process.env.NODE_ENV !== &quot;production&quot; &amp;&amp; {
    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: &quot;Create Next App&quot;,          // ← placeholder
  description: &quot;Generated by create next app&quot;,  // ← placeholder
};
</code></pre>
<p><strong>영향</strong>: Google 검색 결과, 브라우저 탭, SNS 공유 시 &quot;Create Next App&quot; 노출 → 신뢰도 0<br><strong>수정</strong>: 서비스명·설명으로 교체:</p>
<pre><code class="language-typescript">export const metadata: Metadata = {
  title: &quot;SaaS Engine — AI Chat Platform&quot;,
  description: &quot;Supabase 인증 + Polar 구독 + Gemini AI 채팅 통합 SaaS 플랫폼&quot;,
};
</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 === &quot;polling&quot;</code> 상태에서도 &quot;대시보드로&quot; 링크가 완전히 활성화 → 구독 미반영 상태로 이동 → 사용자 혼란</p>
<pre><code class="language-typescript">// 현재: 항상 활성화
&lt;Link href=&quot;/dashboard&quot;&gt;대시보드로&lt;/Link&gt;

// 수정: 폴링 중에는 시각적 비활성화 + 안내
&lt;Link
  href=&quot;/dashboard&quot;
  className={phase === &quot;polling&quot; ? &quot;pointer-events-none opacity-40&quot; : &quot;&quot;}
  aria-disabled={phase === &quot;polling&quot;}
&gt;
  대시보드로 {phase === &quot;polling&quot; ? &quot;(구독 확인 중…)&quot; : &quot;&quot;}
&lt;/Link&gt;
</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&lt;string | null&gt;(null);

// 429 처리
if (res.status === 429) {
  const data = await res.json();
  setError(&quot;일일 사용량 한도를 초과했습니다.&quot;);
  setUpgradeUrl(data.upgrade_url ?? UPGRADE_URL);
  return;
}

// UI
{upgradeUrl &amp;&amp; (
  &lt;Link href={upgradeUrl} className=&quot;mt-2 inline-block rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500&quot;&gt;
    플랜 업그레이드
  &lt;/Link&gt;
)}
</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: &quot;Unauthorized&quot; }, { 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: &quot;Unauthorized&quot; }, { 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 &quot;@vercel/analytics/react&quot;;

// &lt;body&gt; 안에 추가
&lt;Analytics /&gt;
</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}` // &quot;access_denied&quot;, &quot;invalid_grant&quot; 등
</code></pre>
<p><strong>수정</strong>: 사용자 친화적 메시지 맵핑:</p>
<pre><code class="language-typescript">const OAUTH_ERROR_MESSAGES: Record&lt;string, string&gt; = {
  access_denied: &quot;로그인을 취소했습니다. 다시 시도해 주세요.&quot;,
  invalid_grant: &quot;인증 세션이 만료됐습니다. 다시 로그인해 주세요.&quot;,
  oauth_no_session: &quot;로그인 처리에 실패했습니다. 브라우저 쿠키 설정을 확인해 주세요.&quot;,
};

const userMessage = OAUTH_ERROR_MESSAGES[oauthErrorCode ?? &quot;&quot;] 
  ?? `로그인에 실패했습니다. (${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 = &quot;assistant&quot;</code> vs Gemini API <code>&quot;model&quot;</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): 에러 코드 그대로 노출 (&quot;access_denied&quot;)
              └─ 콜백 → 온보딩
                    └─ ❌ 이탈 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 !== &quot;production&quot; 조건
  A-4. 기본 메타데이터 교체                    [Medium] &quot;SaaS Engine&quot; 타이틀·설명
  B-1. checkout/success 폴링 중 링크 비활성화  [High]   phase === &quot;polling&quot; 시 비활성
  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 &quot;assistant&quot; vs &quot;model&quot; 타입 정렬  [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>