2025-12-12 00:11:51 +00:00

43 KiB

API Specification

라온누리 서버 API 명세서


🔧 API 개발 필수 가이드

RESTful API 설계 원칙

HTTP Method로 동작 구분 (경로로 구분하지 않음):

// ✅ 올바른 방식 (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 파일 구조:

// 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 싱글톤 인스턴스

// ❌ 잘못된 방식 - 초기화 문제 및 인증 오류 발생 가능
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()는 매번 호출 시 초기화 상태 불확실
  • adminFbClientsrc/lib/firebase-admin.ts에서 한 번만 초기화
  • 환경변수 FIREBASE_SERVICE_ACCOUNT_KEY를 통해 명시적 인증 보장

API Route 응답 헬퍼 함수 (필수)

모든 API Route는 src/lib/api-response.ts의 헬퍼 함수를 사용해야 합니다.

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<MyResponse> = {
    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
  }
}

절대 사용 금지: 수동 응답 객체 생성

// ❌ Bad - 직접 NextResponse.json 사용 금지
return NextResponse.json(
  {success: false, error: "에러 메시지", code: "ERROR_CODE"},
  {status: 400}
);

// ✅ Good - 헬퍼 함수 사용
return validationErrorResponse("에러 메시지");

Manager ApiCall 패턴 (클라이언트)

Manager에서 ApiCall 사용 시 주의사항:

ApiCall은 성공 시 언래핑된 데이터만 반환하고, 실패 시 자동으로 에러를 throw합니다.

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<Item> {
    const response = await this.ApiCall<CreateItemRequest, CreateItemResponse>(
      HttpMethod.POST,
      '/item',
      data
    );

    // response는 이미 {item: Item} 형태 (언래핑됨)
    return response.item;
  }

  // ❌ 잘못된 방식 - success 체크 불필요
  async createItemWrong(data: CreateItemRequest): Promise<Item> {
    const response = await this.ApiCall<CreateItemRequest, CreateItemResponse>(
      HttpMethod.POST,
      '/item',
      data
    );

    // ❌ response.success는 존재하지 않음 (타입 에러)
    if (!response.success || !response.item) {
      throw new Error("생성 실패");
    }

    return response.item;
  }

  // ✅ 반환값이 없는 경우 (DELETE 등)
  async deleteItem(id: string): Promise<void> {
    await this.ApiCall<null, DeleteItemResponse>(
      HttpMethod.DELETE,
      `/item/${id}`,
      null
    );
    // 성공하면 그대로 종료, 실패하면 자동으로 에러 throw
  }
}

핵심 원칙:

  • ApiCall 반환값 = UnwrapApiResponse<T> (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<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단계):

  • 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 설정

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


2. POST /user/purchase - 플랜 구매/변경

실제 URL: POST /api/user/purchase

인증: 필수

Request:

{
  planType: PlanType;              // FREE, PRO, CLASSROOM, ACADEMY, SCHOOL
  billingCycle: BillingCycle;      // MONTHLY, YEARLY
  amount: number;                  // 결제 금액 (원)
  downgradeMode?: "immediate" | "scheduled";  // 다운그레이드 시 적용 시점
}

Response:

{
  success: true,
  data: {
    creditsAdded: number;   // 환불로 지급된 크레딧 (업그레이드/즉시 다운그레이드)
    isScheduled: boolean;   // true면 다음 결제일에 적용
    isBillingCycleChange: boolean;  // true면 결제 주기만 변경
  }
}

동작:

  1. 업그레이드: 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
  2. 다운그레이드 (immediate): 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
  3. 다운그레이드 (scheduled): scheduledPlan에 저장, 만료 시 자동 적용
  4. 결제 주기 변경: scheduledPlan에 저장, 만료 시 새 주기로 적용

환불 계산:

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:

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

{
  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에 저장됨

Comment API

1. GET /comment/writing/:writingId - 댓글 목록 조회

실제 URL: GET /api/comment/writing/:writingId

인증: 선택적 (현재 사용자 반응 확인용)

Response:

{
  success: true,
  data: {
    comments: CommentWithReplies[];
    totalCount: number;
  }
}

특징:

  • 계층 구조 (댓글 + 답글) 반환
  • 작성자 정보 (displayName, photoURL) 포함
  • 현재 사용자의 반응 포함 (로그인 시)

2. POST /comment/writing/:writingId - 댓글 작성

실제 URL: POST /api/comment/writing/:writingId

인증: 필수

Request:

{
  content: string;
  parentId?: string; // 답글인 경우 부모 댓글 ID
}

Response:

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

3. PUT /comment/:id - 댓글 수정

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

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

Request:

{
  content: string;
}

Response:

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

4. DELETE /comment/:id - 댓글 삭제

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

인증: 필수

권한:

  • 작성자 본인
  • 글 작성자 (관리 차원)
  • 팀 소유자 (팀 주제인 경우)

Response:

{
  success: true
}

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 구현 (선택적)