517 lines
13 KiB
Markdown
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. |