2025-11-27 08:40:46 +00:00

38 KiB

API Specification

라온누리 서버 API 명세서

⚠️ 최신 변경사항 (2025-11-27)

🖼️ 팀 커버 이미지 API

  • POST /api/team/[teamId]/cover-image: 팀 커버 이미지 업로드
    • FormData로 이미지 파일 전송 (JPEG/PNG/WebP/GIF, 최대 5MB)
    • Firebase Storage 업로드 (teams/{teamId}/cover-{timestamp}.{ext})
    • 기존 이미지 자동 삭제
    • 파일 공개 설정 (makePublic)
    • 팀 문서 coverImage 필드 업데이트
    • 권한: 팀 소유자만
  • DELETE /api/team/[teamId]/cover-image: 팀 커버 이미지 삭제
    • Storage에서 파일 삭제
    • 팀 문서 coverImage 필드 제거
    • 권한: 팀 소유자만
  • TeamManager 메서드: uploadCoverImage(), deleteCoverImage()
  • 컴포넌트: TeamCoverImageUploader (드래그앤드롭, 미리보기, AspectRatio 16:9)

⚠️ 변경사항 (2025-11-26)

🔗 익명 계정 연결 기능

  • POST /api/auth/merge-account: 익명 계정 데이터를 정식 계정으로 마이그레이션
    • Firestore 데이터 이전 (writings, topics, comments, userReactions, teams)
    • Realtime DB 데이터 이전 (drafts, monitoring, previewRequests)
    • 원자성 보장 (Firestore Batch, Realtime DB Transaction)
    • 병합 완료 후 통계 반환
  • 서비스 레이어: src/services/firebaseAuth.ts (mergeAndLoginWithEmail, mergeAndLoginWithGoogle)
  • 상태 관리: src/store/authStore.ts (mergeWithEmail, mergeWithGoogle 액션)
  • UI 통합: LoginForm/SignupForm mode prop, LoginDialog link 모드

⚠️ 변경사항 (2025-11-12)

Writing API 구현 완료

  • POST /api/writing: 글 생성 (서버에서 wordCount/charCount 자동 계산)
  • GET /api/writing/[id]: 글 조회 (작성자만 접근)
  • PUT /api/writing/[id]: 글 수정 (작성자만 접근)
  • DELETE /api/writing/[id]: 글 삭제 (작성자만 접근)
  • POST /api/writing/user: 사용자 글 목록
  • POST /api/writing/recent: 최근 글 (limit 파라미터)
  • 서버 레이어: src/lib/server/writing.ts (Firestore CRUD)

Content Hash 기반 3단계 스마트 캐싱

  • POST /api/analyze-pattern: contentHash 파라미터 추가
  • L1 캐시: localStorage (영구, LRU 10개) ~1ms
  • L2 캐시: Firestore patternAnalyses 컬렉션 (영구) ~100ms
  • L3 캐시: Server in-memory (5분, 50개) ~50ms
  • 해시 생성: id:updatedAt 조합 (SHA-256)
  • 자동 변경 감지: 글 추가/수정 → 해시 변경 → 재분석
  • AI 비용 절감: 동일 글 세트는 전체 사용자 기준 1회만 분석
  • 서버 레이어: src/lib/server/patternAnalysis.ts (Firestore CRUD)

⚠️ 변경사항 (2025-11-11)

🤖 실시간 피드백 시스템

  • POST /api/analyze-text: Vertex AI 기반 텍스트 분석 API
    • Delta 전송 지원 (previousText 파라미터)
    • 서버 캐싱 (In-Memory LRU, TTL 1분)
    • Multi-region failover (3개 region)
    • 점수, 찾은 단어, 수정 제안 반환

🌏 Multi-Region Failover

  • Vertex AI 3개 region 자동 전환
  • Rate Limit 대응 (RPM 15 → 45)
  • Region health tracking
  • Exponential backoff

