Create external developer-focused API documentation for News API service with practical integration examples for frontend systems. Features: - 10 major sections covering all API endpoints - Complete TypeScript type definitions - Real-world React/Next.js integration examples - Axios client setup and React Query patterns - Infinite scroll implementation - Error handling strategies - Performance optimization tips API Coverage: - Articles API (6 endpoints): list, latest, search, detail, categories - Outlets API (3 endpoints): list, detail, outlet articles - Comments API (3 endpoints): list, create, count - Multi-language support (9 languages) - Pagination and filtering Code Examples: - Copy-paste ready code snippets - React hooks and components - Next.js App Router examples - React Query integration - Infinite scroll with Intersection Observer - Client-side caching strategies Developer Experience: - TypeScript-first approach - Practical, executable examples - Quick start guide - API reference table - Error handling patterns - Performance optimization tips Target Audience: External frontend developers integrating with News API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1329 lines
28 KiB
Markdown
1329 lines
28 KiB
Markdown
# News API - 개발자 가이드
|
|
|
|
외부 프론트엔드 시스템에서 Site11 News API를 통합하기 위한 실용적인 가이드입니다.
|
|
|
|
---
|
|
|
|
## 📌 Quick Start
|
|
|
|
### Base URL
|
|
```
|
|
http://localhost:8050/api/v1
|
|
```
|
|
|
|
### 첫 API 호출
|
|
|
|
```bash
|
|
# 한국어 최신 기사 5개 조회
|
|
curl 'http://localhost:8050/api/v1/ko/articles/latest?limit=5'
|
|
```
|
|
|
|
```javascript
|
|
// JavaScript/TypeScript
|
|
const response = await fetch('http://localhost:8050/api/v1/ko/articles/latest?limit=5');
|
|
const articles = await response.json();
|
|
console.log(articles);
|
|
```
|
|
|
|
### 인증
|
|
현재 버전은 인증이 필요하지 않습니다. (Public API)
|
|
|
|
---
|
|
|
|
## 🌍 지원 언어
|
|
|
|
| 언어 코드 | 언어 |
|
|
|----------|------|
|
|
| `ko` | 한국어 |
|
|
| `en` | English |
|
|
| `zh_cn` | 简体中文 |
|
|
| `zh_tw` | 繁體中文 |
|
|
| `ja` | 日本語 |
|
|
| `fr` | Français |
|
|
| `de` | Deutsch |
|
|
| `es` | Español |
|
|
| `it` | Italiano |
|
|
|
|
---
|
|
|
|
## 📰 Articles API
|
|
|
|
### 1. 기사 목록 조회
|
|
|
|
가장 기본적인 엔드포인트입니다. 페이지네이션을 지원합니다.
|
|
|
|
```http
|
|
GET /api/v1/{language}/articles
|
|
```
|
|
|
|
**Query Parameters:**
|
|
- `page` (int, default: 1) - 페이지 번호
|
|
- `page_size` (int, default: 20, max: 100) - 페이지당 항목 수
|
|
- `category` (string, optional) - 카테고리 필터
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/ko/articles?page=1&page_size=10&category=technology'
|
|
```
|
|
|
|
```typescript
|
|
// TypeScript + Axios
|
|
import axios from 'axios';
|
|
|
|
const getArticles = async (
|
|
language: string,
|
|
page: number = 1,
|
|
pageSize: number = 20,
|
|
category?: string
|
|
) => {
|
|
const { data } = await axios.get(`/api/v1/${language}/articles`, {
|
|
params: { page, page_size: pageSize, category }
|
|
});
|
|
return data;
|
|
};
|
|
|
|
// 사용 예시
|
|
const articles = await getArticles('ko', 1, 10, 'technology');
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"total": 1523,
|
|
"page": 1,
|
|
"page_size": 10,
|
|
"total_pages": 153,
|
|
"articles": [
|
|
{
|
|
"id": "507f1f77bcf86cd799439011",
|
|
"news_id": "uuid-string",
|
|
"title": "AI 기술의 미래",
|
|
"summary": "인공지능 기술이 우리 생활에 미치는 영향...",
|
|
"language": "ko",
|
|
"created_at": "2024-01-15T10:30:00Z",
|
|
"categories": ["technology", "ai"],
|
|
"images": ["https://example.com/image1.png"],
|
|
"source_keyword": "AI기술",
|
|
"subtopics": [
|
|
{
|
|
"title": "AI의 발전",
|
|
"content": [
|
|
"AI 기술은 최근 몇 년간 급속도로 발전했습니다.",
|
|
"특히 대형 언어 모델의 등장으로..."
|
|
]
|
|
}
|
|
],
|
|
"entities": {
|
|
"people": ["샘 알트만", "일론 머스크"],
|
|
"organizations": ["OpenAI", "Google"],
|
|
"countries": ["미국", "한국"]
|
|
},
|
|
"references": [
|
|
{
|
|
"title": "원문 기사 제목",
|
|
"link": "https://source.com/article",
|
|
"source": "TechCrunch",
|
|
"published": "2024-01-15"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. 최신 기사 조회
|
|
|
|
가장 최근에 생성된 기사를 조회합니다.
|
|
|
|
```http
|
|
GET /api/v1/{language}/articles/latest
|
|
```
|
|
|
|
**Query Parameters:**
|
|
- `limit` (int, default: 10, max: 50) - 조회할 기사 수
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/en/articles/latest?limit=5'
|
|
```
|
|
|
|
```javascript
|
|
// React Hook 예시
|
|
import { useState, useEffect } from 'react';
|
|
|
|
function useLatestArticles(language, limit = 10) {
|
|
const [articles, setArticles] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/v1/${language}/articles/latest?limit=${limit}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setArticles(data);
|
|
setLoading(false);
|
|
});
|
|
}, [language, limit]);
|
|
|
|
return { articles, loading };
|
|
}
|
|
|
|
// 컴포넌트에서 사용
|
|
function LatestNews() {
|
|
const { articles, loading } = useLatestArticles('ko', 5);
|
|
|
|
if (loading) return <div>Loading...</div>;
|
|
|
|
return (
|
|
<div>
|
|
{articles.map(article => (
|
|
<div key={article.id}>
|
|
<h2>{article.title}</h2>
|
|
<p>{article.summary}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
[
|
|
{
|
|
"id": "507f1f77bcf86cd799439011",
|
|
"news_id": "uuid-string",
|
|
"title": "Latest News Title",
|
|
"summary": "Summary text...",
|
|
"language": "en",
|
|
"categories": ["business"],
|
|
"images": ["https://example.com/image.png"],
|
|
"created_at": "2024-01-15T10:30:00Z",
|
|
"source_keyword": "business"
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
### 3. 기사 검색
|
|
|
|
키워드로 기사를 검색합니다.
|
|
|
|
```http
|
|
GET /api/v1/{language}/articles/search
|
|
```
|
|
|
|
**Query Parameters:**
|
|
- `q` (string, required) - 검색 키워드
|
|
- `page` (int, default: 1) - 페이지 번호
|
|
- `page_size` (int, default: 20, max: 100) - 페이지당 항목 수
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/ko/articles/search?q=AI&page=1&page_size=10'
|
|
```
|
|
|
|
```typescript
|
|
// Next.js API Route 예시
|
|
// pages/api/search.ts
|
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
import axios from 'axios';
|
|
|
|
export default async function handler(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse
|
|
) {
|
|
const { q, language = 'ko', page = 1 } = req.query;
|
|
|
|
try {
|
|
const { data } = await axios.get(
|
|
`http://localhost:8050/api/v1/${language}/articles/search`,
|
|
{
|
|
params: { q, page, page_size: 20 }
|
|
}
|
|
);
|
|
res.status(200).json(data);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Search failed' });
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
동일한 `ArticleList` 형식 (기사 목록 조회와 동일)
|
|
|
|
---
|
|
|
|
### 4. 기사 상세 조회
|
|
|
|
특정 기사의 전체 내용을 조회합니다.
|
|
|
|
```http
|
|
GET /api/v1/{language}/articles/{article_id}
|
|
```
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/ko/articles/507f1f77bcf86cd799439011'
|
|
```
|
|
|
|
```typescript
|
|
// React Query 사용 예시
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import axios from 'axios';
|
|
|
|
interface ArticleDetailProps {
|
|
articleId: string;
|
|
language: string;
|
|
}
|
|
|
|
function ArticleDetail({ articleId, language }: ArticleDetailProps) {
|
|
const { data: article, isLoading, error } = useQuery({
|
|
queryKey: ['article', articleId, language],
|
|
queryFn: async () => {
|
|
const { data } = await axios.get(
|
|
`/api/v1/${language}/articles/${articleId}`
|
|
);
|
|
return data;
|
|
}
|
|
});
|
|
|
|
if (isLoading) return <div>Loading...</div>;
|
|
if (error) return <div>Error loading article</div>;
|
|
|
|
return (
|
|
<article>
|
|
<h1>{article.title}</h1>
|
|
<p className="summary">{article.summary}</p>
|
|
|
|
{article.images.length > 0 && (
|
|
<img src={article.images[0]} alt={article.title} />
|
|
)}
|
|
|
|
{article.subtopics.map((subtopic, idx) => (
|
|
<section key={idx}>
|
|
<h2>{subtopic.title}</h2>
|
|
{subtopic.content.map((paragraph, pIdx) => (
|
|
<p key={pIdx}>{paragraph}</p>
|
|
))}
|
|
</section>
|
|
))}
|
|
|
|
{article.references.length > 0 && (
|
|
<div className="references">
|
|
<h3>참고 자료</h3>
|
|
<ul>
|
|
{article.references.map((ref, idx) => (
|
|
<li key={idx}>
|
|
<a href={ref.link} target="_blank" rel="noopener">
|
|
{ref.title} - {ref.source}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
완전한 `Article` 객체 (기사 목록의 단일 항목과 동일한 구조)
|
|
|
|
---
|
|
|
|
### 5. 카테고리 목록 조회
|
|
|
|
해당 언어에서 사용 가능한 모든 카테고리를 조회합니다.
|
|
|
|
```http
|
|
GET /api/v1/{language}/categories
|
|
```
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/ko/categories'
|
|
```
|
|
|
|
```javascript
|
|
// 카테고리 필터 컴포넌트
|
|
function CategoryFilter({ language, onSelectCategory }) {
|
|
const [categories, setCategories] = useState([]);
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/v1/${language}/categories`)
|
|
.then(res => res.json())
|
|
.then(setCategories);
|
|
}, [language]);
|
|
|
|
return (
|
|
<select onChange={(e) => onSelectCategory(e.target.value)}>
|
|
<option value="">All Categories</option>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
[
|
|
"technology",
|
|
"business",
|
|
"politics",
|
|
"sports",
|
|
"entertainment"
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## 🏢 Outlets API
|
|
|
|
Outlets은 **인물(people)**, **토픽(topics)**, **기업(companies)** 3가지 카테고리로 분류됩니다.
|
|
|
|
### 1. Outlet 목록 조회
|
|
|
|
모든 Outlet 또는 특정 카테고리의 Outlet을 조회합니다.
|
|
|
|
```http
|
|
GET /api/v1/outlets
|
|
```
|
|
|
|
**Query Parameters:**
|
|
- `category` (string, optional) - `people`, `topics`, `companies` 중 하나
|
|
|
|
**Example Request:**
|
|
```bash
|
|
# 모든 Outlet 조회
|
|
curl 'http://localhost:8050/api/v1/outlets'
|
|
|
|
# 인물만 조회
|
|
curl 'http://localhost:8050/api/v1/outlets?category=people'
|
|
```
|
|
|
|
```typescript
|
|
// TypeScript 타입 정의
|
|
interface OutletTranslations {
|
|
ko?: string;
|
|
en?: string;
|
|
zh_cn?: string;
|
|
zh_tw?: string;
|
|
ja?: string;
|
|
fr?: string;
|
|
de?: string;
|
|
es?: string;
|
|
it?: string;
|
|
}
|
|
|
|
interface Outlet {
|
|
source_keyword: string;
|
|
category: 'people' | 'topics' | 'companies';
|
|
name_translations: OutletTranslations;
|
|
description_translations: OutletTranslations;
|
|
image?: string;
|
|
|
|
// Deprecated (하위 호환성)
|
|
name?: string;
|
|
description?: string;
|
|
}
|
|
|
|
// 사용 예시
|
|
const getOutlets = async (category?: string): Promise<Outlet[]> => {
|
|
const { data } = await axios.get('/api/v1/outlets', {
|
|
params: category ? { category } : {}
|
|
});
|
|
|
|
// category 지정 시: { people: [...] } 형식
|
|
// 전체 조회 시: { people: [...], topics: [...], companies: [...] }
|
|
return category ? data[category] : data;
|
|
};
|
|
```
|
|
|
|
**Response (category=people):**
|
|
```json
|
|
{
|
|
"people": [
|
|
{
|
|
"source_keyword": "온유",
|
|
"category": "people",
|
|
"name_translations": {
|
|
"ko": "온유",
|
|
"en": "Onew",
|
|
"ja": "オニュ"
|
|
},
|
|
"description_translations": {
|
|
"ko": "샤이니 리더, 뮤지컬 배우",
|
|
"en": "SHINee leader, musical actor",
|
|
"ja": "SHINeeのリーダー、ミュージカル俳優"
|
|
},
|
|
"image": "https://example.com/onew.jpg"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Response (전체 조회):**
|
|
```json
|
|
{
|
|
"people": [...],
|
|
"topics": [...],
|
|
"companies": [...]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Outlet 상세 조회
|
|
|
|
특정 Outlet의 정보를 조회합니다.
|
|
|
|
```http
|
|
GET /api/v1/outlets/{outlet_id}
|
|
```
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/outlets/507f1f77bcf86cd799439011'
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"source_keyword": "온유",
|
|
"category": "people",
|
|
"name_translations": {
|
|
"ko": "온유",
|
|
"en": "Onew",
|
|
"ja": "オニュ"
|
|
},
|
|
"description_translations": {
|
|
"ko": "샤이니 리더, 뮤지컬 배우",
|
|
"en": "SHINee leader, musical actor"
|
|
},
|
|
"image": "https://example.com/onew.jpg"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Outlet별 기사 조회 ⭐ (중요)
|
|
|
|
특정 Outlet 관련 기사를 동적으로 조회합니다. `source_keyword` 기반으로 실시간 쿼리합니다.
|
|
|
|
```http
|
|
GET /api/v1/{language}/outlets/{outlet_id}/articles
|
|
```
|
|
|
|
**Query Parameters:**
|
|
- `page` (int, default: 1) - 페이지 번호
|
|
- `page_size` (int, default: 20, max: 100) - 페이지당 항목 수
|
|
|
|
**Example Request:**
|
|
```bash
|
|
# 한국어로 '온유' 관련 기사 조회
|
|
curl 'http://localhost:8050/api/v1/ko/outlets/507f1f77bcf86cd799439011/articles?page_size=10'
|
|
```
|
|
|
|
```typescript
|
|
// React 컴포넌트 예시
|
|
function OutletArticles({ outletId, language }: { outletId: string, language: string }) {
|
|
const [articles, setArticles] = useState<ArticleList | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
|
|
useEffect(() => {
|
|
const fetchArticles = async () => {
|
|
const { data } = await axios.get(
|
|
`/api/v1/${language}/outlets/${outletId}/articles`,
|
|
{ params: { page, page_size: 20 } }
|
|
);
|
|
setArticles(data);
|
|
};
|
|
|
|
fetchArticles();
|
|
}, [outletId, language, page]);
|
|
|
|
if (!articles) return <div>Loading...</div>;
|
|
|
|
return (
|
|
<div>
|
|
<h2>총 {articles.total}개의 기사</h2>
|
|
{articles.articles.map(article => (
|
|
<ArticleCard key={article.id} article={article} />
|
|
))}
|
|
|
|
{/* 페이지네이션 */}
|
|
<Pagination
|
|
current={page}
|
|
total={articles.total_pages}
|
|
onChange={setPage}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
동일한 `ArticleList` 형식
|
|
|
|
---
|
|
|
|
## 💬 Comments API
|
|
|
|
### 1. 댓글 목록 조회
|
|
|
|
특정 기사의 댓글을 조회합니다.
|
|
|
|
```http
|
|
GET /api/v1/comments?articleId={article_id}
|
|
```
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/comments?articleId=507f1f77bcf86cd799439011'
|
|
```
|
|
|
|
```typescript
|
|
// React Hook
|
|
function useComments(articleId: string) {
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/v1/comments?articleId=${articleId}`)
|
|
.then(res => res.json())
|
|
.then(data => setComments(data.comments));
|
|
}, [articleId]);
|
|
|
|
return comments;
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"comments": [
|
|
{
|
|
"id": "comment-id-1",
|
|
"articleId": "507f1f77bcf86cd799439011",
|
|
"nickname": "독자1",
|
|
"content": "좋은 기사 감사합니다!",
|
|
"createdAt": "2024-01-15T11:20:00Z"
|
|
}
|
|
],
|
|
"total": 42
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. 댓글 작성
|
|
|
|
새로운 댓글을 작성합니다.
|
|
|
|
```http
|
|
POST /api/v1/comments
|
|
```
|
|
|
|
**Request Body:**
|
|
```json
|
|
{
|
|
"articleId": "507f1f77bcf86cd799439011",
|
|
"nickname": "독자1",
|
|
"content": "좋은 기사 감사합니다!"
|
|
}
|
|
```
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl -X POST 'http://localhost:8050/api/v1/comments' \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{
|
|
"articleId": "507f1f77bcf86cd799439011",
|
|
"nickname": "독자1",
|
|
"content": "좋은 기사입니다!"
|
|
}'
|
|
```
|
|
|
|
```typescript
|
|
// React Form 예시
|
|
function CommentForm({ articleId }: { articleId: string }) {
|
|
const [nickname, setNickname] = useState('');
|
|
const [content, setContent] = useState('');
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
try {
|
|
const { data } = await axios.post('/api/v1/comments', {
|
|
articleId,
|
|
nickname,
|
|
content
|
|
});
|
|
|
|
alert('댓글이 등록되었습니다!');
|
|
setNickname('');
|
|
setContent('');
|
|
} catch (error) {
|
|
alert('댓글 등록에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<input
|
|
type="text"
|
|
placeholder="닉네임"
|
|
value={nickname}
|
|
onChange={e => setNickname(e.target.value)}
|
|
required
|
|
/>
|
|
<textarea
|
|
placeholder="댓글 내용"
|
|
value={content}
|
|
onChange={e => setContent(e.target.value)}
|
|
required
|
|
/>
|
|
<button type="submit">댓글 등록</button>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"id": "comment-id-new",
|
|
"articleId": "507f1f77bcf86cd799439011",
|
|
"nickname": "독자1",
|
|
"content": "좋은 기사입니다!",
|
|
"createdAt": "2024-01-15T12:00:00Z"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. 댓글 수 조회
|
|
|
|
기사의 총 댓글 수를 조회합니다.
|
|
|
|
```http
|
|
GET /api/v1/articles/{article_id}/comment-count
|
|
```
|
|
|
|
**Example Request:**
|
|
```bash
|
|
curl 'http://localhost:8050/api/v1/articles/507f1f77bcf86cd799439011/comment-count'
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"count": 42
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📦 TypeScript 타입 정의
|
|
|
|
전체 타입을 한 곳에 모아두고 재사용하세요.
|
|
|
|
```typescript
|
|
// types/news-api.ts
|
|
|
|
// Article Types
|
|
export interface Subtopic {
|
|
title: string;
|
|
content: string[];
|
|
}
|
|
|
|
export interface Reference {
|
|
title: string;
|
|
link: string;
|
|
source: string;
|
|
published?: string;
|
|
}
|
|
|
|
export interface Entities {
|
|
people: string[];
|
|
organizations: string[];
|
|
groups: string[];
|
|
countries: string[];
|
|
events: string[];
|
|
}
|
|
|
|
export interface Article {
|
|
id: string;
|
|
news_id: string;
|
|
title: string;
|
|
summary?: string;
|
|
created_at: string;
|
|
language: string;
|
|
|
|
// Content
|
|
subtopics: Subtopic[];
|
|
categories: string[];
|
|
entities?: Entities;
|
|
|
|
// Source
|
|
source_keyword?: string;
|
|
source_count?: number;
|
|
references: Reference[];
|
|
|
|
// Media
|
|
images: string[];
|
|
image_prompt?: string;
|
|
|
|
// Metadata
|
|
job_id?: string;
|
|
keyword_id?: string;
|
|
pipeline_stages: string[];
|
|
processing_time?: number;
|
|
translated_languages: string[];
|
|
ref_news_id?: string;
|
|
rss_guid?: string;
|
|
}
|
|
|
|
export interface ArticleList {
|
|
total: number;
|
|
page: number;
|
|
page_size: number;
|
|
total_pages: number;
|
|
articles: Article[];
|
|
}
|
|
|
|
export interface ArticleSummary {
|
|
id: string;
|
|
news_id: string;
|
|
title: string;
|
|
summary?: string;
|
|
language: string;
|
|
categories: string[];
|
|
images: string[];
|
|
created_at: string;
|
|
source_keyword?: string;
|
|
}
|
|
|
|
// Outlet Types
|
|
export interface OutletTranslations {
|
|
ko?: string;
|
|
en?: string;
|
|
zh_cn?: string;
|
|
zh_tw?: string;
|
|
ja?: string;
|
|
fr?: string;
|
|
de?: string;
|
|
es?: string;
|
|
it?: string;
|
|
}
|
|
|
|
export interface Outlet {
|
|
source_keyword: string;
|
|
category: 'people' | 'topics' | 'companies';
|
|
name_translations: OutletTranslations;
|
|
description_translations: OutletTranslations;
|
|
image?: string;
|
|
|
|
// Deprecated
|
|
name?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface OutletList {
|
|
people?: Outlet[];
|
|
topics?: Outlet[];
|
|
companies?: Outlet[];
|
|
}
|
|
|
|
// Comment Types
|
|
export interface Comment {
|
|
id: string;
|
|
articleId: string;
|
|
nickname: string;
|
|
content: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface CommentCreate {
|
|
articleId: string;
|
|
nickname: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface CommentList {
|
|
comments: Comment[];
|
|
total: number;
|
|
}
|
|
|
|
// Language Type
|
|
export type Language = 'ko' | 'en' | 'zh_cn' | 'zh_tw' | 'ja' | 'fr' | 'de' | 'es' | 'it';
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 Axios 인스턴스 설정
|
|
|
|
API 클라이언트를 한 곳에서 관리하세요.
|
|
|
|
```typescript
|
|
// lib/api-client.ts
|
|
import axios from 'axios';
|
|
|
|
const apiClient = axios.create({
|
|
baseURL: process.env.NEXT_PUBLIC_NEWS_API_URL || 'http://localhost:8050/api/v1',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
timeout: 10000
|
|
});
|
|
|
|
// 요청 인터셉터 (로깅, 인증 등)
|
|
apiClient.interceptors.request.use(
|
|
config => {
|
|
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`);
|
|
return config;
|
|
},
|
|
error => Promise.reject(error)
|
|
);
|
|
|
|
// 응답 인터셉터 (에러 처리)
|
|
apiClient.interceptors.response.use(
|
|
response => response,
|
|
error => {
|
|
if (error.response) {
|
|
console.error('[API Error]', error.response.status, error.response.data);
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
export default apiClient;
|
|
```
|
|
|
|
```typescript
|
|
// lib/news-api.ts
|
|
import apiClient from './api-client';
|
|
import type { Article, ArticleList, Outlet, Comment, CommentCreate, Language } from '@/types/news-api';
|
|
|
|
export const newsAPI = {
|
|
// Articles
|
|
getArticles: (language: Language, page = 1, pageSize = 20, category?: string) =>
|
|
apiClient.get<ArticleList>(`/${language}/articles`, {
|
|
params: { page, page_size: pageSize, category }
|
|
}),
|
|
|
|
getLatestArticles: (language: Language, limit = 10) =>
|
|
apiClient.get<Article[]>(`/${language}/articles/latest`, {
|
|
params: { limit }
|
|
}),
|
|
|
|
searchArticles: (language: Language, query: string, page = 1, pageSize = 20) =>
|
|
apiClient.get<ArticleList>(`/${language}/articles/search`, {
|
|
params: { q: query, page, page_size: pageSize }
|
|
}),
|
|
|
|
getArticleById: (language: Language, articleId: string) =>
|
|
apiClient.get<Article>(`/${language}/articles/${articleId}`),
|
|
|
|
getCategories: (language: Language) =>
|
|
apiClient.get<string[]>(`/${language}/categories`),
|
|
|
|
// Outlets
|
|
getOutlets: (category?: 'people' | 'topics' | 'companies') =>
|
|
apiClient.get<any>('/outlets', { params: category ? { category } : {} }),
|
|
|
|
getOutletById: (outletId: string) =>
|
|
apiClient.get<Outlet>(`/outlets/${outletId}`),
|
|
|
|
getOutletArticles: (language: Language, outletId: string, page = 1, pageSize = 20) =>
|
|
apiClient.get<ArticleList>(`/${language}/outlets/${outletId}/articles`, {
|
|
params: { page, page_size: pageSize }
|
|
}),
|
|
|
|
// Comments
|
|
getComments: (articleId: string) =>
|
|
apiClient.get<{ comments: Comment[], total: number }>('/comments', {
|
|
params: { articleId }
|
|
}),
|
|
|
|
createComment: (comment: CommentCreate) =>
|
|
apiClient.post<Comment>('/comments', comment),
|
|
|
|
getCommentCount: (articleId: string) =>
|
|
apiClient.get<{ count: number }>(`/articles/${articleId}/comment-count`)
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 🚀 실전 통합 예시
|
|
|
|
### Next.js App Router 예시
|
|
|
|
```typescript
|
|
// app/articles/[lang]/page.tsx
|
|
import { newsAPI } from '@/lib/news-api';
|
|
import type { Language } from '@/types/news-api';
|
|
|
|
interface PageProps {
|
|
params: { lang: Language };
|
|
searchParams: { page?: string; category?: string };
|
|
}
|
|
|
|
export default async function ArticlesPage({ params, searchParams }: PageProps) {
|
|
const page = parseInt(searchParams.page || '1');
|
|
const category = searchParams.category;
|
|
|
|
const { data } = await newsAPI.getArticles(params.lang, page, 20, category);
|
|
|
|
return (
|
|
<div>
|
|
<h1>Articles ({data.total})</h1>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{data.articles.map(article => (
|
|
<ArticleCard key={article.id} article={article} />
|
|
))}
|
|
</div>
|
|
<Pagination
|
|
current={page}
|
|
total={data.total_pages}
|
|
baseUrl={`/articles/${params.lang}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### React Query 사용 예시
|
|
|
|
```typescript
|
|
// hooks/useArticles.ts
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { newsAPI } from '@/lib/news-api';
|
|
import type { Language } from '@/types/news-api';
|
|
|
|
export function useArticles(
|
|
language: Language,
|
|
page: number,
|
|
pageSize: number,
|
|
category?: string
|
|
) {
|
|
return useQuery({
|
|
queryKey: ['articles', language, page, pageSize, category],
|
|
queryFn: async () => {
|
|
const { data } = await newsAPI.getArticles(language, page, pageSize, category);
|
|
return data;
|
|
},
|
|
staleTime: 5 * 60 * 1000, // 5분
|
|
cacheTime: 10 * 60 * 1000 // 10분
|
|
});
|
|
}
|
|
|
|
export function useLatestArticles(language: Language, limit = 10) {
|
|
return useQuery({
|
|
queryKey: ['articles', 'latest', language, limit],
|
|
queryFn: async () => {
|
|
const { data } = await newsAPI.getLatestArticles(language, limit);
|
|
return data;
|
|
},
|
|
staleTime: 1 * 60 * 1000 // 1분
|
|
});
|
|
}
|
|
|
|
export function useOutletArticles(
|
|
language: Language,
|
|
outletId: string,
|
|
page: number,
|
|
pageSize: number
|
|
) {
|
|
return useQuery({
|
|
queryKey: ['outlet-articles', language, outletId, page, pageSize],
|
|
queryFn: async () => {
|
|
const { data } = await newsAPI.getOutletArticles(language, outletId, page, pageSize);
|
|
return data;
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// 컴포넌트에서 사용
|
|
function ArticlesList() {
|
|
const [page, setPage] = useState(1);
|
|
const { data, isLoading, error } = useArticles('ko', page, 20);
|
|
|
|
if (isLoading) return <Skeleton />;
|
|
if (error) return <ErrorMessage />;
|
|
|
|
return (
|
|
<div>
|
|
{data.articles.map(article => (
|
|
<ArticleCard key={article.id} {...article} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 무한 스크롤 구현
|
|
|
|
```typescript
|
|
// components/InfiniteArticleList.tsx
|
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
import { useInView } from 'react-intersection-observer';
|
|
import { newsAPI } from '@/lib/news-api';
|
|
|
|
function InfiniteArticleList({ language }: { language: Language }) {
|
|
const { ref, inView } = useInView();
|
|
|
|
const {
|
|
data,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage
|
|
} = useInfiniteQuery({
|
|
queryKey: ['articles', 'infinite', language],
|
|
queryFn: async ({ pageParam = 1 }) => {
|
|
const { data } = await newsAPI.getArticles(language, pageParam, 20);
|
|
return data;
|
|
},
|
|
getNextPageParam: (lastPage) => {
|
|
return lastPage.page < lastPage.total_pages
|
|
? lastPage.page + 1
|
|
: undefined;
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (inView && hasNextPage) {
|
|
fetchNextPage();
|
|
}
|
|
}, [inView, hasNextPage, fetchNextPage]);
|
|
|
|
return (
|
|
<div>
|
|
{data?.pages.map((page, i) => (
|
|
<div key={i}>
|
|
{page.articles.map(article => (
|
|
<ArticleCard key={article.id} article={article} />
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
{/* 트리거 요소 */}
|
|
<div ref={ref} className="h-10">
|
|
{isFetchingNextPage && <Spinner />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## ⚠️ 에러 처리
|
|
|
|
### HTTP 상태 코드
|
|
|
|
| 코드 | 의미 | 원인 |
|
|
|------|------|------|
|
|
| 200 | 성공 | 정상 응답 |
|
|
| 400 | Bad Request | 잘못된 언어 코드, 파라미터 오류 |
|
|
| 404 | Not Found | 존재하지 않는 기사/outlet ID |
|
|
| 500 | Server Error | 서버 내부 오류 |
|
|
|
|
### 에러 응답 형식
|
|
|
|
```json
|
|
{
|
|
"detail": "Unsupported language: kr"
|
|
}
|
|
```
|
|
|
|
### 에러 처리 예시
|
|
|
|
```typescript
|
|
try {
|
|
const { data } = await newsAPI.getArticleById('ko', articleId);
|
|
return data;
|
|
} catch (error) {
|
|
if (axios.isAxiosError(error)) {
|
|
if (error.response?.status === 404) {
|
|
console.error('기사를 찾을 수 없습니다.');
|
|
} else if (error.response?.status === 400) {
|
|
console.error('잘못된 요청:', error.response.data.detail);
|
|
} else {
|
|
console.error('서버 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 성능 최적화 팁
|
|
|
|
### 1. 클라이언트 사이드 캐싱
|
|
|
|
```typescript
|
|
// React Query 설정
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
|
|
cacheTime: 10 * 60 * 1000, // 10분간 캐시 보관
|
|
refetchOnWindowFocus: false,
|
|
retry: 1
|
|
}
|
|
}
|
|
});
|
|
|
|
function App() {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<YourApp />
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 2. 이미지 최적화
|
|
|
|
```typescript
|
|
// Next.js Image 컴포넌트 사용
|
|
import Image from 'next/image';
|
|
|
|
function ArticleImage({ src, alt }: { src: string; alt: string }) {
|
|
return (
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
width={800}
|
|
height={450}
|
|
loading="lazy"
|
|
placeholder="blur"
|
|
blurDataURL="/placeholder.jpg"
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 3. 페이지네이션 프리페칭
|
|
|
|
```typescript
|
|
// 다음 페이지 미리 로드
|
|
const { data } = useArticles('ko', page, 20);
|
|
|
|
// 다음 페이지 프리페칭
|
|
useEffect(() => {
|
|
if (data && page < data.total_pages) {
|
|
queryClient.prefetchQuery({
|
|
queryKey: ['articles', 'ko', page + 1, 20],
|
|
queryFn: () => newsAPI.getArticles('ko', page + 1, 20)
|
|
});
|
|
}
|
|
}, [page, data]);
|
|
```
|
|
|
|
### 4. 요청 최적화
|
|
|
|
```typescript
|
|
// Debounce 검색
|
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
|
|
|
function SearchArticles() {
|
|
const [query, setQuery] = useState('');
|
|
const debouncedQuery = useDebouncedValue(query, 500);
|
|
|
|
const { data } = useQuery({
|
|
queryKey: ['articles', 'search', debouncedQuery],
|
|
queryFn: () => newsAPI.searchArticles('ko', debouncedQuery, 1, 20),
|
|
enabled: debouncedQuery.length > 0
|
|
});
|
|
|
|
return (
|
|
<input
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
placeholder="검색..."
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 API Reference (Quick Reference)
|
|
|
|
| Method | Endpoint | 설명 | 파라미터 |
|
|
|--------|----------|------|---------|
|
|
| **Articles** |
|
|
| GET | `/{lang}/articles` | 기사 목록 | page, page_size, category |
|
|
| GET | `/{lang}/articles/latest` | 최신 기사 | limit |
|
|
| GET | `/{lang}/articles/search` | 기사 검색 | q, page, page_size |
|
|
| GET | `/{lang}/articles/{id}` | 기사 상세 | - |
|
|
| GET | `/{lang}/categories` | 카테고리 목록 | - |
|
|
| **Outlets** |
|
|
| GET | `/outlets` | Outlet 목록 | category |
|
|
| GET | `/outlets/{id}` | Outlet 상세 | - |
|
|
| GET | `/{lang}/outlets/{id}/articles` | Outlet별 기사 | page, page_size |
|
|
| **Comments** |
|
|
| GET | `/comments` | 댓글 목록 | articleId |
|
|
| POST | `/comments` | 댓글 작성 | body: CommentCreate |
|
|
| GET | `/articles/{id}/comment-count` | 댓글 수 | - |
|
|
|
|
---
|
|
|
|
## 🛠️ 개발 환경 설정
|
|
|
|
### 환경 변수
|
|
|
|
```env
|
|
# .env.local (Next.js)
|
|
NEXT_PUBLIC_NEWS_API_URL=http://localhost:8050/api/v1
|
|
|
|
# .env (React)
|
|
REACT_APP_NEWS_API_URL=http://localhost:8050/api/v1
|
|
```
|
|
|
|
### CORS 설정
|
|
|
|
개발 환경에서 CORS 이슈가 발생하면 프록시 설정:
|
|
|
|
```javascript
|
|
// next.config.js
|
|
module.exports = {
|
|
async rewrites() {
|
|
return [
|
|
{
|
|
source: '/api/v1/:path*',
|
|
destination: 'http://localhost:8050/api/v1/:path*'
|
|
}
|
|
];
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 📞 지원 및 문의
|
|
|
|
- **Swagger UI**: `http://localhost:8050/docs`
|
|
- **ReDoc**: `http://localhost:8050/redoc`
|
|
- **GitHub Issues**: [프로젝트 저장소 Issues](https://github.com/your-org/site11)
|
|
|
|
---
|
|
|
|
**마지막 업데이트**: 2024-01-15
|
|
**API 버전**: v1.1.0
|