45 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()는 매번 호출 시 초기화 상태 불확실adminFbClient는src/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;
}
Image Generation Check API
POST /api/check-image-generation
설명: 이미지 생성 가능 여부 확인 (팀/개인 글쓰기에 따라 다른 검증)
인증: 필수
Request Body:
{
writingId: string; // 글 ID
}
Response:
{
success: true,
data: {
allowed: boolean; // 이미지 생성 가능 여부
reason?: ImageGenerationDisableReason; // 비활성화 사유
remaining?: number; // 남은 횟수
limit?: number; // 전체 한도 (-1은 무제한)
isTeamWriting: boolean; // 팀 글쓰기 여부
}
}
ImageGenerationDisableReason:
type ImageGenerationDisableReason =
| "PLAN_NOT_SUPPORTED" // Pro 플랜 이상 필요
| "LIMIT_EXCEEDED" // 개인 월간 한도 초과
| "TEAM_AI_DISABLED" // 팀 AI 비활성화
| "TEAM_LIMIT_EXCEEDED" // 팀 월간 한도 초과
| "DAILY_LIMIT_EXCEEDED"; // 팀원 일일 한도 초과
검증 로직:
- 팀 글쓰기 (
writing.teamId존재):canTeamUseImageGeneration(teamId, userId)호출- 팀 AI 설정 + 월간/일일 제한 확인
- 개인 글쓰기 (
writing.teamId없음):canUseAIFeature(userId, AIFeatureType.IMAGE_GENERATION)호출- 개인 플랜 + 월간 제한 확인
에러:
400: writingId 누락401: 인증 필요404: 글을 찾을 수 없음 또는 본인 글이 아님
Manager 사용법:
import { writingManager } from "@/managers";
const result = await writingManager.checkImageGenerationAvailability(writingId);
// → { allowed: true, remaining: 5, limit: 10, isTeamWriting: true }
// → { allowed: false, reason: "TEAM_LIMIT_EXCEEDED", isTeamWriting: true }
캐싱: 없음 (실시간 확인 필요)
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: "팀 소유자만 팀원의 글을 분석할 수 있습니다"
}
분석 타입:
-
self (본인 분석):
- 모든 published 글 분석
- 권한: 본인만
-
by-topic (주제별 분석):
- 특정 팀 주제로 작성된 글만 분석
- 권한: 팀 소유자만
- 주제가 팀 주제(
ownerType="team")여야 함
-
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[];
}
}
권한: 로그인한 사용자가 소유한 팀 + 참여한 팀 모두 조회 (중복 제거)
백엔드 로직:
getTeamsByOwner(uid)- 소유한 팀 조회getTeamsByMember(uid)- 참여한 팀 조회 (memberUidsarray-contains)- 두 결과를 병합하고 중복 제거하여 반환
캐싱: 클라이언트에서 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 발급 불가
- ✅ 탈취 방지: 정식 계정 이름으로 타인이 로그인 불가
사용 흐름:
- 클라이언트에서
team.members검색 → 같은 닉네임 발견 - 해당 UID로 Custom Token 요청
- 익명 계정이면 Token 발급, 정식 계정이면 403
- 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에 추가
- Level 2로 변경 → 기존 멤버 이름을
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
}
}
처리 로직:
- 파일 타입 검증 (JPEG/PNG/WebP/GIF만 허용)
- 파일 크기 검증 (5MB 이하)
- Firebase Storage 업로드 (
teams/{teamId}/cover-{timestamp}.{ext}) - 파일 공개 설정 (
makePublic()) - 기존 이미지가 있으면 Storage에서 삭제
- 팀 문서
coverImage필드 업데이트
캐시 무효화: 해당 팀, 공개 팀 목록
🆕 18. DELETE /team/:teamId/cover-image - 팀 커버 이미지 삭제
실제 URL: DELETE /api/team/:teamId/cover-image
설명: 팀 커버 이미지를 Storage에서 삭제하고 팀 문서를 업데이트합니다.
인증: 필수 (팀 소유자만)
Response:
{
success: true
}
처리 로직:
- 팀 문서에서
coverImageURL 조회 - Storage에서 파일 삭제
- 팀 문서
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; // 이전된 미리보기 요청 개수
}
}
}
동작:
-
Firestore 데이터 마이그레이션 (Batch 사용):
writings컬렉션: userId 업데이트topics컬렉션: ownerId 업데이트 (개인 주제만)comments컬렉션: authorId 업데이트userReactions컬렉션: userId 업데이트teams컬렉션: members 키 변경 (anonymousUid → targetUid)
-
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면 결제 주기만 변경
}
}
동작:
- 업그레이드: 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
- 다운그레이드 (immediate): 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
- 다운그레이드 (scheduled):
scheduledPlan에 저장, 만료 시 자동 적용 - 결제 주기 변경:
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[];
}
}
백엔드 로직:
- 개인 주제:
ownerType === PERSONAL && ownerId === currentUserId - 팀 주제:
ownerType === TEAM && ownerId in [teamId1, teamId2, ...]- 클라이언트에서
teamIds: ["team1", "team2"]전달 - 서버에서 필터링
- 클라이언트에서
- 모든 주제를 병합하여 반환
캐싱: 클라이언트에서 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;
}
}
권한 검증:
- 주제가 팀 주제인지 확인:
topic.ownerType === TEAM && isTeamOwnerId(topic.ownerId) - 요청한 teamId와 주제의 teamId 일치 확인:
extractTeamId(topic.ownerId) === teamId - 현재 사용자가 팀 소유자인지 확인:
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 }
};
}
}
보안 고려사항
- 인증 토큰 검증: 모든 쓰기 작업은 Firebase ID Token 검증 필수
- 권한 체크: 팀 소유자 확인 (ownerId === decoded.uid)
- 입력 검증: 모든 입력값 sanitization 및 validation
- Rate Limiting: Redis로 API 호출 횟수 제한 (선택적)
- 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)
- 캐시 무효화: 변경 작업 시 관련 캐시 자동 무효화
개발 순서
- ✅ API 타입 정의 (
src/types/api.ts,src/types/api/team.ts,src/types/api/student.ts) - ✅ BaseManager에
authenticatedFetch,callApi구현 - ✅ BaseManager에 클라이언트 캐싱 메서드 구현
- ✅ TeamManager, StudentManager를 API 호출 방식으로 전환
- ⏳ Next.js API Routes 또는 Server Actions 구현
- ⏳ Redis 캐싱 구현 (선택적)
- ⏳ Rate Limiting 구현 (선택적)