2557 lines
82 KiB
Markdown
2557 lines
82 KiB
Markdown
# 라온누리 - 기술 스택 및 개발 환경
|
||
|
||
> 최종 업데이트: 2025-12-08 (AI 크레딧 환불 시스템, 구매 플로우)
|
||
|
||
---
|
||
|
||
## 기술 스택
|
||
|
||
### 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)** |
|
||
|
||
### 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()` 방어 코드 강화
|
||
|
||
---
|
||
|
||
## 참고 문서
|
||
|
||
- [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조
|
||
- [ROADMAP.md](./ROADMAP.md) - 개발 로드맵
|
||
- [CLAUDE.md](./CLAUDE.md) - Claude Code 가이드
|
||
|
||
---
|
||
|
||
© 2024 BlueNovaLab. All rights reserved. |