# API Specification 라온누리 서버 API 명세서 --- ## 🔧 API 개발 필수 가이드 ### RESTful API 설계 원칙 **HTTP Method로 동작 구분** (경로로 구분하지 않음): ```typescript // ✅ 올바른 방식 (RESTful) POST /api/team/:id/allowed-names // 이름 추가 DELETE /api/team/:id/allowed-names // 이름 제거 POST /api/team/:id/allowed-emails // 이메일 추가 DELETE /api/team/:id/allowed-emails // 이메일 제거 // ❌ 잘못된 방식 (경로로 구분) POST /api/team/:id/allowed-names/add // ❌ POST /api/team/:id/allowed-names/remove // ❌ ``` **API Route 파일 구조**: ```typescript // src/app/api/team/[teamId]/allowed-names/route.ts export async function POST(request: NextRequest, context: RouteContext) { // 추가 로직 } export async function DELETE(request: NextRequest, context: RouteContext) { // 삭제 로직 } ``` **HTTP Method 사용 가이드**: - **GET**: 조회 (캐싱 가능, 멱등성) - **POST**: 생성, 추가, 복잡한 조회 - **PUT**: 전체 수정 (멱등성) - **PATCH**: 부분 수정 - **DELETE**: 삭제 (멱등성) --- ### Firebase Admin SDK 사용 규칙 **❌ 절대 사용 금지**: `getFirestore()` 직접 호출 **✅ 필수 사용**: `adminFbClient` 싱글톤 인스턴스 ```typescript // ❌ 잘못된 방식 - 초기화 문제 및 인증 오류 발생 가능 import {getFirestore} from "firebase-admin/firestore"; const db = getFirestore(); const doc = await db.collection('users').doc(uid).get(); // ✅ 올바른 방식 - 싱글톤 인스턴스 사용 import {adminFbClient} from "@/lib/firebase-admin"; const doc = await adminFbClient.collection('users').doc(uid).get(); ``` **이유**: - `getFirestore()`는 매번 호출 시 초기화 상태 불확실 - `adminFbClient`는 `src/lib/firebase-admin.ts`에서 한 번만 초기화 - 환경변수 `FIREBASE_SERVICE_ACCOUNT_KEY`를 통해 명시적 인증 보장 --- ### API Route 응답 헬퍼 함수 (필수) **모든 API Route는 `src/lib/api-response.ts`의 헬퍼 함수를 사용해야 합니다.** ```typescript import { successResponse, errorResponse, unauthorizedResponse, forbiddenResponse, notFoundResponse, validationErrorResponse, internalErrorResponse } from "@/lib/api-response"; import {UnwrapApiResponse} from "@/types/api"; // ✅ 성공 응답 패턴 export async function GET(request: NextRequest) { const data = await fetchData(); const response: UnwrapApiResponse = { items: data, totalCount: data.length, }; return successResponse(response); } // ✅ 에러 응답 패턴 export async function POST(request: NextRequest) { const authHeader = request.headers.get("authorization"); if (!authHeader?.startsWith("Bearer ")) { return unauthorizedResponse(); // 401 } const body = await request.json(); if (!body.title?.trim()) { return validationErrorResponse("제목이 비어있습니다"); // 400 } const user = await getUser(body.userId); if (!user) { return notFoundResponse("사용자를 찾을 수 없습니다"); // 404 } if (user.id !== currentUserId) { return forbiddenResponse(); // 403 } try { // ... 로직 } catch (error) { return internalErrorResponse(); // 500 } } ``` **❌ 절대 사용 금지**: 수동 응답 객체 생성 ```typescript // ❌ Bad - 직접 NextResponse.json 사용 금지 return NextResponse.json( {success: false, error: "에러 메시지", code: "ERROR_CODE"}, {status: 400} ); // ✅ Good - 헬퍼 함수 사용 return validationErrorResponse("에러 메시지"); ``` --- ### Manager ApiCall 패턴 (클라이언트) **Manager에서 `ApiCall` 사용 시 주의사항**: `ApiCall`은 성공 시 **언래핑된 데이터만** 반환하고, 실패 시 **자동으로 에러를 throw**합니다. ```typescript import {SingletonManager} from "./ManagerBase"; import {HttpMethod, UnwrapApiResponse} from "@/types/api"; import type {CreateItemRequest, CreateItemResponse} from "@/types/api/item"; class ItemManager extends SingletonManager { // ✅ 올바른 방식 - ApiCall은 자동으로 언래핑 async createItem(data: CreateItemRequest): Promise { const response = await this.ApiCall( HttpMethod.POST, '/item', data ); // response는 이미 {item: Item} 형태 (언래핑됨) return response.item; } // ❌ 잘못된 방식 - success 체크 불필요 async createItemWrong(data: CreateItemRequest): Promise { const response = await this.ApiCall( HttpMethod.POST, '/item', data ); // ❌ response.success는 존재하지 않음 (타입 에러) if (!response.success || !response.item) { throw new Error("생성 실패"); } return response.item; } // ✅ 반환값이 없는 경우 (DELETE 등) async deleteItem(id: string): Promise { await this.ApiCall( HttpMethod.DELETE, `/item/${id}`, null ); // 성공하면 그대로 종료, 실패하면 자동으로 에러 throw } } ``` **핵심 원칙**: - `ApiCall` 반환값 = `UnwrapApiResponse` (success, error 필드 없음) - 에러 처리는 `try-catch`로 (자동 throw) - `response.success` 체크 코드는 타입 에러 발생 --- ## 개요 - **Base URL**: `/api` (환경변수 `NEXT_PUBLIC_API_URL`로 설정 가능, 기본값 `/api`) - **엔드포인트**: `/team`, `/user` 등 (Base URL에 `/api` 포함됨) - **실제 호출 URL**: `{BASE_URL}{endpoint}` → `/api/team`, `/api/user` - **인증**: Firebase ID Token을 `Authorization: Bearer {token}` 헤더로 전달 - **응답 형식**: 모든 API는 `ApiResponse` 형식 반환 ```typescript interface ApiResponse { success: boolean; data?: T; error?: ApiError; } interface ApiError { code: string; message: string; details?: any; } ``` --- ## Text Analysis API ### POST /api/analyze-text **설명**: Vertex AI 기반 텍스트 분석 (초등학생 글쓰기 평가) **인증**: 선택 (비로그인도 사용 가능) **Request Body**: ```typescript { text: string; // 분석할 텍스트 (최소 30자) previousText?: string; // 이전 텍스트 (Delta 전송용, 선택) } ``` **Response**: ```typescript { success: true, data: { score: number; // 0~10 점수 breakdown: { sensory: number; // 감각 동사 점수 (0~4) descriptive: number; // 감각 형용사 점수 (0~3) dialogue: number; // 대화 점수 (0~2) onomatopoeia: number; // 의성어/의태어 점수 (0~1) }; foundWords: { sensory: string[]; // 찾은 감각 동사 목록 descriptive: string[]; // 찾은 형용사 목록 onomatopoeia: string[]; // 찾은 의성어 목록 }; suggestions: string[]; // AI 수정 제안 목록 } } ``` **Error Response** (429 Rate Limit): ```typescript { success: false, error: { code: "RATE_LIMIT", message: "Vertex AI 요청 실패 (모든 region 시도 완료)" } } ``` **특징**: - ✅ **Delta 전송**: previousText 제공 시 변경분만 분석 (토큰 40% 절감) - ✅ **서버 캐싱**: 동일 텍스트 1분간 캐싱 (In-Memory LRU) - ✅ **Multi-Region**: 3개 region 자동 전환 (도쿄/싱가포르/미국) - ✅ **Retry**: Exponential backoff (최대 3회) - ✅ **Region Health**: 과부하 region 1분간 제외 **Manager 사용법**: ```typescript // 직접 API 호출 (서비스 레이어) import { analyzeText } from "@/services/textAnalysisService"; const result = await analyzeText("오늘 날씨가 좋다."); // → { score: 2.5, foundWords: {...}, suggestions: [...] } ``` **캐싱 전략**: - 마지막 100자로 해시 생성 - TTL: 60초 - 최대 50개 캐시 **비용**: - Gemini 2.5 Flash: $0.075/1M 입력 토큰 - 평균 500자 분석: ~$0.0003/회 - Delta 사용 시: ~$0.00018/회 (40% 절감) --- ## Pattern Analysis API ### POST /api/analyze-pattern **설명**: 글 작성 패턴 분석 (사용자의 여러 글을 종합 분석) **인증**: 필수 **Request Body**: ```typescript { analysisType?: "self" | "by-topic" | "by-team"; // 분석 타입 (기본: self) targetUserId?: string; // 분석 대상 유저 (팀원 분석 시) topicId?: string; // 주제 ID (by-topic 시 필수) teamId?: string; // 팀 ID (by-team 시 필수) limit?: number; // 분석할 글의 최대 개수 (기본: 10개) contentHash?: string; // 🆕 클라이언트가 계산한 해시 (캐시 조회용, 선택) } ``` **Response**: ```typescript { success: true, pattern: { userId: string; analyzedAt: Date; totalWritingsAnalyzed: number; // 분석 컨텍스트 analysisType?: "self" | "by-topic" | "by-team"; targetUserName?: string; topicId?: string; topicName?: string; teamId?: string; teamName?: string; // 작성 스타일 writingStyle: { averageWordCount: number; averageCharCount: number; preferredLength: "짧음" | "보통" | "긴편"; sentenceStructure: "단문 위주" | "복문 위주" | "혼합형"; }; // 표현력 분석 expressionAnalysis: { averageScore: number; // 0~10 strongPoints: string[]; weakPoints: string[]; breakdown: { sensory: number; // 오감 표현 (0~4) emotion: number; // 감정 표현 (0~2) dialogue: number; // 대화 표현 (0~2) onomatopoeia: number; // 의성어/의태어 (0~2) }; frequentExpressions: { sensory: string[]; emotion: string[]; onomatopoeia: string[]; }; }; // 맞춤법 경향 spellingTendency: { commonErrors: Array<{ error: string; correction: string; frequency: number; }>; improvementRate: number; // 0~100 }; // 발전 추이 progressTrend: { isImproving: boolean; scoreChange: number; improvementAreas: string[]; needsAttention: string[]; }; // AI 종합 평가 summary: { overallAssessment: string; encouragement: string; recommendations: string[]; }; }, contentHash: string; // 🆕 서버가 계산한 해시 (클라이언트 캐싱용) } ``` **에러**: ```typescript // 404 - 분석할 글 없음 { success: false, error: "분석할 글이 없습니다. 글을 작성한 후 다시 시도해주세요." } // 403 - 권한 없음 (by-topic, by-team) { success: false, error: "팀 소유자만 팀원의 글을 분석할 수 있습니다" } ``` **분석 타입**: 1. **self (본인 분석)**: - 모든 published 글 분석 - 권한: 본인만 2. **by-topic (주제별 분석)**: - 특정 팀 주제로 작성된 글만 분석 - 권한: 팀 소유자만 - 주제가 팀 주제(`ownerType="team"`)여야 함 3. **by-team (팀 전체 분석)**: - 팀의 모든 주제로 작성된 글 분석 - 권한: 팀 소유자만 - 팀 외 글(자유 주제, 다른 팀)은 제외 **캐싱 전략** (Content Hash 기반 3단계): - **L1 (Client)**: localStorage에 contentHash를 키로 저장 (영구, LRU 10개) ~1ms - **L2 (Firestore)**: `patternAnalyses/{contentHash}` 컬렉션에 저장 (영구) ~100ms - **L3 (Server)**: In-memory Map에 저장 (5분 TTL, 최대 50개) ~50ms - **변경 감지**: 글 추가/수정 시 `updatedAt` 변경 → 해시 변경 → 자동 재분석 - **AI 비용 절감**: 동일 글 세트는 전체 사용자 기준 1회만 분석 (Firestore 공유) **Hash 생성 규칙**: ```typescript // 입력: analysisType | limit | topicId | teamId | id:updatedAt,id:updatedAt,... // 예시: "self|10|||abc:2024-01-01T00:00:00.000Z,def:2024-01-02T00:00:00.000Z" // SHA-256 → "abc123def456..." ``` **성능**: - 캐시 히트 (동일 글 세트): ~1ms (localStorage) - 캐시 히트 (서버): ~50ms (in-memory) - 캐시 미스: 5~10초 (AI 분석) **Firestore 저장 구조**: ```typescript patternAnalyses/{contentHash} - contentHash: string - pattern: WritingPatternAnalysis - createdAt: Timestamp ``` **사용 흐름**: ```typescript // 클라이언트 (self 분석) 1. 글 목록 조회 2. contentHash 계산 3. L1 확인 (localStorage) → 히트 시 즉시 반환 (~1ms) 4. 서버 요청 (contentHash 포함) 5. 서버 응답 → L1에 저장 // 서버 1. 클라이언트 contentHash로 L3 확인 (in-memory) → 히트 시 즉시 반환 (~50ms) 2. 권한 체크 및 글 조회 3. 서버 contentHash 계산 4. L3 재확인 (in-memory) → 히트 시 즉시 반환 5. L2 확인 (Firestore) → 히트 시 반환 + L3 저장 (~100ms) 6. 캐시 미스: AI 분석 수행 (5~10초) 7. L2 (Firestore) + L3 (in-memory)에 저장 8. contentHash 포함하여 응답 ``` **AI 비용 절감**: - 동일한 글 세트는 **전체 사용자 기준 1회만 분석** (Firestore 공유) - 예: 학생 A가 글 10개 작성 → 학생 B도 같은 글 10개 작성 → B는 AI 분석 없이 Firestore에서 조회 --- ## Team API ### 1. POST `/team` - 팀 생성 실제 URL: `POST /api/team` **인증**: 필수 (정식 계정) **Request**: ```typescript { name: string; // 팀 이름 code: string; // 팀 코드 (고유) securityLevel: 1 | 2 | 3 | 4 | 5; // 🆕 5단계 보안 레벨 allowedNames?: string[]; // 🆕 Level 2용 (명단 기반) allowedEmails?: string[]; // 🆕 Level 4용 (이메일 화이트리스트) } // 보안 레벨: // 1: OPEN (완전 개방, 닉네임 공유 로그인) // 2: NAME_LIST (명단 기반, 등록된 이름만) // 3: AUTH_REQUIRED (로그인 필수, 정식 계정 누구나) // 4: EMAIL_LIST (이메일 화이트리스트) // 5: CLOSED (닫힌 팀, 신규 가입 불가) ``` **Response**: ```typescript { success: true, data: { teamId: string; team: Team; // 생성된 팀 객체 } } ``` **권한**: 현재 로그인한 사용자가 자동으로 팀 소유자가 됨 --- ### 2. GET `/team/:id` - 팀 조회 실제 URL: `GET /api/team/:id` **인증**: 선택적 (공개 팀은 인증 없이 조회 가능) **Response**: ```typescript { success: true, data: { team: Team; } } ``` **캐싱**: 클라이언트에서 5분간 캐싱 --- ### 3. POST `/team/search` - 팀 코드로 조회 실제 URL: `POST /api/team/search` **인증**: 선택적 **Request**: ```typescript { code: string; // 팀 코드 (정규화됨) } ``` **Response**: ```typescript { success: true, data: { team: Team | null; // 팀이 없으면 null } } ``` **캐싱**: 클라이언트에서 1분간 캐싱 --- ### 4. GET `/team/list` - 내 팀 목록 실제 URL: `GET /api/team/list` **인증**: 필수 (정식 계정) **Response**: ```typescript { success: true, data: { teams: Team[]; } } ``` **권한**: 로그인한 사용자가 소유한 팀 + 참여한 팀 모두 조회 (중복 제거) **백엔드 로직**: 1. `getTeamsByOwner(uid)` - 소유한 팀 조회 2. `getTeamsByMember(uid)` - 참여한 팀 조회 (`memberUids` array-contains) 3. 두 결과를 병합하고 중복 제거하여 반환 **캐싱**: 클라이언트에서 1분간 캐싱 --- ### 5. PUT `/team/:id` - 팀 정보 수정 실제 URL: `PUT /api/team/:id` **인증**: 필수 **Request**: ```typescript { teamId: string; data: { name?: string; securityMode?: "simple" | "normal" | "open"; requirePin?: boolean; allowAnonymousJoin?: boolean; isActive?: boolean; } } ``` **Response**: ```typescript { success: true, data: { team: Team; } } ``` **권한**: 팀 소유자만 수정 가능 **캐시 무효화**: 해당 팀, 팀 목록 --- ### 6. DELETE `/team/:id` - 팀 삭제 (Soft Delete) 실제 URL: `DELETE /api/team/:id` **인증**: 필수 **Request**: ```typescript { teamId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한**: 팀 소유자만 삭제 가능 **캐시 무효화**: 해당 팀, 팀 목록 --- ### 7. POST `/team/add-student` - 팀에 학생 추가 실제 URL: `POST /api/team/add-student` **인증**: 필수 (내부 사용 - StudentManager에서 호출) **Request**: ```typescript { teamId: string; studentId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **캐시 무효화**: 해당 팀 --- ### 8. POST `/team/add-member` - 팀에 멤버 추가 실제 URL: `POST /api/team/add-member` **인증**: 필수 **Request**: ```typescript { teamId: string; uid: string; nickname?: string; // 🆕 닉네임 (선택적) } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **캐시 무효화**: 해당 팀 --- ### 9. POST `/team/remove-student` - 팀에서 학생 제거 실제 URL: `POST /api/team/remove-student` **인증**: 필수 **Request**: ```typescript { teamId: string; studentId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한**: 팀 소유자만 제거 가능 (서버에서 검증) **캐시 무효화**: 해당 팀 --- ### 10. POST `/team/generate-code` - 고유 팀 코드 생성 실제 URL: `POST /api/team/generate-code` **인증**: 선택적 **Request**: ```typescript { maxAttempts?: number; // 기본값: 10 } ``` **Response**: ```typescript { success: true, data: { code: string; // 생성된 고유 팀 코드 } } ``` --- ### 11. POST `/team/check-code` - 팀 코드 존재 여부 실제 URL: `POST /api/team/check-code` **인증**: 선택적 **Request**: ```typescript { code: string; } ``` **Response**: ```typescript { success: true, data: { exists: boolean; } } ``` --- ### 🆕 12. POST `/team/get-custom-token` - Custom Token 생성 (Level 1 재로그인) 실제 URL: `POST /api/team/get-custom-token` **설명**: Level 1 (OPEN) 팀에서 같은 닉네임으로 재로그인 시 사용. 익명 계정만 허용. **인증**: 불필요 (공개 API) **Request**: ```typescript { uid: string; // 로그인하려는 사용자의 Firebase UID } ``` **Response**: ```typescript // 성공 (익명 계정) { success: true, data: { customToken: string; // Firebase Custom Token } } // 실패 (정식 계정) { success: false, error: "해당 이름은 다른 사용자가 사용 중입니다." } ``` **보안**: - ✅ 익명 계정 검증: Firebase Admin SDK로 `providerData` 확인 - ✅ 정식 계정 차단: Google/Email 계정은 Custom Token 발급 불가 - ✅ 탈취 방지: 정식 계정 이름으로 타인이 로그인 불가 **사용 흐름**: 1. 클라이언트에서 `team.members` 검색 → 같은 닉네임 발견 2. 해당 UID로 Custom Token 요청 3. 익명 계정이면 Token 발급, 정식 계정이면 403 4. Token으로 `signInWithCustomToken()` 호출 --- ### 🆕 13. POST `/team/remove-member` - 팀 멤버 제거/나가기 실제 URL: `POST /api/team/remove-member` **설명**: 팀에서 멤버를 제거합니다. 소유자는 다른 멤버를 강퇴할 수 있고, 일반 멤버는 본인을 제거(팀 나가기)할 수 있습니다. **인증**: 필수 **Request**: ```typescript { teamId: string; uid: string; // 제거할 멤버의 UID } ``` **Response**: ```typescript { success: true } ``` **권한 체크**: - ✅ **팀 소유자**: 다른 멤버 강퇴 가능 (자신은 불가) - ✅ **일반 멤버**: 본인만 제거 가능 (팀 나가기) - ❌ 소유자가 본인을 제거: "팀 소유자는 팀을 나갈 수 없습니다. 팀을 삭제하거나 소유권을 이전해주세요." - ❌ 일반 멤버가 타인을 제거: "팀을 관리할 권한이 없습니다." **에러**: - 404: 팀을 찾을 수 없음 - 403: 권한 없음 (위 권한 체크 참조) **사용 예시**: ```typescript // 팀 나가기 await teamManager.removeMember(teamId, currentUser.uid); ``` --- ### 🆕 14. POST `/team/:teamId/security-level` - 보안 레벨 변경 실제 URL: `POST /api/team/:teamId/security-level` **인증**: 필수 (팀 소유자만) **Request**: ```typescript { securityLevel: 1 | 2 | 3 | 4 | 5; autoPopulateList?: boolean; // 기본값: true } ``` **Response**: ```typescript { success: true, data: { team: Team; // 업데이트된 팀 } } ``` **autoPopulateList 동작**: - `true`: 기존 멤버를 자동으로 명단에 추가 - Level 2로 변경 → 기존 멤버 이름을 `allowedNames`에 추가 - Level 4로 변경 → 기존 정식 계정 이메일을 `allowedEmails`에 추가 - `false`: 명단을 자동 생성하지 않음 (수동 관리) **캐시 무효화**: 해당 팀, 팀 목록 --- ### 🆕 15. POST/DELETE `/team/:teamId/allowed-names` - 허용 이름 관리 (Level 2) 실제 URL: - `POST /api/team/:teamId/allowed-names` - 이름 추가 - `DELETE /api/team/:teamId/allowed-names` - 이름 제거 **인증**: 필수 (팀 소유자만) **Request (POST)**: ```typescript { name: string; // 추가할 이름 } ``` **Request (DELETE)**: ```typescript { name: string; // 제거할 이름 } ``` **Response**: ```typescript { success: true, data: { allowedNames: string[]; // 업데이트된 명단 } } ``` **참고**: RESTful 원칙에 따라 HTTP Method로 동작 구분 **캐시 무효화**: 해당 팀 --- ### 🆕 16. POST/DELETE `/team/:teamId/allowed-emails` - 허용 이메일 관리 (Level 4) 실제 URL: - `POST /api/team/:teamId/allowed-emails` - 이메일 추가 - `DELETE /api/team/:teamId/allowed-emails` - 이메일 제거 **인증**: 필수 (팀 소유자만) **Request (POST)**: ```typescript { email: string; // 추가할 이메일 (소문자로 자동 변환) } ``` **Request (DELETE)**: ```typescript { email: string; // 제거할 이메일 } ``` **Response**: ```typescript { success: true, data: { allowedEmails: string[]; // 업데이트된 이메일 목록 } } ``` **유효성 검사**: 이메일 형식 검증 (`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`) **캐시 무효화**: 해당 팀 --- ### 🆕 17. POST `/team/:teamId/cover-image` - 팀 커버 이미지 업로드 실제 URL: `POST /api/team/:teamId/cover-image` **설명**: 팀 커버 이미지를 Firebase Storage에 업로드하고 팀 문서를 업데이트합니다. **인증**: 필수 (팀 소유자만) **Request**: ```typescript // FormData 형식 { file: File; // 이미지 파일 (JPEG/PNG/WebP/GIF, 최대 5MB) } ``` **Response**: ```typescript { success: true, data: { coverImageUrl: string; // 업로드된 이미지의 공개 URL } } ``` **처리 로직**: 1. 파일 타입 검증 (JPEG/PNG/WebP/GIF만 허용) 2. 파일 크기 검증 (5MB 이하) 3. Firebase Storage 업로드 (`teams/{teamId}/cover-{timestamp}.{ext}`) 4. 파일 공개 설정 (`makePublic()`) 5. 기존 이미지가 있으면 Storage에서 삭제 6. 팀 문서 `coverImage` 필드 업데이트 **캐시 무효화**: 해당 팀, 공개 팀 목록 --- ### 🆕 18. DELETE `/team/:teamId/cover-image` - 팀 커버 이미지 삭제 실제 URL: `DELETE /api/team/:teamId/cover-image` **설명**: 팀 커버 이미지를 Storage에서 삭제하고 팀 문서를 업데이트합니다. **인증**: 필수 (팀 소유자만) **Response**: ```typescript { success: true } ``` **처리 로직**: 1. 팀 문서에서 `coverImage` URL 조회 2. Storage에서 파일 삭제 3. 팀 문서 `coverImage` 필드 제거 **캐시 무효화**: 해당 팀, 공개 팀 목록 --- ## Auth API ### POST `/auth/merge-account` - 익명 계정 데이터 병합 실제 URL: `POST /api/auth/merge-account` **인증**: 필수 (정식 계정으로 로그인된 상태) **Request**: ```typescript { anonymousUid: string; // 병합할 익명 계정의 UID } ``` **Response**: ```typescript { success: true, data: { mergedCounts: { writings: number; // 이전된 글 개수 topics: number; // 이전된 주제 개수 comments: number; // 이전된 댓글 개수 userReactions: number; // 이전된 반응 개수 teamMemberships: number; // 이전된 팀 멤버십 개수 drafts: number; // 이전된 초안 개수 monitoring: number; // 이전된 모니터링 세션 개수 previewRequests: number; // 이전된 미리보기 요청 개수 } } } ``` **동작**: 1. **Firestore 데이터 마이그레이션** (Batch 사용): - `writings` 컬렉션: userId 업데이트 - `topics` 컬렉션: ownerId 업데이트 (개인 주제만) - `comments` 컬렉션: authorId 업데이트 - `userReactions` 컬렉션: userId 업데이트 - `teams` 컬렉션: members 키 변경 (anonymousUid → targetUid) 2. **Realtime DB 데이터 마이그레이션** (Transaction 사용): - `drafts/{anonymousUid}` → `drafts/{targetUid}` 이동 - `monitoring/{topicId}/{anonymousUid}` → `monitoring/{topicId}/{targetUid}` 이동 - `previewRequests/{topicId}/{anonymousUid}` → `previewRequests/{topicId}/{targetUid}` 이동 **제약사항**: - 익명 계정과 정식 계정은 다른 UID여야 함 - 익명 계정 데이터는 병합 후 자동 삭제되지 않음 (수동 정리 필요) **에러 코드**: - `400`: anonymousUid 누락 - `401`: 인증되지 않은 요청 - `500`: 마이그레이션 실패 **캐싱**: 없음 (일회성 작업) --- ## User API **중요**: User vs FirestoreUser 구분 - **FirestoreUser**: DB 저장용 (uid, createdAt, lastLoginAt, settings만) - **User**: API 응답/UI용 (Firebase Auth + FirestoreUser 결합) ### 1. POST `/user` - 사용자 생성 실제 URL: `POST /api/user` **인증**: 필수 **Request**: ```typescript { uid: string; // Firebase Auth UID displayName: string; // Firebase Auth displayName 설정용 teamId: string; // 최초 가입 팀 isAnonymous: boolean; // 익명 여부 } ``` **Response**: ```typescript { success: true, data: { user: User; // Firebase Auth + Firestore 결합된 완전한 User } } ``` **부수 효과**: - Firestore에 FirestoreUser 생성 (최소 데이터) - 팀에 멤버 추가 (`team.members[uid]`) - Firebase Auth displayName 설정 **캐시 무효화**: 팀별 사용자 목록 --- ### 2. POST `/user/purchase` - 플랜 구매/변경 실제 URL: `POST /api/user/purchase` **인증**: 필수 **Request**: ```typescript { planType: PlanType; // FREE, PRO, CLASSROOM, ACADEMY, SCHOOL billingCycle: BillingCycle; // MONTHLY, YEARLY amount: number; // 결제 금액 (원) downgradeMode?: "immediate" | "scheduled"; // 다운그레이드 시 적용 시점 } ``` **Response**: ```typescript { success: true, data: { creditsAdded: number; // 환불로 지급된 크레딧 (업그레이드/즉시 다운그레이드) isScheduled: boolean; // true면 다음 결제일에 적용 isBillingCycleChange: boolean; // true면 결제 주기만 변경 } } ``` **동작**: 1. **업그레이드**: 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급 2. **다운그레이드 (immediate)**: 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급 3. **다운그레이드 (scheduled)**: `scheduledPlan`에 저장, 만료 시 자동 적용 4. **결제 주기 변경**: `scheduledPlan`에 저장, 만료 시 새 주기로 적용 **환불 계산**: ```typescript const refundKRW = calculateProratedRefund(currentPlan, PLAN_MONTHLY_PRICES); const credits = convertKRWToCredits(refundKRW); // 100원 = 10 크레딧 ``` **캐시 무효화**: 사용자 정보 --- ### 3. GET `/user/plan/estimate-refund` - 예상 환불 크레딧 조회 실제 URL: `GET /api/user/plan/estimate-refund` **인증**: 필수 **설명**: 다운그레이드 전 예상 환불 크레딧을 조회합니다. UI에서 "즉시 다운그레이드" 옵션에 표시됩니다. **Response**: ```typescript { success: true, data: { estimatedCredits: number; // 예상 환불 크레딧 estimatedKRW: number; // 예상 환불 금액 (원) validUntil: string; // 현재 플랜 만료일 (ISO 8601) currentPlanType: PlanType; // 현재 플랜 타입 } } ``` **에러**: - `400`: 활성 플랜 없음 - `401`: 인증 필요 **캐싱**: 없음 (실시간 계산) --- ## Writing API ### 1. POST `/writing` - 글 생성 실제 URL: `POST /api/writing` **인증**: 필수 **Request**: ```typescript { title: string; content: string; status?: "draft" | "published"; topicId?: string | null; } ``` **Response**: ```typescript { success: true, data: { writingId: string; writing: Writing; } } ``` **부수 효과**: 서버에서 wordCount, charCount 자동 계산 **캐시 무효화**: 사용자 글 목록, 최근 글 --- ### 2. GET `/writing/:id` - 글 조회 실제 URL: `GET /api/writing/:id` **인증**: 필수 **권한**: 작성자만 조회 가능 (`writing.userId === currentUserId`) **Response**: ```typescript { success: true, data: { writing: Writing | null; } } ``` **에러**: - `404 Not Found`: 글이 존재하지 않음 - `403 Forbidden`: 작성자가 아님 **캐싱**: 클라이언트에서 5분간 캐싱 --- ### 3. POST `/writing/user` - 사용자의 글 목록 실제 URL: `POST /api/writing/user` **인증**: 필수 **Request**: ```typescript { userId?: string; // 없으면 현재 사용자 } ``` **Response**: ```typescript { success: true, data: { writings: Writing[]; } } ``` **캐싱**: 클라이언트에서 1분간 캐싱 --- ### 4. POST `/writing/recent` - 최근 글 실제 URL: `POST /api/writing/recent` **인증**: 필수 **Request**: ```typescript { limit?: number; // 기본값: 5 } ``` **Response**: ```typescript { success: true, data: { writings: Writing[]; } } ``` **캐싱**: 클라이언트에서 30초간 캐싱 --- ### 5. PUT `/writing/:id` - 글 수정 실제 URL: `PUT /api/writing/:id` **인증**: 필수 **Request**: ```typescript { writingId: string; data: { title?: string; content?: string; status?: "draft" | "published"; topicId?: string | null; wordCount?: number; charCount?: number; distortionAreas?: DistortionAreaData[]; // 🆕 왜곡 영역 설정 analysis?: WritingAnalysis; // 🆕 AI 분석 결과 (영역 제한용) } } ``` **Response**: ```typescript { success: true, data: { writing: Writing; } } ``` **권한**: 작성자만 수정 가능 **캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글 --- ### 6. DELETE `/writing/:id` - 글 삭제 실제 URL: `DELETE /api/writing/:id` **인증**: 필수 **Request**: ```typescript { writingId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한**: 작성자만 삭제 가능 **캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글 --- ### 7. POST `/writing/:id/analyze` - 글 분석 실행 실제 URL: `POST /api/writing/:id/analyze` **인증**: 필수 (작성자 본인만 가능) **설명**: 저장된 글을 서버에서 불러와 AI 분석을 실행하고 결과를 저장합니다. **Request**: ```typescript { locale?: "ko" | "en" | "ja"; // 기본값: "ko" } ``` **Response**: ```typescript { success: true, data: { analysis: WritingAnalysis; cached: boolean; // true면 기존 분석 결과 반환 (재사용) } } ``` **특징**: - **Content Hash 기반 재사용**: 글 내용이 변경되지 않았으면 기존 분석 결과 반환 (비용 절감) - **최소 길이**: 30자 이상이어야 분석 가능 - **저장**: 분석 결과는 자동으로 `writings/{id}.analysis`에 저장됨 --- ## Comment API ### 1. GET `/comment/writing/:writingId` - 댓글 목록 조회 실제 URL: `GET /api/comment/writing/:writingId` **인증**: 선택적 (현재 사용자 반응 확인용) **Response**: ```typescript { success: true, data: { comments: CommentWithReplies[]; totalCount: number; } } ``` **특징**: - 계층 구조 (댓글 + 답글) 반환 - 작성자 정보 (displayName, photoURL) 포함 - 현재 사용자의 반응 포함 (로그인 시) ### 2. POST `/comment/writing/:writingId` - 댓글 작성 실제 URL: `POST /api/comment/writing/:writingId` **인증**: 필수 **Request**: ```typescript { content: string; parentId?: string; // 답글인 경우 부모 댓글 ID } ``` **Response**: ```typescript { success: true, data: { comment: Comment; } } ``` ### 3. PUT `/comment/:id` - 댓글 수정 실제 URL: `PUT /api/comment/:id` **인증**: 필수 (작성자 본인만) **Request**: ```typescript { content: string; } ``` **Response**: ```typescript { success: true, data: { comment: Comment; } } ``` ### 4. DELETE `/comment/:id` - 댓글 삭제 실제 URL: `DELETE /api/comment/:id` **인증**: 필수 **권한**: - 작성자 본인 - 글 작성자 (관리 차원) - 팀 소유자 (팀 주제인 경우) **Response**: ```typescript { success: true } ``` --- ## Topic API ### 1. POST `/topic/available` - 사용 가능한 주제 목록 실제 URL: `POST /api/topic/available` **인증**: 필수 **Request**: ```typescript { teamIds?: string[]; // 팀 주제를 가져올 팀 ID 목록 } ``` **Response**: ```typescript { success: true, data: { topics: Topic[]; } } ``` **백엔드 로직**: 1. 개인 주제: `ownerType === PERSONAL && ownerId === currentUserId` 2. 팀 주제: `ownerType === TEAM && ownerId in [teamId1, teamId2, ...]` - 클라이언트에서 `teamIds: ["team1", "team2"]` 전달 - 서버에서 필터링 3. 모든 주제를 병합하여 반환 **캐싱**: 클라이언트에서 5분간 캐싱 **참고**: 그룹(Group) 기능은 제거되었습니다. 팀(Team) 기능만 사용합니다. --- ### 2. GET `/topic/:id` - 주제 조회 실제 URL: `GET /api/topic/:id` **인증**: 선택적 **Response**: ```typescript { success: true, data: { topic: Topic | null; } } ``` **캐싱**: 클라이언트에서 5분간 캐싱 --- ### 3. POST `/topic` - 개인 주제 생성 실제 URL: `POST /api/topic` **인증**: 필수 **Request**: ```typescript { title: string; description: string; category: "daily" | "imagination" | "emotion" | "experience"; difficulty: "easy" | "medium" | "hard"; keywords?: string[]; examplePrompts?: string[]; titleTemplate?: string; contentTemplate?: string; } ``` **Response**: ```typescript { success: true, data: { topicId: string; topic: Topic; } } ``` **권한**: 로그인한 사용자가 자동으로 소유자가 됨 **캐시 무효화**: 주제 목록 --- ### 4. PUT `/topic/:id` - 개인 주제 수정 실제 URL: `PUT /api/topic/:id` **인증**: 필수 **Request**: ```typescript { topicId: string; data: { title?: string; description?: string; category?: "daily" | "imagination" | "emotion" | "experience"; difficulty?: "easy" | "medium" | "hard"; keywords?: string[]; examplePrompts?: string[]; titleTemplate?: string; contentTemplate?: string; isActive?: boolean; } } ``` **Response**: ```typescript { success: true, data: { topic: Topic; } } ``` **권한**: 주제 소유자만 수정 가능 **캐시 무효화**: 해당 주제, 주제 목록 --- ### 5. DELETE `/topic/:id` - 개인 주제 삭제 실제 URL: `DELETE /api/topic/:id` **인증**: 필수 **Request**: ```typescript { topicId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한**: 주제 소유자만 삭제 가능 **캐시 무효화**: 해당 주제, 주제 목록 --- ### 6. POST `/topic/increment-usage` - 주제 사용 횟수 증가 실제 URL: `POST /api/topic/increment-usage` **인증**: 선택적 **Request**: ```typescript { topicId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **참고**: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음) --- ### 7. POST `/topic/team` - 팀 주제 생성 실제 URL: `POST /api/topic/team` **인증**: 필수 (팀 소유자만) **Request**: ```typescript { teamId: string; // 팀 ID (ownerId = teamId) title: string; description: string; category: "daily" | "imagination" | "emotion" | "experience"; difficulty: "easy" | "medium" | "hard"; keywords?: string[]; examplePrompts?: string[]; titleTemplate?: string; contentTemplate?: string; } ``` **Response**: ```typescript { success: true, data: { topicId: string; topic: Topic; // ownerType: TEAM, ownerId: teamId } } ``` **권한**: - 로그인한 사용자가 팀 소유자인지 확인 (`team.ownerId === currentUserId`) - 팀이 활성화 상태인지 확인 (`team.isActive === true`) **백엔드 처리**: ```typescript const topic = { ...requestData, ownerType: TopicOwnerType.TEAM, ownerId: teamId, createdBy: currentUserId, usageCount: 0, isActive: true, createdAt: serverTimestamp(), updatedAt: serverTimestamp(), }; ``` **캐시 무효화**: 주제 목록, 팀 주제 목록 --- ### 8. GET `/topic/team/:teamId` - 팀 주제 목록 조회 실제 URL: `GET /api/topic/team/:teamId` **인증**: 선택적 (공개 조회 가능) **Response**: ```typescript { success: true, data: { topics: Topic[]; // ownerType === TEAM && ownerId === teamId } } ``` **백엔드 로직**: ```typescript const topics = await db.collection('topics') .where('ownerType', '==', TopicOwnerType.TEAM) .where('ownerId', '==', teamId) .where('isActive', '==', true) .orderBy('createdAt', 'desc') .get(); ``` **캐싱**: 클라이언트에서 5분간 캐싱 --- ### 9. PUT `/topic/team/:id` - 팀 주제 수정 실제 URL: `PUT /api/topic/team/:id` **인증**: 필수 (팀 소유자만) **Request**: ```typescript { topicId: string; teamId: string; // 소유권 검증용 data: { title?: string; description?: string; category?: "daily" | "imagination" | "emotion" | "experience"; difficulty?: "easy" | "medium" | "hard"; keywords?: string[]; examplePrompts?: string[]; titleTemplate?: string; contentTemplate?: string; isActive?: boolean; } } ``` **Response**: ```typescript { success: true, data: { topic: Topic; } } ``` **권한 검증**: 1. 주제가 팀 주제인지 확인: `topic.ownerType === TEAM && isTeamOwnerId(topic.ownerId)` 2. 요청한 teamId와 주제의 teamId 일치 확인: `extractTeamId(topic.ownerId) === teamId` 3. 현재 사용자가 팀 소유자인지 확인: `team.ownerId === currentUserId` **캐시 무효화**: 해당 주제, 주제 목록, 팀 주제 목록 --- ### 10. DELETE `/topic/team/:id` - 팀 주제 삭제 실제 URL: `DELETE /api/topic/team/:id` **인증**: 필수 (팀 소유자만) **Request**: ```typescript { topicId: string; teamId: string; // 소유권 검증용 } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한 검증**: PUT과 동일 **캐시 무효화**: 해당 주제, 주제 목록, 팀 주제 목록 **참고**: Soft delete 방식 (`isActive: false`로 설정) --- ## 에러 코드 | 코드 | 설명 | |------|------| | `UNAUTHORIZED` | 인증 필요 | | `FORBIDDEN` | 권한 없음 | | `NOT_FOUND` | 리소스 없음 | | `VALIDATION_ERROR` | 유효성 검사 실패 | | `TEAM_CODE_INVALID` | 팀 코드 형식 오류 | | `TEAM_INACTIVE` | 비활성화된 팀 | | `PIN_REQUIRED` | PIN 입력 필요 | | `PIN_INVALID` | PIN 불일치 | | `STUDENT_NAME_DUPLICATE` | 팀 내 이름 중복 | | `ALREADY_EXISTS` | 리소스 중복 | | `INTERNAL_ERROR` | 서버 오류 | --- ## 구현 노트 ### Next.js API Routes 구현 예시 ```typescript // app/api/team/route.ts import { NextRequest, NextResponse } from 'next/server'; import { verifyIdToken } from '@/lib/auth'; import { createTeam } from '@/services/teamService'; export async function POST(request: NextRequest) { try { // 1. 인증 확인 const token = request.headers.get('Authorization')?.replace('Bearer ', ''); const decoded = await verifyIdToken(token); if (!decoded) { return NextResponse.json({ success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 }); } // 2. Request 파싱 const body = await request.json(); // 3. 유효성 검사 if (!body.name || !body.code) { return NextResponse.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '필수 필드가 누락되었습니다.' } }, { status: 400 }); } // 4. 비즈니스 로직 실행 const teamId = await createTeam({ ...body, ownerId: decoded.uid }); // 5. 응답 return NextResponse.json({ success: true, data: { teamId, team: {...} } }); } catch (error: any) { return NextResponse.json({ success: false, error: { code: 'INTERNAL_ERROR', message: error.message } }, { status: 500 }); } } ``` ### Server Actions 구현 예시 ```typescript // app/actions/team.ts 'use server' import { auth } from '@/lib/auth'; import { createTeam as createTeamFirestore } from '@/services/teamService'; import type { ApiResponse } from '@/types/api'; import type { CreateTeamRequest, CreateTeamResponse } from '@/types/api/team'; export async function createTeam(data: CreateTeamRequest): Promise> { try { const session = await auth(); if (!session) { return { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }; } const teamId = await createTeamFirestore({ ...data, ownerId: session.uid }); return { success: true, data: { teamId, team: {...} } }; } catch (error: any) { return { success: false, error: { code: 'INTERNAL_ERROR', message: error.message } }; } } ``` --- ## 보안 고려사항 1. **인증 토큰 검증**: 모든 쓰기 작업은 Firebase ID Token 검증 필수 2. **권한 체크**: 팀 소유자 확인 (ownerId === decoded.uid) 3. **입력 검증**: 모든 입력값 sanitization 및 validation 4. **Rate Limiting**: Redis로 API 호출 횟수 제한 (선택적) 5. **PIN 보안**: PIN은 평문으로 받아 서버에서 SHA-256 해시로 저장 --- ## Redis 캐싱 전략 (서버 사이드) ### 캐싱 대상 - 팀 정보: `redis:team:{teamId}` - TTL 5분 - 팀 코드 조회: `redis:team:code:{code}` - TTL 1분 - 학생 정보: `redis:student:{studentId}` - TTL 5분 - 팀별 학생 목록: `redis:students:team:{teamId}` - TTL 30초 ### 캐시 무효화 - 팀 생성/수정/삭제 시: 해당 팀 + 팀 목록 - 학생 생성/수정 시: 해당 학생 + 팀별 학생 목록 - 강퇴 시: 학생 + 팀 + 팀별 학생 목록 --- ## 클라이언트 캐싱 전략 매니저 레벨에서 in-memory 캐싱 (SingletonManager): - **조회 작업**: 캐싱 활성화 (GET 요청) - **변경 작업**: 캐싱 안 함 (POST/PUT/DELETE) - **캐시 무효화**: 변경 작업 시 관련 캐시 자동 무효화 --- ## 개발 순서 1. ✅ API 타입 정의 (`src/types/api.ts`, `src/types/api/team.ts`, `src/types/api/student.ts`) 2. ✅ BaseManager에 `authenticatedFetch`, `callApi` 구현 3. ✅ BaseManager에 클라이언트 캐싱 메서드 구현 4. ✅ TeamManager, StudentManager를 API 호출 방식으로 전환 5. ⏳ Next.js API Routes 또는 Server Actions 구현 6. ⏳ Redis 캐싱 구현 (선택적) 7. ⏳ Rate Limiting 구현 (선택적)