# 라온누리 - 데이터 모델 및 스키마 > 최종 업데이트: 2025-11-07 이 문서는 Firestore 데이터베이스 구조와 TypeScript 타입 정의를 설명합니다. **참고**: API 타입 정의는 [API_SPEC.md](./API_SPEC.md)를 참조하세요. --- ## Firestore 컬렉션 구조 ### 컬렉션 개요 ``` firestore ├── teams/ # ✅ 팀 (팀 코드 시스템) ├── students/ # ✅ 학생 계정 (Anonymous Auth) ├── users/ # 🔜 사용자 프로필 및 진행 상황 ├── writings/ # ✅ 작성한 글 ├── topics/ # ✅ 글쓰기 주제 ├── lessons/ # 🔜 학습 레슨 ├── stickers/ # 🔜 스티커 마스터 데이터 └── userStickers/ # 🔜 사용자별 스티커 획득 기록 ``` **범례**: - ✅ 구현 완료 - 🔜 구현 예정 --- ## 1. Team (팀) ✅ **컬렉션**: `teams/{teamId}` ### 스키마 ```typescript interface Team { id: string; // 문서 ID code: string; // 팀 코드 (예: "춤추는파란사자") name: string; // 팀 이름 (예: "2학년 1반") ownerId: string; // 팀 소유자 UID (정식 계정) // 보안 설정 securityMode: 'simple' | 'normal' | 'open'; requirePin: boolean; // PIN 입력 필요 여부 allowAnonymousJoin: boolean; // 명단 없는 학생 자동 가입 허용 // 멤버 studentIds: string[]; // students 컬렉션 참조 // 타임스탬프 createdAt: Timestamp; updatedAt: Timestamp; isActive: boolean; // 활성 상태 } ``` ### 보안 모드 | 모드 | requirePin | allowAnonymousJoin | 설명 | |------|-----------|-------------------|------| | `simple` | false | true | 팀 코드 + 이름 (초등 저학년) | | `normal` | true | false | 팀 코드 + 이름 + PIN (보안 강화) | | `open` | false | true | 누구나 자유롭게 참여 | ### 예시 데이터 ```json { "id": "team_abc123", "code": "춤추는파란사자", "name": "2학년 1반", "ownerId": "teacher_xyz", "securityMode": "simple", "requirePin": false, "allowAnonymousJoin": true, "studentIds": ["student_001", "student_002", "student_003"], "createdAt": "2024-11-06T00:00:00Z", "updatedAt": "2024-11-07T10:00:00Z", "isActive": true } ``` ### 인덱스 - `code` (단일 필드, 고유) - `ownerId` + `isActive` (복합 인덱스) **TypeScript**: `src/types/team.ts` --- ## 2. Student (학생 계정) ✅ **컬렉션**: `students/{studentId}` ### 스키마 ```typescript interface Student { id: string; // 문서 ID firebaseUid: string; // Firebase Anonymous Auth UID linkedUserId?: string; // 연결된 정식 계정 UID (1:1, 선택적) // 기본 정보 name: string; // 학생 이름 // 보안 pinHash?: string; // SHA-256 해시된 PIN // 팀 정보 teamIds: string[]; // 속한 팀 ID 배열 (다중 팀 지원) // 메타데이터 isAnonymous: boolean; // true (Anonymous Auth) createdAt: Timestamp; lastLoginAt: Timestamp; } ``` ### 예시 데이터 ```json { "id": "student_001", "firebaseUid": "anon_xyz123", "linkedUserId": "user_parent_abc", "name": "김민지", "pinHash": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", "teamIds": ["team_abc123", "team_def456"], "isAnonymous": true, "createdAt": "2024-11-06T09:00:00Z", "lastLoginAt": "2024-11-07T14:30:00Z" } ``` ### 인덱스 - `firebaseUid` (단일 필드, 고유) - `linkedUserId` (단일 필드) - `teamIds` (array-contains) **TypeScript**: `src/types/student.ts` --- ## 3. User (사용자) **컬렉션**: `users/{userId}` (🔜 구현 예정) ### 스키마 ```typescript interface User { // Firebase Auth 정보 uid: string; // Firebase UID (문서 ID와 동일) email: string; // 이메일 displayName: string; // 표시 이름 photoURL?: string; // 프로필 사진 URL (선택) createdAt: Timestamp; // 가입일 updatedAt: Timestamp; // 최종 수정일 // 게임화 데이터 level: number; // 현재 레벨 (1부터 시작) experience: number; // 현재 경험치 (XP) totalStickers: number; // 획득한 스티커 총 개수 // 학습 통계 writingCount: number; // 작성한 글 수 completedLessons: string[]; // 완료한 레슨 ID 배열 currentStreak: number; // 연속 출석일 longestStreak: number; // 최장 연속 출석일 // 활동 기록 lastLoginAt: Timestamp; // 마지막 로그인 시간 lastWritingAt?: Timestamp; // 마지막 글 작성 시간 } ``` ### 예시 데이터 ```json { "uid": "abc123xyz", "email": "student@example.com", "displayName": "김학생", "photoURL": "https://example.com/photo.jpg", "createdAt": "2024-10-28T00:00:00Z", "updatedAt": "2024-10-28T10:30:00Z", "level": 5, "experience": 450, "totalStickers": 12, "writingCount": 8, "completedLessons": ["lesson_001", "lesson_002", "lesson_003"], "currentStreak": 3, "longestStreak": 7, "lastLoginAt": "2024-10-28T10:00:00Z", "lastWritingAt": "2024-10-27T15:30:00Z" } ``` ### 인덱스 - `email` (단일 필드) - `level` (단일 필드, 내림차순) - `writingCount` (단일 필드, 내림차순) --- ## 4. Writing (작성글) ✅ **컬렉션**: `writings/{writingId}` ### 스키마 ```typescript interface Writing { id: string; // 문서 ID userId: string; // 작성자 UID (TODO: studentId로 변경 예정) topicId?: string | null; // 주제 ID (null은 자유 주제) // 글 내용 title: string; // 제목 content: string; // 본문 내용 (HTML) wordCount: number; // 단어 수 charCount: number; // 글자 수 // 상태 status: 'draft' | 'published'; // 임시저장/발행 // 타임스탬프 createdAt: Timestamp; // 최초 작성일 updatedAt: Timestamp; // 최종 수정일 } ``` **현재 구현**: - ✅ CRUD 기능 완료 (WritingManager) - 🔜 피드백 시스템 (향후 추가) - 🔜 userId → studentId 마이그레이션 예정 ### 예시 데이터 ```json { "id": "writing_001", "userId": "abc123xyz", "topicId": "topic_daily_001", "title": "내가 좋아하는 계절", "content": "나는 가을을 제일 좋아해요. 왜냐하면...", "wordCount": 120, "status": "submitted", "createdAt": "2024-10-27T14:00:00Z", "updatedAt": "2024-10-27T15:00:00Z", "submittedAt": "2024-10-27T15:00:00Z", "feedback": { "authorId": "parent_abc", "authorName": "김엄마", "content": "계절의 특징을 잘 표현했어요!", "rating": 5, "createdAt": "2024-10-27T20:00:00Z" } } ``` ### 인덱스 - `userId` + `createdAt` (복합 인덱스, 내림차순) - `topicId` + `createdAt` (복합 인덱스, 내림차순) - `status` + `createdAt` (복합 인덱스) --- ## 5. Topic (글쓰기 주제) ✅ **컬렉션**: `topics/{topicId}` ### 스키마 ```typescript interface Topic { id: string; // 문서 ID title: string; // 주제 제목 description: string; // 주제 설명 // 분류 category: 'daily' | 'imagination' | 'emotion' | 'experience'; difficulty: 'easy' | 'medium' | 'hard'; // 소유자 정보 ownerType: 'system' | 'team' | 'personal'; ownerId: string; // 팀 주제: teamId, 개인 주제: userId // 메타데이터 keywords: string[]; // 관련 키워드 examplePrompts: string[]; // 예시 질문/프롬프트 // 템플릿 (선택적) titleTemplate?: string; // 제목 템플릿 (예: "{date} 일기") contentTemplate?: string; // 내용 템플릿 // 통계 usageCount: number; // 사용된 횟수 // 타임스탬프 createdAt: Timestamp; // 생성일 updatedAt: Timestamp; // 수정일 // 관리자 정보 createdBy: string; // 작성자 UID isActive: boolean; // 활성화 여부 } ``` **주제 소유 유형**: - `system`: 시스템 기본 주제 (모든 사용자) - 미래 확장용 - `team`: 팀 주제 (팀 멤버만, `ownerId = teamId`) - `personal`: 개인 주제 (작성자만) **현재 구현**: - ✅ CRUD 기능 완료 (TopicManager) - ✅ 개인 주제 생성/수정/삭제 - ✅ 팀 주제 생성/수정/삭제 - ✅ 템플릿 처리 (클라이언트) - ✅ TopicSelector에 팀/개인 배지 표시 ### 카테고리 설명 | 카테고리 | 설명 | 예시 | |---------|------|------| | `daily` | 일상 경험 | "오늘 있었던 일", "가족과 함께한 시간" | | `imagination` | 상상과 창작 | "만약 내가 슈퍼히어로라면", "꿈속에서의 모험" | | `emotion` | 감정 표현 | "가장 행복했던 순간", "속상했던 경험" | | `experience` | 특별한 경험 | "처음 해본 것", "여행에서의 추억" | ### 예시 데이터 ```json { "id": "topic_daily_001", "title": "내가 좋아하는 계절", "description": "사계절 중 가장 좋아하는 계절과 그 이유를 써보아요.", "category": "daily", "difficulty": "easy", "keywords": ["계절", "봄", "여름", "가을", "겨울", "날씨"], "examplePrompts": [ "어떤 계절을 가장 좋아하나요?", "그 계절에는 무엇을 하나요?", "왜 그 계절이 좋은가요?" ], "usageCount": 145, "createdAt": "2024-10-01T00:00:00Z", "updatedAt": "2024-10-01T00:00:00Z", "createdBy": "admin_001", "isActive": true } ``` ### 인덱스 - `category` + `difficulty` (복합 인덱스) - `isActive` + `usageCount` (복합 인덱스, 내림차순) --- ## 6. Lesson (학습 레슨) 🔜 **컬렉션**: `lessons/{lessonId}` (구현 예정) ### 스키마 ```typescript interface Lesson { id: string; // 문서 ID title: string; // 레슨 제목 description: string; // 레슨 설명 // 분류 level: number; // 추천 레벨 (1-100) category: string; // 카테고리 (예: "문단 쓰기", "비유 표현") order: number; // 순서 (같은 카테고리 내) // 콘텐츠 content: { theory: string; // 이론 설명 (마크다운) examples: string[]; // 예시 문장들 exercises: Exercise[]; // 연습 문제 }; // 선행 조건 requiredLessons?: string[]; // 선행 레슨 ID 배열 // 보상 reward: { experience: number; // 획득 경험치 stickers?: string[]; // 획득 가능한 스티커 ID }; // 통계 completionCount: number; // 완료한 학생 수 averageScore?: number; // 평균 점수 (0-100) // 타임스탬프 createdAt: Timestamp; updatedAt: Timestamp; // 관리자 createdBy: string; isActive: boolean; } interface Exercise { id: string; type: 'multiple_choice' | 'short_answer' | 'writing'; question: string; options?: string[]; // 객관식인 경우 correctAnswer?: string | number; explanation?: string; // 해설 } ``` ### 예시 데이터 ```json { "id": "lesson_001", "title": "문장 부호 사용하기", "description": "마침표, 물음표, 느낌표를 올바르게 사용하는 방법을 배워요.", "level": 1, "category": "기초 문법", "order": 1, "content": { "theory": "# 문장 부호란?\n문장의 끝에는 마침표(.), 물음표(?), 느낌표(!)를 사용해요.", "examples": [ "오늘은 날씨가 좋아요.", "너는 어디에 가니?", "와, 정말 멋지다!" ], "exercises": [ { "id": "ex_001", "type": "multiple_choice", "question": "다음 중 올바른 문장은?", "options": [ "오늘은 날씨가 좋아요", "오늘은 날씨가 좋아요.", "오늘은 날씨가 좋아요?" ], "correctAnswer": 1, "explanation": "평서문은 마침표(.)로 끝나요." } ] }, "reward": { "experience": 50, "stickers": ["sticker_beginner_001"] }, "completionCount": 234, "averageScore": 87.5, "createdAt": "2024-10-01T00:00:00Z", "updatedAt": "2024-10-01T00:00:00Z", "createdBy": "admin_001", "isActive": true } ``` ### 인덱스 - `category` + `order` (복합 인덱스, 오름차순) - `level` + `order` (복합 인덱스, 오름차순) --- ## 7. Sticker (스티커) 🔜 **컬렉션**: `stickers/{stickerId}` (구현 예정) ### 스키마 ```typescript interface Sticker { id: string; // 문서 ID name: string; // 스티커 이름 imageUrl: string; // 이미지 URL description: string; // 설명 // 분류 category: 'learning' | 'challenge' | 'special'; // 카테고리 rarity: 'common' | 'rare' | 'epic' | 'legendary'; // 희귀도 // 획득 조건 unlockCondition: { type: 'writing_count' | 'lesson_completion' | 'level' | 'streak' | 'manual'; value?: number; // 조건 값 (type에 따라 다름) lessonId?: string; // lesson_completion인 경우 }; // 통계 unlockCount: number; // 획득한 사용자 수 // 타임스탬프 createdAt: Timestamp; createdBy: string; isActive: boolean; } ``` ### 희귀도 및 카테고리 | 희귀도 | 설명 | 예시 | |-------|------|------| | `common` | 일반 | 기본 달성 스티커 | | `rare` | 레어 | 10개 글 작성 | | `epic` | 에픽 | 50개 글 작성, 레벨 20 달성 | | `legendary` | 전설 | 100개 글 작성, 모든 레슨 완료 | | 카테고리 | 설명 | |---------|------| | `learning` | 학습 관련 (레슨 완료) | | `challenge` | 도전 과제 (글 수, 연속 출석) | | `special` | 특별 이벤트 | ### 예시 데이터 ```json { "id": "sticker_beginner_001", "name": "첫 글쓰기", "imageUrl": "https://example.com/stickers/first_writing.png", "description": "첫 번째 글을 완성했어요!", "category": "challenge", "rarity": "common", "unlockCondition": { "type": "writing_count", "value": 1 }, "unlockCount": 856, "createdAt": "2024-10-01T00:00:00Z", "createdBy": "admin_001", "isActive": true } ``` ### 인덱스 - `category` + `rarity` (복합 인덱스) - `unlockCount` (단일 필드, 내림차순) --- ## 8. UserSticker (사용자 스티커) 🔜 **컬렉션**: `userStickers/{userStickerId}` (구현 예정) ### 스키마 ```typescript interface UserSticker { id: string; // 문서 ID userId: string; // 사용자 UID stickerId: string; // 스티커 ID unlockedAt: Timestamp; // 획득 시간 } ``` ### 예시 데이터 ```json { "id": "us_abc123_sticker001", "userId": "abc123xyz", "stickerId": "sticker_beginner_001", "unlockedAt": "2024-10-27T15:30:00Z" } ``` ### 인덱스 - `userId` + `unlockedAt` (복합 인덱스, 내림차순) - `userId` + `stickerId` (복합 인덱스, 중복 방지용) --- ## TypeScript 타입 정의 파일 ### 데이터 모델 타입 모든 데이터 모델 타입은 `src/types/` 디렉토리에 정의됩니다. ``` src/types/ ├── team.ts # ✅ Team 데이터 모델 ├── student.ts # ✅ Student 데이터 모델 ├── writing.ts # ✅ Writing 데이터 모델 ├── topic.ts # ✅ Topic 데이터 모델 ├── user.ts # 🔜 User 관련 타입 (예정) ├── lesson.ts # 🔜 Lesson 관련 타입 (예정) ├── sticker.ts # 🔜 Sticker 관련 타입 (예정) └── api/ # ✅ API Request/Response 타입 ├── team.ts # Team API 타입 (10개 엔드포인트) ├── student.ts # Student API 타입 (13개 엔드포인트) ├── writing.ts # Writing API 타입 (6개 엔드포인트) └── topic.ts # Topic API 타입 (6개 엔드포인트) ``` ### 사용 예시 ```typescript // 데이터 모델 import import type { Team } from "@/types/team"; import type { Student } from "@/types/student"; import type { Writing } from "@/types/writing"; // API 타입 import import type { CreateTeamRequest, CreateTeamResponse } from "@/types/api/team"; import type { GetStudentsByTeamResponse } from "@/types/api/student"; // Manager에서 사용 import { teamManager, studentManager } from "@/managers"; const teams = await teamManager.getMyTeams(); // Team[] 반환 ``` ### API 공통 타입 `src/types/api.ts`에 정의된 공통 타입: ```typescript // 표준 응답 형식 interface ApiResponse { success: boolean; data?: T; error?: ApiError; } // 에러 형식 interface ApiError { code: string; message: string; details?: any; } // HTTP Method Enum enum HttpMethod { GET = "GET", POST = "POST", PUT = "PUT", DELETE = "DELETE", } ``` --- ## Firestore 보안 규칙 ### 파일 위치 - `firestore.rules` (프로젝트 루트) ### 기본 규칙 예시 **참고**: 실제 구현 시 Next.js API Routes/Server Actions에서 권한 체크 수행 ```javascript rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // 팀: 읽기는 모두 가능, 쓰기는 서버에서만 match /teams/{teamId} { allow read: if request.auth != null; allow write: if false; // API에서만 쓰기 (권한 체크) } // 학생: 본인 또는 연결된 계정만 읽기, 쓰기는 서버에서만 match /students/{studentId} { allow read: if request.auth != null && ( request.auth.uid == resource.data.firebaseUid || request.auth.uid == resource.data.linkedUserId ); allow write: if false; // API에서만 쓰기 } // 사용자는 자신의 문서만 읽고 쓸 수 있음 match /users/{userId} { allow read, write: if request.auth != null && request.auth.uid == userId; } // 작성글: 작성자만 읽고 쓸 수 있음 match /writings/{writingId} { allow read: if request.auth != null && resource.data.userId == request.auth.uid; allow write: if false; // API에서만 쓰기 } // 주제: 모든 인증된 사용자가 읽을 수 있음 match /topics/{topicId} { allow read: if request.auth != null; allow write: if false; // API에서만 쓰기 } // 레슨: 모든 인증된 사용자가 읽을 수 있음 match /lessons/{lessonId} { allow read: if request.auth != null; allow write: if false; } // 스티커: 모든 인증된 사용자가 읽을 수 있음 match /stickers/{stickerId} { allow read: if request.auth != null; allow write: if false; } // 사용자 스티커: 자신의 것만 읽을 수 있음 match /userStickers/{userStickerId} { allow read: if request.auth != null && resource.data.userId == request.auth.uid; allow write: if false; // 서버에서만 부여 } } } ``` **보안 참고사항**: - 모든 쓰기 작업은 Next.js API Routes에서 수행 - API에서 Firebase Admin SDK 사용하여 Firestore 접근 - 클라이언트 매니저는 API 호출만 수행 (Firestore 직접 접근 안 함) --- ## 관련 문서 - [API_SPEC.md](./API_SPEC.md) - API 명세서 (35개 엔드포인트) - [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조 - [TECH_STACK.md](./TECH_STACK.md) - 기술 스택 - [ROADMAP.md](./ROADMAP.md) - 개발 로드맵 - [CLAUDE.md](./CLAUDE.md) - Claude Code 개발 가이드 --- © 2024 BlueNovaLab. All rights reserved.