# API Specification 라온누리 서버 API 명세서 ## ⚠️ 최신 변경사항 (2025-11-10) ### 🔐 5단계 보안 레벨 시스템 팀 생성 시 5가지 보안 레벨 선택 가능: - **Level 1 (OPEN)**: 완전 개방, 닉네임 공유 로그인 - **Level 2 (NAME_LIST)**: 명단 기반, allowedNames 체크 - **Level 3 (AUTH_REQUIRED)**: 로그인 필수, 정식 계정 누구나 - **Level 4 (EMAIL_LIST)**: 이메일 화이트리스트, allowedEmails 체크 - **Level 5 (CLOSED)**: 닫힌 팀, 신규 가입 불가 ### 📦 User 타입 분리 - **FirestoreUser**: DB 저장용 (최소 데이터만) - **User**: UI 사용용 (Firebase Auth + Firestore 결합) - 이름, 이메일, 사진 등은 Firebase Auth가 Single Source of Truth ### 🏷️ 닉네임 저장 위치 변경 - ❌ 기존: `users.nicknames[teamId]` - ✅ 신규: `team.members[uid].nickname` ### 🎯 RESTful API 설계 규칙 **HTTP Method로 동작 구분** (경로가 아님): ``` ✅ POST /api/resource → 생성/추가 ✅ DELETE /api/resource → 삭제/제거 ✅ PUT /api/resource → 전체 수정 ✅ GET /api/resource → 조회 ❌ POST /api/resource/add → 사용 금지 ❌ POST /api/resource/remove → 사용 금지 ``` --- ## 개요 - **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; } ``` --- ## 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/remove-student` - 팀에서 학생 제거 실제 URL: `POST /api/team/remove-student` **인증**: 필수 **Request**: ```typescript { teamId: string; studentId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한**: 팀 소유자만 제거 가능 (서버에서 검증) **캐시 무효화**: 해당 팀 --- ### 9. POST `/team/generate-code` - 고유 팀 코드 생성 실제 URL: `POST /api/team/generate-code` **인증**: 선택적 **Request**: ```typescript { maxAttempts?: number; // 기본값: 10 } ``` **Response**: ```typescript { success: true, data: { code: string; // 생성된 고유 팀 코드 } } ``` --- ### 10. POST `/team/check-code` - 팀 코드 존재 여부 실제 URL: `POST /api/team/check-code` **인증**: 선택적 **Request**: ```typescript { code: string; } ``` **Response**: ```typescript { success: true, data: { exists: boolean; } } ``` --- ### 🆕 11. 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`: 명단을 자동 생성하지 않음 (수동 관리) **캐시 무효화**: 해당 팀, 팀 목록 --- ### 🆕 12. 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로 동작 구분 **캐시 무효화**: 해당 팀 --- ### 🆕 13. 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@]+$/`) **캐시 무효화**: 해당 팀 --- ## 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. GET `/student/:id` - 학생 조회 실제 URL: `GET /api/student/:id` **인증**: 선택적 **Response**: ```typescript { success: true, data: { student: Student; } } ``` **캐싱**: 클라이언트에서 5분간 캐싱 --- ### 3. POST `/student/by-uid` - Firebase UID로 학생 조회 실제 URL: `POST /api/student/by-uid` **인증**: 필수 (Anonymous Auth) **Request**: ```typescript { firebaseUid: string; } ``` **Response**: ```typescript { success: true, data: { student: Student | null; } } ``` **용도**: 로그인 시 기존 학생 확인 **캐싱**: 클라이언트에서 5분간 캐싱 --- ### 4. POST `/student/by-team` - 팀별 학생 목록 실제 URL: `POST /api/student/by-team` **인증**: 선택적 **Request**: ```typescript { teamId: string; } ``` **Response**: ```typescript { success: true, data: { students: Student[]; } } ``` **캐싱**: 클라이언트에서 30초간 캐싱 (자주 변경) --- ### 5. POST `/student/find-by-name` - 이름으로 학생 찾기 실제 URL: `POST /api/student/find-by-name` **인증**: 선택적 **Request**: ```typescript { teamId: string; name: string; } ``` **Response**: ```typescript { success: true, data: { student: Student | null; } } ``` **용도**: 학생 로그인 시 기존 학생 확인 --- ### 6. PUT `/student/:id` - 학생 정보 수정 실제 URL: `PUT /api/student/:id` **인증**: 필수 **Request**: ```typescript { studentId: string; data: { name?: string; pinHash?: string; linkedUserId?: string; } } ``` **Response**: ```typescript { success: true, data: { student: Student; } } ``` **캐시 무효화**: 해당 학생, 팀별 학생 목록 --- ### 7. POST `/student/kick` - 팀에서 학생 강퇴 실제 URL: `POST /api/student/kick` **인증**: 필수 **Request**: ```typescript { studentId: string; teamId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한**: 팀 소유자만 강퇴 가능 **부수 효과**: - `student.teamIds`에서 팀 제거 - `team.studentIds`에서 학생 제거 **캐시 무효화**: 해당 학생, 팀, 팀별 학생 목록 --- ### 8. POST `/student/update-pin` - PIN 변경 실제 URL: `POST /api/student/update-pin` **인증**: 필수 **Request**: ```typescript { studentId: string; newPin: string; // 평문 (4자리 숫자) } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **검증**: 서버에서 PIN 형식 검증 (4자리 숫자) **보안**: PIN은 SHA-256 해시로 저장 **캐시 무효화**: 해당 학생 --- ### 9. POST `/student/validate-pin` - PIN 검증 실제 URL: `POST /api/student/validate-pin` **인증**: 선택적 **Request**: ```typescript { studentId: string; pin: string; } ``` **Response**: ```typescript { success: true, data: { valid: boolean; } } ``` **용도**: 학생 로그인 시 PIN 확인 --- ### 10. POST `/student/link` - 정식 계정 연결 실제 URL: `POST /api/student/link` **인증**: 필수 (정식 계정) **Request**: ```typescript { studentId: string; // userId는 Authorization 헤더에서 추출 } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **권한**: 현재 로그인한 사용자와 연결 **검증**: - 학생이 이미 다른 계정과 연결되어 있으면 에러 **캐시 무효화**: 해당 학생, 내 학생 목록 --- ### 11. POST `/student/unlink` - 정식 계정 연결 해제 실제 URL: `POST /api/student/unlink` **인증**: 필수 **Request**: ```typescript { studentId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **캐시 무효화**: 해당 학생, 내 학생 목록 --- ### 12. GET `/student/my-students` - 내 학생 목록 실제 URL: `GET /api/student/my-students` **인증**: 필수 (정식 계정) **Response**: ```typescript { success: true, data: { students: Student[]; } } ``` **권한**: 로그인한 사용자가 소유한 학생만 조회 (`linkedUserId` 기준) **캐싱**: 클라이언트에서 1분간 캐싱 --- ### 13. POST `/student/update-last-login` - 마지막 로그인 시간 업데이트 실제 URL: `POST /api/student/update-last-login` **인증**: 선택적 **Request**: ```typescript { studentId: string; } ``` **Response**: ```typescript { success: true, data: { success: true; } } ``` **참고**: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음) --- ## 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` **인증**: 선택적 **Response**: ```typescript { success: true, data: { writing: Writing | null; } } ``` **캐싱**: 클라이언트에서 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; } } ``` **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; } } ``` **권한**: 작성자만 삭제 가능 **캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글 --- ## 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 구현 (선택적)