⚠️ 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<T> 형식 반환
interface ApiResponse<T> {
  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:

{
  text: string;           // 분석할 텍스트 (최소 30자)
  previousText?: string;  // 이전 텍스트 (Delta 전송용, 선택)
}

Response:

{
  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):

{
  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 사용법:

// 직접 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:

{
  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:

{
  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;  // 🆕 서버가 계산한 해시 (클라이언트 캐싱용)
}

에러:

// 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단계 캐싱 (2025-11-12):

캐싱 전략:

  • 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 생성 규칙:

// 입력: 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 저장 구조:

patternAnalyses/{contentHash}
  - contentHash: string
  - pattern: WritingPatternAnalysis
  - createdAt: Timestamp

사용 흐름:

// 클라이언트 (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:

{
  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:

{
  success: true,
  data: {
    teamId: string;
    team: Team;           // 생성된 팀 객체
  }
}

권한: 현재 로그인한 사용자가 자동으로 팀 소유자가 됨


2. GET /team/:id - 팀 조회

실제 URL: GET /api/team/:id

인증: 선택적 (공개 팀은 인증 없이 조회 가능)

Response:

{
  success: true,
  data: {
    team: Team;
  }
}

캐싱: 클라이언트에서 5분간 캐싱


3. POST /team/search - 팀 코드로 조회

실제 URL: POST /api/team/search

인증: 선택적

Request:

{
  code: string;           // 팀 코드 (정규화됨)
}

Response:

{
  success: true,
  data: {
    team: Team | null;    // 팀이 없으면 null
  }
}

캐싱: 클라이언트에서 1분간 캐싱


4. GET /team/list - 내 팀 목록

실제 URL: GET /api/team/list

인증: 필수 (정식 계정)

Response:

{
  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:

{
  teamId: string;
  data: {
    name?: string;
    securityMode?: "simple" | "normal" | "open";
    requirePin?: boolean;
    allowAnonymousJoin?: boolean;
    isActive?: boolean;
  }
}

Response:

{
  success: true,
  data: {
    team: Team;
  }
}

권한: 팀 소유자만 수정 가능

캐시 무효화: 해당 팀, 팀 목록


6. DELETE /team/:id - 팀 삭제 (Soft Delete)

실제 URL: DELETE /api/team/:id

인증: 필수

Request:

{
  teamId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 팀 소유자만 삭제 가능

캐시 무효화: 해당 팀, 팀 목록


7. POST /team/add-student - 팀에 학생 추가

실제 URL: POST /api/team/add-student

인증: 필수 (내부 사용 - StudentManager에서 호출)

Request:

{
  teamId: string;
  studentId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

캐시 무효화: 해당 팀


8. POST /team/add-member - 팀에 멤버 추가

실제 URL: POST /api/team/add-member

인증: 필수

Request:

{
  teamId: string;
  uid: string;
  nickname?: string;  // 🆕 닉네임 (선택적)
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

캐시 무효화: 해당 팀


9. POST /team/remove-student - 팀에서 학생 제거

실제 URL: POST /api/team/remove-student

인증: 필수

Request:

{
  teamId: string;
  studentId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 팀 소유자만 제거 가능 (서버에서 검증)

캐시 무효화: 해당 팀


10. POST /team/generate-code - 고유 팀 코드 생성

실제 URL: POST /api/team/generate-code

인증: 선택적

Request:

{
  maxAttempts?: number;   // 기본값: 10
}

Response:

{
  success: true,
  data: {
    code: string;         // 생성된 고유 팀 코드
  }
}

11. POST /team/check-code - 팀 코드 존재 여부

실제 URL: POST /api/team/check-code

인증: 선택적

Request:

{
  code: string;
}

Response:

{
  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:

{
  uid: string;  // 로그인하려는 사용자의 Firebase UID
}

Response:

// 성공 (익명 계정)
{
  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:

{
  teamId: string;
  uid: string;  // 제거할 멤버의 UID
}

Response:

{
  success: true
}

권한 체크:

  • 팀 소유자: 다른 멤버 강퇴 가능 (자신은 불가)
  • 일반 멤버: 본인만 제거 가능 (팀 나가기)
  • 소유자가 본인을 제거: "팀 소유자는 팀을 나갈 수 없습니다. 팀을 삭제하거나 소유권을 이전해주세요."
  • 일반 멤버가 타인을 제거: "팀을 관리할 권한이 없습니다."

에러:

  • 404: 팀을 찾을 수 없음
  • 403: 권한 없음 (위 권한 체크 참조)

사용 예시:

// 팀 나가기
await teamManager.removeMember(teamId, currentUser.uid);

🆕 14. POST /team/:teamId/security-level - 보안 레벨 변경

실제 URL: POST /api/team/:teamId/security-level

인증: 필수 (팀 소유자만)

Request:

{
  securityLevel: 1 | 2 | 3 | 4 | 5;
  autoPopulateList?: boolean;  // 기본값: true
}

Response:

{
  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):

{
  name: string;  // 추가할 이름
}

Request (DELETE):

{
  name: string;  // 제거할 이름
}

Response:

{
  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):

{
  email: string;  // 추가할 이메일 (소문자로 자동 변환)
}

Request (DELETE):

{
  email: string;  // 제거할 이메일
}

Response:

{
  success: true,
  data: {
    allowedEmails: string[];  // 업데이트된 이메일 목록
  }
}

유효성 검사: 이메일 형식 검증 (/^[^\s@]+@[^\s@]+\.[^\s@]+$/)

캐시 무효화: 해당 팀


🆕 17. POST /team/:teamId/cover-image - 팀 커버 이미지 업로드

실제 URL: POST /api/team/:teamId/cover-image

설명: 팀 커버 이미지를 Firebase Storage에 업로드하고 팀 문서를 업데이트합니다.

인증: 필수 (팀 소유자만)

Request:

// FormData 형식
{
  file: File;  // 이미지 파일 (JPEG/PNG/WebP/GIF, 최대 5MB)
}

Response:

{
  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:

{
  success: true
}

처리 로직:

  1. 팀 문서에서 coverImage URL 조회
  2. Storage에서 파일 삭제
  3. 팀 문서 coverImage 필드 제거

캐시 무효화: 해당 팀, 공개 팀 목록


Auth API

POST /auth/merge-account - 익명 계정 데이터 병합

실제 URL: POST /api/auth/merge-account

인증: 필수 (정식 계정으로 로그인된 상태)

Request:

{
  anonymousUid: string;  // 병합할 익명 계정의 UID
}

Response:

{
  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:

{
  uid: string;           // Firebase Auth UID
  displayName: string;   // Firebase Auth displayName 설정용
  teamId: string;        // 최초 가입 팀
  isAnonymous: boolean;  // 익명 여부
}

Response:

{
  success: true,
  data: {
    user: User;          // Firebase Auth + Firestore 결합된 완전한 User
  }
}

부수 효과:

  • Firestore에 FirestoreUser 생성 (최소 데이터)
  • 팀에 멤버 추가 (team.members[uid])
  • Firebase Auth displayName 설정

캐시 무효화: 팀별 사용자 목록


Writing API

1. POST /writing - 글 생성

실제 URL: POST /api/writing

인증: 필수

Request:

{
  title: string;
  content: string;
  status?: "draft" | "published";
  topicId?: string | null;
}

Response:

{
  success: true,
  data: {
    writingId: string;
    writing: Writing;
  }
}

부수 효과: 서버에서 wordCount, charCount 자동 계산

캐시 무효화: 사용자 글 목록, 최근 글


2. GET /writing/:id - 글 조회

실제 URL: GET /api/writing/:id

인증: 필수

권한: 작성자만 조회 가능 (writing.userId === currentUserId)

Response:

{
  success: true,
  data: {
    writing: Writing | null;
  }
}

에러:

  • 404 Not Found: 글이 존재하지 않음
  • 403 Forbidden: 작성자가 아님

캐싱: 클라이언트에서 5분간 캐싱


3. POST /writing/user - 사용자의 글 목록

실제 URL: POST /api/writing/user

인증: 필수

Request:

{
  userId?: string;  // 없으면 현재 사용자
}

Response:

{
  success: true,
  data: {
    writings: Writing[];
  }
}

캐싱: 클라이언트에서 1분간 캐싱


4. POST /writing/recent - 최근 글

실제 URL: POST /api/writing/recent

인증: 필수

Request:

{
  limit?: number;  // 기본값: 5
}

Response:

{
  success: true,
  data: {
    writings: Writing[];
  }
}

캐싱: 클라이언트에서 30초간 캐싱


5. PUT /writing/:id - 글 수정

실제 URL: PUT /api/writing/:id

인증: 필수

Request:

{
  writingId: string;
  data: {
    title?: string;
    content?: string;
    status?: "draft" | "published";
    topicId?: string | null;
    wordCount?: number;
    charCount?: number;
    distortionAreas?: DistortionAreaData[]; // 🆕 왜곡 영역 설정
    analysis?: WritingAnalysis; // 🆕 AI 분석 결과 (영역 제한용)
  }
}

Response:

{
  success: true,
  data: {
    writing: Writing;
  }
}

권한: 작성자만 수정 가능

캐시 무효화: 해당 글, 사용자 글 목록, 최근 글


6. DELETE /writing/:id - 글 삭제

실제 URL: DELETE /api/writing/:id

인증: 필수

Request:

{
  writingId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 작성자만 삭제 가능

캐시 무효화: 해당 글, 사용자 글 목록, 최근 글


7. POST /writing/:id/analyze - 글 분석 실행

실제 URL: POST /api/writing/:id/analyze

인증: 필수 (작성자 본인만 가능)

설명: 저장된 글을 서버에서 불러와 AI 분석을 실행하고 결과를 저장합니다.

Request:

{
  locale?: "ko" | "en" | "ja";  // 기본값: "ko"
}

Response:

{
  success: true,
  data: {
    analysis: WritingAnalysis;
    cached: boolean;  // true면 기존 분석 결과 반환 (재사용)
  }
}

특징:

  • Content Hash 기반 재사용: 글 내용이 변경되지 않았으면 기존 분석 결과 반환 (비용 절감)
  • 최소 길이: 30자 이상이어야 분석 가능
  • 저장: 분석 결과는 자동으로 writings/{id}.analysis에 저장됨

Topic API

1. POST /topic/available - 사용 가능한 주제 목록

실제 URL: POST /api/topic/available

인증: 필수

Request:

{
  teamIds?: string[];    // 팀 주제를 가져올 팀 ID 목록
}

Response:

{
  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:

{
  success: true,
  data: {
    topic: Topic | null;
  }
}

캐싱: 클라이언트에서 5분간 캐싱


3. POST /topic - 개인 주제 생성

실제 URL: POST /api/topic

인증: 필수

Request:

{
  title: string;
  description: string;
  category: "daily" | "imagination" | "emotion" | "experience";
  difficulty: "easy" | "medium" | "hard";
  keywords?: string[];
  examplePrompts?: string[];
  titleTemplate?: string;
  contentTemplate?: string;
}

Response:

{
  success: true,
  data: {
    topicId: string;
    topic: Topic;
  }
}

권한: 로그인한 사용자가 자동으로 소유자가 됨

캐시 무효화: 주제 목록


4. PUT /topic/:id - 개인 주제 수정

실제 URL: PUT /api/topic/:id

인증: 필수

Request:

{
  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:

{
  success: true,
  data: {
    topic: Topic;
  }
}

권한: 주제 소유자만 수정 가능

캐시 무효화: 해당 주제, 주제 목록


5. DELETE /topic/:id - 개인 주제 삭제

실제 URL: DELETE /api/topic/:id

인증: 필수

Request:

{
  topicId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 주제 소유자만 삭제 가능

캐시 무효화: 해당 주제, 주제 목록


6. POST /topic/increment-usage - 주제 사용 횟수 증가

실제 URL: POST /api/topic/increment-usage

인증: 선택적

Request:

{
  topicId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

참고: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음)


7. POST /topic/team - 팀 주제 생성

실제 URL: POST /api/topic/team

인증: 필수 (팀 소유자만)

Request:

{
  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:

{
  success: true,
  data: {
    topicId: string;
    topic: Topic;       // ownerType: TEAM, ownerId: teamId
  }
}

권한:

  • 로그인한 사용자가 팀 소유자인지 확인 (team.ownerId === currentUserId)
  • 팀이 활성화 상태인지 확인 (team.isActive === true)

백엔드 처리:


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:

{
  success: true,
  data: {
    topics: Topic[];    // ownerType === TEAM && ownerId === teamId
  }
}

백엔드 로직:


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:

{
  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:

{
  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:

{
  topicId: string;
  teamId: string;      // 소유권 검증용
}

Response:

{
  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 구현 예시

// 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 구현 예시

// 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<ApiResponse<CreateTeamResponse>> {
  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 구현 (선택적)