12 KiB
12 KiB
Development Guide
라온누리 프로젝트의 구현 세부사항 가이드입니다.
AI Delta 전송
diff-match-patch 사용 (src/app/api/analyze-text/route.ts)
동작 원리
- 5자 미만 변경은 누적 (previousText 유지)
- Delta 전송 기준: 5자 이상, 80% 미만, 200자 미만
- 정확한 diff 계산 (앞/중간/뒤 수정 모두 감지)
구현 예시
// 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()호출 (로그인 시)
동작 플로우
// 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
{
"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만
- 로직: 별도 파일
// functions/src/index.ts
import { cleanupExpiredReservations } from "./scheduledFunctions/cleanupExpiredReservations";
import { cleanupExpiredDrafts } from "./scheduledFunctions/cleanupExpiredDrafts";
export { cleanupExpiredReservations, cleanupExpiredDrafts };
배포된 Functions
| 함수 | 타입 | 실행 주기 | 설명 |
|---|---|---|---|
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에서 클라이언트 재사용
구현
// 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;
}
데이터 저장
// Inspiration 타입
interface Inspiration {
id: string;
title: string;
content: string;
imageUrl: string;
unsplashCredit?: {
name: string;
profileUrl: string;
};
createdAt: Timestamp;
}
자동 생성 플로우
- Vertex AI: 3개 영감 생성 (제목 + 내용)
- Unsplash API: 키워드로 이미지 검색
- Firebase Storage: 이미지 다운로드 및 저장
- Firestore:
inspirations컬렉션에 저장 - Rate Limit: API 호출 간격 1초
참조: functions/src/scheduledFunctions/generateDailyInspirations.ts
XSS 보안
백엔드 자동 세탁
**src/lib/server/writing.ts**에서 모든 HTML 자동 sanitize
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
// 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]/*
import { useRequireAuth } from "@/hooks/useRequireAuth";
export default function ProtectedPage() {
// 인증 필수 (비인증 시 자동 리다이렉트)
const isLoadingAuth = useRequireAuth();
if (isLoadingAuth) {
return null; // 또는 <LoadingSkeleton />
}
// 이후 코드는 user가 보장됨
return <Box>...</Box>;
}
데이터 로딩이 있는 경우 (리다이렉트 방지):
const [isLoadingData, setIsLoadingData] = useState(true);
// additionalLoading: 데이터 로딩 중에는 리다이렉트하지 않음
const isLoadingAuth = useRequireAuth(isLoadingData);
useEffect(() => {
fetchData().finally(() => setIsLoadingData(false));
}, []);
2. 부분 인증 필요 페이지 (Partially Protected)
적용 페이지: /write (접근 자유, 저장 시 인증)
const { isAuthenticated, openLoginDialog } = useAuthStore();
function handleProtectedAction() {
if (!isAuthenticated) {
openLoginDialog();
return;
}
// 인증 필요 로직
}
참조: src/hooks/useRequireAuth.ts
Server Component vs Client Component
언제 Server Component를 사용해야 하는가?
✅ Server Component 사용이 필수인 경우:
- SEO가 중요한 페이지 - 검색엔진 크롤링 필요
- SNS 공유 미리보기 - 카카오톡/페이스북 링크 미리보기 지원
- 공개 콘텐츠 - 비로그인 사용자도 볼 수 있는 콘텐츠
- 초기 로딩 성능 - 데이터가 포함된 HTML을 서버에서 생성
예시: 글 상세보기 페이지 (/writing/[writingId])
- 학생들이 카카오톡으로 글 공유 시 미리보기 필요
- 검색엔진에서 찾을 수 있어야 함
- 서버에서 권한 체크 (클라이언트 깜빡임 없음)
Server Component 구현 패턴
// ✅ 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 (공통 뒤로가기 버튼)
// 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>
);
}
사용 예시:
// 기본 사용 (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 - 개발 가이드
- API_SPEC.md - API 명세서
- SECURITY.md - 보안 정책
- TECH_STACK.md - 기술 스택
© 2024 BlueNovaLab. All rights reserved.