2025-12-24 08:05:05 +00:00

2815 lines
90 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 라온누리 - 기술 스택 및 개발 환경
> 최종 업데이트: 2025-12-23 (이미지 4:3 비율 표준화)
---
## 기술 스택
### Core Framework
| 기술 | 버전 | 용도 |
|-----|------|------|
| **Next.js** | 16.0.0 | React 프레임워크 (App Router) |
| **React** | 19.2.0 | UI 라이브러리 |
| **TypeScript** | 5.x | 타입 안전성 |
### UI & Styling
| 기술 | 버전 | 용도 |
|-----|------|------|
| **Chakra UI** | v3.30.0 | 컴포넌트 라이브러리 |
| **@chakra-ui/charts** | v3.29.0 | 🆕 **차트 컴포넌트** (Sparkline, Area/Bar/Line 차트) |
| **Recharts** | v3.4.1 | 🆕 **차트 라이브러리** (Chakra Charts 내부 사용) |
| **Emotion** | 11.14.0 | CSS-in-JS |
| **Framer Motion** | 12.23.24 | 애니메이션 라이브러리 |
| **React Icons** | 5.5.0 | 아이콘 세트 |
| **Tiptap** | v3.9.1 | 리치 텍스트 에디터 |
| **next-intl** | v4.5.2 | 🆕 **다국어 지원 (i18n)** |
### Image Processing
| 기술 | 버전 | 용도 |
|-----|------|------|
| **react-cropper** | ^2.3.3 | 🆕 **이미지 크롭 React 래퍼** (4:3 비율 크롭) |
| **cropperjs** | ^1.6.1 | 🆕 **이미지 크롭 라이브러리** (회전, 확대/축소, 리셋) |
### Backend & Database
| 기술 | 버전 | 용도 |
|-----|------|------|
| **Firebase** | 12.4.0 | BaaS (Backend as a Service) |
| **Firebase Auth** | - | 사용자 인증 |
| **Firestore** | - | NoSQL 데이터베이스 (글 저장) |
| **Firebase Realtime Database** | - | 🆕 **실시간 데이터 동기화** (글쓰기 모니터링) |
| **Firebase Storage** | - | 🆕 **파일 저장소** (팀 커버 이미지, AI 생성 이미지) |
| **@google/genai** | 1.29.0 | Google Gemini API SDK (텍스트 분석, 맞춤법 검사) |
| **Redis** | - | Cache 데이터 베이스 (예정) |
**AI 서비스**:
- **Gemini 2.5 Flash-Lite**: 텍스트 분석 (오감/감정/대화/의성어 평가, Delta 전송)
- **Gemini 2.5 Flash-Lite**: 맞춤법 검사 (초등학생 눈높이)
- **Gemini 2.5 Flash-Lite**: 글 작성 패턴 분석 (최근 10개 글 종합 분석, AI 평가 및 맞춤형 추천)
- **Vertex AI Imagen 4.0 Fast**: 이미지 생성 (글 장면 시각화, 일관된 애니메이션 스타일)
- **Vertex AI 모드**: Multi-region failover 지원 (`vertexai: true`)
- **Response Schema**: JSON 응답 강제 (`Type.OBJECT`, `Type.ARRAY` 등)
### Utilities
| 기술 | 버전 | 용도 |
|-----|------|------|
| **use-debounce** | v10.0.6 | React debounce hook (5초 API 호출 제한) |
### Charts
| 기술 | 버전 | 용도 |
|-----|------|------|
| **@chakra-ui/charts** | v3.29.0 | 🆕 **Chakra UI 차트 컴포넌트** (실시간 모니터링 그래프) |
| **recharts** | v3.4.1 | 🆕 **차트 라이브러리** (Area, Line, Bar 차트) |
### State Management
| 기술 | 버전 | 용도 |
|-----|------|------|
| **Zustand** | 5.0.8 | 전역 상태 관리 |
### Development Tools
| 기술 | 버전 | 용도 |
|-----|------|------|
| **ESLint** | 9.x | 코드 린팅 |
| **babel-plugin-react-compiler** | 1.0.0 | React 컴파일러 최적화 |
---
## 개발 명령어
### 주요 명령어
```bash
# 개발 서버 시작 (포트 3001)
npm run dev
# 프로덕션 빌드
npm run build
# 프로덕션 서버 시작 (포트 3001)
npm start
# ESLint 실행
npm run lint
```
### 중요 사항
- **포트**: 개발/프로덕션 서버 모두 포트 `3001` 사용 (기본 3000이 아님)
- **Webpack 모드**: `--webpack` 플래그 사용 (React Compiler 요구사항)
- **Turbopack 미사용**: React Compiler와 호환성을 위해 webpack 모드 사용
---
## 프로젝트 설정
### Next.js 설정 (`next.config.ts`)
```typescript
// React Compiler 활성화
const nextConfig = {
reactCompiler: true,
// ... 기타 설정
};
```
### TypeScript 설정
- **Path Alias**: `@/*``./src/*`
- 모든 import는 `@/` 경로 사용
```typescript
// 예시
import { useAuthStore } from "@/store/authStore";
import { Navbar } from "@/components/navigation/Navbar";
```
### ESLint 설정
- Next.js 공식 ESLint 설정 사용
- React 19 및 Next.js 16 규칙 적용
---
## Firebase 설정
### Firebase Config
Firebase 설정은 `src/config/firebase.ts` 파일에 정의되어 있습니다.
**보안 참고**:
- Public API Key는 클라이언트 SDK 표준 방식 (Firebase 프로젝트 설정에서 도메인 제한)
- 환경변수 대신 코드에 포함 (일반적인 Firebase 클라이언트 앱 패턴)
### 환경 변수
```bash
# 사이트 URL (프로덕션)
NEXT_PUBLIC_URL=https://raonnuri.life
# API Base URL (선택적, 기본값: /api)
NEXT_PUBLIC_API_URL=/api
```
---
## Firebase 설정
### 인증 제공자
| 제공자 | 상태 | 설정 위치 | 용도 |
|-------|------|----------|------|
| **이메일/비밀번호** | ✅ 활성화 | Firebase Console > Authentication | 정식 계정 (학부모/고학년) |
| **Google OAuth** | ✅ 활성화 | Firebase Console > Authentication | 정식 계정 (소셜 로그인) |
| **Anonymous** | ✅ 활성화 | Firebase Console > Authentication | **학생 팀 코드 로그인** |
| **네이버** | 🔜 준비 중 | - | 정식 계정 (소셜 로그인) |
| **카카오** | 🔜 준비 중 | - | 정식 계정 (소셜 로그인) |
### Firestore 데이터베이스
상세한 데이터베이스 스키마와 모델 정의는 [DATA_MODELS.md](./DATA_MODELS.md) 문서를 참조하세요.
---
## Chakra UI v3 커스텀 테마
- **파일**: `src/theme/system.ts`
- **브랜드 컬러**: 핑크(#FF6B9D), 오렌지(#FFA07A), 청록(#4ECDC4)
- **다크모드**: 시맨틱 토큰으로 자동 전환
- **반응형 타이포그래피**: hero, heading, body 등 텍스트 스타일 정의
- **🆕 슬롯 레시피** (2025-11-10):
- `menu`: 커스텀 메뉴 스타일 (애니메이션, hover 효과)
- `dialog`: Dialog 자동 배경색, border, shadow
- `select`: Select 드롭다운 자동 배경색, hover 효과
- **시맨틱 토큰**:
- `bg`, `fg`, `border`: 전역 배경/전경/테두리 색상
- `brand.*`: 브랜드 컬러 시맨틱 토큰
- `navbar.*`, `menu.*`: 컴포넌트별 시맨틱 토큰
- `landing.*`: 랜딩 페이지 전용 토큰
---
## 아키텍처 패턴
### 1. Manager 패턴 + API 아키텍처 (3계층 구조)
```
UI Layer (Components/Pages)
↓ 매니저 호출
Manager Layer (비즈니스 로직 + 클라이언트 캐싱)
├─> TeamManager (싱글톤)
│ ├─> createTeam() → POST /team
│ ├─> getTeam() → GET /team/:id (5분 캐싱)
│ ├─> getMyTeams() → GET /team/list (소유+참여 팀, 1분 캐싱)
│ ├─> updateTeam() → PUT /team/:id
│ ├─> deleteTeam() → DELETE /team/:id
│ └─> generateUniqueTeamCode() → POST /team/generate-code
├─> UserManager (싱글톤)
│ ├─> createUser() → POST /user
│ ├─> getUser() → GET /user/:id (Firebase Auth + Firestore 자동 결합, 5분 캐싱)
│ ├─> getUsersByTeam() → GET /user/by-team/:teamId (30초 캐싱)
│ ├─> updateLastLogin() → POST /user/:uid/update-last-login
│ ├─> findUserByNickname() → POST /user/find-by-nickname (Level 1용)
│ └─> setUserNickname() → POST /user/:uid/nickname (DEPRECATED - 팀에서 관리)
├─> WritingManager (싱글톤)
│ ├─> createWriting()
│ ├─> getWriting()
│ └─> getUserWritings()
└─> TopicManager (싱글톤)
├─> getAvailableTopics()
└─> createPersonalTopic()
↓ HTTP API 호출
API Layer (Next.js API Routes / Server Actions) - 구현 대기
├─> /api/team/* (팀 관련 엔드포인트)
├─> /api/student/* (학생 관련 엔드포인트)
└─> ID Token 검증, 권한 체크, Firestore 접근
Database Layer
├─> Firestore (영구 데이터)
└─> Redis (캐싱, Rate Limiting) - 예정
```
**Manager 패턴의 장점**:
-**UI와 비즈니스 로직 완전 분리**
- ✅ **싱글톤 패턴**으로 전역 인스턴스 관리
-**클라이언트 사이드 캐싱**: GET 요청 자동 캐싱 (TTL 기반)
-**캐시 무효화**: 변경 작업 시 관련 캐시 자동 삭제
-**API 추상화**: HTTP 호출 로직을 BaseManager에서 처리
-**타입 안전성**: Request/Response 타입 완전 정의
-**테스트 용이성**: API 모킹으로 단위 테스트 가능
-**유연성**: Firestore 직접 접근 → API 호출로 전환 완료
**BaseManager 기능**:
```typescript
// src/managers/ManagerBase.ts
abstract class BaseManager {
// 인증
protected async getIdToken(): Promise<string | null>
protected getCurrentUserId(): string | null
protected isAuthenticated(): boolean
// API 호출
protected async authenticatedFetch<T>(endpoint, options)
protected async ApiCall<Req, Res>(method, endpoint, data)
}
abstract class SingletonManager extends BaseManager {
// 캐싱
protected getCached<T>(key, ttl?): T | null
protected setCached<T>(key, data): void
protected invalidateCache(key): void
protected invalidateCachePattern(pattern): void
protected clearCache(): void
// API + 캐싱 통합
protected async callApiWithCache<Req, Res>(cacheKey, method, endpoint, data, ttl)
}
```
**사용 예시**:
```typescript
// team/page.tsx
import { teamManager } from "@/managers";
// 팀 목록 조회 - 소유한 팀 + 참여한 팀 (1분간 캐싱됨)
const teams = await teamManager.getMyTeams();
// 🆕 팀 생성 (5단계 보안 레벨)
const teamId = await teamManager.createTeam({
name: "우리반",
code: "춤추는파란사자",
securityLevel: 2, // 1-5 (명단 기반)
allowedNames: ["홍길동", "김철수"]
});
// 🆕 보안 레벨 변경
await teamManager.updateSecurityLevel(teamId, 4, true); // Level 4, 자동 명단 생성
// 🆕 닉네임 조회
const nickname = teamManager.getMemberNickname(team, uid, user?.name);
```
**참고 문서**:
- [API_SPEC.md](./API_SPEC.md) - 전체 API 명세서
### 2. 인증 플로우 및 라우팅 (currentStudent 중심 아키텍처)
```
1. AuthInitializer (클라이언트)
└─> initializeAuth() 호출 (마운트 시)
└─> onAuthStateChanged 리스너 등록
├─> firebaseUser.isAnonymous ?
│ └─> getStudentByFirebaseUid() → authStore.currentStudent 설정
└─> else ?
└─> getStudentsByUserId() → authStore.ownedStudents 설정
2. authStore (Zustand) - 재설계됨
├─> **currentStudent** (Student | null) - 현재 활동 중인 학생 (필수)
├─> user (User | null) - 정식 계정 (선택적)
├─> ownedStudents (Student[]) - 정식 계정이 소유한 학생들
├─> isAnonymous (boolean) - Anonymous Auth 여부
├─> isAuthenticated - 정식 계정 로그인 여부
├─> isLoading
└─> 액션:
├─> login/signup/loginWithGoogle (기존)
├─> **loginAsUser(teamCode, name)** - 팀 코드 로그인 (익명 계정 생성)
├─> **linkWithEmail(email, password)** - 신규 이메일 계정 생성 (linkWithCredential)
├─> **linkWithGoogle()** - 신규 Google 계정 생성 (linkWithCredential)
├─> **mergeWithEmail(email, password)** - 기존 이메일 계정과 병합 (API 데이터 마이그레이션)
└─> **mergeWithGoogle()** - 기존 Google 계정과 병합 (API 데이터 마이그레이션)
3. 인증 기반 라우팅
├─> 랜딩 페이지 (/)
│ └─> 로그인 상태 확인
│ └─> isAuthenticated || currentStudent ? redirect(/home) : 랜딩 표시
└─> 유저 홈 (/home)
└─> 인증 상태 확인
└─> !currentStudent ? redirect(/) : 대시보드 표시
4. 보호된 페이지 패턴
└─> useAuthStore()로 currentStudent 확인
└─> 미인증 시 redirect(/) 또는 openLoginDialog()
5. 인증/데이터 로딩 훅
├─> useRequireAuth(additionalLoading?)
│ ├─> 인증 필수 페이지에서 사용
│ ├─> 미인증 시 자동 리다이렉트
│ └─> additionalLoading: 데이터 로딩 중 리다이렉트 방지
└─> useTeamData({teamId, requiredAccess, t})
├─> 팀 페이지 공통 데이터 로딩 (team + members)
├─> requiredAccess: "member" | "owner" 권한 체크
└─> 반환: team, members, isLoadingTeam, isLoadingMembers, isOwner, refreshMembers
```
### 3. 글쓰기 및 저장 로직 (Manager 패턴 적용)
```
🆕 Write 페이지 라우트 구조 (2025-12-04 분리)
/write → 모드 선택 화면
/write/text → 글 먼저 모드 (글쓰기 → 이미지)
/write/image → 그림 먼저 모드 (이미지 → 글쓰기)
/write/edit/[id] → 수정 모드
1. 사용자가 /write 페이지 접근
├─> 모드 선택 화면 표시 (글 먼저 / 그림 먼저)
├─> 기존 URL 호환성 리다이렉트:
│ ├─> /write?mode=wrt → /write/text
│ ├─> /write?mode=img → /write/image
│ └─> /write?id=xxx → /write/edit/xxx
└─> 사용자가 모드 선택 → 해당 라우트로 이동
1-1. 글쓰기 모드 진입 (/write/text 또는 /write/image)
├─> LocalStorage에서 임시 저장된 글 불러오기 (DraftManager)
└─> 에디터에 복원
2. 주제 선택
├─> 작성 중인 내용 없음: 바로 주제 변경 + 템플릿 적용
└─> 작성 중인 내용 있음:
├─> 🆕 **경고 Dialog 표시**
│ ├─> "제목과 내용이 모두 초기화됩니다"
│ └─> "임시 저장된 내용은 저장된 글조각에서 복구 가능"
├─> 사용자 선택:
│ ├─> "취소": 주제 변경 취소
│ └─> "확인하고 초기화": 주제 변경 + 내용 초기화
└─> 확인 시 템플릿 미리채우기 (제목/내용)
3. 글 작성 중
├─> 제목: Editable 컴포넌트 (인라인 편집)
├─> 본문: Tiptap 순수 텍스트 에디터 (포맷팅 비활성화)
│ └─> 초등학생을 위한 단순한 텍스트 입력에 집중
├─> 2초마다 LocalStorage에 자동 저장 (DraftManager, FIFO)
├─> 저장 상태 표시 (저장 중 → 저장됨 → 시간)
└─> 하단 고정 버튼 (취소, 저장)
4. 저장 버튼 클릭 (🆕 2025-11-28 플로우 개편)
├─> 미인증 시: 로그인 다이얼로그 표시
└─> 인증 시:
└─> writingManager.createWriting() 호출
├─> 유효성 검사 (제목, 내용)
├─> 텍스트 통계 계산 (글자 수, 단어 수)
├─> Firestore에 저장
└─> 🆕 **저장 성공 후**:
├─> LocalStorage draft 삭제
├─> writingManager.analyzeWritingBackground(writingId, locale)
│ └─> Fire-and-forget 패턴 (응답 무시, .catch(() => {}))
└─> router.push(`/imageUpload?writingId=${writingId}`)
5. 이미지 업로드/선택 플로우 (🆕 2025-11-28)
```
/imageUpload 페이지 (글 저장 후 자동 이동)
├─> 이미 이미지 있음?
│ └─> router.replace(`/interaction?writingId=${writingId}`)
└─> 이미지 선택 (2가지 옵션)
├─> AI 생성
│ ├─> 1. 장면 추출 (SceneExtraction API)
│ ├─> 2. 장면 선택 (SceneSelector 컴포넌트)
│ ├─> 3. 프롬프트 최적화 (PromptOptimization API)
│ ├─> 4. 이미지 생성 (Imagen 4.0 Fast)
│ └─> 5. router.push(`/interaction?writingId=${writingId}`)
└─> 직접 업로드
├─> 드래그앤드롭 또는 파일 선택
├─> 파일 검증 (JPEG/PNG/WebP, 5MB 제한)
├─> 🆕 **클라이언트 사이드 리사이즈** (Canvas API)
│ ├─> 1920x1080 최대 크기
│ ├─> 85% 품질 압축
│ └─> data URL 생성
├─> Firebase Storage 업로드
│ └─> writingManager.uploadUserImage(writingId, file)
└─> router.push(`/interaction?writingId=${writingId}`)
```
6. 인터랙션 편집 플로우 (🆕 2025-11-28 리다이렉트 추가)
```
/interaction 페이지
├─> 이미지 없음?
│ └─> router.replace(`/imageUpload?writingId=${writingId}`)
└─> 이미지 있음:
├─> 왜곡 영역 편집 (EditorCanvas)
├─> 모션/물리 설정 조정
├─> 에디터/인터랙션 모드 전환
└─> 저장 시 Writing.distortionAreas 업데이트
```
7. WritingManager API
├─> createWriting() - 새 글 작성
├─> getWriting() - 글 조회
├─> getUserWritings() - 사용자 글 목록
├─> getRecentWritings() - 최근 글 목록
├─> updateWriting() - 글 수정
├─> deleteWriting() - 글 삭제
├─> 🆕 **uploadUserImage(writingId, file)** - 사용자 이미지 업로드 (클라이언트 사이드)
└─> 🆕 **analyzeWritingBackground(writingId, locale)** - 백그라운드 분석 (fire-and-forget)
8. DraftManager (클라이언트 전용)
├─> saveDraft() - 글조각 저장 (최대 10개, FIFO)
├─> getDraft() - 글조각 조회
├─> getAllDrafts() - 전체 글조각 목록
├─> deleteDraft() - 글조각 삭제
├─> setCurrentDraftId() - 현재 편집 중인 draft 설정
└─> migrateLegacyDraft() - 기존 단일 draft 마이그레이션
```
### 4. 실시간 글쓰기 모니터링 아키텍처 (Firebase Realtime Database)
```
관리자 (팀 관리 페이지)
↓ 주제 선택
LiveWritingMonitor 컴포넌트
├─> 주제 드롭다운 (teamTopics)
└─> subscribeToTopic(teamId, topicId, callback)
↓ Firebase Realtime DB 구독
monitoring/{teamId}/{topicId}/{userId}
↑ 5초마다 업데이트
학생 (글쓰기 페이지)
├─> 팀 주제 선택 감지
└─> startMonitoring(teamId, topicId, getStats)
├─> 5초마다 통계 전송 (contentLength, wordCount)
├─> onDisconnect().remove() 설정
└─> 페이지 이탈 시 자동 정리
미리보기 요청-응답 플로우
관리자: requestPreview(userId)
↓ 요청 생성
previewRequests/{userId}/{requestId}
↓ 학생 리스너 감지
학생: listenForPreviewRequests(callback)
↓ 현재 글 내용 전송
previewResponses/{requestId}
↓ 관리자 구독
관리자: Promise 해결 → Dialog 표시
```
**주요 구성 요소**:
- **WritingSessionManager** (`src/managers/WritingSessionManager.ts`):
- `startMonitoring(teamId, topicId, getStatsCallback)`: 5초 주기 통계 전송
- `stopMonitoring()`: 전송 중지 + DB 삭제
- `subscribeToTopic(teamId, topicId, callback)`: 실시간 구독 (관리자)
- `requestPreview(userId)`: 미리보기 요청 (Promise 반환)
- `listenForPreviewRequests(onRequestCallback)`: 미리보기 리스너 (학생)
- 상세 디버그 로그 (전송/수신/에러 추적)
- **LiveWritingMonitor** (`src/components/team/LiveWritingMonitor.tsx`):
- 주제 선택 Select 컴포넌트 (Chakra UI Select)
- **모든 팀 멤버 표시** (getUsersByTeam)
- **3가지 상태 관리**:
- 🟢 작성 중 (isActive: true, lastUpdated < 30초) - 초록 배지, 핑크 테두리
- 🟠 나감 (isActive: false, 마지막 통계 유지) - 주황 배지, 주황 테두리
- 대기 ( 번도 작성 ) - 회색 배지, 투명도 60%
- **정렬 순서**: 작성 나감 대기
- StudentMonitorCard 컴포넌트 (개별 학생 카드)
- 유저 정보와 통계 자동 결합 (userManager 활용)
- **작성 속도 실시간 계산** (클라이언트 , 글자/)
- **Sparkline 그래프** (Area Chart, 최근 10개 히스토리)
- **인터랙티브 툴팁** (속도 + 데이터)
- 미리보기 Dialog (작성 중인 학생만)
- **30초 타임아웃**: 업데이트 없으면 "나감" 처리
- **마지막 통계 유지**: Firebase 삭제되어도 클라이언트 상태 유지
- 마지막 업데이트 시간 표시 ("N초 ")
**Realtime DB 구조**:
```json
{
"monitoring": {
"{teamId}": {
"{topicId}": {
"{userId}": {
"userId": "abc123",
"contentLength": 1500,
"wordCount": 300,
"topicId": "topic_123",
"lastUpdated": 1731400800000
}
}
}
},
"previewRequests": {
"{userId}": {
"{requestId}": {
"requestedBy": "admin_uid",
"timestamp": 1234567890,
"requestId": "req_xyz"
}
}
},
"previewResponses": {
"{requestId}": {
"content": "현재 작성 중인 글...",
"timestamp": 1234567890,
"requestId": "req_xyz"
}
}
}
```
**Security Rules** (`database.rules.json`):
```json
{
"rules": {
"monitoring": {
"$teamId": {
"$topicId": {
".read": "auth != null",
"$userId": {
".write": "auth != null && auth.uid == $userId"
}
}
}
},
"previewRequests": {
"$userId": {
".read": "auth != null && auth.uid == $userId",
".write": "auth != null"
}
},
"previewResponses": {
"$requestId": {
".read": "auth != null",
".write": "auth != null"
}
}
}
}
```
**권한 정책**:
- 통계 읽기: 인증된 모든 사용자 ( 소유자만 UI 접근 가능)
- 통계 쓰기: 본인만
- 미리보기: 요청자와 대상자만
**작성 속도 계산 로직** (클라이언트 ):
```typescript
// 5초마다 데이터 수신
const charDiff = 현재글자수 - 이전글자수;
const speed = charDiff * 12; // 5초 * 12 = 60초(1분)
// 히스토리 저장 (최근 10개)
speedHistory.push({ speed, timestamp: Date.now() });
if (speedHistory.length > 10) speedHistory.shift();
// Sparkline 그래프로 시각화
- Area Chart (면적 그래프)
- Teal 색상, 투명도 30%
- 툴팁: 마우스 오버 "N자/분" + "N초 전" 표시
- 0 표시 (작성 멈춤 시각화)
```
**비용 효율성**:
- Firebase Realtime DB Spark 플랫폼: 동시 접속 100명까지 **완전 무료**
- GB 다운로드 기반 과금 (쓰기/읽기 횟수 무관)
- 30명 × 1시간 수업 = ~1.5MB (무료 한도 1GB/day의 0.15%)
### 5. 상태 관리 원칙
- **전역 상태**: Zustand 사용 (인증, 사용자 진행 상황, 알림)
- **로컬 상태**: `useState` 사용 ( 입력, UI 토글, 에디터 내용)
- **로컬 저장소**: LocalStorage (임시 저장 )
- **서버 상태**: Firestore 직접 호출 (React Query는 나중에 고려)
### 6. 태그 입력 필드 패턴 (Tag Input Field)
CreateTopicDialog (개인/ 공용) 제목 템플릿 입력에 사용되는 고급 UI 패턴입니다.
**통합 Dialog 설계**:
- `TopicFormData` export: 입력 데이터만 수집하여 반환
- `onSubmit` 콜백 패턴: 부모 컴포넌트가 /개인 주제 생성 결정
- **관심사 분리**: Dialog는 UI만 담당, 비즈니스 로직은 부모에게 위임
**구현 방식**:
```typescript
// 상태 구조
type TemplatePart = {
id: string;
type: "text" | "placeholder";
value: string; // "{date}" 또는 일반 텍스트
label?: string; // "날짜" (placeholder인 경우)
};
const [templateParts, setTemplateParts] = useState<TemplatePart[]>([]);
const [currentInput, setCurrentInput] = useState("");
const [selectedPartIndex, setSelectedPartIndex] = useState<number | null>(null);
```
**주요 기능**:
- 자동 플레이스홀더 감지: `{date}`, `{time}` 입력 자동으로 태그 변환
- 키보드 네비게이션:
- `←` (왼쪽 화살표): 이전 part 선택
- `→` (오른쪽 화살표): 다음 part 선택 또는 입력 필드로 복귀
- `Backspace`: 선택된 part 삭제 (입력이 비어있으면 마지막 part 선택)
- `Delete`: 선택된 part 삭제 (다음 part로 이동)
- `Enter`: 현재 입력 확정
- 마우스 인터랙션:
- part 클릭: 해당 part 선택
- × 버튼: 즉시 삭제
- 시각적 피드백:
- 선택된 텍스트: 파란 배경 + 파란 테두리
- 선택된 태그: 진한 파란 테두리 + 투명도 감소
**사용 사례**:
- 템플릿 입력 (제목/내용 템플릿)
- 이메일 받는 사람 입력 (Gmail 스타일)
- 태그 입력 (해시태그, 키워드)
- 멘션 입력 (Slack, Discord 스타일)
**참고 파일**: `src/components/writing/CreateTopicDialog.tsx:61-321`
---
### 6. 팀 코드 시스템 아키텍처 (초등 저학년 로그인 간소화)
#### 핵심 개념
**문제**: 초등 저학년은 이메일/비밀번호 로그인이 어려움
**해결**: 소유자가 코드를 발급하고, 학생은 간단히 로그인
**아키텍처**: currentStudent 중심, 정식 계정은 선택사항
#### 계정 구조
```
정식 계정 (User) - 선택적
├─ Firebase Auth: user456 (Email/Google)
├─ Firestore: users/user456
└─ ownedStudentIds: ["studentDoc1", "studentDoc2"]
├─> 학생 계정 1 (Student) - 독립적, 필수
│ ├─ Firebase Auth: anon123 (Anonymous)
│ ├─ Firestore: students/studentDoc1
│ ├─ linkedUserId: user456 (1:1 관계)
│ ├─ teamIds: ["team1", "team2"]
│ └─ 모든 활동 데이터(writings, topics)는 studentId로 기록
└─> 학생 계정 2 (Student)
└─ ... (동일 구조)
```
#### 팀 코드 생성
**한글 3단어 조합**: `[형용사/동사] + [색상] + [동물]`
```
예시: "춤추는 파란 사자"
조합 수:
- 형용사/동사: 100개
- 색상: 20개
- 동물: 50개
→ 총 100,000가지 조합 (10만 개)
특징:
✅ 기억하기 쉬움 (이미지 연상)
✅ 구두 전달 가능 (말로 쉽게 전달)
✅ 타이핑 오타 최소화 (자동완성 가능)
✅ 초등 저학년도 이해 가능
✅ 재미있고 친근함 (팀 정체성 형성)
```
**파일**: `src/data/classCodeWords.ts`, `src/utils/classCodeGenerator.ts`
#### 학생 로그인 플로우 (개선됨 - 2025-11-06)
```
1. 학생 로그인 (팀 코드 3단계)
Step 1: 팀 코드 입력
├─> "춤추는 파란 사자" 입력
├─> Firestore teams 조회
├─> 소유자 체크 (본인 팀이면 /manage로 리다이렉트)
└─> Step 2로 진행
Step 2: 이름 입력 (선택 → 입력으로 개선)
├─> 이름 직접 입력 (예: "김민지")
├─> PIN 필요하면 Step 3 (PIN)
└─> PIN 불필요하면 Step 3 (완료 화면)
Step 3: PIN 입력 또는 완료 화면
├─> [PIN 필요] 숫자 패드로 PIN 입력 → 검증 → 완료 화면
└─> [완료 화면] 참여/로그인 구분
├─> 신규: 🎉 "환영합니다! {이름}님, {팀명}에 참여했어요"
└─> 재로그인: 👋 "반가워요! {이름}님, 다시 돌아왔군요!"
2. 로그인 완료
├─> Anonymous Auth 유지/생성
├─> authStore.currentStudent 설정
└─> /team/[teamId] 멤버 페이지로 이동
2. 정식 계정 연결 (선택적)
├─> UserProfileButton → "계정 연결하기"
├─> LoginDialog (link 모드)
├─> 2가지 시나리오:
│ ├─ **신규 계정 생성** (linkWithCredential):
│ │ ├─ 이메일 회원가입 또는 Google 로그인
│ │ ├─ linkWithCredential() 호출
│ │ └─ Anonymous(anon123) → Email(user456) 전환 (UID 유지)
│ │
│ └─ **기존 계정 병합** (API 데이터 마이그레이션):
│ ├─ 이메일 로그인 또는 Google 로그인
│ ├─ POST /api/auth/merge-account 호출
│ ├─ Firestore 데이터 이전 (writings, topics, comments, teams)
│ ├─ Realtime DB 데이터 이전 (drafts, monitoring)
│ └─ 익명 계정(anon123) → 정식 계정(user456)으로 데이터 이전
└─> 이후 user456으로 로그인 가능 (모든 데이터 통합)
3. 정식 계정 로그인 (학생 자동 선택)
├─> user456으로 로그인
├─> Firestore users/user456 조회
├─> ownedStudentIds로 students 조회
├─> 학생이 1명: 자동 선택
├─> 학생이 2명+: StudentPicker 표시 (누구로 활동할까요?)
└─> authStore.currentStudent 설정
```
#### 보안 모드
| 모드 | 인증 단계 | 사용 사례 |
|------|----------|----------|
| **simple** | 코드 + 이름 | 교실 전용, 저학년 (1-2학년) |
| **normal** | 코드 + 이름 + PIN | 가정 학습 포함, 고학년 (3-4학년) |
| **open** | 코드 + 자유 가입 | 전학생, 체험 학생 허용 |
#### Rate Limiting (학생 친화적)
```
5회 실패: 💡 "어려우면 팀을 만든 사람에게 물어보세요!"
10회 실패: ⚠️ "입력을 확인해주세요. 띄어쓰기는 안 해도 괜찮아요!"
15회 실패: 🔒 "2분 후에 다시 시도해주세요. 팀 관리자에게 도움을 요청하세요."
```
#### 데이터 흐름
```
모든 활동 데이터는 studentId로 기록:
writings/{writingId}
├─ studentId: "studentDoc1" ← 핵심! (userId 아님)
├─ title: "나의 하루"
└─ content: "..."
조회 시:
- 팀 코드 계정: getUserWritings(currentStudent.id)
- 정식 계정: ownedStudents.map(s => getUserWritings(s.id))
```
**참고 파일**:
- `src/managers/TeamManager.ts` - 관련 API 호출 + 캐싱
- `src/managers/ManagerBase.ts` - API 호출 캐싱 공통 로직
- `src/services/firebaseAuth.ts:125-316` - 학생 로그인 로직
- `src/store/authStore.ts` - currentStudent 중심 상태 관리
- `src/types/api/team.ts` - API 타입 정의
- `src/types/api/student.ts` - 학생 API 타입 정의
- `API_SPEC.md` - 전체 API 명세서 (23개 엔드포인트)
---
### 7. 실시간 피드백 시스템 (3-Layer 아키텍처) + 맞춤법 검사
#### 핵심 개념
**목적**: Gemini API로 초등학생 글쓰기를 실시간 평가하면서 비용 최적화
**문제**:
- 매번 전체 텍스트 전송 = 비용 폭증
- Rate Limit (15 RPM) = 사용자 2명만 접속해도 초과
**해결**: Delta 전송 + 캐싱 + Multi-Region Failover + 분석 히스토리
#### 아키텍처
```
┌─────────────────────────────────────────┐
│ Layer 1: API Route │
│ src/app/api/analyze-text/route.ts │
│ │
│ 역할: │
│ - Delta 계산 (변경된 부분만 추출) │
│ - 서버 캐싱 (In-Memory LRU, TTL 1분) │
│ - textAnalysisService 호출 │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Layer 2: Business Logic │
│ src/services/textAnalysisService.ts │
│ │
│ 역할: │
│ - 프롬프트 생성 (히스토리 포함) │
│ - Response Schema 정의 (Type.OBJECT) │
│ - JSON 파싱 (AI 응답 처리) │
│ - 점수 제한 (최대 10점) │
│ - vertexAI 호출 │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Layer 3: Infrastructure │
│ src/services/vertexAI.ts │
│ │
│ 역할: │
│ - GoogleGenAI 클라이언트 관리 (SDK) │
│ - Response Schema 전달 (JSON 강제) │
│ - Multi-region failover │
│ - Retry with exponential backoff │
│ - regionHealthManager 연동 │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 맞춤법 검사 (독립적) │
│ src/services/spellingService.ts │
│ │
│ 역할: │
│ - Gemini 기반 맞춤법 검사 │
│ - Response Schema (SpellingError[]) │
│ - 초등학생 눈높이 설명 생성 │
│ - 별도 debounce (5초) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Region Health Manager │
│ src/services/regionHealthManager.ts │
│ │
│ 역할: │
│ - Region별 과부하 상태 추적 │
│ - 429 에러 시 1분간 region 제외 │
│ - 자동 복구 (1분 경과 시) │
└─────────────────────────────────────────┘
```
#### Multi-Region Strategy
**사용 가능한 Regions** (우선순위 ):
```
1. asia-northeast1 (도쿄) - 한국 최근접, ~50ms 🥇
2. asia-southeast1 (싱가포르) - 백업, ~100ms 🥈
3. us-central1 (미국) - 최종 대체, ~200ms 🥉
```
**장애 시나리오**:
```
요청 → 도쿄 region
↓ 429 Rate Limit
도쿄를 1분간 "과부하" 마킹
다음 요청 → 싱가포르 (자동 전환)
↓ 성공 ✅
계속 싱가포르 사용
↓ 1분 후
도쿄 자동 복구
다시 도쿄 우선 사용 (빠름)
```
#### Delta 전송 메커니즘
**문제**: 500자 전체 전송 = 토큰 낭비
**해결**: 변경분만 전송
```typescript
// 클라이언트
const previousText = "오늘 날씨가 좋다."; // 15자
const currentText = "오늘 날씨가 좋다. 하늘이 맑다."; // 24자
fetch('/api/analyze-text', {
body: JSON.stringify({
text: currentText,
previousText: previousText // Delta 계산용
})
});
// 서버
const delta = text.slice(previousText.length); // " 하늘이 맑다." (9자)
// → 9자만 Vertex AI로 전송 (60% 절감)
```
#### 성능 최적화
**비용 절감**:
```
순수 AI: $0.18/글
Debounce: $0.036/글 (80% 절감)
Delta: $0.014/글 (92% 절감)
Delta + Cache: $0.009/글 (95% 절감) ⭐
```
**처리량 증가**:
```
Single Region: 15 RPM
Multi-Region (3개): 45 RPM (3배)
동시 사용자: 1~2명 → 3~5명
```
**가용성 향상**:
```
Single: 95%
Multi-Region: 99.9% (자동 failover)
```
#### 새로운 평가 기준 (2025-11-11 개편)
**총 10점 = 오감(4) + 감정(2) + 대화(2) + 의성어(2)**
| 항목 | 배점 | 설명 |
|------|------|------|
| **오감 표현** | 0~4점 | 시각/청각/후각/미각/촉각, 1개당 +1점 |
| **감정 표현** | 0~2점 | 기쁨/슬픔/놀람 , 1개당 +1점 |
| **대화 표현** | 0~2점 | 큰따옴표(" ") 사용 +2점 |
| **의성어/의태어** | 0~2점 | 쿵쿵, 반짝반짝 , 1개당 +1점 |
**변경사항**:
- `descriptive` (감각/감정 형용사, 0~3점)
- `emotion` (감정 표현, 0~2점)
- 오감과 감정 분리로 평가 명확화
#### 분석 히스토리 시스템
**Draft 타입 확장**:
```typescript
interface AnalysisHistoryItem {
version: number; // 1, 2, 3, ...
content: string; // 해당 버전의 텍스트
timestamp: string; // ISO string
analysis: { // 분석 결과 전체
score: number;
breakdown: {...};
foundWords: {...};
suggestions: string[];
};
}
interface Draft {
// ... 기존 필드
analysisHistory?: AnalysisHistoryItem[]; // 최대 5개
}
```
**AI 피드백 개선**:
- 이전 버전과 비교하여 개선점 칭찬
- 학생의 발전 과정 인정
- 제안 최소화 (0~1개, 정말 부족한 것만)
- 7점 이상이면 칭찬만
#### 참고 파일
**서비스 레이어**:
- `src/services/vertexAI.ts` - Gemini API 범용 래퍼 (`@google/genai`, temperature=0)
- `src/services/textAnalysisService.ts` - 텍스트 분석 (히스토리 기반, 가중 평균 점수 계산)
- `src/services/scoringConfigService.ts` - 🆕 채점 설정 병합 (기본주제 우선순위)
- `src/services/spellingService.ts` - 맞춤법 검사 (독립적)
- `src/services/regionHealthManager.ts` - Region 상태 관리
**API & 컴포넌트**:
- `src/app/api/analyze-text/route.ts` - 텍스트 분석 API
- `src/app/api/spelling/check/route.ts` - 맞춤법 검사 API
- ~~`src/components/writing/ScoreDisplay.tsx`~~ - 삭제됨 (하이라이트로 대체)
- ~~`src/components/writing/SpellingErrorDisplay.tsx`~~ - 삭제됨 (하이라이트로 대체)
- `src/app/[locale]/write/text/page.tsx` - Delta 추적 + 통합 + Toast 알림
- `src/app/[locale]/write/image/page.tsx` - 이미지 먼저 모드 + Toast 알림
- `src/app/[locale]/write/edit/[id]/page.tsx` - 수정 모드
**타입 정의**:
- `src/types/draft.ts` - Draft, AnalysisHistoryItem
**문서**:
- `TECHNICAL_IMPLEMENTATION.md` - 상세 기술 구현 가이드
- `SERVICE_DIRECTION.md` - 서비스 방향성 논의 (8차)
---
### 8. 글 작성 패턴 분석 시스템
#### 핵심 개념
**목적**: 사용자의 여러 글을 분석하여 작성 패턴, 강점, 약점을 파악하고 맞춤형 피드백 제공
**분석 항목**:
1. **작성 스타일**: 평균 글자/단어 , 선호하는 길이, 문장 구조 (단문/복문/혼합)
2. **표현력 분석**: 평균 점수, 강점/약점, 카테고리별 점수, 자주 쓰는 표현
3. **맞춤법 경향**: 자주 하는 실수, 개선율
4. **발전 추이**: 최근 5개 vs 이전 5개 비교, 개선 영역, 주의 필요 영역
5. **AI 종합 평가**: 전반적 평가, 격려 메시지, 맞춤형 추천 3가지
**서비스 레이어**:
- `patternAnalysisService.ts` - 10개 종합 분석, Gemini 기반 AI 평가, Response Schema 사용
**API**:
- `POST /api/analyze-pattern` - 최근 10개 분석 (5분 캐싱)
- 인증 필요 (ID Token)
- Firestore에서 조회 AI 분석 (병렬) 패턴 분석 캐싱
**컴포넌트**:
- `WritingPatternDialog` - 패턴 분석 다이얼로그 (로딩/에러/성공 상태)
- `WritingPatternDisplay` - 분석 결과 표시 (종합 평가, 발전 추이, 작성 스타일, 표현력, 강점/약점, 자주 쓰는 표현, 추천)
**타입**:
- `src/types/writingPattern.ts` - WritingPatternAnalysis, AnalyzePatternRequest/Response
**참고 파일**:
- `src/services/patternAnalysisService.ts` - 패턴 분석 로직
- `src/app/api/analyze-pattern/route.ts` - 패턴 분석 API
- `src/components/writing/WritingPatternDialog.tsx` - 다이얼로그
- `src/components/writing/WritingPatternDisplay.tsx` - 표시 컴포넌트
- `src/app/home/page.tsx` - "작성 패턴 분석" 카드 추가
---
### 9. 실시간 하이라이트 시스템 (Tiptap Extensions)
#### 핵심 개념
**목적**: 에디터에서 맞춤법 오류와 감각 단어를 실시간으로 시각적으로 표시
**하이라이트 종류**:
1. **맞춤법 오류**: 빨간 물결 밑줄 (`spelling-error` 클래스)
2. **감각 동사**: 초록색 하이라이트 (`sensory-word` 클래스)
3. **감각 형용사**: 파란색 하이라이트 (`emotion-word` 클래스)
4. **의성어/의태어**: 보라색 하이라이트 (`onomatopoeia-word` 클래스)
**Tiptap Extensions**:
- `SpellingHighlight` - 맞춤법 오류 하이라이트
- data-original, data-correction, data-reason 속성
- Meta를 통한 강제 업데이트
- `SensoryWordHighlight` - 감각 단어 하이라이트
- data-word, data-type 속성
- 색상별 구분 (초록/파랑/보라)
**DecorationSet 기반**:
- ProseMirror Decoration을 사용한 효율적인 하이라이트
- 문서 변경 자동 업데이트
- 하이라이트 위치를 정확하게 추적
**WritingEditor 통합**:
- `spellingErrors`, `foundWords` props 추가
- Extension 옵션 실시간 업데이트
- 브라우저 기본 맞춤법 검사 비활성화 (`spellcheck="false"`)
**참고 파일**:
- `src/extensions/spelling-highlight.ts` - 맞춤법 하이라이트 Extension
- `src/extensions/sensory-word-highlight.ts` - 감각 단어 하이라이트 Extension
- `src/components/writing/WritingEditor.tsx` - Extensions 통합
---
### 10. 인터랙티브 툴팁 시스템
#### 핵심 개념
**목적**: 하이라이트된 단어를 클릭하면 상세 정보를 툴팁으로 표시
**툴팁 종류**:
1. **맞춤법 오류 툴팁**:
- 원본 수정 (취소선 굵은 글씨)
- 이유 설명 (초등학생 눈높이)
- 빨간색 테두리
2. **감각 단어 툴팁**:
- 단어 표시
- 단어 타입 (감각 동사/형용사/의성어)
- 격려 메시지 ("이렇게 구체적으로 표현하면 글이 생생해져요!")
- 색상별 테두리 (초록/파랑/보라)
**기술 구현**:
- Portal 사용 (z-index 문제 해결)
- 클릭한 요소의 data attributes 읽기
- 외부 클릭/ESC 키로 닫기
- Fade-in 애니메이션
**WritingEditor 통합**:
- DOM 클릭 이벤트 리스너 등록
- 하이라이트 클래스 확인 (spelling-error, sensory-word )
- 툴팁 위치 계산 (getBoundingClientRect)
- 여러 툴팁 동시 표시 가능 (배열 관리)
**참고 파일**:
- `src/components/writing/EditorTooltip.tsx` - 툴팁 컴포넌트
- `src/components/writing/WritingEditor.tsx` - 클릭 이벤트 처리
---
### 11. Toast 알림 시스템
#### 핵심 개념
**목적**: 텍스트 분석 맞춤법 검사 진행 상태를 사용자에게 알림
**알림 종류**:
1. **텍스트 분석**:
- 시작: "글을 분석하고 있어요..." (loading, duration: Infinity)
- 완료: "분석 완료! 점수: X.X점" (success, 3초)
- 실패: "분석 실패, 다시 시도해주세요." (error, 3초)
2. **맞춤법 검사**:
- 시작: "맞춤법을 검사하고 있어요..." (loading, duration: Infinity)
- 완료: "맞춤법 검사 완료! X개의 오류 발견" 또는 "맞춤법 오류 없음!" (success, 3초)
- 실패: "맞춤법 검사 실패" (error, 3초)
**기술 구현**:
- Chakra UI Toaster 사용
- Toast ID 관리 (ref로 저장, dismiss 호출)
- 로딩 상태 toast는 수동으로 dismiss
- 성공/실패 기존 loading toast 제거 toast 표시
**참고 파일**:
- `src/app/[locale]/write/text/page.tsx` - Toast 알림 통합
- `src/app/[locale]/write/image/page.tsx` - Toast 알림 통합
---
### 12. 다국어 지원 시스템 (i18n)
#### 핵심 개념
**목적**: 한국어/영어 사용자 모두 접근 가능한 글로벌 플랫폼 구축
**라이브러리**: next-intl (Next.js App Router 표준 i18n 라이브러리)
**지원 언어**:
- 한국어 (ko) - 기본값
- 영어 (en)
- 일본어 (ja) - 어린이 친화적 표현 (한자 최소화, ひらがな 우선)
#### 아키텍처
```
브라우저 요청 (/)
Middleware (src/middleware.ts)
├─> Accept-Language 헤더 확인
├─> NEXT_LOCALE 쿠키 확인
└─> 적절한 locale로 리다이렉트
├─> 한국어 우선: /ko
└─> 영어 우선: /en
[locale] 라우팅 (src/app/[locale]/*)
├─> layout.tsx (locale별 레이아웃)
│ ├─> NextIntlClientProvider (번역 메시지 주입)
│ ├─> Provider (Chakra UI)
│ ├─> Navbar (다국어 메뉴)
│ └─> AuthInitializer
└─> page.tsx (각 페이지)
└─> useTranslations('namespace') 훅 사용
└─> {t('key')} 형태로 번역 표시
```
#### 번역 파일 구조
**파일 위치**: `messages/{locale}.json`
```json
// messages/ko.json
{
"site": {
"name": "라온누리",
"tagline": "재미있게 글쓰기를 배워보자!",
"subtitle": "친구들과 함께 신나는 글쓰기 모험을 떠나요"
},
"navbar": {
"home": "홈",
"write": "글쓰기",
"learn": "학습하기",
"stickers": "스티커"
},
"landing": {
"hero": {
"cta": "지금 시작하기",
"teamCode": "팀 코드로 참여"
},
"features": {...},
"howItWorks": {...}
},
"home": {
"hero": {
"welcome": "환영합니다, {name}님!", // 파라미터 지원
"subtitle": "오늘도 멋진 글쓰기를 시작해볼까요?"
},
"quickStart": {...}
}
}
```
#### 설정 파일
**i18n/routing.ts** - 라우팅 설정:
```typescript
export const routing = defineRouting({
locales: ['ko', 'en', 'ja'],
defaultLocale: 'ko',
localePrefix: 'always', // URL에 항상 표시 (/ko/*, /en/*, /ja/*)
localeDetection: true // 브라우저 언어 자동 감지
});
// next-intl 타입 안전 내비게이션 API
export const {Link, redirect, usePathname, useRouter} = createNavigation(routing);
```
**i18n/request.ts** - 번역 메시지 로더:
```typescript
export default getRequestConfig(async ({requestLocale}) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});
```
**middleware.ts** - 자동 언어 감지:
```typescript
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/', '/(ko|en)/:path*']
};
```
#### 컴포넌트 사용 패턴
**Server Component** (기본):
```typescript
import {useTranslations} from 'next-intl';
export default function Page() {
const t = useTranslations('namespace');
return <h1>{t('key')}</h1>;
}
```
**Client Component**:
```typescript
"use client";
import {useTranslations} from 'next-intl';
export default function ClientComponent() {
const t = useTranslations('namespace');
return <p>{t('key')}</p>;
}
```
**파라미터가 있는 번역**:
```typescript
const t = useTranslations('home');
// messages/ko.json: "welcome": "환영합니다, {name}님!"
<h1>{t('hero.welcome', {name: userName})}</h1>
// → "환영합니다, 홍길동님!"
```
**타입 안전 Link** (locale 자동 처리):
```typescript
import {Link} from '@/i18n/routing';
<Link href="/home">홈으로</Link>
// 현재 locale이 ko면 → /ko/home
// 현재 locale이 en이면 → /en/home
```
#### 언어 전환 버튼
**LocaleSwitcher** (`src/components/navigation/LocaleSwitcher.tsx`):
```typescript
const LOCALES = [
{code: 'ko', name: '한국어', flag: '🇰🇷'},
{code: 'en', name: 'English', flag: '🇺🇸'},
{code: 'ja', name: '日本語', flag: '🇯🇵'},
];
const locale = useLocale();
const currentLocale = LOCALES.find(l => l.code === locale);
return (
<Menu.Root positioning={{placement: "bottom-end"}}>
<Menu.Trigger asChild>
<Button>
<LuGlobe /> {currentLocale.flag} {currentLocale.code.toUpperCase()}
</Button>
</Menu.Trigger>
<Menu.Content>
{LOCALES.map((loc) => (
<Menu.Item onClick={() => handleLocaleChange(loc.code)}>
{loc.flag} {loc.name}
{locale === loc.code && <LuCheck />}
</Menu.Item>
))}
</Menu.Content>
</Menu.Root>
);
```
**동작**:
- 드롭다운 메뉴에서 언어 선택
- 국기 이모지로 시각적 구분
- 현재 언어에 체크 마크 표시
- `NEXT_LOCALE` 쿠키에 저장 (다음 방문 기억)
#### 브라우저 언어 자동 감지
**첫 방문 시나리오**:
```
1. 사용자가 / 접속 (쿠키 없음)
2. Middleware가 Accept-Language 헤더 확인
- "en-US,en;q=0.9" → /en/으로 리다이렉트
- "ko-KR,ko;q=0.9" → /ko/로 리다이렉트
- "ja-JP,ja;q=0.9" → /ja/로 리다이렉트
- 지원하지 않는 언어 → /ko/ (기본값)
3. NEXT_LOCALE 쿠키 저장
다음 방문 시:
1. 쿠키에서 저장된 언어 확인
2. 해당 언어로 바로 리다이렉트
```
#### 완성된 다국어 페이지
| 페이지 | 경로 | 상태 | 번역 항목 |
|-------|------|------|-----------|
| **Navbar** | 모든 페이지 | 완료 | , 글쓰기, 학습하기, 스티커 (4개) |
| **Landing** | `/[locale]` | 완료 | Hero(사이트명, 태그라인, CTA 버튼), Features(4개 카드), Steps(3단계), CTA 섹션, Footer ( 20+ 항목) |
| **Home** | `/[locale]/home` | 완료 | 웰컴 메시지, QuickStart(9개 액션 카드), RecentActivity ( 15+ 항목) |
| **Auth** | LoginDialog | 완료 | LoginDialog, LoginForm, SignupForm, UserProfileButton, StudentLoginFlow, SavedDraftsDialog ( 50+ 항목) |
| **Team** | `/[locale]/team/*` | 완료 | List, Create, Detail, Manage + SecurityLevelSelector ( 60+ 항목) |
| **Write** | `/[locale]/write` | 완료 | 분석/저장 메시지, 버튼, 상태 표시 ( 20+ 항목) |
**총 번역 키**: 220개 이상 (ko.json, en.json)
#### next.config.ts 설정
```typescript
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig = {
reactCompiler: true,
};
export default withNextIntl(nextConfig);
```
#### 참고 파일
**설정**:
- `src/i18n/routing.ts` - 라우팅 설정
- `src/i18n/request.ts` - 번역 로더
- `src/middleware.ts` - 언어 감지 미들웨어
- `messages/ko.json`, `messages/en.json` - 번역 파일
**컴포넌트**:
- `src/components/navigation/LocaleSwitcher.tsx` - 언어 전환 버튼
- `src/components/navigation/Navbar.tsx` - 다국어 메뉴
- `src/app/[locale]/page.tsx` - Landing 페이지 (전체 번역)
- `src/app/[locale]/home/page.tsx` - Home 페이지 (전체 번역)
**타입 안전성**:
- next-intl은 타입 추론을 지원하지만, JSON 구조에 따라 자동 완성됨
- 없는 사용 런타임 에러 (개발 모드에서는 경고)
#### 장점
**타입 안전**: useTranslations 훅으로 타입 체크
**Server/Client 지원**: RSC에서도 번역 가능
**자동 코드 스플리팅**: 필요한 번역만 로드
**SEO 친화적**: locale별 URL (/ko/*, /en/*)
**쿠키 기반 기억**: 사용자 선택 언어 저장
**브라우저 자동 감지**: Accept-Language 헤더
---
### 13. 개별 글 분석 결과 저장 및 재사용 시스템 (AI 비용 최적화)
#### 핵심 개념
**목적**: 글마다 AI 분석 결과를 저장하여 패턴 분석 재분석 방지
**문제**:
- 패턴 분석 매번 모든 글을 재분석 (10개 = 10회 AI 호출)
- 1개만 수정해도 전체 재분석 (9개 글은 변경 없음)
- AI 비용 폭증 (사용자당 $0.50/)
**해결**: contentHash 기반 분석 결과 저장 + 변경 감지
#### 데이터 구조
```typescript
// Writing 타입에 analysis 필드 추가
interface Writing {
id: string;
content: string;
// ...기존 필드
analysis?: WritingAnalysis; // 🆕 AI 분석 결과
}
interface WritingAnalysis {
score: number; // AI 평가 점수
breakdown: {
sensory: number; // 오감 표현 점수
emotion: number; // 감정 표현 점수
dialogue: number; // 대화 표현 점수
onomatopoeia: number; // 의성어 점수
};
foundWords: {
sensory: string[];
emotion: string[];
onomatopoeia: string[];
};
suggestions?: string[];
spellingErrors?: SpellingError[]; // 맞춤법 오류 목록
analyzedAt: Date;
contentHash: string; // SHA-256(content)
}
```
#### 아키텍처
```
글 저장 플로우:
저장 버튼 클릭
AI 분석 + 맞춤법 검사 (병렬)
WritingAnalysis 생성
├─ score, breakdown, foundWords
├─ spellingErrors
├─ contentHash (SHA-256)
└─ analyzedAt
Firestore 저장 (writing + analysis)
패턴 분석 플로우:
패턴 분석 요청
writings 조회 (analysis 포함)
각 글마다 체크:
├─ analysis 있음?
│ └─ contentHash 일치?
│ ├─ YES → 재사용 ⚡ (0 AI 호출)
│ └─ NO → 재분석 💰 (1 AI 호출)
└─ analysis 없음? → 재분석 💰
패턴 분석 수행 (종합)
```
#### 구현 파일
**타입**:
- `src/types/writing.ts` - `WritingAnalysis`, `SpellingError` 타입
- `src/types/api/writing.ts` - `CreateWritingRequest.analysis` 추가
**유틸**:
- `src/utils/contentHash.ts` - `generateWritingContentHash()` 추가
**서버**:
- `src/lib/server/writing.ts` - `createWriting()` analysis 저장
- `src/app/api/writing/route.ts` - analysis 전달
- `src/app/api/analyze-pattern/route.ts` - analysis 재사용 로직
**클라이언트**:
- `src/app/[locale]/write/text/page.tsx` - 저장 분석 수행
- `src/app/[locale]/write/image/page.tsx` - 저장 분석 수행
- `src/managers/WritingManager.ts` - `CreateWritingParams.analysis`
#### 비용 절감 효과
**시나리오 1: 10개 글, 첫 패턴 분석**
```
조회: 10 reads
AI 분석: 10회 (analysis 없음)
비용: $0.20
```
**시나리오 2: 10개 글, 재분석 (변경 없음)**
```
조회: 10 reads
AI 분석: 0회 (모든 contentHash 일치)
비용: $0.01 (Firestore만)
절감: 95% ✅
```
**시나리오 3: 10개 글, 1개 수정 후 재분석**
```
조회: 10 reads
AI 분석: 1회 (9개는 contentHash 일치)
비용: $0.03
절감: 85% ✅
```
**연간 비용** (사용자 1000명 기준):
```
이전: $180,000/년
개선: $18,000/년
절감: $162,000/년 (90%) 💰
```
#### 맞춤법 에러 히스토리 완성
이제 `spellingErrorsHistory` 실제 데이터로 채워짐:
```typescript
// 패턴 분석 API
const spellingErrorsHistory = writings.map(
(writing) => writing.analysis?.spellingErrors || []
);
// patternAnalysisService.ts에서 활용
const commonErrors = extractCommonErrors(spellingErrorsHistory);
// → [{ error: "했읍니다", correction: "했습니다", frequency: 15 }, ...]
const improvementRate = calculateImprovementRate(spellingErrorsHistory);
// → 최근 5개 vs 이전 5개 에러 개수 비교
```
#### 참고 파일
**타입**: `src/types/writing.ts`
**유틸**: `src/utils/contentHash.ts`
**서버**: `src/lib/server/writing.ts`, `src/app/api/analyze-pattern/route.ts`
**클라이언트**: `src/app/[locale]/write/{text,image,edit}/page.tsx`, `src/managers/WritingManager.ts`
---
### 10. AI 이미지 생성 시스템 (Vertex AI Imagen 4.0)
#### 핵심 개념
**목적**: 학생이 작성한 글의 장면을 자동으로 추출하여 일관된 스타일의 삽화 이미지 생성
**기술 스택**:
- **Vertex AI Imagen 4.0 Fast**: Google 최신 이미지 생성 모델
- **Gemini 2.5 Flash**: 장면 추출 프롬프트 최적화
- **Multi-region Failover**: us-east5 us-south1 us-central1
#### 일관된 스타일 가이드 (2025-11-20 개선)
**화풍**: 따뜻한 디지털 일러스트, 애니메이션/만화 스타일
**색감**: 부드럽고 따뜻한 , 자연스러운 채도
**분위기**: 친근하고 밝은, 초등학생 눈높이에 맞는
**피해야 할 요소**:
- 지나치게 귀여운 데포르메
- 과도한 사실주의 (semi-realistic, photorealistic)
- 어두운 분위기, 미국식 스타일
**Negative Prompt** (강화):
```
text, words, letters, watermark, signature,
overly cute, chibi style,
excessive realism, photorealistic, semi-realistic,
dark atmosphere, gloomy, oversaturated colors,
low quality, blurry, distorted, poorly drawn
```
#### 아키텍처 플로우
```
1⃣ 글 작성 완료
저장된 글 상세 페이지
├─> "이미지 생성" 버튼 클릭
└─> GenerateImageDialog 열기
2⃣ 장면 추출 (Step 1: Extracting)
POST /api/extract-scenes
├─> body: { title, content, locale }
├─> sceneExtractionService.extractScenes()
│ ├─> Gemini 2.5 Flash로 장면 분석
│ ├─> 2-5개 주요 장면 추출
│ └─> Response Schema (Scene[])
└─> { scenes, totalScenes }
3⃣ 장면 선택 (Step 2: Selecting)
SceneSelector 컴포넌트
├─> RadioCard.Root로 장면 카드 표시
├─> 각 장면: 제목 + 내용 미리보기
└─> 사용자 선택 → selectedSceneId 저장
4⃣ 이미지 생성 (Step 3: Generating)
POST /api/generate-image
├─> body: { writingId, title (scene), content (scene), locale }
├─> Authorization: Bearer {idToken}
├─> 🔒 인증 & 권한 확인
│ ├─> verifyIdToken()
│ └─> writing.userId === userId 체크
├─> 🎨 프롬프트 최적화
│ └─> optimizePromptForImage(title, content, locale)
│ ├─> Gemini Flash로 키워드 추출 (6-12개)
│ │ - 주요 피사체, 행동, 배경, 시각적 디테일, 분위기
│ │ - "Korean elementary student" 국적 명시
│ │ - 일관된 스타일 키워드 자동 추가
│ │
│ └─> keywords.join(", ")
│ 예: "Korean elementary student with bright smile,
│ catching red dodgeball,
│ sunny school playground in Korea,
│ warm natural lighting,
│ friendly digital illustration,
│ anime-inspired art style"
├─> 🖼️ Imagen API 호출
│ └─> generateImage(optimizedPrompt, config)
│ ├─> model: imagen-4.0-generate-001
│ ├─> aspectRatio: 16:9
│ ├─> numberOfImages: 1
│ └─> negativePrompt (강화됨)
├─> 💾 Firebase Storage 업로드
│ └─> uploadGeneratedImage(writingId, dataUrl, 'png')
│ ├─> 경로: generated-images/{writingId}/{timestamp}.png
│ └─> 공개 URL 반환
└─> 📝 Firestore 업데이트
└─> writings/{writingId}.generatedImage = {
url, prompt, generatedAt, modelName
}
5⃣ 결과 표시 (Step 4: Done)
<HintDisplay>
├─> 생성된 이미지 미리보기
├─> 사용된 프롬프트 표시
└─> "장면 변경" / "닫기" 버튼
```
#### 프롬프트 최적화 시스템
**2단계 프로세스**:
1. **AI 키워드 추출** (Gemini Flash):
```typescript
// promptOptimization.ts
input: {
sceneTitle: "막판 역전",
sceneContent: "지훈이를 아웃시켰다...",
locale: "ko"
}
output: {
keywords: [
"Korean elementary student with confident expression",
"catching red dodgeball mid-air",
"elementary school playground during golden hour",
"classmates running excitedly",
"dynamic joyful pose",
"warm natural lighting",
"friendly digital illustration",
"anime-inspired art style",
"warm gentle colors",
"clean composition"
]
}
```
2. **폴백 프롬프트** (AI 실패 시):
```typescript
// imagenService.ts
const prompt = [
"friendly digital illustration",
"anime-inspired art style",
"warm gentle colors",
title,
summary,
"soft warm lighting",
"cheerful atmosphere"
].join(", ");
```
#### 주요 특징
1. **일관된 스타일** 🎨:
- 모든 이미지에 동일한 스타일 가이드 적용
- AI 프롬프트에 스타일 키워드 자동 포함
- Negative prompt로 원하지 않는 스타일 차단
2. **한국 문화권 고려** 🇰🇷:
- "Korean elementary student" 명시
- "school playground in Korea" 등 한국 배경
- 아시아권 얼굴 특징 반영
3. **장면 자동 추출** ✂️:
- Gemini가 글에서 시각화하기 좋은 장면 2-5개 추출
- 사용자가 원하는 장면 선택
- 다시 생성 가능 (장면 변경 버튼)
4. **비용 최적화** 💰:
- 프롬프트 최적화로 토큰 절감
- Multi-region failover로 Rate Limit 회피
- 이미지당 평균 $0.04
5. **다국어 지원** 🌏:
- 한국어/영어/일본어 프롬프트 생성
- locale에 따라 다른 스타일 가이드
#### 참고 파일
**프롬프트**:
- `src/prompts/promptOptimization.ts` - AI 키워드 추출 프롬프트 (3개 언어)
**서비스**:
- `src/services/vertexAI.ts` - Imagen API 래퍼 (multi-region failover)
- `src/services/imagenService.ts` - 이미지 생성 비즈니스 로직
- `src/services/sceneExtractionService.ts` - 장면 추출 로직
**API**:
- `src/app/api/extract-scenes/route.ts` - 장면 추출 API
- `src/app/api/generate-image/route.ts` - 이미지 생성 API
**컴포넌트**:
- `src/components/writing/GenerateImageDialog.tsx` - 4단계 플로우 다이얼로그
- `src/components/writing/SceneSelector.tsx` - 장면 선택 UI
**유틸**:
- `src/utils/imageStorage.ts` - Firebase Storage 업로드
---
### 11. AI 글쓰기 도우미 시스템
#### 개요
학생이 글쓰기 중 막혔을 때 AI가 **주제 맥락을 고려한** 4단계 점진적 힌트를 제공하는 시스템
#### 아키텍처 플로우
```
1⃣ 선생님 (팀 관리)
팀 관리 페이지 (/team/[teamId]/manage)
├─> Switch.Root (AI 도움 On/Off)
├─> handleAIToggle(checked)
└─> teamManager.updateAIConfig(teamId, config)
↓ PUT
API: /api/team/[teamId]/ai-config
↓ Firestore
teams/{teamId}.aiAssistanceConfig = {
enabled: true,
detectionTimeMinutes: 5,
maxHintsPerWriting: 5,
cooldownMinutes: 3,
allowedHintLevels: [1,2,3,4],
requireSelfEdit: true
}
2⃣ 학생 (글쓰기)
글쓰기 페이지 (/write)
├─> 팀 주제 선택 (topicInfo.ownerType === "team")
├─> teamManager.getAIConfig(topicInfo.ownerId)
│ ↓ GET (5분 캐싱)
│ API: /api/team/[teamId]/ai-config
│ ↓ Firestore
│ teams/{teamId}.aiAssistanceConfig 조회
└─> setAiAssistEnabled(config?.enabled)
3⃣ 작성 멈춤 감지
useWritingInactivityDetection({
detectionTimeMinutes: 5,
enabled: aiAssistEnabled && !!selectedTopic,
onInactive: () => setShowInactivityPrompt(true)
})
├─> 에디터 입력 → resetTimer()
├─> 5분간 입력 없음 → onInactive() 실행
└─> <InactivityPrompt isVisible={true} />
(플로팅 버튼: "막히셨나요?")
4⃣ AI 힌트 요청
handleRequestHint(level)
├─> 클라이언트 검증
│ ├─> 주제 선택 확인
│ ├─> 제한 확인 (aiHintsUsed < maxHints)
│ └─> 쿨다운 확인 (lastHintTime + cooldown)
├─> POST /api/writing-assistance
│ body: {
│ level: 1~4,
│ currentContent: string,
│ topicInfo: Topic, // 🔑 주제 정보 전달
│ locale: "ko" | "en" | "ja"
│ }
└─> 서버 처리 (writing-assistance/route.ts)
├─> 🔒 팀 설정 검증
│ ├─> getTeamAIConfig(topicInfo.ownerId)
│ ├─> config.enabled === false → 403 "AI_DISABLED"
│ └─> level ∉ config.allowedHintLevels → 403 "LEVEL_NOT_ALLOWED"
└─> ✅ 검증 통과
generateHint(params)
├─> buildHintPrompt(level, content, topicInfo, locale)
│ ├─> 주제 정보 활용
│ │ - title: "나의 여름방학"
│ │ - keywords: ["여름", "가족"]
│ │ - category: "daily"
│ │ - examplePrompts: [...]
│ │
│ └─> 레벨별 프롬프트 생성
│ - Level 1: "여름방학에서 가장 기억에 남는 순간은?"
│ - Level 2: "그 순간의 감정을 자세히 표현해보세요"
│ - Level 3: ["A. 가족", "B. 친구", "C. 혼자"]
│ - Level 4: "예: 나는 여름 해변에서..."
├─> Vertex AI (Gemini 2.5 Flash)
│ temperature: 0.8
│ schema: SINGLE_HINT_SCHEMA | CHOICE_HINT_SCHEMA
└─> 서버 캐싱 (1분 TTL, 50개)
key: `${level}-${topicId}-${contentHash}`
5⃣ 힌트 표시
<HintDisplay
level={1~4}
topicTitle={selectedTopic.title}
content={hint.content}
encouragement={hint.encouragement}
onNext={() => handleRequestHint(level+1)}
/>
├─> Level 1, 2, 4: 단일 텍스트 (Box)
└─> Level 3: RadioCard.Root (선택지 3개)
```
#### 데이터 모델
```typescript
// Team (Firestore)
interface Team {
// ... 기존 필드
aiAssistanceConfig?: {
enabled: boolean;
detectionTimeMinutes: number; // 5분
maxHintsPerWriting: number; // 5회
cooldownMinutes: number; // 3분
allowedHintLevels: number[]; // [1,2,3,4]
requireSelfEdit: boolean; // true
};
}
// Writing (선택적)
interface Writing {
// ... 기존 필드
aiAssistanceHistory?: AIAssistanceRecord[];
}
interface AIAssistanceRecord {
timestamp: Timestamp;
hintLevel: 1 | 2 | 3 | 4;
topicId?: string;
topicTitle?: string;
context: string; // 마지막 50단어
hintProvided: string;
wasUsed: boolean;
}
```
#### 주요 특징
1. **주제 맥락 활용** 🔑:
- AI 프롬프트에 주제 정보 전달 (title, keywords, category)
- 맥락에 맞는 힌트 생성 ("여름방학"이면 여름 관련 질문)
2. **4단계 점진적 힌트** 📈:
- Level 1 (질문): "주인공은 어떤 기분일까요?"
- Level 2 (방향): "감정 변화를 써보세요"
- Level 3 (선택): ["A. 친구", "B. 가족", "C. 혼자"]
- Level 4 (예시): "예: 나는 용기를 내어..."
3. **서버 검증** 🔒:
- 팀 설정 확인 (Firestore)
- enabled=false → 403 에러
- 허용되지 않은 레벨 → 403 에러
4. **사용 제한** ⏱️:
- 글당 최대 5회
- 힌트 간 3분 쿨다운
- 클라이언트 + 서버 이중 검증
5. **다국어 지원** 🌏:
- 한국어/영어/일본어 프롬프트
- locale에 따라 다른 AI 응답
6. **캐싱 전략** ⚡:
- 팀 AI 설정: 5분 캐싱 (TeamManager)
- AI 힌트: 서버 메모리 1분 (동일 컨텍스트 재요청 방지)
#### 참고 파일
**타입**: `src/types/team.ts` (AIAssistanceConfig), `src/types/writing.ts` (AIAssistanceRecord)
**훅**: `src/hooks/useWritingInactivityDetection.ts`
**서비스**: `src/services/writingAssistanceService.ts`
**프롬프트**: `src/prompts/writingAssistance.ts` (4단계 × 3개 언어)
**UI**: `src/components/writing/{InactivityPrompt, HintDisplay}.tsx`
**API**: `src/app/api/writing-assistance/route.ts`, `src/app/api/team/[teamId]/ai-config/route.ts`
**서버**: `src/lib/server/team.ts` (getTeamAIConfig, updateTeamAIConfig)
**Manager**: `src/managers/TeamManager.ts` (getAIConfig, updateAIConfig)
---
### 21. AI 기능 플랜 기반 제한 시스템
#### 핵심 개념
**목적**: AI 기능(글 분석, 이미지 생성, AI 도우미)을 사용자/조직의 플랜에 따라 제한하여 과금 모델 구현
#### 플랜 구조
| 플랜 | 단위 | AI 분석 | AI 도우미 | 이미지 생성 |
|------|------|---------|-----------|-------------|
| Free | User | 10회/월 | ❌ | ❌ |
| Classroom | User | 무제한 | ✅ | ✅ |
| Academy | User | 무제한 | ✅ | ✅ |
| School | Organization | 무제한 | ✅ | ✅ |
#### 데이터 구조
```
Organization (학교) → School Plan 보유
User (선생님) → 개인 플랜 또는 Organization 소속
Team (반)
```
**플랜 우선순위**: Organization(School Plan) > User 개인 플랜
#### Firestore 컬렉션
```
organizations/{organizationId}
- id, name, plan, adminIds, createdAt, updatedAt, isActive
aiUsage/{identifier}_{yearMonth}
- userId?: string (로그인 사용자)
- ipHash?: string (비로그인 - IP 해시)
- yearMonth, analysisCount, assistanceCount, imageGenerationCount, lastUpdatedAt
```
#### 아키텍처 플로우
```
1. API 요청 (analyze-text, generate-image, writing-assistance)
2. 플랜 검증
- 로그인: getEffectivePlan(userId)
- 비로그인: getGuestEffectivePlan() → Free 플랜
3. 기능 사용 가능 여부 체크
- canUseAIFeature(identifier, featureType)
- Free 플랜: 월별 사용량 확인
- 유료 플랜: 기능 활성화 여부 확인
4. AI 처리
5. 사용량 증가
- incrementAIUsage(identifier, featureType)
- Firestore 트랜잭션으로 동시성 보장
```
#### 주요 특징
1. **Organization 계층 구조** 🏫:
- School Plan은 Organization 레벨에서 관리
- User가 organizationId를 가지면 조직 플랜 자동 적용
- 조직 플랜이 개인 플랜보다 우선
2. **익명 사용자 지원** 👤:
- IP 주소 해싱 (SHA-256, 16자)
- Free 플랜 제한 자동 적용
- 세션 간 사용량 추적
3. **사용량 추적** 📊:
- 월별 집계 (`yearMonth: "2024-12"`)
- 기능별 카운터 (analysis, assistance, imageGeneration)
- Firestore 트랜잭션으로 race condition 방지
4. **플랜 결정 로직** 🎯:
```typescript
async function getEffectivePlan(userId): EffectivePlan {
const user = await getUser(userId);
if (!user) return FREE_PLAN;
// Organization 플랜 우선
if (user.organizationId) {
const org = await getOrganization(user.organizationId);
if (org?.isActive && isOrganizationPlanValid(org)) {
return {
type: org.plan.type,
limits: PLAN_LIMITS[org.plan.type],
source: PlanSource.ORGANIZATION,
organizationId: org.id,
};
}
}
// 개인 플랜 fallback
if (user.plan && isPlanValid(user.plan)) {
return {
type: user.plan.type,
limits: PLAN_LIMITS[user.plan.type],
source: PlanSource.USER,
};
}
return FREE_PLAN;
}
```
5. **API 레벨 검증** 🔒:
- `analyze-text`: 선택적 인증 (optionalAuth)
- `generate-image`: 필수 인증 + Classroom 이상
- `writing-assistance`: 필수 인증 + Classroom 이상
- 제한 초과 시 403 에러 + 업그레이드 안내
6. **클라이언트 캐싱** ⚡:
- AIUsageManager: 1분 TTL
- 플랜 변경 시 자동 캐시 무효화
#### Enum 타입 시스템
```typescript
export enum PlanType {
FREE = "free",
CLASSROOM = "classroom",
ACADEMY = "academy",
SCHOOL = "school",
}
export enum AIFeatureType {
ANALYSIS = "analysis",
ASSISTANCE = "assistance",
IMAGE_GENERATION = "imageGeneration",
}
export enum PlanSource {
USER = "user",
ORGANIZATION = "organization",
}
```
#### 참고 파일
**타입**: `src/types/plan.ts` (8개 enum/interface)
**서버 레이어**:
- `src/lib/server/planLimits.ts` - 플랜별 제한 상수
- `src/lib/server/planResolver.ts` - 플랜 결정 로직
- `src/lib/server/aiUsage.ts` - 사용량 추적
- `src/lib/server/organization.ts` - Organization CRUD
**API**:
- `src/app/api/ai-usage/route.ts` - 사용량 조회
- `src/app/api/organization/route.ts` - Organization 관리
- `src/app/api/analyze-text/route.ts` - 텍스트 분석 (플랜 검증 추가)
- `src/app/api/generate-image/route.ts` - 이미지 생성 (플랜 검증 추가)
**Manager**:
- `src/managers/AIUsageManager.ts` - 클라이언트 사용량 관리
- `src/managers/OrganizationManager.ts` - 클라이언트 조직 관리
**테스트**:
- `src/types/__tests__/plan.test.ts` - Enum 검증
- `src/lib/server/__tests__/planLimits.test.ts` - 제한 상수 검증
- `src/lib/server/__tests__/aiUsage.test.ts` - IP 해싱, 문서 ID 생성
- `src/lib/server/__tests__/planResolver.test.ts` - 플랜 결정 로직 (70개 통과)
---
### 22. 팀 AI 2단계 계층 구조
#### 핵심 개념
**목적**: 팀 AI 기능을 마스터 스위치 + 하위 옵션으로 분리하여 플랜 제한 적용 명확화
#### 계층 구조
```
[팀 AI 기능] ⬅️ 플랜 제한 적용 (maxAIEnabledTeams)
↓ (켜져있을 때만 표시)
[AI 글쓰기 도우미] ⬅️ aiEnabled=true일 때만 활성화
↓ (켜져있을 때만 표시)
[상세 설정...]
```
#### 데이터 모델
```typescript
interface Team {
// 1단계: 마스터 스위치 (플랜 제한 적용)
aiEnabled?: boolean; // 팀 전체 AI 기능 활성화
// 2단계: 하위 옵션 (aiEnabled=true일 때만 유효)
aiAssistanceConfig?: {
enabled: boolean; // AI 글쓰기 도우미
detectionTimeMinutes: number;
maxHintsPerWriting: number;
cooldownMinutes: number;
allowedHintLevels: number[];
requireSelfEdit: boolean;
};
}
```
#### 플랜별 제한 (maxAIEnabledTeams)
| 플랜 | AI 활성화 가능 팀 개수 |
|------|---------------------|
| Free | 0개 (불가) |
| Pro | 0개 (개인 전용) |
| Classroom | 1개 |
| Academy | 5개 |
| School | 무제한 |
#### API 검증 로직
```typescript
// PUT /api/team/[teamId]
if (data.aiEnabled !== undefined && data.aiEnabled !== team.aiEnabled) {
if (data.aiEnabled === true) {
const effectivePlan = await getEffectivePlan(userId);
const maxAllowed = getMaxAIEnabledTeams(effectivePlan.type);
if (maxAllowed !== -1) {
const userTeams = await getAllUserTeams(userId);
const currentCount = userTeams.filter(
(t) => t.aiEnabled === true && t.id !== teamId
).length;
if (currentCount >= maxAllowed) {
return validationErrorResponse(
`플랜 제한: 최대 ${maxAllowed} 팀까지만 AI를 활성화할 있습니다.`
);
}
}
}
}
```
#### UI 상태 피드백
**TeamAISettings 컴포넌트** 3가지 상태:
1. **활성화 상태** (teamAIEnabled=true)
- ✓ 팀 AI가 활성화되어 있습니다 (초록색)
- AI 글쓰기 도우미 토글 표시
2. **플랜 제한** (canToggleMaster=false)
- AI 활성화 제한 도달 (빨간색)
- 해결 방법 안내: "다른 팀의 AI를 비활성화하거나 플랜을 업그레이드하세요"
- [플랜 업그레이드] 버튼
3. **비활성화** (teamAIEnabled=false, canToggleMaster=true)
- 스위치를 켜서 팀 AI 기능을 활성화하세요 (회색)
#### 주요 특징
1. **종속성 관리** 🔗:
- 마스터 스위치 OFF → 도우미 자동 비활성화
- 마스터 스위치 OFF → 도우미 UI 숨김
2. **플랜 제한 분리** 🎯:
- `maxAIEnabledTeams`: 팀 AI 마스터 스위치에만 적용
- AI 글쓰기 도우미: 플랜 제한 없음 (마스터만 체크)
3. **명확한 사용자 피드백** 💬:
- 스위치 비활성화 이유 명시
- 활성화 방법 안내
- 즉시 해결 가능 (업그레이드 버튼)
#### 참고 파일
**타입**: `src/types/team.ts` (Team.aiEnabled 필드 추가)
**Manager**:
- `src/managers/TeamManager.ts` - updateAIEnabled(), getAIEnabledTeamsCount()
**API**:
- `src/app/api/team/[teamId]/route.ts` - PUT 메서드 플랜 검증
**컴포넌트**:
- `src/components/team/TeamAISettings.tsx` - 2단계 UI
**다국어**: `messages/*.json` - team.manage.teamAI namespace (ko/en/ja)
---
### 23. 공개 팀 코드 보안 강화
#### 핵심 개념
**목적**: 공개 팀에서 팀 코드를 비멤버에게 노출하지 않아 무단 가입 방지
#### 보안 정책
```
멤버인 경우 → 팀 코드 표시 ✅
멤버 아닌 경우 → 팀 코드 숨김 🔒
```
#### 구현 레이어
**1. 타입 레벨** (`src/types/team.ts`):
```typescript
interface Team {
code?: string; // optional로 변경 (공개 팀에서 제거 가능)
}
```
**2. 서버 API 레벨**:
a) **공개 팀 목록** (`src/lib/server/team.ts - getPublicTeams`):
```typescript
const teams = querySnapshot.docs.slice(0, limit).map((doc) => {
const data = doc.data();
const {code, ...teamDataWithoutCode} = data; // 🔒 코드 제거
return { id: doc.id, ...teamDataWithoutCode };
}) as Team[];
```
b) **개별 공개 팀 조회** (`src/app/api/team/[teamId]/public/route.ts`):
```typescript
const isMember = uid && uid in team.members;
const teamData = isMember ? team : (() => {
const {code, ...teamWithoutCode} = team; // 🔒 멤버 아니면 제거
return teamWithoutCode as typeof team;
})();
```
**3. UI 컴포넌트 레벨** (`src/components/team/TeamCard.tsx`):
```tsx
{/* 팀 코드 (코드가 있을 때만 표시) */}
{team.code && (
<Box w="full" p={2} bg="bg.muted" borderRadius="md" textAlign="center">
<Text fontSize="sm" fontWeight="bold" color="brand.fg">
{team.code}
</Text>
</Box>
)}
```
#### 타입 안전성 처리
**필수 코드 사용처**:
- `StudentLoginFlow.tsx`: 팀 코드 null 체크
- `TeamInfoCard.tsx`: 복사 함수 null 체크
- `firebaseAuth.ts`: Non-null assertion (`team.code!`)
#### 주요 특징
1. **계층적 보안** 🛡️:
- 서버에서 데이터 제거 (클라이언트 우회 불가)
- 타입 시스템으로 안전성 보장
- UI에서 조건부 렌더링
2. **멤버 우대** 👥:
- 팀 멤버는 모든 정보 접근 가능
- 공개 팀 페이지에서도 코드 확인
3. **사용자 경험** ✨:
- 코드 없어도 UI 깨지지 않음
- 내 팀 페이지는 항상 코드 표시
#### 참고 파일
**타입**: `src/types/team.ts` (Team.code?: string)
**서버**:
- `src/lib/server/team.ts` - getPublicTeams()
- `src/app/api/team/[teamId]/public/route.ts` - GET
**컴포넌트**: `src/components/team/TeamCard.tsx`
---
### 24. AI 크레딧 환불 시스템
#### 핵심 개념
**목적**: 플랜 업그레이드 시 남은 기간 환불액을 AI 크레딧으로 지급하여 사용자 손실 최소화
#### 크레딧 시스템
**환율**:
```
100원 = 10 크레딧
```
**AI 기능별 비용**:
- AI 텍스트 분석: 20 크레딧/회
- AI 이미지 생성: 100 크레딧/회
- AI 글쓰기 도우미: 10 크레딧/회
**정책**:
- ✅ 영구 사용 (만료 없음)
- ✅ 월 제한 초과 시 크레딧으로 사용 가능
- ✅ 업그레이드 시에만 지급 (다운그레이드 X)
#### Prorated 환불 계산
```typescript
// Example
현재: Classroom (월 19,000원, 15일 남음)
→ Academy 구매 (월 39,000원)
계산:
- 남은 기간: 15일
- 일할 환불: 19,000 × (15/30) = 9,500원
- 크레딧 전환: 9,500 ÷ 10 = 950 크레딧
- 결과: aiCredits += 950
```
#### 데이터 모델
```typescript
// FirestoreUser 확장
interface FirestoreUser {
aiCredits?: number; // AI 크레딧 잔액 (기본값: 0)
}
// AI 기능 비용 상수
export const AI_FEATURE_COSTS = {
ANALYSIS: 20,
IMAGE_GENERATION: 100,
ASSISTANCE: 10,
} as const;
// 환율
export const CREDIT_EXCHANGE_RATE = {
KRW_PER_CREDIT: 10,
} as const;
```
#### 구매 플로우 (서버 원자적 처리)
```
클라이언트: userManager.purchasePlan(type, cycle, amount)
서버 (POST /api/user/purchase):
├─ Mock 결제 승인 [1.5s] ⏳
├─ 기존 플랜 확인 (getUser)
├─ 업그레이드인지 체크 (isUpgrade)
├─ Prorated 환불 계산 (calculateProratedRefund)
├─ 크레딧 전환 (convertKRWToCredits)
├─ Firestore 업데이트 (원자적):
│ ├─ plan 업데이트
│ └─ aiCredits increment
└─ creditsAdded 반환
클라이언트: Toast ("950 크레딧이 지급되었습니다")
```
#### 주요 함수
**credits.ts** (서버 유틸리티):
```typescript
// Prorated 환불 계산
calculateProratedRefund(currentPlan: UserPlan): number
// 업그레이드 여부
isUpgrade(currentPlan: PlanType, newPlan: PlanType): boolean
// 플랜별 월 가격
PLAN_MONTHLY_PRICES: Record<PlanType, number>
```
**planLimits.ts** (상수):
```typescript
// 크레딧 전환
convertKRWToCredits(krw: number): number
// AI 기능 비용
AI_FEATURE_COSTS
// 환율
CREDIT_EXCHANGE_RATE
```
#### UI 표시
**UserSettingsDialog (구독 탭)**:
```tsx
<GlassBox>
<HStack justify="space-between">
<Text>AI 크레딧</Text>
<Badge colorPalette="orange">
<LuSparkles /> 950 크레딧
</Badge>
</HStack>
<Text color="muted">
플랜 업그레이드 시 환불받은 크레딧입니다.
월 제한을 초과해도 크레딧으로 AI 기능을 사용할 수 있어요.
</Text>
</GlassBox>
```
#### 향후 확장 (크레딧 차감)
AI API들에서 플랜 제한 초과 시 크레딧 사용:
```typescript
// analyze-text, generate-image API
if (planLimitExceeded) {
if (user.aiCredits >= AI_FEATURE_COSTS.ANALYSIS) {
// 크레딧 차감
await adminFbClient.collection("users").doc(userId).update({
aiCredits: FieldValue.increment(-AI_FEATURE_COSTS.ANALYSIS),
});
} else {
return error("크레딧 부족");
}
}
```
#### 참고 파일
**타입**: `src/types/firestoreUser.ts` (aiCredits 필드)
**유틸리티**:
- `src/lib/server/credits.ts` - 환불 계산
- `src/lib/server/planLimits.ts` - 크레딧 상수
**API**: `src/app/api/user/purchase/route.ts`
**Manager**: `src/managers/UserManager.ts` - purchasePlan()
**컴포넌트**:
- `src/components/pricing/PurchaseConfirmationDialog.tsx` - 구매 확인
- `src/components/settings/UserSettingsDialog.tsx` - 크레딧 표시
**다국어**: `messages/*.json` - purchaseDialog, subscription.aiCredits
---
## 코드 품질 및 리팩토링
### 인증 플로우 리팩토링 (2025-12-09)
#### 목표
로그인/회원가입 플로우의 중복 로직 제거, 유지보수성 향상
#### 제거된 중복 로직
**1. 이메일/비밀번호 검증** (~10줄 × 2파일 중복)
- LoginForm.tsx, SignupForm.tsx에서 동일한 정규식 사용
- **해결**: `validation.ts` 유틸리티 생성
**2. Shake 애니메이션** (~25줄 × 6회 중복)
- 각 필드마다 useState + useEffect + motion.div 반복
- **해결**: `useShakeAnimation` 커스텀 훅
**3. SSE 스트리밍 처리** (~30줄 × 2함수 중복)
- mergeWithEmail/mergeWithGoogle에서 동일한 fetch + 파싱 로직
- **해결**: `sseStreamProcessor.ts` 공통 함수
**4. 병합 로직** (~70줄 × 2함수 중복)
- authStore의 mergeWithEmail/mergeWithGoogle 80% 동일 코드
- **해결**: `performMerge` 헬퍼 함수 (provider별 콜백)
**5. firebaseAuth 병합 함수** (~30줄 × 2함수 중복)
- mergeAndLoginWithEmail/mergeAndLoginWithGoogle 동일 패턴
- **해결**: `mergeAndLogin` 제네릭 함수
#### 생성된 파일
```
src/utils/validation.ts (+130줄) - 폼 검증 함수들
src/utils/sseStreamProcessor.ts (+45줄) - SSE 처리
src/hooks/useShakeAnimation.ts (+45줄) - Shake 애니메이션 훅
```
#### 수정된 파일 및 코드 감소량
| 파일 | 감소량 | 개선점 |
|------|--------|--------|
| `LoginForm.tsx` | -35줄 | validation 유틸 + shake 훅 적용 |
| `SignupForm.tsx` | -70줄 | validation 유틸 + shake 훅 적용 (4개 필드) |
| `LoginDialog.tsx` | -10줄 | createAsyncHandler 팩토리 패턴 |
| `firebaseAuth.ts` | -20줄 | mergeAndLogin 통합 함수 |
| `authStore.ts` | -80줄 | performMerge 헬퍼 함수 |
| **합계** | **-215줄** | **순 감소 ~105줄** |
#### 추가 개선사항
**Firestore 실시간 구독 (authStore.ts)**
- `initializeAuth()`에서 `onSnapshot` 구독 시작
- `aiCredits`, `plan`, `settings` 변경 시 자동 업데이트
- 로그아웃 시 구독 자동 해제 (`unsubscribeUserDoc`)
**API 버그 수정**
- `POST /api/user` 응답 형식 수정 (user → {user})
- `UserManager.createUser()` 방어 코드 강화
---
### 25. 인터랙션 페이지 UX 패턴 (Sticky Header + 7:3 그리드 + 애니메이션)
#### 핵심 개념
**목적**: 이미지를 보면서 실시간으로 인터랙션을 편집할 수 있는 최적화된 레이아웃
#### Sticky Header 패턴 (IntersectionObserver + Sentinel)
**기술**: `WritingDetailHeader.tsx`와 동일한 패턴 재사용
**구조**:
```tsx
// InteractionHeader.tsx
<>
{/* Sentinel - 스크롤 감지용 */}
<Box ref={sentinelRef} h="1px" />
{/* Sticky Header */}
<Box
position="sticky"
top="0rem"
zIndex={100}
bg={isSticky ? "bg/80" : "transparent"}
backdropFilter={isSticky ? "blur(12px)" : "none"}
borderBottom={isSticky ? "1px solid" : "none"}
boxShadow={isSticky ? "sm" : "none"}
transition="all 0.2s ease"
>
<Container maxW="95vw">
<HStack justify="space-between">
{/* 왼쪽: 뒤로가기 + 제목 */}
<HStack gap={3} flex={1} minW="0">
<BackButton />
<Heading
size={isSticky ? "xl" : "2xl"}
lineClamp={isSticky ? 1 : undefined}
transition="font-size 0.2s ease"
>
{title}
</Heading>
</HStack>
{/* 오른쪽: 배지 + 모드 전환 + 이미지 변경 */}
<HStack gap={3}>
<ScoreBadge size={isSticky ? "sm" : "md"} />
<AreaUnlockBadge size={isSticky ? "sm" : "md"} />
<EditorModeSwitch />
<Button size={isSticky ? "sm" : "md"}>
<LuImagePlus />
{!isSticky && t('changeImage')}
</Button>
</HStack>
</HStack>
</Container>
</Box>
</>
```
**IntersectionObserver 로직**:
```typescript
const [isSticky, setIsSticky] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsSticky(!entry.isIntersecting); // sentinel이 안 보이면 sticky
},
{ threshold: 0 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
```
**glassmorphism 효과**:
- 반투명 배경 (`bg/80`)
- 블러 효과 (`blur(12px)`)
- 부드러운 전환 (0.2s ease)
#### 7:3 그리드 레이아웃
**구조**:
```tsx
<Box
display="grid"
gridTemplateColumns="7fr 3fr" // 이미지 70%, 컨트롤러 30%
gap={6}
alignItems="start"
>
{/* 왼쪽: 이미지 영역 */}
<VStack gap={4} alignItems="stretch" minW="0" width="100%">
<InteractiveImageViewer mode="editor" ... />
</VStack>
{/* 오른쪽: 컨트롤러 영역 (스크롤 가능) */}
<Box
maxH="80dvh"
overflowY="auto"
overflowX="hidden"
pr={2}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-thumb': {
background: 'var(--chakra-colors-border-muted)',
borderRadius: '4px',
},
}}
>
<VStack gap={5} alignItems="stretch">
{/* 모드 전환 스위치 */}
{/* 에디터 버튼 (추가/삭제/숨기기) */}
{/* 영역 목록 (일반/고급 모드 공통) */}
{/* 파라미터 패널 (고급 모드 전용) */}
{/* 선택된 영역 설정 */}
</VStack>
</Box>
</Box>
```
**주요 특징**:
- ✅ **한 화면 편집**: 이미지 보면서 인터랙션 수정 (스크롤 불필요)
- ✅ **독립 스크롤**: 컨트롤러만 내부 스크롤 (80dvh 제한)
- ✅ **반응형 이미지**: `width="100%"` + `aspectRatio` 유지
- ✅ **Container 확장**: `maxW="95vw"` (화면 최대 활용)
#### framer-motion 애니메이션 패턴
**MotionBox 생성**:
```typescript
import {motion, AnimatePresence} from "framer-motion";
const MotionBox = motion.create(Box);
const MotionVStack = motion.create(VStack);
```
**AnimatePresence 활용**:
```tsx
{/* 고급 모드 에디터 컨트롤 패널 */}
<AnimatePresence mode="wait">
{showEditor && isAdvanced && (
<MotionBox
key="editor-controls"
initial={{opacity: 0, height: 0}}
animate={{opacity: 1, height: "auto"}}
exit={{opacity: 0, height: 0}}
transition={{duration: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
{/* 영역 목록 + 파라미터 패널 */}
</MotionBox>
)}
</AnimatePresence>
{/* 선택된 영역 설정 */}
<AnimatePresence mode="wait">
{selectedArea && (
<MotionVStack
key="selected-area-panel" // 영역 변경 시 재마운트 방지
initial={{opacity: 0, y: 10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: 10}}
transition={{duration: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
{/* 모션/이징/속도/강도 선택기 */}
</MotionVStack>
)}
</AnimatePresence>
{/* 조건부 UI (모션 있을 때만 표시) */}
<AnimatePresence mode="wait">
{selectedArea.movement.preset !== 'none' && (
<MotionBox
key="easing-selector"
initial={{opacity: 0, height: 0}}
animate={{opacity: 1, height: "auto"}}
exit={{opacity: 0, height: 0}}
transition={{duration: 0.25, ease: [0.22, 1, 0.36, 1]}}
>
<SimpleEasingSelector ... />
</MotionBox>
)}
</AnimatePresence>
```
**애니메이션 특징**:
- ✅ **Easing Curve**: `[0.22, 1, 0.36, 1]` (자연스러운 감속)
- ✅ **Duration**: 0.2~0.3초 (빠르면서도 부드러운)
- ✅ **mode="wait"**: 퇴장 완료 후 등장 시작
- ✅ **key 전략**: 고정 key로 영역 변경 시 재마운트 방지
#### 반응형 이미지 처리 (aspectRatio 충돌 해결)
**문제**: VStack `maxH` + Box `aspectRatio` 충돌 → 이미지 찌그러짐
**해결**:
```tsx
{/* VStack에서 maxH 제거 */}
<VStack gap={4} alignItems="stretch" minW="0" width="100%">
{/* aspectRatio가 자유롭게 작동 */}
</VStack>
```
**InteractiveImageViewer 개선**:
```tsx
// 에디터 모드
<Box
width="100%" // 부모 크기 따름
aspectRatio={imageSize.width / imageSize.height} // 비율 유지
>
<EditorCanvas ... />
</Box>
```
**라이브러리 동작**:
- EditorCanvas: `width: 100%, height: 100%` (부모 크기 따름)
- ImageDistortion: `width: 100%, height: 100%` (부모 크기 따름)
- ResizeObserver로 실제 렌더링 크기 측정
#### z-index 계층 구조
```
Navbar (z-index: 1000)
└─ Sticky Headers (z-index: 100)
├─ WritingDetailHeader
└─ InteractionHeader
└─ 하단 저장 버튼 바 (z-index: 10)
```
#### 주요 컴포넌트
| 컴포넌트 | 파일 | 역할 |
|---------|------|------|
| **InteractionHeader** | `src/components/interaction/InteractionHeader.tsx` | Sticky header (제목, 배지, 모드 전환, 이미지 변경) |
| **CustomAreaList** | `src/components/interaction/CustomAreaList.tsx` | 왜곡 영역 목록 (일반/고급 모드 공통) |
| **CustomParameterPanel** | `src/components/interaction/CustomParameterPanel.tsx` | 파라미터 패널 (고급 모드 전용) |
#### 성능 최적화
- **IntersectionObserver**: scroll 이벤트 대비 성능 우수
- **ResizeObserver**: Canvas 크기 동적 조정
- **AnimatePresence**: DOM 제거 시에도 애니메이션
- **key 전략**: 불필요한 재마운트 방지
---
## 참고 문서
- [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조
- [ROADMAP.md](./ROADMAP.md) - 개발 로드맵
- [CLAUDE.md](./CLAUDE.md) - Claude Code 가이드
---
© 2024 BlueNovaLab. All rights reserved.