32 KiB
라온누리 - 데이터 모델 및 스키마
최종 업데이트: 2025-11-28 (댓글 시스템 추가)
이 문서는 Firestore 데이터베이스 및 Firebase Realtime Database 구조와 TypeScript 타입 정의를 설명합니다.
참고: API 타입 정의는 API_SPEC.md를 참조하세요.
데이터베이스 구조
Firestore (영구 데이터)
firestore
├── teams/ # ✅ 팀 (팀 코드 시스템)
├── users/ # ✅ 사용자 프로필 및 메타데이터
├── writings/ # ✅ 작성한 글
├── topics/ # ✅ 글쓰기 주제
├── comments/ # 🆕 댓글 (계층 구조 지원)
├── userReactions/ # 🆕 댓글 반응
├── patternAnalyses/ # ✅ 패턴 분석 결과 (contentHash 캐싱)
├── lessons/ # 🔜 학습 레슨
├── stickers/ # 🔜 스티커 마스터 데이터
└── userStickers/ # 🔜 사용자별 스티커 획득 기록
Firebase Realtime Database (실시간 데이터)
realtime-db
├── monitoring/ # 🆕 실시간 글쓰기 모니터링
│ └── {teamId}/
│ └── {topicId}/
│ └── {userId}/
├── previewRequests/ # 🆕 미리보기 요청
│ └── {userId}/
├── previewResponses/ # 🆕 미리보기 응답
│ └── {requestId}/
└── teamCodeReservations/ # 🆕 팀 코드 예약 (Race Condition 방지)
└── {code-with-hyphens}/ # 예: "춤추는-파란-사자"
범례:
- ✅ 구현 완료
- 🔜 구현 예정
Realtime Database 데이터 모델
teamCodeReservations (팀 코드 예약) ✅
경로: teamCodeReservations/{code-with-hyphens}
목적: 팀 코드 생성 시 Race Condition 방지 (Atomic 예약)
스키마:
interface TeamCodeReservation {
userId: string; // 예약한 사용자 UID
createdAt: number; // 예약 시각 (timestamp)
expiresAt: number; // 만료 시각 (createdAt + 5분)
locale?: string; // 생성 언어 (ko, en, ja)
}
예시 데이터:
{
"teamCodeReservations": {
"춤추는-파란-사자": {
"userId": "abc123...",
"createdAt": 1700000000000,
"expiresAt": 1700000300000,
"locale": "ko"
},
"dancing-blue-lion": {
"userId": "def456...",
"createdAt": 1700000050000,
"expiresAt": 1700000350000,
"locale": "en"
}
}
}
특징:
- ✅ Atomic 예약: Transaction으로 동시 요청 처리
- ✅ 5분 TTL: 자동 만료 (팀 생성 안 하면 해제)
- ✅ 자동 정리: cleanupExpiredReservations() 함수
- ✅ 언어별 코드: ko/en/ja 각각 다른 단어 사용
Security Rules:
.read: 모두 허용 (중복 체크).write: 본인만 수정 가능.validate: userId, createdAt, expiresAt 필수 + TTL 검증
1. Team (팀) ✅
컬렉션: teams/{teamId}
스키마
interface Team {
id: string; // 문서 ID
code: string; // 팀 코드 (예: "춤추는 파란 사자")
name: string; // 팀 이름 (예: "2학년 1반")
ownerId: string; // 팀 소유자 UID
// 보안 레벨 (5단계 시스템)
securityLevel: TeamSecurityLevel; // 1~5 (OPEN, NAME_LIST, AUTH_REQUIRED, EMAIL_LIST, CLOSED)
// 명단 관리
allowedNames?: string[]; // Level 2용: 허용된 이름 목록
allowedEmails?: string[]; // Level 4용: 허용된 이메일 목록
// AI 글쓰기 도우미 설정
aiAssistanceConfig?: AIAssistanceConfig;
// 공개 설정
isPublic?: boolean; // 팀 공개 여부 (기본: false)
allowPublicWritings?: boolean; // 외부 글 공개 허용 (기본: false)
description?: string; // 팀 소개 (공개 팀용)
coverImage?: string; // 🆕 팀 커버 이미지 URL (Firebase Storage)
// 멤버 관리 (uid를 키로 사용)
members: {
[uid: string]: TeamMember; // 팀별 메타데이터
};
// 타임스탬프
createdAt: Timestamp;
updatedAt: Timestamp;
isActive: boolean; // 활성 상태
}
보안 레벨 (5단계)
| Level | Enum | 이름 | 익명 허용 | 가입 제한 | 주요 사용처 |
|---|---|---|---|---|---|
| 1 | OPEN |
완전 개방 | ✅ | 닉네임 공유 로그인 | 공개 워크샵, 체험 수업 |
| 2 | NAME_LIST |
명단 기반 | ✅ | allowedNames 체크 |
저학년 반 (익명이지만 통제) |
| 3 | AUTH_REQUIRED |
로그인 필수 | ❌ | 정식 계정 누구나 | 고학년 반 (구글 계정) ⭐ 추천 |
| 4 | EMAIL_LIST |
이메일 제한 | ❌ | allowedEmails 체크 |
특정 학생만 (전학생 차단) |
| 5 | CLOSED |
닫힌 팀 | ❌ | 신규 가입 차단 | 졸업반, 종료된 프로젝트 |
예시 데이터
{
"id": "team_abc123",
"code": "춤추는 파란 사자",
"name": "2학년 1반",
"ownerId": "user_xyz",
"securityLevel": 3,
"isPublic": true,
"allowPublicWritings": false,
"description": "창의적인 글쓰기를 배우는 우리 반입니다!",
"coverImage": "https://storage.googleapis.com/raonnuri-84830.firebasestorage.app/teams/team_abc123/cover-1732688400000.png",
"members": {
"user_001": {
"joinedAt": "2024-11-06T00:00:00Z",
"role": "member",
"nickname": "춤추는 작가"
}
},
"createdAt": "2024-11-06T00:00:00Z",
"updatedAt": "2024-11-27T10: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 SpellingError {
original: string; // 틀린 단어
correction: string; // 올바른 단어
reason: string; // 이유
}
// AI 분석 결과 (개별 글)
interface WritingAnalysis {
score: number; // AI 평가 점수 (0~100)
breakdown: {
sensory: number; // 오감 표현 점수 (4점 만점)
emotion: number; // 감정 표현 점수 (2점 만점)
dialogue: number; // 대화 표현 점수 (2점 만점)
onomatopoeia: number; // 의성어 점수 (2점 만점)
};
foundWords: {
sensory: string[]; // 발견된 오감 단어
emotion: string[]; // 발견된 감정 단어
onomatopoeia: string[]; // 발견된 의성어
};
suggestions?: string[]; // AI 제안 사항
spellingErrors?: SpellingError[]; // 맞춤법 오류 목록
analyzedAt: Date; // 분석 시간
contentHash: string; // 분석 시점의 content 해시 (SHA-256)
}
// AI 생성 이미지 정보
interface GeneratedImage {
url: string; // Firebase Storage에 저장된 이미지 URL
prompt: string; // 생성에 사용된 프롬프트
generatedAt: Timestamp; // 생성 시각
modelName: string; // "imagen-4.0-generate-001"
bytesBase64?: string; // (선택) base64 인코딩 원본
}
// 이미지 왜곡 영역 정보 (responsive-image-canvas)
interface DistortionAreaData {
id: string;
basePoints: Array<{x: number; y: number}>;
movement: {
preset?: 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
vectorA: {x: number; y: number};
vectorB: {x: number; y: number};
duration: number;
easing: string;
strength?: number;
};
distortionStrength: number;
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
}
interface Writing {
id: string; // 문서 ID
userId: string; // 작성자 UID
topicId?: string | null; // 주제 ID (null은 자유 주제)
// 글 내용
title: string; // 제목
content: string; // 본문 내용 (HTML)
wordCount: number; // 단어 수
charCount: number; // 글자 수
// 상태
status: 'draft' | 'published'; // 임시저장/발행
commentCount: number; // 🆕 댓글 총 개수 (기본값: 0)
// 공개 설정
teamId?: string; // 팀 주제로 작성 시 자동 설정
visibility?: 'public' | 'team' | 'private'; // 🆕 공개 범위 (기본: private)
// 🆕 AI 분석 결과 (저장 시 자동 생성)
analysis?: WritingAnalysis; // AI 분석 결과 (선택적)
// 🆕 AI 도움 이력
aiAssistanceHistory?: AIAssistanceRecord[];
// 🆕 AI 생성 이미지
generatedImage?: GeneratedImage;
// 🆕 이미지 왜곡 영역 설정
distortionAreas?: DistortionAreaData[];
// 타임스탬프
createdAt: Timestamp; // 최초 작성일
updatedAt: Timestamp; // 최종 수정일
}
현재 구현:
- ✅ CRUD 기능 완료 (WritingManager)
- ✅ AI 분석 결과 저장 시스템 (WritingAnalysis)
- ✅ contentHash 기반 재분석 방지 (비용 절감)
- ✅ 맞춤법 에러 히스토리 저장
- 🔜 피드백 시스템 (향후 추가)
예시 데이터
{
"id": "writing_001",
"userId": "abc123xyz",
"topicId": "topic_daily_001",
"title": "내가 좋아하는 계절",
"content": "나는 가을을 제일 좋아해요. 왜냐하면...",
"wordCount": 120,
"charCount": 450,
"status": "published",
"visibility": "public",
"analysis": {
"score": 85,
"breakdown": {
"sensory": 3.5,
"emotion": 1.8,
"dialogue": 0,
"onomatopoeia": 1.2
},
"foundWords": {
"sensory": ["파랗다", "시원하다", "향기롭다"],
"emotion": ["좋아해요", "기쁘다"],
"onomatopoeia": ["살랑살랑", "바스락"]
},
"suggestions": ["대화 표현을 추가하면 더욱 생동감 있는 글이 될 거예요!"],
"spellingErrors": [
{
"original": "좋와해요",
"correction": "좋아해요",
"reason": "동사 '좋아하다'의 활용형이에요"
}
],
"analyzedAt": "2024-10-27T15:00:00Z",
"contentHash": "a3b4c5d6e7f8..."
},
"createdAt": "2024-10-27T14:00:00Z",
"updatedAt": "2024-10-27T15:00:00Z"
}
인덱스
userId+createdAt(복합 인덱스, 내림차순)topicId+createdAt(복합 인덱스, 내림차순)status+createdAt(복합 인덱스)teamId+visibility(복합 인덱스, 팀 공개 글 조회용)
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. Comment (댓글) 🆕
컬렉션: comments/{commentId}
스키마
interface CommentReactions {
like: number; // 좋아요 👍
love: number; // 최고예요 ❤️
smile: number; // 웃겨요 😂
clap: number; // 멋져요 👏
}
interface Comment {
id: string; // 문서 ID
writingId: string; // 글 ID
userId: string; // 작성자 UID
content: string; // 댓글 내용 (최대 500자)
parentId: string | null; // 대댓글인 경우 부모 댓글 ID
reactions: CommentReactions; // 반응 카운트
// 타임스탬프
createdAt: Timestamp;
updatedAt: Timestamp;
isDeleted: boolean; // Soft delete
}
특징:
- 계층형 구조 지원 (1단계 대댓글)
- Soft delete (삭제된 댓글입니다 표시)
- 반응형 데이터 (좋아요 등)
인덱스
writingId+createdAt(복합 인덱스)
7. UserReaction (사용자 반응) 🆕
컬렉션: userReactions/{reactionId}
스키마
interface UserReaction {
id: string; // 문서 ID (commentId_userId)
commentId: string; // 댓글 ID
userId: string; // 사용자 UID
type: 'like' | 'love' | 'smile' | 'clap'; // 반응 타입
createdAt: Timestamp;
}
특징:
- 사용자당 댓글 1개에 1개의 반응만 가능 (토글 방식)
- ID를
commentId_userId로 설정하여 중복 방지
8. 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(복합 인덱스, 오름차순)
9. 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(단일 필드, 내림차순)
10. 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(복합 인덱스, 중복 방지용)
11. WritingSession (실시간 글쓰기 모니터링) 🆕
데이터베이스: Firebase Realtime Database (휘발성 데이터)
Realtime DB 구조
11.1. monitoring (글쓰기 통계)
경로: monitoring/{teamId}/{topicId}/{userId}
interface WritingStats {
userId: string; // 작성자 UID
contentLength: number; // 현재 글자 수 (공백 포함)
wordCount: number; // 현재 단어 수
topicId: string; // 현재 작성 중인 주제 ID
lastUpdated: number; // 마지막 업데이트 시간 (timestamp) - 항상 변경됨
}
interface SpeedDataPoint {
speed: number; // 작성 속도 (글자/분)
timestamp: number; // 기록 시간
}
interface MonitoringData extends WritingStats {
displayName: string; // 표시 이름
speedHistory: SpeedDataPoint[]; // 속도 히스토리 (최근 10개)
currentSpeed: number; // 현재 속도 (글자/분)
isActive: boolean; // 활성 상태 (false면 나간 상태)
preview?: string; // 미리보기 (선택적)
}
예시 데이터:
{
"monitoring": {
"team_abc123": {
"topic_xyz": {
"user_001": {
"userId": "user_001",
"contentLength": 1500,
"wordCount": 300,
"topicId": "topic_xyz",
"lastUpdated": 1731398400000
},
"user_002": {
"userId": "user_002",
"contentLength": 800,
"wordCount": 160,
"topicId": "topic_xyz",
"lastUpdated": 1731398395000
}
}
}
}
}
클라이언트 측 계산 (선생님 화면):
// MonitoringData (UI용)
{
userId: "user_001",
contentLength: 1500,
wordCount: 300,
topicId: "topic_xyz",
lastUpdated: 1731398400000,
displayName: "철수",
currentSpeed: 420, // 글자/분 (클라이언트 계산)
isActive: true, // 활성 상태 (30초 타임아웃 체크)
speedHistory: [ // 최근 10개 (클라이언트 계산)
{ speed: 480, timestamp: 1731398370000 },
{ speed: 420, timestamp: 1731398375000 },
{ speed: 360, timestamp: 1731398380000 },
{ speed: 0, timestamp: 1731398385000 }, // 멈춤!
{ speed: 0, timestamp: 1731398390000 },
{ speed: 420, timestamp: 1731398395000 }, // 재시작
{ speed: 480, timestamp: 1731398400000 }
]
}
활성 상태 판단 로직:
// 1. Firebase에서 데이터 수신 → isActive: true
// 2. 30초 타임아웃 체크
const timeSinceUpdate = Date.now() - lastUpdated;
if (timeSinceUpdate > 30000) {
isActive = false; // 30초 이상 업데이트 없음 → 나감
}
// 3. Firebase에서 삭제되어도 마지막 통계 유지
if (!newData[userId] && prevData[userId]) {
keep prevData[userId] with isActive: false;
}
11.2. previewRequests (미리보기 요청)
경로: previewRequests/{userId}/{requestId}
interface PreviewRequest {
requestedBy: string; // 요청한 관리자 UID
timestamp: number; // 요청 시간
requestId: string; // 고유 요청 ID
}
11.3. previewResponses (미리보기 응답)
경로: previewResponses/{requestId}
interface PreviewResponse {
content: string; // 현재 작성 중인 글 내용
timestamp: number; // 응답 시간
requestId: string; // 요청 ID (매칭용)
}
업데이트 주기
- 통계 전송: 5초마다 (학생이 팀 주제로 작성 중일 때만)
- 자동 정리:
onDisconnect().remove()로 페이지 이탈 시 자동 삭제 - 미리보기: 요청 시에만 (10초 타임아웃)
Security Rules
파일: database.rules.json
{
"rules": {
"monitoring": {
"$teamId": {
"$topicId": {
"$userId": {
".read": "auth != null && (auth.uid == $userId || root.child('teamOwners').child($teamId).val() == auth.uid)",
".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"
}
}
}
}
권한:
- 통계 쓰기: 본인만
- 통계 읽기: 본인 + 팀 소유자
- 미리보기 요청: 누구나 쓰기, 대상자만 읽기
- 미리보기 응답: 누구나 읽기/쓰기 (requestId로 필터링)
TypeScript: src/types/writingSession.ts
TypeScript 타입 정의 파일
데이터 모델 타입
모든 데이터 모델 타입은 src/types/ 디렉토리에 정의됩니다.
src/types/
├── team.ts # ✅ Team 데이터 모델
├── firestoreUser.ts # ✅ User 데이터 모델 (FirestoreUser, User 분리)
├── writing.ts # ✅ Writing 데이터 모델
├── topic.ts # ✅ Topic 데이터 모델
├── draft.ts # ✅ Draft 데이터 모델 (글조각)
├── comment.ts # 🆕 Comment 데이터 모델
├── writingPattern.ts # ✅ WritingPattern 분석 데이터 모델
├── writingSession.ts # 🆕 WritingSession 실시간 모니터링 타입
├── lesson.ts # 🔜 Lesson 관련 타입 (예정)
├── sticker.ts # 🔜 Sticker 관련 타입 (예정)
└── api/ # ✅ API Request/Response 타입
├── team.ts # Team API 타입
├── user.ts # User API 타입
├── writing.ts # Writing API 타입
├── comment.ts # Comment API 타입
└── topic.ts # Topic API 타입
사용 예시
// 데이터 모델 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 /comments/{commentId} {
allow read: if request.auth != null;
allow write: if false;
}
// 주제: 모든 인증된 사용자가 읽을 수 있음
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 명세서 (35개 엔드포인트)
- PROJECT_STRUCTURE.md - 프로젝트 구조
- TECH_STACK.md - 기술 스택
- ROADMAP.md - 개발 로드맵
- CLAUDE.md - Claude Code 개발 가이드
© 2024 BlueNovaLab. All rights reserved.