RaonNuri_Public_Documents/DEVELOPMENT_GUIDE.md
2025-12-02 06:27:21 +00:00

517 lines
13 KiB
Markdown

# Development Guide
라온누리 프로젝트의 구현 세부사항 가이드입니다.
---
## AI Delta 전송
**diff-match-patch** 사용 (`src/app/api/analyze-text/route.ts`)
### 동작 원리
- 5자 미만 변경은 누적 (previousText 유지)
- Delta 전송 기준: 5자 이상, 80% 미만, 200자 미만
- 정확한 diff 계산 (앞/중간/뒤 수정 모두 감지)
### 구현 예시
```typescript
// src/app/api/analyze-text/route.ts
import * as dmp from "diff-match-patch";
const differ = new dmp.diff_match_patch();
const diffs = differ.diff_main(previousText, currentText);
differ.diff_cleanupSemantic(diffs);
// 변경 비율 계산
const changeRatio = calculateChangeRatio(diffs, previousText.length);
// Delta 전송 판단
if (changeSize >= 5 && changeRatio < 0.8 && changeSize < 200) {
// Delta 전송
return analyzeText(currentText, previousText);
} else {
// 전체 전송 또는 스킵
}
```
### 테스트 케이스
- ✅ 뒷부분 추가
- ✅ 중간 수정
- ✅ 앞부분 추가
- ✅ 삭제
- ✅ 대량 변경
- ✅ 누적 변경
**참조**: `src/app/api/analyze-text/route.ts:46-120`
---
## Draft 저장
**localStorage + Realtime DB 하이브리드**
### 구조
- **DraftManager**: `syncToCloud()`, `mergeDrafts()` 사용
- **Draft.syncStatus**: 'local' | 'synced' | 'syncing'
- **페이지 로드 시**: `mergeDrafts()` 호출 (로그인 시)
### 동작 플로우
```typescript
// 1. 로컬 저장 (즉시)
draftManager.saveDraft(draft); // localStorage
// 2. 클라우드 동기화 (2초 debounce)
draftManager.syncToCloud(draftId); // Realtime DB
// 3. 기기 간 동기화 (페이지 로드 시)
const mergedDrafts = await draftManager.mergeDrafts();
// updatedAt 비교로 최신 버전 선택
```
### Realtime DB 구조
```
drafts/
{userId}/
{draftId}/
title: string
content: string
topicId?: string
updatedAt: number
syncStatus: 'synced'
```
### Security Rules
```json
{
"rules": {
"drafts": {
"$userId": {
".read": "auth != null && auth.uid == $userId",
".write": "auth != null && auth.uid == $userId"
}
}
}
}
```
**참조**: `src/managers/DraftManager.ts`
---
## Firebase Cloud Functions
### 파일 분리 구조
- **index.ts**: export만
- **로직**: 별도 파일
```typescript
// functions/src/index.ts
import { cleanupExpiredReservations } from "./scheduledFunctions/cleanupExpiredReservations";
import { cleanupExpiredDrafts } from "./scheduledFunctions/cleanupExpiredDrafts";
export { cleanupExpiredReservations, cleanupExpiredDrafts };
```
### 배포된 Functions
| 함수 | 타입 | 실행 주기 | 설명 |
|------|------|----------|------|
| `cleanupExpiredReservations` | Scheduled | 매 시간 | 팀 코드 예약 정리 |
| `cleanupExpiredDrafts` | Scheduled | 매일 새벽 3시 | 180일+ drafts 정리 |
| `generateDailyInspirations` | Scheduled | 매일 새벽 | AI 영감 자동 생성 |
| `generateInspirationsManual` | HTTP | 수동 호출 | 관리자 수동 영감 생성 |
| `onTeamDeleted` | Firestore Trigger | 팀 삭제 시 | Cascade 삭제 (writings 제외) |
| `onWritingCreated` | Firestore Trigger | 글 생성 시 | 추후 자동 분석 |
### 공통 규칙
- **한글 로그**: 모든 logger 메시지 한글 작성
- **리전**: asia-northeast1 (도쿄)
- **배포**: `cd functions && npm run build && firebase deploy --only functions`
**참조**: `functions/src/`
---
## AI 영감 생성 시스템
### 기술 스택
- **Vertex AI**: Gemini 2.5 Flash 모델 (`@google/genai`)
- **Unsplash API**: 이미지 검색 및 다운로드
- **싱글톤 패턴**: `vertexAI.ts`에서 클라이언트 재사용
### 구현
```typescript
// functions/src/services/vertexAI.ts
import { GoogleGenerativeAI } from "@google/genai";
let vertexAI: GoogleGenerativeAI | null = null;
export function getVertexAI(): GoogleGenerativeAI {
if (!vertexAI) {
vertexAI = new GoogleGenerativeAI({
apiKey: process.env.GOOGLE_API_KEY,
});
}
return vertexAI;
}
```
### 데이터 저장
```typescript
// Inspiration 타입
interface Inspiration {
id: string;
title: string;
content: string;
imageUrl: string;
unsplashCredit?: {
name: string;
profileUrl: string;
};
createdAt: Timestamp;
}
```
### 자동 생성 플로우
1. **Vertex AI**: 3개 영감 생성 (제목 + 내용)
2. **Unsplash API**: 키워드로 이미지 검색
3. **Firebase Storage**: 이미지 다운로드 및 저장
4. **Firestore**: `inspirations` 컬렉션에 저장
5. **Rate Limit**: API 호출 간격 1초
**참조**: `functions/src/scheduledFunctions/generateDailyInspirations.ts`
---
## 팀 코드 예약 시스템
### Race Condition 방지
**Realtime DB Transaction** 사용:
```typescript
// src/lib/server/teamCodeReservation.ts
export async function generateAndReserveTeamCode(
userId: string,
locale: string = "ko"
): Promise<string> {
const maxAttempts = 10;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const code = generateTeamCode(locale);
const codeRef = realtimeDb.ref(`teamCodeReservations/${code}`);
// Atomic 예약
const result = await codeRef.transaction((current) => {
if (current !== null) return; // 이미 예약됨
return {
userId,
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000, // 5분 TTL
locale,
};
});
if (result.committed) {
return code; // 성공
}
}
throw new Error("팀 코드 생성 실패");
}
```
### 예약 해제
```typescript
// 새 코드 받기 시 이전 예약 해제
export async function POST(request: NextRequest) {
const { previousCode } = await request.json();
if (previousCode) {
await releaseTeamCodeReservation(previousCode);
}
const newCode = await generateAndReserveTeamCode(userId);
return successResponse({ code: newCode });
}
```
### 실패 시 반환
```typescript
// 팀 생성 실패 시에도 예약 해제
try {
const team = await createTeam({ code, ... });
await releaseTeamCodeReservation(code);
return successResponse({ team });
} catch (error) {
await releaseTeamCodeReservation(code); // catch 블록에서도 해제
throw error;
}
```
**참조**: `src/lib/server/teamCodeReservation.ts`
---
## XSS 보안
### 백엔드 자동 세탁
**`src/lib/server/writing.ts`**에서 모든 HTML 자동 sanitize
```typescript
import { sanitizeHtml } from "@/utils/sanitizeHtml";
// 글 생성 시
const sanitizedTitle = sanitizeHtml(data.title);
const sanitizedContent = sanitizeHtml(data.content);
// 글 수정 시
if (data.title) {
updateData.title = sanitizeHtml(data.title);
}
if (data.content) {
updateData.content = sanitizeHtml(data.content);
}
```
### sanitizeHtml 함수
**라이브러리**: `sanitize-html`
```typescript
// src/utils/sanitizeHtml.ts
import sanitizeHtmlLib from "sanitize-html";
export function sanitizeHtml(dirty: string): string {
return sanitizeHtmlLib(dirty, {
allowedTags: [
"p", "strong", "em", "h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "a", "img", "blockquote", "code", "pre",
"br", "hr", "span", "div"
],
allowedAttributes: {
a: ["href", "target", "rel"],
img: ["src", "alt", "width", "height"],
},
allowedSchemes: ["http", "https", "mailto"],
});
}
```
**특징**:
- ✅ 안전한 HTML 보존
-`<script>`, `<iframe>`, `onclick` 등 차단
-`javascript:`, 위험한 `data:` URI 차단
- 프론트엔드: 별도 처리 불필요 (서버에서 자동)
**참조**: `SECURITY.md`, `src/utils/sanitizeHtml.ts`
---
## 페이지 인증 규칙
### 1. 완전 인증 필수 페이지 (Protected Pages)
**적용 페이지**: `/home`, `/team`, `/team/create`, `/team/[teamId]/*`
```typescript
import { useRequireAuth } from "@/hooks/useRequireAuth";
export default function ProtectedPage() {
// 인증 필수 (비인증 시 자동 리다이렉트)
const isLoadingAuth = useRequireAuth();
if (isLoadingAuth) {
return null; // 또는 <LoadingSkeleton />
}
// 이후 코드는 user가 보장됨
return <Box>...</Box>;
}
```
**데이터 로딩이 있는 경우** (리다이렉트 방지):
```typescript
const [isLoadingData, setIsLoadingData] = useState(true);
// additionalLoading: 데이터 로딩 중에는 리다이렉트하지 않음
const isLoadingAuth = useRequireAuth(isLoadingData);
useEffect(() => {
fetchData().finally(() => setIsLoadingData(false));
}, []);
```
### 2. 부분 인증 필요 페이지 (Partially Protected)
**적용 페이지**: `/write` (접근 자유, 저장 시 인증)
```typescript
const { isAuthenticated, openLoginDialog } = useAuthStore();
function handleProtectedAction() {
if (!isAuthenticated) {
openLoginDialog();
return;
}
// 인증 필요 로직
}
```
**참조**: `src/hooks/useRequireAuth.ts`
---
## Server Component vs Client Component
### 언제 Server Component를 사용해야 하는가?
**✅ Server Component 사용이 필수인 경우:**
1. **SEO가 중요한 페이지** - 검색엔진 크롤링 필요
2. **SNS 공유 미리보기** - 카카오톡/페이스북 링크 미리보기 지원
3. **공개 콘텐츠** - 비로그인 사용자도 볼 수 있는 콘텐츠
4. **초기 로딩 성능** - 데이터가 포함된 HTML을 서버에서 생성
**예시: 글 상세보기 페이지** (`/writing/[writingId]`)
- 학생들이 카카오톡으로 글 공유 시 미리보기 필요
- 검색엔진에서 찾을 수 있어야 함
- 서버에서 권한 체크 (클라이언트 깜빡임 없음)
### Server Component 구현 패턴
```typescript
// ✅ Server Component (SEO/SNS 공유 최적화)
import {getWriting} from "@/lib/server/writing";
import {getTranslations} from "next-intl/server";
import {BackButton} from "@/components/layout/BackButton";
export default async function WritingDetailPage({
params,
}: {
params: Promise<{locale: string; writingId: string}>;
}) {
const {writingId} = await params;
const t = await getTranslations('interaction');
// 서버에서 데이터 fetch (Firebase Admin SDK)
const writing = await getWriting(writingId);
if (!writing) {
return <ErrorPage />;
}
return (
<Container>
<BackButton /> {/* Client Component */}
<Heading>{writing.title}</Heading>
<div dangerouslySetInnerHTML={{__html: writing.content}} />
<CommentList writingId={writingId} /> {/* Client Component */}
</Container>
);
}
```
### 공통 컴포넌트 패턴
#### BackButton (공통 뒤로가기 버튼)
```typescript
// src/components/layout/BackButton.tsx
"use client";
import {Button, ButtonProps} from "@chakra-ui/react";
import {LuArrowLeft} from "react-icons/lu";
import {useRouter} from "@/i18n/routing";
import {useTranslations} from "next-intl";
export function BackButton({href, label, ...props}: BackButtonProps) {
const router = useRouter();
const t = useTranslations('interaction');
return (
<Button variant="ghost" onClick={() => href ? router.push(href) : router.back()}>
<LuArrowLeft />
{label || t('back')}
</Button>
);
}
```
**사용 예시:**
```typescript
// 기본 사용 (router.back())
<BackButton />
// 특정 경로로 이동
<BackButton href="/home" />
// 커스텀 라벨
<BackButton label="목록으로" />
```
**참조**: `src/components/layout/BackButton.tsx`
---
## Server vs Client 레이어 분리
```
┌─────────────────────────────────────────┐
│ Server Components │
│ - Direct DB access (Firebase Admin) │
│ - No client state/hooks │
│ - async/await functions │
│ - SEO-friendly │
└─────────────────────────────────────────┘
↓ uses
┌─────────────────────────────────────────┐
│ src/lib/server/* (Server Functions) │
│ - getWriting(id) │
│ - getTopic(id) │
│ - getTeam(id) │
│ - Firebase Admin SDK │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Client Components ("use client") │
│ - Interactive UI (buttons, forms) │
│ - React hooks (useState, useEffect) │
│ - Client-side routing │
└─────────────────────────────────────────┘
↓ uses
┌─────────────────────────────────────────┐
│ src/managers/* (Client Managers) │
│ - writingManager.getWriting() │
│ - API Routes 호출 (fetch) │
│ - Firebase Client SDK │
└─────────────────────────────────────────┘
```
---
## 관련 문서
- [CLAUDE.md](./CLAUDE.md) - 개발 가이드
- [API_SPEC.md](./API_SPEC.md) - API 명세서
- [SECURITY.md](./SECURITY.md) - 보안 정책
- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택
---
© 2024 BlueNovaLab. All rights reserved.