RaonNuri_Public_Documents/DATA_MODELS.md
2025-11-10 01:43:07 +00:00

20 KiB

라온누리 - 데이터 모델 및 스키마

최종 업데이트: 2025-11-07

이 문서는 Firestore 데이터베이스 구조와 TypeScript 타입 정의를 설명합니다.

참고: API 타입 정의는 API_SPEC.md를 참조하세요.


Firestore 컬렉션 구조

컬렉션 개요

firestore
├── teams/                    # ✅ 팀 (팀 코드 시스템)
├── students/                 # ✅ 학생 계정 (Anonymous Auth)
├── users/                    # 🔜 사용자 프로필 및 진행 상황
├── writings/                 # ✅ 작성한 글
├── topics/                   # ✅ 글쓰기 주제
├── lessons/                  # 🔜 학습 레슨
├── stickers/                 # 🔜 스티커 마스터 데이터
└── userStickers/             # 🔜 사용자별 스티커 획득 기록

범례:

  • 구현 완료
  • 🔜 구현 예정

1. Team (팀)

컬렉션: teams/{teamId}

스키마

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 누구나 자유롭게 참여

예시 데이터

{
  "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}

스키마

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

예시 데이터

{
  "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} (🔜 구현 예정)

스키마

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;      // 마지막 글 작성 시간
}

예시 데이터

{
  "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}

스키마

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 마이그레이션 예정

예시 데이터

{
  "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}

스키마

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 특별한 경험 "처음 해본 것", "여행에서의 추억"

예시 데이터

{
  "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} (구현 예정)

스키마

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

예시 데이터

{
  "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} (구현 예정)

스키마

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 특별 이벤트

예시 데이터

{
  "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} (구현 예정)

스키마

interface UserSticker {
  id: string;                     // 문서 ID
  userId: string;                 // 사용자 UID
  stickerId: string;              // 스티커 ID
  unlockedAt: Timestamp;          // 획득 시간
}

예시 데이터

{
  "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개 엔드포인트)

사용 예시

// 데이터 모델 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에 정의된 공통 타입:

// 표준 응답 형식
interface ApiResponse<T> {
  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에서 권한 체크 수행

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 직접 접근 안 함)

관련 문서


© 2024 BlueNovaLab. All rights reserved.