๐ ํค๋๋ฆฌ์ค ๋ธ๋ก๊ทธ API ์์ง - RESTful ์ฝํ ์ธ ๊ด๋ฆฌ ์์คํ
headless-blog-api-engine
๋ฌธ์ ์ค๋ช
๐ฏ ํ๋ก์ ํธ ๊ฐ์
ํ๋ก ํธ์๋์ ์์ ํ ๋ถ๋ฆฌ๋ ํค๋๋ฆฌ์ค CMS(Headless CMS) ๋ฐฉ์์ ๋ธ๋ก๊ทธ API ์๋ฒ๋ฅผ ์์ PHP 8.3+๋ก ๊ตฌํํฉ๋๋ค. Laravel, Symfony ๋ฑ ํ๋ ์์ํฌ ์์ด PSR ํ์ค์ ๋ฐ๋ฅด๋ RESTful API๋ฅผ ์ง์ ์ค๊ณํ๊ณ , JWT ์ธ์ฆ, ์บ์ฑ, ํ์ผ ์ ๋ก๋, ๊ฒ์, ํ์ด์ง๋ค์ด์ , ๋ฒ์ ๊ด๋ฆฌ ๋ฑ ์ค๋ฌด ์์ค์ ๊ธฐ๋ฅ์ ๋ชจ๋ ๊ตฌํํฉ๋๋ค.
ํ์ ๊ธฐ๋ฅ
-
RESTful API ์ค๊ณ
GET /api/posts- ๊ฒ์๊ธ ๋ชฉ๋ก (ํ์ด์ง๋ค์ด์ , ํํฐ๋ง, ์ ๋ ฌ)GET /api/posts/{id}- ๊ฒ์๊ธ ์์ธPOST /api/posts- ๊ฒ์๊ธ ์์ฑ (์ธ์ฆ ํ์)PUT /api/posts/{id}- ๊ฒ์๊ธ ์์ (์ธ์ฆ ํ์)DELETE /api/posts/{id}- ๊ฒ์๊ธ ์ญ์ (์ธ์ฆ ํ์)POST /api/auth/login- JWT ํ ํฐ ๋ฐ๊ธPOST /api/auth/refresh- ํ ํฐ ๊ฐฑ์GET /api/categories- ์นดํ ๊ณ ๋ฆฌ ๋ชฉ๋กGET /api/tags- ํ๊ทธ ๋ชฉ๋ก
-
JWT ์ธ์ฆ ์์คํ
- ํ ํฐ ์์ฑ/๊ฒ์ฆ (HS256 ์๊ณ ๋ฆฌ์ฆ)
- Refresh Token ๊ตฌํ
- Authorization ํค๋ ํ์ฑ
- ํ ํฐ ๋ง๋ฃ ์ฒ๋ฆฌ
-
๊ณ ๊ธ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ
- ๊ฒ์๊ธ(posts), ์นดํ ๊ณ ๋ฆฌ(categories), ํ๊ทธ(tags) ํ ์ด๋ธ
- ๋ค๋๋ค ๊ด๊ณ (post_tags ์ค๊ฐ ํ ์ด๋ธ)
- ๊ฒ์๊ธ ๋ฒ์ ํ์คํ ๋ฆฌ (post_revisions)
- ๋ณตํฉ ์ธ๋ฑ์ค ์ต์ ํ
- ํธ๋์ญ์ ์ฒ๋ฆฌ
-
ํ์ผ ์ ๋ก๋ & ๋ฏธ๋์ด ๊ด๋ฆฌ
- ์ด๋ฏธ์ง ์ ๋ก๋ (multipart/form-data)
- ์ธ๋ค์ผ ์๋ ์์ฑ (GD ๋ผ์ด๋ธ๋ฌ๋ฆฌ)
- ํ์ผ ํ์ /ํฌ๊ธฐ ๊ฒ์ฆ
- ๋ฏธ๋์ด ๋ผ์ด๋ธ๋ฌ๋ฆฌ API (
GET /api/media)
-
์บ์ฑ ์์คํ
- ํ์ผ ๊ธฐ๋ฐ ์บ์ (cache/ ๋๋ ํ ๋ฆฌ)
- ๊ฒ์๊ธ ๋ชฉ๋ก/์์ธ ์บ์ฑ
- Cache-Control ํค๋ ์ค์
- ์บ์ ๋ฌดํจํ (๊ฒ์๊ธ ์์ /์ญ์ ์)
-
์ ๋ฌธ ๊ฒ์ & ํํฐ๋ง
- ์ ๋ชฉ/๋ด์ฉ ์ ๋ฌธ ๊ฒ์ (LIKE ๋๋ FULLTEXT INDEX)
- ์นดํ ๊ณ ๋ฆฌ/ํ๊ทธ ํํฐ๋ง
- ๋ ์ง ๋ฒ์ ๊ฒ์
- ๋ณตํฉ ์ฟผ๋ฆฌ ๋น๋ ๊ตฌํ
-
๋ณด์ ๊ฐํ
- PDO Prepared Statements
- ์ถ๋ ฅ ์ด์ค์ผ์ดํ (htmlspecialchars)
- CORS ์ค์
- Rate Limiting (IP ๊ธฐ๋ฐ ์์ฒญ ์ ํ)
- SQL Injection, XSS ๋ฐฉ์ด
-
์๋ฌ ํธ๋ค๋ง & ๋ก๊น
- JSON ์๋ฌ ์๋ต ํ์คํ (RFC 7807 Problem Details)
- ์ปค์คํ Exception ํด๋์ค
- ์๋ฌ ๋ก๊ทธ ํ์ผ (logs/error.log)
- HTTP ์ํ ์ฝ๋ ์ ํํ ๋ฐํ
-
API ๋ฌธ์ํ
- OpenAPI(Swagger) ์คํ JSON ์๋ ์์ฑ
/api/docs์๋ํฌ์ธํธ์์ API ๋ช ์ธ ์ ๊ณต- ๊ฐ ์๋ํฌ์ธํธ๋ณ ์์ฒญ/์๋ต ์์
์ ํ ์ถ๊ฐ ๊ธฐ๋ฅ (๋ณด๋์ค)
- ๋ฒ์ ๊ด๋ฆฌ: ๊ฒ์๊ธ ์์ ์ ์ด์ ๋ฒ์ ์๋ ์ ์ฅ,
GET /api/posts/{id}/revisions๋ก ํ์คํ ๋ฆฌ ์กฐํ - ์ผ๊ด ์์
:
POST /api/posts/bulk(์ฌ๋ฌ ๊ฒ์๊ธ ํ ๋ฒ์ ์ญ์ /์นดํ ๊ณ ๋ฆฌ ๋ณ๊ฒฝ) - ํต๊ณ API:
GET /api/stats(์ด ๊ฒ์๊ธ ์, ์นดํ ๊ณ ๋ฆฌ๋ณ ๋ถํฌ, ํ๊ทธ ํด๋ผ์ฐ๋ ๋ฐ์ดํฐ) - Webhook: ๊ฒ์๊ธ ๋ฐํ ์ ์ธ๋ถ URL๋ก POST ์์ฒญ ์ ์ก
- Markdown ์ง์: ๊ฒ์๊ธ ๋ณธ๋ฌธ์ Markdown์ผ๋ก ์์ฑ, HTML๋ก ์๋ ๋ณํํ์ฌ ์๋ต
- ์ด๋ฏธ์ง ์ต์ ํ: WebP ๋ณํ, Lazy Loading ๋ฉํ๋ฐ์ดํฐ ์ ๊ณต
- API ํค ๊ด๋ฆฌ: JWT ์ธ์ API Key ๋ฐฉ์ ์ธ์ฆ ์ถ๊ฐ ์ง์
๐จ ๊ธฐ์ ์คํ
- ์ธ์ด: PHP 8.3+
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (PDO)
- ์ธ์ฆ: JWT (์๋ ๊ตฌํ, HS256)
- ์ด๋ฏธ์ง ์ฒ๋ฆฌ: GD ๋ผ์ด๋ธ๋ฌ๋ฆฌ
- ์บ์ฑ: ํ์ผ ๊ธฐ๋ฐ ์บ์
- ์๋ฒ: PHP ๋ด์ฅ ์๋ฒ (
php -S localhost:8000)
๐ ์์ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ
headless-blog-api-engine/
โโโ public/
โ โโโ index.php # API ๋ผ์ฐํฐ
โ โโโ uploads/ # ์
๋ก๋๋ ์ด๋ฏธ์ง
โโโ src/
โ โโโ Controllers/ # API ์ปจํธ๋กค๋ฌ
โ โโโ Models/ # ๋ฐ์ดํฐ ๋ชจ๋ธ
โ โโโ Middleware/ # ์ธ์ฆ/CORS ๋ฏธ๋ค์จ์ด
โ โโโ Services/ # JWT, Cache, FileUpload ์๋น์ค
โ โโโ Database.php # PDO ์ฐ๊ฒฐ ๊ด๋ฆฌ
โโโ cache/ # ์บ์ ํ์ผ ์ ์ฅ์
โโโ logs/ # ์๋ฌ ๋ก๊ทธ
โโโ database/
โ โโโ blog.db # SQLite ํ์ผ
โ โโโ schema.sql # ํ
์ด๋ธ ์คํค๋ง
โโโ config/
โ โโโ config.php # ์ค์ ํ์ผ (JWT secret, DB path)
โโโ README.md
๐ ํ์ต ๋ชฉํ
- PSR-4 ์คํ ๋ก๋ฉ ์์ด ์๋ ํด๋์ค ๋ก๋ฉ ๊ตฌํ
- RESTful API ์ค๊ณ ์์น ์ฒด๋
- JWT ์ธ์ฆ ๋ฉ์ปค๋์ฆ ์ดํด
- ํ์ผ ๊ธฐ๋ฐ ์บ์ฑ ์ ๋ต
- SQL ์ฟผ๋ฆฌ ์ต์ ํ (์ธ๋ฑ์ค, JOIN)
- ๋ณด์ ์ทจ์ฝ์ ๋ฐฉ์ด ๊ธฐ๋ฒ
- API ๋ฒ์ ๋ ์ ๋ต (
/api/v1/...)
์ด ํ๋ก์ ํธ๋ฅผ ์์ฑํ๋ฉด React, Vue, Next.js ๋ฑ ์ด๋ค ํ๋ก ํธ์๋์๋ ์ฐ๋ ๊ฐ๋ฅํ ๋ ๋ฆฝ์ ์ธ ์ฝํ ์ธ API ์๋ฒ๋ฅผ ๋ง๋ค ์ ์์ต๋๋ค!
์ ์ฝ ์กฐ๊ฑด
- โข PHP 8.3 ์ด์ ํ์ (ํ์ ํํ , readonly ํ๋กํผํฐ ํ์ฉ)
- โข Laravel, Symfony, Composer ํจํค์ง ์ฌ์ฉ ๊ธ์ง - ์์ PHP๋ง ์ฌ์ฉ
- โข PDO + SQLite๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ตฌํ
- โข JWT ํ ํฐ์ ์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์์ด ์ง์ ๊ตฌํ (base64_encode, hash_hmac)
- โข ๋ชจ๋ API ์๋ต์ JSON ํ์ (Content-Type: application/json)
- โข HTTP ๋ฉ์๋(GET, POST, PUT, DELETE) ์ ํํ ๊ตฌ๋ถ
- โข ๋ชจ๋ ์ฌ์ฉ์ ์ ๋ ฅ์ htmlspecialchars, filter_var๋ก ๊ฒ์ฆ
- โข Prepared Statement๋ก SQL Injection ๋ฐฉ์ด
- โข CORS ํค๋ ์ค์ ํ์ (Access-Control-Allow-Origin)
- โข ์๋ฌ ๋ฐ์ ์ ์ ์ ํ HTTP ์ํ ์ฝ๋ ๋ฐํ (400, 401, 404, 500 ๋ฑ)
- โข ํ์ผ ์ ๋ก๋๋ UPLOAD_ERR_OK ํ์ธ, MIME ํ์ ๊ฒ์ฆ
- โข ์บ์ ํ์ผ๋ช ์ MD5 ํด์ ์ฌ์ฉ
- โข Rate Limiting์ ์ธ์ ๋๋ ํ์ผ ๊ธฐ๋ฐ์ผ๋ก ๊ตฌํ
- โข API ๋ฌธ์๋ JSON ํ์์ผ๋ก ์ ๊ณต (/api/docs ์๋ํฌ์ธํธ)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir headless-blog-api-engine && cd headless-blog-api-engine
์ด ํด๋์์ ํค๋๋ฆฌ์ค ๋ธ๋ก๊ทธ API ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค.
๋จผ์ ํ๋ก์ ํธ ๊ธฐ๋ณธ ๊ตฌ์กฐ์ SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๋ฅผ ์ค์ ํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. ๋ค์ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ๋ฅผ ์์ฑํ์ธ์:
```
public/
index.php
uploads/
src/
Controllers/
Models/
Middleware/
Services/
Database.php
cache/
logs/
database/
schema.sql
config/
config.php
README.md
```
2. `config/config.php` ํ์ผ์ ๋ค์ ์ค์ ์ ์์ฑํ์ธ์:
- JWT secret key (๋๋ค ๋ฌธ์์ด)
- ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ฒฝ๋ก (`database/blog.db`)
- ์
๋ก๋ ๋๋ ํ ๋ฆฌ ๊ฒฝ๋ก
- ์บ์ TTL (3600์ด)
- Rate Limit ์ค์ (IP๋น ๋ถ๋น 60ํ)
3. `database/schema.sql` ํ์ผ์ ๋ค์ ํ
์ด๋ธ ์คํค๋ง๋ฅผ ์์ฑํ์ธ์:
- **users**: id, username, password_hash, created_at
- **posts**: id, user_id, title, slug, content, category_id, status(draft/published), view_count, created_at, updated_at, published_at
- **categories**: id, name, slug, description
- **tags**: id, name, slug
- **post_tags**: post_id, tag_id (๋ณตํฉ ๊ธฐ๋ณธํค)
- **post_revisions**: id, post_id, title, content, revised_at, revised_by
- **media**: id, filename, filepath, mime_type, size, uploaded_at
์ธ๋ฑ์ค ์ถ๊ฐ:
- posts ํ
์ด๋ธ: `INDEX idx_status_published (status, published_at)`
- posts ํ
์ด๋ธ: `INDEX idx_category (category_id)`
- post_tags ํ
์ด๋ธ: `INDEX idx_post (post_id)`, `INDEX idx_tag (tag_id)`
4. `src/Database.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- PDO ์ฑ๊ธํค ํจํด์ผ๋ก SQLite ์ฐ๊ฒฐ ๊ด๋ฆฌ
- `getInstance()` ๋ฉ์๋๋ก PDO ๊ฐ์ฒด ๋ฐํ
- ์๋ฌ ๋ชจ๋๋ฅผ `PDO::ERRMODE_EXCEPTION`์ผ๋ก ์ค์
- `schema.sql` ํ์ผ์ ์ฝ์ด ํ
์ด๋ธ ์๋ ์์ฑํ๋ `initDatabase()` ๋ฉ์๋ ํฌํจ
5. `public/index.php`์ ๊ธฐ๋ณธ ๋ผ์ฐํฐ๋ฅผ ์์ฑํ์ธ์:
- `$_SERVER['REQUEST_URI']`์ `$_SERVER['REQUEST_METHOD']`๋ก ๋ผ์ฐํ
- ๊ฐ๋จํ ๋ผ์ฐํธ ๋งค์นญ ํจ์ ๊ตฌํ (์ ๊ท์ ์ฌ์ฉ)
- 404 ์๋ต ์ฒ๋ฆฌ
- JSON ์๋ต ํฌํผ ํจ์ (`jsonResponse($data, $statusCode)`)
6. README.md์ ํ๋ก์ ํธ ์ค๋ช
, ์ค์น ๋ฐฉ๋ฒ, API ์๋ฒ ์คํ ๋ช
๋ น์ด๋ฅผ ์์ฑํ์ธ์:
```bash
php -S localhost:8000 -t public
```
๋ชจ๋ ํ์ผ์ ์์ฑํ๊ณ , `php -S localhost:8000 -t public` ๋ช
๋ น์ผ๋ก ์๋ฒ๋ฅผ ์คํํ์ ๋ `http://localhost:8000/api/health` ์๋ํฌ์ธํธ์์ `{"status": "ok"}` JSON ์๋ต์ด ๋์ค๋๋ก ๊ตฌํํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ํ๋ก์ ํธ ํด๋ ๊ตฌ์กฐ๊ฐ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๊ฐ ์ ์๋๋ฉฐ, PHP ๋ด์ฅ ์๋ฒ๋ก API๊ฐ ์คํ๋ฉ๋๋ค. `/api/health` ์๋ํฌ์ธํธ์์ JSON ์๋ต์ ํ์ธํ ์ ์์ต๋๋ค.
์ด์ JWT ์ธ์ฆ ์์คํ
์ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. `src/Services/JWTService.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `generateToken($userId, $username)`: JWT ํ ํฐ ์์ฑ (HS256)
- ํค๋: `{"alg": "HS256", "typ": "JWT"}`
- ํ์ด๋ก๋: `{"user_id": ..., "username": ..., "iat": ..., "exp": ...}` (๋ง๋ฃ 1์๊ฐ)
- ์๋ช
: `hash_hmac('sha256', $header.$payload, $secret)`
- ์ต์ข
ํ ํฐ: `base64_encode($header).".".base64_encode($payload).".".base64_encode($signature)`
- `verifyToken($token)`: ํ ํฐ ๊ฒ์ฆ ๋ฐ ํ์ด๋ก๋ ๋ฐํ
- ํ ํฐ ๋ถํด ๋ฐ ์๋ช
๊ฒ์ฆ
- ๋ง๋ฃ ์๊ฐ ํ์ธ
- ์ ํจํ๋ฉด ํ์ด๋ก๋ ๋ฐฐ์ด ๋ฐํ, ์คํจ ์ false
- `refreshToken($oldToken)`: ๊ธฐ์กด ํ ํฐ์ผ๋ก ์ ํ ํฐ ๋ฐ๊ธ (๋ง๋ฃ ์ฐ์ฅ)
2. `src/Middleware/AuthMiddleware.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `authenticate()`: Authorization ํค๋์์ Bearer ํ ํฐ ์ถ์ถ
- JWTService๋ก ํ ํฐ ๊ฒ์ฆ
- ์ ํจํ๋ฉด `$_SERVER['AUTH_USER']`์ ์ฌ์ฉ์ ์ ๋ณด ์ ์ฅ
- ์คํจ ์ 401 Unauthorized JSON ์๋ต
3. `src/Controllers/AuthController.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `login()`: POST /api/auth/login
- JSON ์์ฒญ ๋ณธ๋ฌธ์์ username, password ๋ฐ๊ธฐ
- users ํ
์ด๋ธ์์ username ์กฐํ
- `password_verify()`๋ก ๋น๋ฐ๋ฒํธ ํ์ธ
- ์ฑ๊ณต ์ JWT ํ ํฐ ๋ฐํ: `{"token": "...", "expires_in": 3600}`
- ์คํจ ์ 401 ์๋ต
- `refresh()`: POST /api/auth/refresh
- Authorization ํค๋์์ ๊ธฐ์กด ํ ํฐ ๋ฐ๊ธฐ
- ์ ํ ํฐ ๋ฐ๊ธํ์ฌ ๋ฐํ
4. `public/index.php`์ ์ธ์ฆ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
- `POST /api/auth/login` โ AuthController::login()
- `POST /api/auth/refresh` โ AuthController::refresh()
5. ํ
์คํธ์ฉ ์ฌ์ฉ์๋ฅผ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ถ๊ฐํ์ธ์:
- username: `admin`
- password: `password123` (bcrypt ํด์๋ก ์ ์ฅ)
- ์ด๊ธฐ ๋ฐ์ดํฐ ์ฝ์
์คํฌ๋ฆฝํธ๋ฅผ `database/seed.php`๋ก ์์ฑ
์๋ฒ๋ฅผ ์ฌ์์ํ๊ณ ๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "password123"}'
```
JWT ํ ํฐ์ด ๋ฐํ๋์ด์ผ ํฉ๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: JWT ์ธ์ฆ ์์คํ ์ด ๊ตฌํ๋๊ณ , /api/auth/login ์๋ํฌ์ธํธ์์ ์ ํจํ ํ ํฐ์ ๋ฐ๊ธ๋ฐ์ ์ ์์ต๋๋ค. ํ ํฐ ๊ฒ์ฆ ๋ฏธ๋ค์จ์ด๊ฐ ์๋ํฉ๋๋ค.
๊ฒ์๊ธ CRUD API๋ฅผ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. `src/Models/Post.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `getAll($filters, $page, $perPage)`: ๊ฒ์๊ธ ๋ชฉ๋ก ์กฐํ
- $filters: `["status" => "published", "category_id" => 1, "search" => "keyword"]`
- WHERE ์ ๋์ ์์ฑ (Prepared Statement ์ฌ์ฉ)
- LIMIT/OFFSET์ผ๋ก ํ์ด์ง๋ค์ด์
- ์นดํ
๊ณ ๋ฆฌ๋ช
, ํ๊ทธ ๋ชฉ๋ก JOIN์ผ๋ก ํจ๊ป ์กฐํ
- ๋ฐํ: `["data" => [...], "total" => 100, "page" => 1, "per_page" => 10]`
- `getById($id)`: ๊ฒ์๊ธ ์์ธ ์กฐํ (์กฐํ์ ์ฆ๊ฐ)
- `create($data)`: ๊ฒ์๊ธ ์์ฑ
- slug ์๋ ์์ฑ (title์ URL-safe ๋ฌธ์์ด๋ก ๋ณํ)
- created_at, updated_at ์๋ ์ค์
- `update($id, $data)`: ๊ฒ์๊ธ ์์
- ์์ ์ ๊ธฐ์กด ๋ฐ์ดํฐ๋ฅผ post_revisions์ ์ ์ฅ
- updated_at ๊ฐฑ์
- `delete($id)`: ๊ฒ์๊ธ ์ญ์ (์ค์ DELETE)
- `attachTags($postId, $tagIds)`: ํ๊ทธ ์ฐ๊ฒฐ (post_tags ํ
์ด๋ธ)
- `detachTags($postId)`: ๊ธฐ์กด ํ๊ทธ ์ ๊ฑฐ
2. `src/Controllers/PostController.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `index()`: GET /api/posts
- ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก ํํฐ๋ง: `?status=published&category_id=1&search=keyword&page=1&per_page=10`
- Post::getAll() ํธ์ถํ์ฌ ๊ฒฐ๊ณผ ๋ฐํ
- `show($id)`: GET /api/posts/{id}
- Post::getById() ํธ์ถ
- 404 ์ฒ๋ฆฌ
- `store()`: POST /api/posts (์ธ์ฆ ํ์)
- JSON ์์ฒญ ๋ณธ๋ฌธ ํ์ฑ
- ํ์ ํ๋ ๊ฒ์ฆ (title, content)
- Post::create() ํธ์ถ
- tags ๋ฐฐ์ด ์์ผ๋ฉด Post::attachTags() ํธ์ถ
- 201 Created ์๋ต
- `update($id)`: PUT /api/posts/{id} (์ธ์ฆ ํ์)
- ๊ถํ ํ์ธ (์์ฑ์ ๋ณธ์ธ๋ง ์์ ๊ฐ๋ฅ)
- Post::update() ํธ์ถ
- ํ๊ทธ ์
๋ฐ์ดํธ (๊ธฐ์กด ํ๊ทธ ์ ๊ฑฐ ํ ์ ํ๊ทธ ์ฐ๊ฒฐ)
- `destroy($id)`: DELETE /api/posts/{id} (์ธ์ฆ ํ์)
- ๊ถํ ํ์ธ
- Post::delete() ํธ์ถ
- 204 No Content ์๋ต
3. `public/index.php`์ ๊ฒ์๊ธ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
- `GET /api/posts` โ PostController::index()
- `GET /api/posts/{id}` โ PostController::show($id)
- `POST /api/posts` โ AuthMiddleware + PostController::store()
- `PUT /api/posts/{id}` โ AuthMiddleware + PostController::update($id)
- `DELETE /api/posts/{id}` โ AuthMiddleware + PostController::destroy($id)
4. ํ
์คํธ์ฉ ๊ฒ์๊ธ 3๊ฐ๋ฅผ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ถ๊ฐํ์ธ์ (seed.php):
- ๊ฐ๊ฐ ๋ค๋ฅธ ์นดํ
๊ณ ๋ฆฌ, ์ฌ๋ฌ ํ๊ทธ ์ฐ๊ฒฐ
๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
# ๊ฒ์๊ธ ๋ชฉ๋ก
curl http://localhost:8000/api/posts
# ๊ฒ์๊ธ ์์ฑ (์ธ์ฆ ํ์)
curl -X POST http://localhost:8000/api/posts \
-H "Authorization: Bearer {ํ ํฐ}" \
-H "Content-Type: application/json" \
-d '{"title": "New Post", "content": "Content here", "category_id": 1, "tags": [1, 2]}'
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ฒ์๊ธ CRUD API๊ฐ ์์ฑ๋๊ณ , ๋ชฉ๋ก ์กฐํ, ์์ฑ, ์์ , ์ญ์ ๊ฐ ๋ชจ๋ ์ ์ ์๋ํฉ๋๋ค. ์ธ์ฆ์ด ํ์ํ ์๋ํฌ์ธํธ๋ JWT ํ ํฐ ์์ด ์ ๊ทผ ์ 401 ์๋ฌ๋ฅผ ๋ฐํํฉ๋๋ค.
์นดํ
๊ณ ๋ฆฌ/ํ๊ทธ API์ ๊ฒ์ ๊ธฐ๋ฅ์ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. `src/Models/Category.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `getAll()`: ๋ชจ๋ ์นดํ
๊ณ ๋ฆฌ ์กฐํ (๊ฒ์๊ธ ์ ํฌํจ, COUNT ์๋ธ์ฟผ๋ฆฌ)
- `getById($id)`: ์นดํ
๊ณ ๋ฆฌ ์์ธ ์กฐํ
- `create($data)`: ์นดํ
๊ณ ๋ฆฌ ์์ฑ (์ธ์ฆ ํ์)
- `update($id, $data)`: ์นดํ
๊ณ ๋ฆฌ ์์
- `delete($id)`: ์นดํ
๊ณ ๋ฆฌ ์ญ์ (ํด๋น ์นดํ
๊ณ ๋ฆฌ์ ๊ฒ์๊ธ๋ ํจ๊ป ์ญ์ ๋๋ NULL ์ฒ๋ฆฌ)
2. `src/Models/Tag.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `getAll()`: ๋ชจ๋ ํ๊ทธ ์กฐํ (์ฌ์ฉ ํ์ ํฌํจ, COUNT ์๋ธ์ฟผ๋ฆฌ)
- `getById($id)`: ํ๊ทธ ์์ธ ์กฐํ
- `create($data)`: ํ๊ทธ ์์ฑ
- `delete($id)`: ํ๊ทธ ์ญ์ (post_tags์์ ์ฐ๊ฒฐ ์ ๊ฑฐ)
- `search($keyword)`: ํ๊ทธ๋ช
์ผ๋ก ๊ฒ์ (LIKE)
3. `src/Controllers/CategoryController.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `index()`: GET /api/categories
- `show($id)`: GET /api/categories/{id}
- `store()`: POST /api/categories (์ธ์ฆ ํ์)
- `update($id)`: PUT /api/categories/{id} (์ธ์ฆ ํ์)
- `destroy($id)`: DELETE /api/categories/{id} (์ธ์ฆ ํ์)
4. `src/Controllers/TagController.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `index()`: GET /api/tags
- `show($id)`: GET /api/tags/{id}
- `store()`: POST /api/tags (์ธ์ฆ ํ์)
- `destroy($id)`: DELETE /api/tags/{id} (์ธ์ฆ ํ์)
5. Post ๋ชจ๋ธ์ ๊ฒ์ ๊ธฐ๋ฅ์ ๊ฐํํ์ธ์:
- `search($keyword)`: ์ ๋ชฉ/๋ด์ฉ์์ ์ ๋ฌธ ๊ฒ์
- `WHERE title LIKE ? OR content LIKE ?`
- ๋๋ SQLite FULLTEXT INDEX ์ฌ์ฉ (FTS5)
- ๊ฒ์ ๊ฒฐ๊ณผ์ ํ์ด๋ผ์ดํธ ์ ์ฉ (๋งค์นญ๋ ํค์๋ ๊ฐ์กฐ)
6. `public/index.php`์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
- `GET /api/categories` โ CategoryController::index()
- `POST /api/categories` โ AuthMiddleware + CategoryController::store()
- `GET /api/tags` โ TagController::index()
- `POST /api/tags` โ AuthMiddleware + TagController::store()
- `GET /api/posts/search?q=keyword` โ PostController::search()
7. ํ
์คํธ์ฉ ์นดํ
๊ณ ๋ฆฌ 3๊ฐ, ํ๊ทธ 5๊ฐ๋ฅผ ์ถ๊ฐํ์ธ์ (seed.php).
๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
# ์นดํ
๊ณ ๋ฆฌ ๋ชฉ๋ก
curl http://localhost:8000/api/categories
# ํ๊ทธ ๋ชฉ๋ก
curl http://localhost:8000/api/tags
# ๊ฒ์๊ธ ๊ฒ์
curl "http://localhost:8000/api/posts/search?q=PHP"
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ์นดํ ๊ณ ๋ฆฌ/ํ๊ทธ API๊ฐ ์๋ํ๊ณ , ๊ฒ์๊ธ ๊ฒ์ ๊ธฐ๋ฅ์ด ๊ตฌํ๋ฉ๋๋ค. ๊ฐ ์นดํ ๊ณ ๋ฆฌ/ํ๊ทธ์ ์ฐ๊ฒฐ๋ ๊ฒ์๊ธ ์๊ฐ ํจ๊ป ํ์๋ฉ๋๋ค.
ํ์ผ ์
๋ก๋ & ๋ฏธ๋์ด ๋ผ์ด๋ธ๋ฌ๋ฆฌ API๋ฅผ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. `src/Services/FileUploadService.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `upload($file)`: ํ์ผ ์
๋ก๋ ์ฒ๋ฆฌ
- `$_FILES['file']` ๊ฒ์ฆ (UPLOAD_ERR_OK, MIME ํ์
, ํฌ๊ธฐ ์ ํ 5MB)
- ํ์ฉ MIME ํ์
: image/jpeg, image/png, image/gif, image/webp
- ํ์ผ๋ช
์ค๋ณต ๋ฐฉ์ง (UUID ๋๋ ํ์์คํฌํ ์ฌ์ฉ)
- `public/uploads/` ๋๋ ํ ๋ฆฌ์ ์ ์ฅ
- ๋ฐํ: `["filename" => ..., "filepath" => ..., "size" => ..., "mime_type" => ...]`
- `generateThumbnail($filepath, $width, $height)`: GD ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ก ์ธ๋ค์ผ ์์ฑ
- ์๋ณธ ๋น์จ ์ ์งํ๋ฉฐ ๋ฆฌ์ฌ์ด์ฆ
- `public/uploads/thumbnails/` ๋๋ ํ ๋ฆฌ์ ์ ์ฅ
- ๋ฐํ: ์ธ๋ค์ผ ๊ฒฝ๋ก
- `delete($filepath)`: ํ์ผ ์ญ์ (์๋ณธ + ์ธ๋ค์ผ)
2. `src/Models/Media.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `create($data)`: ๋ฏธ๋์ด ์ ๋ณด ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ
- `getAll($page, $perPage)`: ๋ฏธ๋์ด ๋ชฉ๋ก ์กฐํ (ํ์ด์ง๋ค์ด์
)
- `getById($id)`: ๋ฏธ๋์ด ์์ธ ์กฐํ
- `delete($id)`: ๋ฏธ๋์ด ์ญ์ (ํ์ผ๋ ํจ๊ป ์ญ์ )
3. `src/Controllers/MediaController.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `index()`: GET /api/media
- Media::getAll() ํธ์ถ
- ์ธ๋ค์ผ URL ํฌํจํ์ฌ ๋ฐํ
- `upload()`: POST /api/media (์ธ์ฆ ํ์)
- multipart/form-data๋ก ํ์ผ ๋ฐ๊ธฐ
- FileUploadService::upload() ํธ์ถ
- ์ธ๋ค์ผ ์์ฑ (300x300)
- Media::create()๋ก DB์ ์ ์ฅ
- 201 Created ์๋ต (ํ์ผ URL ํฌํจ)
- `show($id)`: GET /api/media/{id}
- `destroy($id)`: DELETE /api/media/{id} (์ธ์ฆ ํ์)
- FileUploadService::delete() ํธ์ถ
- Media::delete() ํธ์ถ
4. `public/index.php`์ ๋ฏธ๋์ด ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
- `GET /api/media` โ MediaController::index()
- `POST /api/media` โ AuthMiddleware + MediaController::upload()
- `GET /api/media/{id}` โ MediaController::show($id)
- `DELETE /api/media/{id}` โ AuthMiddleware + MediaController::destroy($id)
5. Post ๋ชจ๋ธ์ `featured_image_id` ํ๋๋ฅผ ์ถ๊ฐํ๊ณ , ๊ฒ์๊ธ ์์ฑ/์์ ์ ๋ํ ์ด๋ฏธ์ง๋ฅผ ์ฐ๊ฒฐํ ์ ์๋๋ก ์์ ํ์ธ์.
6. `public/uploads/` ๋๋ ํ ๋ฆฌ๊ฐ ์์ผ๋ฉด ์๋ ์์ฑํ๋๋ก ์ฒ๋ฆฌํ์ธ์.
๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
# ์ด๋ฏธ์ง ์
๋ก๋
curl -X POST http://localhost:8000/api/media \
-H "Authorization: Bearer {ํ ํฐ}" \
-F "file=@test.jpg"
# ๋ฏธ๋์ด ๋ชฉ๋ก
curl http://localhost:8000/api/media
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ํ์ผ ์ ๋ก๋ API๊ฐ ์๋ํ๊ณ , ์ ๋ก๋๋ ์ด๋ฏธ์ง์ ์ธ๋ค์ผ์ด ์๋ ์์ฑ๋ฉ๋๋ค. ๋ฏธ๋์ด ๋ชฉ๋ก ์กฐํ ์ ํ์ผ URL๊ณผ ์ธ๋ค์ผ URL์ด ํจ๊ป ๋ฐํ๋ฉ๋๋ค.
์บ์ฑ ์์คํ
๊ณผ Rate Limiting์ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. `src/Services/CacheService.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `get($key)`: ์บ์ ์กฐํ
- `cache/{$key}.json` ํ์ผ ์ฝ๊ธฐ
- TTL ํ์ธ (๋ง๋ฃ๋๋ฉด null ๋ฐํ)
- `set($key, $value, $ttl)`: ์บ์ ์ ์ฅ
- JSON์ผ๋ก ์ง๋ ฌํํ์ฌ ํ์ผ์ ์ ์ฅ
- `["data" => $value, "expires_at" => time() + $ttl]` ํ์
- `delete($key)`: ์บ์ ์ญ์
- `clear()`: ๋ชจ๋ ์บ์ ํ์ผ ์ญ์
2. Post ๋ชจ๋ธ์ ์บ์ฑ ์ ์ฉ:
- `getAll()`: ์บ์ ํค `posts_list_{$page}_{$filters_hash}` ์ฌ์ฉ
- ์บ์ ์์ผ๋ฉด ๋ฐํ, ์์ผ๋ฉด DB ์กฐํ ํ ์บ์ ์ ์ฅ (TTL 3600์ด)
- `getById($id)`: ์บ์ ํค `post_{$id}` ์ฌ์ฉ
- `create()`, `update()`, `delete()`: ๊ด๋ จ ์บ์ ๋ฌดํจํ
- `posts_list_*` ์บ์ ๋ชจ๋ ์ญ์
- `post_{$id}` ์บ์ ์ญ์
3. `src/Middleware/RateLimitMiddleware.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `check()`: IP ๊ธฐ๋ฐ ์์ฒญ ์ ํ
- `$_SERVER['REMOTE_ADDR']`๋ก IP ์ถ์ถ
- `cache/rate_limit_{$ip}.json` ํ์ผ์ ์์ฒญ ๊ธฐ๋ก ์ ์ฅ
- 1๋ถ ๋์ 60ํ ์ด๊ณผ ์ 429 Too Many Requests ์๋ต
- `X-RateLimit-Remaining` ํค๋์ ๋จ์ ์์ฒญ ์ ํ์
- ๋งค ์์ฒญ๋ง๋ค ์นด์ดํธ ์ฆ๊ฐ, 1๋ถ ํ ๋ฆฌ์
4. `public/index.php`์ Rate Limit ๋ฏธ๋ค์จ์ด๋ฅผ ๋ชจ๋ API ์์ฒญ์ ์ ์ฉํ์ธ์:
- ๋ผ์ฐํ
์ ์ RateLimitMiddleware::check() ํธ์ถ
5. Cache-Control ํค๋ ์ค์ :
- ๊ฒ์๊ธ ๋ชฉ๋ก/์์ธ ์กฐํ ์ `Cache-Control: public, max-age=3600` ํค๋ ์ถ๊ฐ
- ์ธ์ฆ์ด ํ์ํ ์๋ํฌ์ธํธ๋ `Cache-Control: no-store` ์ค์
6. ์บ์ ํต๊ณ API๋ฅผ ์ถ๊ฐํ์ธ์:
- `GET /api/cache/stats` (์ธ์ฆ ํ์)
- ์บ์ ํ์ผ ์, ์ด ํฌ๊ธฐ ๋ฐํ
- `DELETE /api/cache` (์ธ์ฆ ํ์)
- ๋ชจ๋ ์บ์ ์ญ์
๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
# ๊ฒ์๊ธ ๋ชฉ๋ก ์กฐํ (์บ์ ์์ฑ)
curl -i http://localhost:8000/api/posts
# ๋์ผ ์์ฒญ (์บ์์์ ๋ฐํ, ๋น ๋ฆ)
curl -i http://localhost:8000/api/posts
# Rate Limit ํ
์คํธ (60ํ ์ด์ ์์ฒญ)
for i in {1..70}; do curl http://localhost:8000/api/posts; done
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ์บ์ฑ ์์คํ ์ด ์๋ํ์ฌ ๋์ผํ ์์ฒญ์ด ๋น ๋ฅด๊ฒ ์๋ต๋๊ณ , Rate Limiting์ด ์ ์ฉ๋์ด ๊ณผ๋ํ ์์ฒญ ์ 429 ์๋ฌ๊ฐ ๋ฐํ๋ฉ๋๋ค. ์บ์ ๋ฌดํจํ๊ฐ ์ ์ ์๋ํฉ๋๋ค.
๊ฒ์๊ธ ๋ฒ์ ๊ด๋ฆฌ์ ํต๊ณ API๋ฅผ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. Post ๋ชจ๋ธ์ ๋ฒ์ ๊ด๋ฆฌ ๊ธฐ๋ฅ์ ์ถ๊ฐํ์ธ์:
- `update()` ๋ฉ์๋ ์์ :
- ์์ ์ ๊ธฐ์กด ๋ฐ์ดํฐ๋ฅผ `post_revisions` ํ
์ด๋ธ์ ์ ์ฅ
- `revised_by`์ ํ์ฌ ์ฌ์ฉ์ ID ์ ์ฅ
- `getRevisions($postId)`: ๊ฒ์๊ธ์ ๋ชจ๋ ๋ฒ์ ํ์คํ ๋ฆฌ ์กฐํ
- ์ต์ ์ ์ ๋ ฌ
- `restoreRevision($postId, $revisionId)`: ํน์ ๋ฒ์ ์ผ๋ก ๋ณต์
- ํด๋น ๋ฒ์ ์ title, content๋ฅผ ํ์ฌ ๊ฒ์๊ธ์ ๋ฎ์ด์ฐ๊ธฐ
- ๋ณต์ ์์๋ ์ ๋ฒ์ ์์ฑ
2. `src/Controllers/PostController.php`์ ๋ฒ์ ๊ด๋ฆฌ ์๋ํฌ์ธํธ ์ถ๊ฐ:
- `revisions($postId)`: GET /api/posts/{id}/revisions (์ธ์ฆ ํ์)
- Post::getRevisions() ํธ์ถ
- `restore($postId, $revisionId)`: POST /api/posts/{id}/revisions/{revisionId}/restore (์ธ์ฆ ํ์)
- Post::restoreRevision() ํธ์ถ
3. `src/Controllers/StatsController.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `overview()`: GET /api/stats
- ์ด ๊ฒ์๊ธ ์
- ์นดํ
๊ณ ๋ฆฌ๋ณ ๊ฒ์๊ธ ์ (GROUP BY)
- ํ๊ทธ ์ฌ์ฉ ํ์ TOP 10
- ์ต๊ทผ 7์ผ๊ฐ ๊ฒ์๊ธ ์ (๋ ์ง๋ณ GROUP BY)
- ์ด ์กฐํ์
- `postStats($id)`: GET /api/posts/{id}/stats
- ํด๋น ๊ฒ์๊ธ์ ์กฐํ์, ์์ ํ์, ์ต๊ทผ ์์ ์๊ฐ
4. `public/index.php`์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
- `GET /api/posts/{id}/revisions` โ PostController::revisions($id)
- `POST /api/posts/{id}/revisions/{revisionId}/restore` โ AuthMiddleware + PostController::restore($id, $revisionId)
- `GET /api/stats` โ StatsController::overview()
- `GET /api/posts/{id}/stats` โ StatsController::postStats($id)
5. ํต๊ณ ๋ฐ์ดํฐ๋ ์บ์ฑ ์ ์ฉ:
- ์บ์ ํค `stats_overview` (TTL 1800์ด)
- ๊ฒ์๊ธ ์์ฑ/์์ /์ญ์ ์ ์บ์ ๋ฌดํจํ
๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
# ๊ฒ์๊ธ ์์ (๋ฒ์ ์๋ ์ ์ฅ)
curl -X PUT http://localhost:8000/api/posts/1 \
-H "Authorization: Bearer {ํ ํฐ}" \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title", "content": "Updated content"}'
# ๋ฒ์ ํ์คํ ๋ฆฌ ์กฐํ
curl -H "Authorization: Bearer {ํ ํฐ}" \
http://localhost:8000/api/posts/1/revisions
# ํต๊ณ ์กฐํ
curl http://localhost:8000/api/stats
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ฒ์๊ธ ์์ ์ ์ด์ ๋ฒ์ ์ด ์๋์ผ๋ก ์ ์ฅ๋๊ณ , ๋ฒ์ ํ์คํ ๋ฆฌ๋ฅผ ์กฐํ/๋ณต์ํ ์ ์์ต๋๋ค. ํต๊ณ API๊ฐ ์๋ํ์ฌ ์นดํ ๊ณ ๋ฆฌ๋ณ ๋ถํฌ, ์ธ๊ธฐ ํ๊ทธ ๋ฑ์ ํ์ธํ ์ ์์ต๋๋ค.
์๋ฌ ํธ๋ค๋ง, ๋ก๊น
, CORS ์ค์ ์ ๊ฐํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. `src/Exceptions/` ๋๋ ํ ๋ฆฌ๋ฅผ ์์ฑํ๊ณ ์ปค์คํ
์์ธ ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `ApiException.php`: ๊ธฐ๋ณธ API ์์ธ ํด๋์ค
- ํ๋กํผํฐ: `$statusCode`, `$message`, `$errors`
- `toJson()`: RFC 7807 Problem Details ํ์์ผ๋ก ๋ฐํ
```json
{
"type": "https://example.com/errors/not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "Post with ID 999 does not exist",
"instance": "/api/posts/999"
}
```
- `ValidationException.php`: ์
๋ ฅ ๊ฒ์ฆ ์คํจ (422)
- `UnauthorizedException.php`: ์ธ์ฆ ์คํจ (401)
- `ForbiddenException.php`: ๊ถํ ์์ (403)
- `NotFoundException.php`: ๋ฆฌ์์ค ์์ (404)
2. `src/Services/LogService.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `error($message, $context)`: ์๋ฌ ๋ก๊ทธ ๊ธฐ๋ก
- `logs/error.log` ํ์ผ์ ์ถ๊ฐ
- ํ์: `[2024-01-15 10:30:00] ERROR: {$message} | Context: {json_encode($context)}`
- `info($message)`: ์ ๋ณด ๋ก๊ทธ ๊ธฐ๋ก
- `request($method, $uri, $statusCode, $duration)`: API ์์ฒญ ๋ก๊ทธ
- `logs/access.log` ํ์ผ์ ์ถ๊ฐ
- ํ์: `[2024-01-15 10:30:00] {$method} {$uri} - {$statusCode} ({$duration}ms)`
3. `public/index.php`์ ์ ์ญ ์๋ฌ ํธ๋ค๋ฌ๋ฅผ ์ถ๊ฐํ์ธ์:
- `set_exception_handler(function($e) { ... })`
- ApiException์ด๋ฉด ํด๋น ์ํ ์ฝ๋์ ๋ฉ์์ง๋ก JSON ์๋ต
- ๊ธฐํ ์์ธ๋ 500 Internal Server Error
- LogService::error()๋ก ์๋ฌ ๋ก๊ทธ ๊ธฐ๋ก
- ๋ชจ๋ API ์์ฒญ์ ์์/์ข
๋ฃ ์๊ฐ์ ์ธก์ ํ์ฌ LogService::request() ํธ์ถ
4. CORS ์ค์ :
- `src/Middleware/CorsMiddleware.php` ํด๋์ค ์์ฑ
- `handle()`: CORS ํค๋ ์ค์
- `Access-Control-Allow-Origin: *` (๋๋ ํน์ ๋๋ฉ์ธ)
- `Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS`
- `Access-Control-Allow-Headers: Content-Type, Authorization`
- `Access-Control-Max-Age: 86400`
- OPTIONS ์์ฒญ ์ 204 No Content ์๋ต
- `public/index.php`์์ ๋ชจ๋ ์์ฒญ ์ ์ CorsMiddleware::handle() ํธ์ถ
5. ์
๋ ฅ ๊ฒ์ฆ ํฌํผ ํจ์ ์ถ๊ฐ:
- `src/Helpers/Validator.php` ํด๋์ค ์์ฑ
- `validate($data, $rules)`: ๊ท์น ๊ธฐ๋ฐ ๊ฒ์ฆ
- ๊ท์น ์: `["title" => "required|max:200", "email" => "required|email"]`
- ๊ฒ์ฆ ์คํจ ์ ValidationException ๋์ง๊ธฐ
6. ๋ชจ๋ ์ปจํธ๋กค๋ฌ์์ ์
๋ ฅ ๊ฒ์ฆ ์ ์ฉ:
- PostController::store(), update()์์ Validator::validate() ์ฌ์ฉ
- ํ์ ํ๋, ๊ธธ์ด ์ ํ, ํ์
๊ฒ์ฆ
๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
# ์๋ชป๋ ์์ฒญ (๊ฒ์ฆ ์คํจ)
curl -X POST http://localhost:8000/api/posts \
-H "Authorization: Bearer {ํ ํฐ}" \
-H "Content-Type: application/json" \
-d '{"title": ""}'
# ์กด์ฌํ์ง ์๋ ๊ฒ์๊ธ ์กฐํ
curl http://localhost:8000/api/posts/99999
```
`logs/error.log`์ `logs/access.log` ํ์ผ์ ๋ก๊ทธ๊ฐ ๊ธฐ๋ก๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ปค์คํ ์์ธ ํด๋์ค๊ฐ ์๋ํ์ฌ ์๋ฌ ์๋ต์ด RFC 7807 ํ์์ผ๋ก ๋ฐํ๋๊ณ , ๋ชจ๋ ์๋ฌ์ API ์์ฒญ์ด ๋ก๊ทธ ํ์ผ์ ๊ธฐ๋ก๋ฉ๋๋ค. CORS ํค๋๊ฐ ์ค์ ๋์ด ๋ธ๋ผ์ฐ์ ์์ API ํธ์ถ์ด ๊ฐ๋ฅํฉ๋๋ค.
API ๋ฌธ์ํ (OpenAPI Spec)์ Webhook ๊ธฐ๋ฅ์ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. `src/Services/OpenApiService.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `generate()`: OpenAPI 3.0 ์คํ JSON ์์ฑ
- ๊ธฐ๋ณธ ์ ๋ณด: title, version, description
- ์๋ฒ URL: `http://localhost:8000`
- ๋ชจ๋ ์๋ํฌ์ธํธ ์ ์:
- ๊ฒฝ๋ก, HTTP ๋ฉ์๋, ํ๋ผ๋ฏธํฐ, ์์ฒญ/์๋ต ์คํค๋ง
- ์ธ์ฆ ํ์ ์ฌ๋ถ (Bearer Token)
- ์์ ์๋ต ํฌํจ
- ๋ฐํ: OpenAPI JSON ๊ฐ์ฒด
2. `src/Controllers/DocsController.php` ํด๋์ค๋ฅผ ์์ฑํ์ธ์:
- `spec()`: GET /api/docs
- OpenApiService::generate() ํธ์ถํ์ฌ JSON ๋ฐํ
- `ui()`: GET /api/docs/ui
- Swagger UI HTML ํ์ด์ง ๋ฐํ
- CDN์์ Swagger UI ๋ก๋ํ์ฌ `/api/docs` ์คํ ํ์
3. `public/index.php`์ ๋ฌธ์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
- `GET /api/docs` โ DocsController::spec()
- `GET /api/docs/ui` โ DocsController::ui()
4. Webhook ๊ธฐ๋ฅ ๊ตฌํ:
- `src/Services/WebhookService.php` ํด๋์ค ์์ฑ
- `send($event, $data)`: ์ธ๋ถ URL๋ก POST ์์ฒญ ์ ์ก
- `file_get_contents()` ๋๋ `curl`๋ก HTTP ์์ฒญ
- ์์ฒญ ๋ณธ๋ฌธ: `{"event": "post.published", "data": {...}}`
- ํ์์์ 5์ด ์ค์
- ์คํจ ์ LogService::error() ๊ธฐ๋ก
- Webhook URL์ config.php์ ์ค์
5. Post ๋ชจ๋ธ์ Webhook ํธ๋ฆฌ๊ฑฐ ์ถ๊ฐ:
- `create()`: ๊ฒ์๊ธ ์์ฑ ์ `post.created` ์ด๋ฒคํธ ์ ์ก
- `update()`: ๊ฒ์๊ธ ์์ ์ `post.updated` ์ด๋ฒคํธ ์ ์ก
- `delete()`: ๊ฒ์๊ธ ์ญ์ ์ `post.deleted` ์ด๋ฒคํธ ์ ์ก
- status๊ฐ 'published'๋ก ๋ณ๊ฒฝ๋ ๋ `post.published` ์ด๋ฒคํธ ์ ์ก
6. Webhook ์ค์ API ์ถ๊ฐ:
- `GET /api/webhooks` (์ธ์ฆ ํ์): ๋ฑ๋ก๋ Webhook URL ๋ชฉ๋ก ์กฐํ
- `POST /api/webhooks` (์ธ์ฆ ํ์): ์ Webhook URL ๋ฑ๋ก
- `DELETE /api/webhooks/{id}` (์ธ์ฆ ํ์): Webhook ์ญ์
7. ํ
์คํธ์ฉ Webhook ์์ ์๋ฒ๋ฅผ ๋ณ๋ ํฌํธ(8001)์์ ์คํ:
- `test/webhook-receiver.php` ํ์ผ ์์ฑ
- ๋ชจ๋ POST ์์ฒญ์ ๋ฐ์ ๋ก๊ทธ์ ๊ธฐ๋ก
๋ค์ ์์ฒญ์ ํ
์คํธํ์ธ์:
```bash
# API ๋ฌธ์ ์กฐํ
curl http://localhost:8000/api/docs
# Swagger UI ์ด๊ธฐ
open http://localhost:8000/api/docs/ui
# Webhook ๋ฑ๋ก
curl -X POST http://localhost:8000/api/webhooks \
-H "Authorization: Bearer {ํ ํฐ}" \
-H "Content-Type: application/json" \
-d '{"url": "http://localhost:8001/webhook"}'
# ๊ฒ์๊ธ ์์ฑ (Webhook ํธ๋ฆฌ๊ฑฐ)
curl -X POST http://localhost:8000/api/posts \
-H "Authorization: Bearer {ํ ํฐ}" \
-H "Content-Type: application/json" \
-d '{"title": "Test", "content": "Content", "status": "published"}'
```
Webhook ์์ ์๋ฒ ๋ก๊ทธ์์ ์ด๋ฒคํธ๊ฐ ๊ธฐ๋ก๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: OpenAPI ๋ฌธ์๊ฐ ์๋ ์์ฑ๋๊ณ , Swagger UI์์ ๋ชจ๋ ์๋ํฌ์ธํธ๋ฅผ ํ์ธํ ์ ์์ต๋๋ค. Webhook ๊ธฐ๋ฅ์ด ์๋ํ์ฌ ๊ฒ์๊ธ ์์ฑ/์์ /์ญ์ ์ ์ธ๋ถ URL๋ก ์ด๋ฒคํธ๊ฐ ์ ์ก๋ฉ๋๋ค.
์ฑ๋ฅ ์ต์ ํ, ๋ณด์ ๊ฐํ, ํ
์คํธ ์ฝ๋๋ฅผ ์ถ๊ฐํ์ธ์.
**์๊ตฌ์ฌํญ:**
1. ์ฑ๋ฅ ์ต์ ํ:
- **๋ฐ์ดํฐ๋ฒ ์ด์ค ์ธ๋ฑ์ค ์ถ๊ฐ**:
- posts ํ
์ด๋ธ: `CREATE INDEX idx_title_search ON posts(title)`
- posts ํ
์ด๋ธ: `CREATE INDEX idx_created_at ON posts(created_at DESC)`
- **N+1 ์ฟผ๋ฆฌ ๋ฐฉ์ง**:
- Post::getAll()์์ ์นดํ
๊ณ ๋ฆฌ, ํ๊ทธ๋ฅผ JOIN์ผ๋ก ํ ๋ฒ์ ์กฐํ
- **ํ์ด์ง๋ค์ด์
์ต์ ํ**:
- OFFSET ๋์ ์ปค์ ๊ธฐ๋ฐ ํ์ด์ง๋ค์ด์
์ต์
์ถ๊ฐ (`?cursor=last_id`)
- **์บ์ ์๋ฐ**:
- `src/Commands/WarmCache.php` ์คํฌ๋ฆฝํธ ์์ฑ
- ๋ชจ๋ ๊ฒ์๊ธ ๋ชฉ๋ก/์์ธ๋ฅผ ๋ฏธ๋ฆฌ ์บ์ฑ
2. ๋ณด์ ๊ฐํ:
- **CSRF ๋ฐฉ์ด**:
- `src/Middleware/CsrfMiddleware.php` ํด๋์ค ์์ฑ
- POST/PUT/DELETE ์์ฒญ ์ `X-CSRF-Token` ํค๋ ๊ฒ์ฆ
- ํ ํฐ์ ์ธ์
์ ์ ์ฅ, `/api/csrf-token` ์๋ํฌ์ธํธ์์ ๋ฐ๊ธ
- **SQL Injection ๋ฐฉ์ด ํ์ธ**:
- ๋ชจ๋ ์ฟผ๋ฆฌ๊ฐ Prepared Statement ์ฌ์ฉํ๋์ง ๊ฒ์ฆ
- **XSS ๋ฐฉ์ด**:
- ๋ชจ๋ ์ถ๋ ฅ์ `htmlspecialchars()` ์ ์ฉ ํ์ธ
- **๋น๋ฐ๋ฒํธ ๋ณด์**:
- bcrypt ํด์ ์ฌ์ฉ ํ์ธ (cost 12 ์ด์)
- **ํ์ผ ์
๋ก๋ ๋ณด์**:
- MIME ํ์
๊ฒ์ฆ ๊ฐํ (ํ์ฅ์์ ์ค์ ํ์
๋น๊ต)
- ์
๋ก๋ ๋๋ ํ ๋ฆฌ ์คํ ๊ถํ ์ ๊ฑฐ
3. ํ
์คํธ ์ฝ๋ ์์ฑ:
- `tests/` ๋๋ ํ ๋ฆฌ ์์ฑ
- `tests/ApiTest.php` ํ์ผ ์์ฑ (PHPUnit ์์ด ๊ฐ๋จํ ํ
์คํธ ํจ์)
- `testPostCreation()`: ๊ฒ์๊ธ ์์ฑ API ํ
์คํธ
- `testPostUpdate()`: ๊ฒ์๊ธ ์์ API ํ
์คํธ
- `testAuthentication()`: JWT ์ธ์ฆ ํ
์คํธ
- `testRateLimit()`: Rate Limit ํ
์คํธ
- `testCaching()`: ์บ์ ์๋ ํ
์คํธ
- ๊ฐ ํ
์คํธ ํจ์๋ API ์์ฒญ์ ๋ณด๋ด๊ณ ์๋ต ๊ฒ์ฆ
- `php tests/ApiTest.php` ๋ช
๋ น์ผ๋ก ์คํ
4. ํฌ์ค ์ฒดํฌ API ๊ฐํ:
- `GET /api/health` ์๋ํฌ์ธํธ ์์
- ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์ํ ํ์ธ
- ์บ์ ๋๋ ํ ๋ฆฌ ์ฐ๊ธฐ ๊ถํ ํ์ธ
- ์
๋ก๋ ๋๋ ํ ๋ฆฌ ์ฐ๊ธฐ ๊ถํ ํ์ธ
- ๋ฐํ: `{"status": "ok", "database": "connected", "cache": "writable", "uploads": "writable"}`
5. README.md ์
๋ฐ์ดํธ:
- API ์๋ํฌ์ธํธ ์ ์ฒด ๋ชฉ๋ก ์ถ๊ฐ
- ์ธ์ฆ ๋ฐฉ๋ฒ ์ค๋ช
- ์์ ์์ฒญ/์๋ต
- ์ฑ๋ฅ ์ต์ ํ ํ
- ๋ณด์ ์ฒดํฌ๋ฆฌ์คํธ
6. ํ๊ฒฝ ๋ณ์ ์ค์ :
- `.env.example` ํ์ผ ์์ฑ
- `JWT_SECRET`, `DB_PATH`, `UPLOAD_DIR`, `WEBHOOK_URL` ๋ฑ ์ค์
- `config/config.php`์์ ํ๊ฒฝ ๋ณ์ ๋ก๋
๋ค์ ๋ช
๋ น์ผ๋ก ํ
์คํธ๋ฅผ ์คํํ์ธ์:
```bash
# ํ
์คํธ ์คํ
php tests/ApiTest.php
# ์บ์ ์๋ฐ
php src/Commands/WarmCache.php
# ํฌ์ค ์ฒดํฌ
curl http://localhost:8000/api/health
```
๋ชจ๋ ํ
์คํธ๊ฐ ํต๊ณผํ๊ณ , ํฌ์ค ์ฒดํฌ์์ `status: ok`๊ฐ ๋ฐํ๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ฑ๋ฅ ์ต์ ํ๊ฐ ์ ์ฉ๋์ด API ์๋ต ์๋๊ฐ ๊ฐ์ ๋๊ณ , ๋ณด์ ์ทจ์ฝ์ ์ด ๋ชจ๋ ํด๊ฒฐ๋ฉ๋๋ค. ํ ์คํธ ์ฝ๋๊ฐ ์๋ํ์ฌ ์ฃผ์ ๊ธฐ๋ฅ์ ์๋์ผ๋ก ๊ฒ์ฆํ ์ ์์ต๋๋ค. README.md๊ฐ ์์ฑ๋์ด ๋ค๋ฅธ ๊ฐ๋ฐ์๊ฐ ํ๋ก์ ํธ๋ฅผ ์ฝ๊ฒ ์ดํดํ๊ณ ์ฌ์ฉํ ์ ์์ต๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!