마지막 업데이트: 2026년 5월 22일
선행 문서: AUTHORIZATION_MODEL.md
💡 AZ-900 개념 — Azure Functions REST API
Elevate.Server는 Azure Functions v4 런타임으로 제공되는 HTTP 트리거 함수들의 집합이다. 각 함수가 하나의 API 엔드포인트 역할을 한다.host.json에서routePrefix: ""로 설정하여 기본 접두사(/api)가 코드 라우트에 포함되도록 한다. 함수는 요청이 들어올 때만 실행되는 서버리스 방식이라 유휴 상태에서는 비용이 거의 발생하지 않는다.
💡 AZ-900 개념 — Azure Cosmos DB (NoSQL)
Cosmos DB는 JSON 문서를 저장하는 NoSQL 데이터베이스다. 관계형 DB와 달리 고정 스키마가 없어 필드를 자유롭게 추가할 수 있다. 데이터는 "파티션 키"라는 필드로 여러 서버에 분산 저장된다. Elevate는category필드를 파티션 키로 사용하여 게시글을 카테고리별로 분산한다. 같은 컨테이너(posts)에 게시글과 에셋 메타데이터를 함께 저장하고documentType필드로 구분한다.
https://{function-app-host}/api/{경로}
{function-app-host}=func-elv-server-ep-dev.azurewebsites.net. host.json에서 routePrefix=""로 설정되어 있어/api접두사는 각 함수 route에 포함됨.
관리자 API: Authorization: Bearer {Entra ID Access Token}
모든 요청에 x-correlation-id 헤더가 응답에 포함됨.
{
"code": "에러 코드 (string)",
"message": "에러 설명 (string)",
"correlationId": "요청 상관 ID (string)",
"details": { "reason": "상세 내용" } // 선택적
}| HTTP 상태 | 에러 코드 | 설명 |
|---|---|---|
| 400 | BadRequest |
유효성 검사 실패, 잘못된 파라미터 |
| 401 | Unauthorized |
토큰 없음 또는 JWT 검증 실패 |
| 403 | Forbidden |
테넌트 불일치 또는 Guest 계정 |
| 404 | NotFound |
리소스 없음 |
| 409 | Conflict |
슬러그 중복 등 충돌 |
| 500 | InternalServerError |
서버 내부 오류 |
- 방식:
?limit=N&page=P - limit 범위: 1 – 100, 기본값 20
- page 범위: 1 – 10000, 기본값 1
- 응답 구조:
{
"items": [...],
"totalCount": 41,
"totalPages": 3,
"page": 1
}page는 1부터 시작totalPages는Math.ceil(totalCount / limit)기준- 현재 공개/관리자 게시글 목록 API는 모두 이 페이지 기반 응답을 사용한다.
GET /api/public/posts
쿼리 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
limit |
number | 20 | 1–100 |
page |
number | 1 | 1–10000 |
category |
string | — | 단일 카테고리 필터 |
categories |
string | — | 쉼표 구분 복수 카테고리 OR 필터 (copilot,m365 등) |
tag |
string | — | 단일 태그 필터 |
tags |
string | — | 쉼표 구분 복수 태그 AND 필터 (tags=teach,copilot) |
q |
string | — | 제목 / 요약 / slug 대상 검색어 |
응답 200
{
"items": [
{
"id": "uuid",
"slug": "string",
"category": "string",
"title": "string",
"excerpt": "string",
"tags": ["string"],
"status": "published",
"publishedAt": "ISO8601 | null",
"updatedAt": "ISO8601",
"thumbnail": {
"url": "https://{storage}.blob.core.windows.net/images/...",
"signedUrl": "https://{storage}.blob.core.windows.net/images/...?{sas}"
},
"eventDates": [{ "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }],
"eventLocation": "string | null",
"eventTarget": "string | null"
}
],
"totalCount": 41,
"totalPages": 3,
"page": 1
}
thumbnail.signedUrl: 서버사이드에서 선주입된 읽기 SAS URL (1시간 유효). 클라이언트는signedUrl을 우선 사용, 없으면url폴백.
eventDates,eventLocation,eventTarget:category = "event"인 게시글에만 포함. 나머지 카테고리에서는null.
GET /api/public/posts/{category}/{slug}
경로 파라미터
| 파라미터 | 설명 |
|---|---|
category |
게시글 카테고리 |
slug |
게시글 슬러그 |
응답 200
{
"id": "uuid",
"slug": "string",
"category": "string",
"title": "string",
"excerpt": "string",
"tags": ["string"],
"status": "published",
"publishedAt": "ISO8601 | null",
"updatedAt": "ISO8601",
"contentMarkdown": "string (HTML, 본문 내 blob URL은 SAS URL로 치환됨)",
"series": "string | null",
"seriesOrder": "number | null",
"thumbnail": {
"url": "https://{storage}.blob.core.windows.net/images/...",
"signedUrl": "https://{storage}.blob.core.windows.net/images/...?{sas}"
},
"youtube": "string | null",
"eventDates": [{ "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }],
"eventLocation": "string | null",
"eventTarget": "string | null"
}
thumbnail.signedUrl및contentMarkdown내 모든 blob URL에 읽기 SAS (1시간 유효)가 서버사이드에서 선주입됨.
에러:
404 NotFound: 게시글 없음 또는 비공개
GET /api/public/series/{seriesSlug}/posts
경로 파라미터
| 파라미터 | 설명 |
|---|---|
seriesSlug |
시리즈 슬러그 (p.series 필드 값) |
쿼리 파라미터: 없음 (전체 목록 반환, 페이지네이션 미지원)
응답 200
{
"items": [...],
"nextCursor": null
}
items구조는 2.1과 동일.status = 'published'인 게시글만 포함.seriesOrder ASC정렬.
GET /api/public/series
쿼리 파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
category |
string (선택) | 카테고리 필터. 미입력 시 전체 카테고리 |
응답 200
{
"items": [
{
"name": "시리즈명",
"posts": [
{ "id": "uuid", "slug": "string", "title": "string", "seriesOrder": 1 }
]
}
]
}GET /api/public/tags
쿼리 파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
categories |
string | 쉼표 구분 카테고리 필터 (update,agenthon 등). 미입력 시 전체 카테고리 |
응답 200
{
"items": ["string"] // 알파벳 정렬, 중복 제거, published 게시글 기준
}GET /api/health
응답 200: {"status":"healthy","version":"1.0.0","correlationId":"..."}
GET /api/public/image(이미지 프록시) 엔드포인트는 2026년 4월 22일 제거됨. 이미지 접근은 게시글 API의thumbnail.signedUrl및contentMarkdown내 SAS URL 선주입 방식으로 대체됨.
모든 관리자 API:
Authorization: Bearer {token}필수
GET /api/admin/posts
쿼리 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
limit |
number | 20 | 1–100 |
page |
number | 1 | 1–10000 |
category |
string | — | 카테고리 필터 |
tag |
string | — | 태그 필터 |
status |
string | — | draft, published, archived 중 하나 |
응답 200: 2.1과 동일 구조, status 필드에 모든 상태 포함, seriesOrder 필드 포함
GET /api/admin/posts/{id}
응답 200
{
"id": "uuid",
"slug": "string",
"category": "string",
"title": "string",
"excerpt": "string",
"contentMarkdown": "string",
"tags": ["string"],
"status": "draft | published | archived",
"publishedAt": "ISO8601 | null",
"updatedAt": "ISO8601",
"series": "string | null",
"seriesOrder": "number | null",
"thumbnail": "string (URL) | null",
"youtube": "string | null",
"eventDates": [{ "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }],
"eventLocation": "string | null",
"eventTarget": "string | null"
}에러: 404 NotFound
POST /api/admin/posts
요청 바디
{
"title": "string (필수)",
"category": "string (필수)",
"contentMarkdown": "string (필수)",
"tags": ["string"] "(필수, 빈 배열 가능)",
"status": "draft | published | archived (필수)",
"excerpt": "string (선택)",
"slug": "string (선택, 미입력 시 title에서 자동 생성)",
"series": "string | null (선택)",
"seriesOrder": "number | null (선택, 시리즈 내 순서)",
"thumbnail": "string (URL) | null (선택)",
"youtube": "string | null (선택)",
"eventDates": [{ "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }],
"eventLocation": "string | null (선택, event 카테고리 전용)",
"eventTarget": "string | null (선택, event 카테고리 전용)"
}썸네일 자동 추출 규칙 (서버 측 적용):
thumbnail미전달(undefined) → 게시글 본문 HTML에서 첫 번째<img>src 자동 추출 (Azure Blob URL 한정)thumbnail: null명시 → 썸네일 없음으로 저장 (null)thumbnail: { url: "..." }또는 URL 문자열 → 해당 값 저장
상태 전이 규칙:
status = 'published'→publishedAt = nowstatus = 'draft'또는'archived'→publishedAt = null
슬러그 생성 규칙:
slug입력 시:toSlugBase(slug)처리 후 중복 시409 Conflictslug미입력 시:toSlugBase(title)후 중복이면 자동으로-2,-3증가
응답 201
{
"id": "uuid",
"slug": "string",
"category": "string",
"title": "string",
"excerpt": "string",
"contentMarkdown": "string",
"tags": ["string"],
"status": "string",
"publishedAt": "ISO8601 | null",
"updatedAt": "ISO8601",
"series": "string | null",
"seriesOrder": "number | null",
"thumbnail": "string | null"
}에러: 400 BadRequest (검증 실패), 409 Conflict (슬러그 중복)
PUT /api/admin/posts/{id}
요청 바디 (모든 필드 선택적 – Partial Update)
{
"title": "string (선택)",
"excerpt": "string (선택)",
"contentMarkdown": "string (선택)",
"tags": ["string"] "(선택)",
"status": "draft | published | archived (선택)",
"series": "string | null (선택)",
"seriesOrder": "number | null (선택)",
"thumbnail": "string | null (선택)",
"youtube": "string | null (선택)",
"eventDates": [{ "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }],
"eventLocation": "string | null (선택, event 카테고리 전용)",
"eventTarget": "string | null (선택, event 카테고리 전용)"
}썸네일 업데이트 규칙 (서버 측 적용):
thumbnail미전달 → 기존 썸네일 있으면 유지, 없으면 본문에서 자동 추출thumbnail: null명시 → null로 초기화thumbnail값 전달 → 해당 값으로 갱신
상태 전이 규칙:
- 비공개 →
published:publishedAt = now자동 설정 - 임의 상태 →
draft:publishedAt = null초기화
응답 200: 3.2와 동일 구조
에러: 400 BadRequest, 404 NotFound
DELETE /api/admin/posts/{id}
응답 204 (No Content)
에러: 404 NotFound
⚠️ 미구현: 현재 코드에 해당 엔드포인트가 존재하지 않음.adminDeletePost함수는 단건 삭제(DELETE /api/admin/posts/{id})만 처리한다. 이 섹션은 향후 구현 계획 기준 문서임.
DELETE /api/admin/posts/bulk
요청 바디
{
"ids": ["uuid", "uuid", ...]
}ids: 삭제할 게시글 ID 배열 (1개 이상 필수)- 존재하지 않는 ID는 무시 (멱등성 보장)
- 모든 삭제가 병렬 처리됨
응답 204 (No Content)
에러: 400 BadRequest (ids 미입력 또는 빈 배열), 500 InternalServerError (일부 삭제 실패)
라우팅 주의:
api/admin/posts/bulk는api/admin/posts/{id}보다 먼저 등록되어 exact match가 우선됨.
⚠️ 미구현: 현재 코드에 해당 엔드포인트가 존재하지 않음.adminController.js에getCategories함수 없음. 이 섹션은 향후 구현 계획 기준 문서임.
GET /api/admin/categories
Cosmos DB에 존재하는 모든 게시글의 카테고리 값을 중복 없이 반환한다. Admin 필터 UI에서 사용.
응답 200
{
"items": ["string"]
}내부 쿼리:
SELECT DISTINCT VALUE p.category FROM p WHERE ...(파티션 교차 쿼리)
⚠️ 미구현: 현재 코드에 해당 엔드포인트가 존재하지 않음.adminController.js에getAdminSeries함수 없음. 이 섹션은 향후 구현 계획 기준 문서임.
GET /api/admin/series?category={category}
쿼리 파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
category |
string (필수) | 카테고리 이름 |
응답 200
{
"items": [
{
"name": "string",
"maxOrder": "number"
}
]
}name: 시리즈 이름maxOrder: 해당 시리즈의 현재 최대seriesOrder값 (다음 순서 제안에 사용:maxOrder + 1)
내부 쿼리:
SELECT p.series as name, MAX(p.seriesOrder) as maxOrder ... GROUP BY p.series
POST /api/admin/assets/sas
요청 바디
{
"fileName": "string (필수, 원본 파일명)",
"contentType": "string (필수, MIME 타입)",
"sizeBytes": "number (선택, 최대 10485760)"
}허용 MIME 타입: image/jpeg, image/png, image/webp, image/gif, image/heic, image/heif, image/avif
응답 200
{
"uploadUrl": "https://...blob.core.windows.net/.../...?sasToken (유효 15분, PUT 업로드용)",
"blobUrl": "https://...blob.core.windows.net/.../... (퍼블릭 접근 URL)",
"expiresAt": "ISO8601"
}에러: 400 BadRequest (fileName/contentType 미입력, 허용되지 않는 MIME 타입, 크기 초과)
POST /api/admin/assets
요청 바디
{
"blobUrl": "string (필수, 유효한 URL)",
"contentType": "string (필수, 허용 MIME 타입)",
"sizeBytes": "integer (필수, 1 ~ 10485760)",
"fileName": "string (필수)",
"postId": "string | null (선택)"
}응답 201
{
"assetId": "uuid",
"url": "string (blobUrl)",
"mimeType": "string",
"sizeBytes": "number",
"width": 0,
"height": 0
}에러: 400 BadRequest
DELETE /api/admin/assets/{assetId}
처리 순서:
- Cosmos DB에서 에셋 메타데이터 삭제
- Azure Blob Storage에서 파일 삭제 (
deleteIfExists)
응답 204 (No Content)
에러: 404 NotFound
GET /api/admin/analytics/summary
응답 200
{
"totalPv": "number (전체 페이지뷰 합산)",
"totalUv": "number (전체 유니크 방문자 합산)",
"avgTimeOnPage": "string (현재 구현: '00:00:00' 고정)",
"dailyTrend": "array (현재 구현: 빈 배열)",
"topPosts": [
{
"title": "string",
"views": "number",
"slug": "string"
}
]
}주의:
views,uniqueVisitors는 Cosmos DB 포스트 도큐먼트의 필드로, 현재 자동 수집 메커니즘이 구현되어 있지 않을 수 있다 (운영 확인 필요).
POST /api/admin/files/sas
이미지 외 첨부파일(문서, PDF 등)을 Azure Blob Storage에 업로드하기 위한 SAS 토큰을 발급한다. 이미지 업로드 SAS(
/api/admin/assets/sas)와 유사하지만 허용 MIME 타입이 다를 수 있다.
요청 바디
{
"fileName": "string (필수, 원본 파일명)",
"contentType": "string (필수, MIME 타입)",
"sizeBytes": "number (선택)"
}응답 200
{
"uploadUrl": "https://...blob.core.windows.net/.../...?sasToken (유효 15분, PUT 업로드용)",
"blobUrl": "https://...blob.core.windows.net/.../...",
"expiresAt": "ISO8601"
}에러: 400 BadRequest (fileName/contentType 미입력)
POST /api/admin/files
Azure Blob에 업로드 완료된 첨부파일의 메타데이터를 Cosmos DB에 등록한다.
요청 바디
{
"blobUrl": "string (필수, 유효한 URL)",
"contentType": "string (필수)",
"sizeBytes": "integer (필수)",
"fileName": "string (필수)",
"postId": "string | null (선택)"
}응답 201
{
"fileId": "uuid",
"url": "string (blobUrl)",
"contentType": "string",
"sizeBytes": "number",
"fileName": "string"
}에러: 400 BadRequest
DELETE /api/admin/files/{fileId}
처리 순서:
- Cosmos DB에서 첨부파일 메타데이터 삭제
- Azure Blob Storage에서 파일 삭제 (
deleteIfExists)
응답 204 (No Content)
에러: 404 NotFound
{
"id": "uuid",
"documentType": "post",
"partitionKey": "{category값}",
"category": "string",
"slug": "string (고유)",
"title": "string",
"excerpt": "string",
"contentMarkdown": "string",
"tags": ["string"],
"status": "draft | published | archived",
"publishedAt": "ISO8601 | null",
"updatedAt": "ISO8601",
"createdAt": "ISO8601",
"series": "string | null",
"seriesOrder": "number | null",
"thumbnail": "string (URL) | null",
"youtube": "string | null",
"eventDates": [{ "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }],
"eventLocation": "string | null",
"eventTarget": "string | null",
"views": "number (선택, 분석용)",
"uniqueVisitors": "number (선택, 분석용)",
"_ts": "Cosmos 내부 타임스탬프"
}{
"id": "uuid",
"documentType": "asset",
"category": "_asset",
"partitionKey": "_asset",
"postId": "string | null",
"blobUrl": "string (Azure Storage URL)",
"contentType": "string (MIME 타입)",
"sizeBytes": "number",
"fileName": "string (원본 파일명)",
"width": 0,
"height": 0,
"createdAt": "ISO8601",
"updatedAt": "ISO8601",
"_ts": "Cosmos 내부 타임스탬프"
}| 환경 변수 | 설명 | 기본값 |
|---|---|---|
COSMOS_ENDPOINT |
Cosmos DB 엔드포인트 | 필수 |
COSMOS_DATABASE_NAME |
데이터베이스 이름 | elevate |
COSMOS_CONTAINER_NAME |
포스트 컨테이너 이름 | posts |
COSMOS_ASSETS_CONTAINER_NAME |
에셋 컨테이너 이름 | posts (포스트와 동일) |
COSMOS_KEY |
Cosmos 계정 키 (개발 환경만) | — |
STORAGE_ACCOUNT_NAME |
Storage 계정 이름 | 필수 |
STORAGE_CONTAINER_NAME |
Blob 컨테이너 이름 | images |
ENTRA_TENANT_ID / AUTH_TENANT_ID |
Tenant ID | 62ae463a-9f12-4edf-8544-4f6ca3834524 |
ENTRA_AUDIENCE / AUTH_API_AUDIENCE |
API Audience | c4ea0eaf-6aaa-42e0-85ff-eef864cd2728 (GUID-only) |