2025-11-10 08:17:05 +00:00

653 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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-11-10 (주제 변경 경고 Dialog, 다중 글조각 관리)
---
## 기술 스택
### Core Framework
| 기술 | 버전 | 용도 |
|-----|------|------|
| **Next.js** | 16.0.0 | React 프레임워크 (App Router) |
| **React** | 19.2.0 | UI 라이브러리 |
| **TypeScript** | 5.x | 타입 안전성 |
### UI & Styling
| 기술 | 버전 | 용도 |
|-----|------|------|
| **Chakra UI** | v3.28.0 | 컴포넌트 라이브러리 |
| **Emotion** | 11.14.0 | CSS-in-JS |
| **Framer Motion** | 12.23.24 | 애니메이션 라이브러리 |
| **React Icons** | 5.5.0 | 아이콘 세트 |
| **Tiptap** | latest | 리치 텍스트 에디터 |
### Backend & Database
| 기술 | 버전 | 용도 |
|-----|------|------|
| **Firebase** | 12.4.0 | BaaS (Backend as a Service) |
| **Firebase Auth** | - | 사용자 인증 |
| **Firestore** | - | NoSQL 데이터베이스 (글 저장) |
| **Redis** | - | Cache 데이터 베이스 (예정) |
### 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 규칙 적용
---
## 환경 변수
### `.env.local` 파일 구조
```bash
# Firebase 설정 (필수)
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
# 사이트 URL (프로덕션)
NEXT_PUBLIC_URL=https://raonnuri.com
# API Base URL (선택적, 기본값: /api)
NEXT_PUBLIC_API_URL=/api
```
### 환경 변수 사용 예시
```typescript
// src/config/firebase.ts
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
// ...
};
```
---
## Firebase 설정
### 인증 제공자
| 제공자 | 상태 | 설정 위치 | 용도 |
|-------|------|----------|------|
| **이메일/비밀번호** | ✅ 활성화 | Firebase Console > Authentication | 정식 계정 (학부모/고학년) |
| **Google OAuth** | ✅ 활성화 | Firebase Console > Authentication | 정식 계정 (소셜 로그인) |
| **Anonymous** | ✅ 활성화 | Firebase Console > Authentication | **학생 팀 코드 로그인** |
| **네이버** | 🔜 준비 중 | - | 정식 계정 (소셜 로그인) |
| **카카오** | 🔜 준비 중 | - | 정식 계정 (소셜 로그인) |
### Firestore 데이터베이스
```
프로젝트 루트
└── firestore.rules # Firestore 보안 규칙 (예정)
```
**컬렉션 구조**:
- `writings/` ✅ - 작성한 글
```typescript
{
userId: string;
title: string;
content: string; // HTML
wordCount: number;
charCount: number;
status: 'draft' | 'published';
topicId?: string | null; // 주제 ID (null은 자유 주제)
createdAt: Timestamp;
updatedAt: Timestamp;
}
```
- `topics/` ✅ - 글쓰기 주제 (팀 주제 + 개인 주제)
```typescript
{
title: string;
description: string;
category: TopicCategory; // Enum: daily | imagination | emotion | experience
difficulty: TopicDifficulty; // Enum: easy | medium | hard
ownerType: TopicOwnerType; // Enum: system | team | personal
ownerId?: string; // 팀 주제: teamId, 개인 주제: userId
keywords: string[];
examplePrompts: string[];
titleTemplate?: string; // 제목 템플릿
contentTemplate?: string; // 내용 템플릿
usageCount: number;
createdAt: Timestamp;
updatedAt: Timestamp;
createdBy: string;
isActive: boolean;
}
// 팀 주제: ownerId = teamId 직접 사용 (예: abc123)
// 유틸 함수: getTeamOwnerId(teamId), extractTeamId(ownerId) - 단순 반환
```
- `classrooms/` ✅ - **팀 (팀 코드 시스템)**
```typescript
{
code: string; // "춤추는 파란 사자" (한글 팀 코드)
name: string; // "2학년 1반"
ownerId: string; // 팀 소유자 UID
securityMode: 'simple' | 'normal' | 'open';
requirePin: boolean;
allowAnonymousJoin: boolean;
studentIds: string[]; // students 컬렉션 참조
createdAt: Timestamp;
updatedAt: Timestamp;
isActive: boolean;
}
```
- `students/` ✅ - **학생 계정 (독립적, Anonymous Auth 기반)**
```typescript
{
firebaseUid: string; // Anonymous Auth UID
linkedUserId?: string; // 연결된 정식 계정 (선택적, 1:1)
name: string;
pinHash?: string; // SHA-256 해시
classroomIds: string[]; // 다중 팀 지원
isAnonymous: true;
createdAt: Timestamp;
lastLoginAt: Timestamp;
}
```
- `users/` 🔜 - 사용자 프로필 및 진행 상황 (정식 계정)
```typescript
{
uid: string;
email: string;
ownedStudentIds: string[]; // students 컬렉션 ID 배열
role: 'student' | 'parent' | 'teacher';
// ...
}
```
- `lessons/` 🔜 - 학습 레슨
- `stickers/` 🔜 - 스티커 마스터 데이터
- `userStickers/` 🔜 - 사용자별 스티커 획득 기록
---
## 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 (기존)
├─> **loginAsStudent(classCode, name, pin?)** - 팀 코드 로그인
├─> **switchStudent(student)** - 학생 전환
├─> **linkCurrentStudentWithEmail()** - 계정 연결
└─> **linkCurrentStudentWithGoogle()** - Google 계정 연결
3. 인증 기반 라우팅
├─> 랜딩 페이지 (/)
│ └─> 로그인 상태 확인
│ └─> isAuthenticated || currentStudent ? redirect(/home) : 랜딩 표시
└─> 유저 홈 (/home)
└─> 인증 상태 확인
└─> !currentStudent ? redirect(/) : 대시보드 표시
4. 보호된 페이지 패턴
└─> useAuthStore()로 currentStudent 확인
└─> 미인증 시 redirect(/) 또는 openLoginDialog()
```
### 3. 글쓰기 및 저장 로직 (Manager 패턴 적용)
```
1. 사용자가 /write 페이지 접근
├─> LocalStorage에서 임시 저장된 글 불러오기 (DraftManager)
└─> 에디터에 복원
2. 주제 선택
├─> 작성 중인 내용 없음: 바로 주제 변경 + 템플릿 적용
└─> 작성 중인 내용 있음:
├─> 🆕 **경고 Dialog 표시**
│ ├─> "제목과 내용이 모두 초기화됩니다"
│ └─> "임시 저장된 내용은 저장된 글조각에서 복구 가능"
├─> 사용자 선택:
│ ├─> "취소": 주제 변경 취소
│ └─> "확인하고 초기화": 주제 변경 + 내용 초기화
└─> 확인 시 템플릿 미리채우기 (제목/내용)
3. 글 작성 중
├─> 제목: Editable 컴포넌트 (인라인 편집)
├─> 본문: Tiptap 순수 텍스트 에디터 (포맷팅 비활성화)
│ └─> 초등학생을 위한 단순한 텍스트 입력에 집중
├─> 2초마다 LocalStorage에 자동 저장 (DraftManager, FIFO)
├─> 저장 상태 표시 (저장 중 → 저장됨 → 시간)
└─> 하단 고정 버튼 (취소, 저장)
4. 저장 버튼 클릭
├─> 미인증 시: 로그인 다이얼로그 표시
└─> 인증 시:
└─> writingManager.createWriting() 호출
├─> 유효성 검사 (제목, 내용)
├─> 텍스트 통계 계산 (글자 수, 단어 수)
├─> Firestore에 저장
└─> LocalStorage draft 삭제 후 /home 이동
5. WritingManager API
├─> createWriting() - 새 글 작성
├─> getWriting() - 글 조회
├─> getUserWritings() - 사용자 글 목록
├─> getRecentWritings() - 최근 글 목록
├─> updateWriting() - 글 수정
└─> deleteWriting() - 글 삭제
6. DraftManager (클라이언트 전용)
├─> saveDraft() - 글조각 저장 (최대 10개, FIFO)
├─> getDraft() - 글조각 조회
├─> getAllDrafts() - 전체 글조각 목록
├─> deleteDraft() - 글조각 삭제
├─> setCurrentDraftId() - 현재 편집 중인 draft 설정
└─> migrateLegacyDraft() - 기존 단일 draft 마이그레이션
```
### 4. 상태 관리 원칙
- **전역 상태**: Zustand 사용 (인증, 사용자 진행 상황, 알림)
- **로컬 상태**: `useState` 사용 (폼 입력, UI 토글, 에디터 내용)
- **로컬 저장소**: LocalStorage (임시 저장 글)
- **서버 상태**: Firestore 직접 호출 (React Query는 나중에 고려)
### 5. 태그 입력 필드 패턴 (Tag Input Field)
CreateTopicDialog의 제목 템플릿 입력에 사용되는 고급 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. 정식 계정 연결 (선택적, 학부모/고학년)
├─> 설정 → "내 계정 만들기"
├─> 이메일 회원가입 또는 Google 로그인
├─> linkWithCredential() 호출
│ └─ Anonymous(anon123) → Email(user456) 전환
├─> Firestore 연결:
│ ├─ students/studentDoc.linkedUserId = user456
│ └─ users/user456.ownedStudentIds = [studentDoc]
└─> 이후 user456으로 로그인 가능 (currentStudent 자동 설정)
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/StudentManager.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개 엔드포인트)
---
## 참고 문서
- [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조
- [ROADMAP.md](./ROADMAP.md) - 개발 로드맵
- [CLAUDE.md](./CLAUDE.md) - Claude Code 가이드
---
© 2024 BlueNovaLab. All rights reserved.