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

13 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

함수 타입 실행 주기 설명
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에서 클라이언트 재사용

구현

// 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;
}

자동 생성 플로우

  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 사용:

// 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("팀 코드 생성 실패");
}

예약 해제

// 새 코드 받기 시 이전 예약 해제
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 });
}

실패 시 반환

// 팀 생성 실패 시에도 예약 해제
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

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 사용이 필수인 경우:

  1. SEO가 중요한 페이지 - 검색엔진 크롤링 필요
  2. SNS 공유 미리보기 - 카카오톡/페이스북 링크 미리보기 지원
  3. 공개 콘텐츠 - 비로그인 사용자도 볼 수 있는 콘텐츠
  4. 초기 로딩 성능 - 데이터가 포함된 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                  │
└─────────────────────────────────────────┘

관련 문서


© 2024 BlueNovaLab. All rights reserved.