๐Ÿ“ ํ—ค๋“œ๋ฆฌ์Šค ๋ธ”๋กœ๊ทธ API ์—”์ง„ - RESTful ์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ ์‹œ์Šคํ…œ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
๊ณ ๊ธ‰ PHP Vanilla PHP AI: Claude

๐Ÿ“ ํ—ค๋“œ๋ฆฌ์Šค ๋ธ”๋กœ๊ทธ API ์—”์ง„ - RESTful ์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ ์‹œ์Šคํ…œ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… headless-blog-api-engine
์กฐํšŒ์ˆ˜ 207 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐ŸŽฏ ํ”„๋กœ์ ํŠธ ๊ฐœ์š”

ํ”„๋ก ํŠธ์—”๋“œ์™€ ์™„์ „ํžˆ ๋ถ„๋ฆฌ๋œ ํ—ค๋“œ๋ฆฌ์Šค CMS(Headless CMS) ๋ฐฉ์‹์˜ ๋ธ”๋กœ๊ทธ API ์„œ๋ฒ„๋ฅผ ์ˆœ์ˆ˜ PHP 8.3+๋กœ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. Laravel, Symfony ๋“ฑ ํ”„๋ ˆ์ž„์›Œํฌ ์—†์ด PSR ํ‘œ์ค€์„ ๋”ฐ๋ฅด๋Š” RESTful API๋ฅผ ์ง์ ‘ ์„ค๊ณ„ํ•˜๊ณ , JWT ์ธ์ฆ, ์บ์‹ฑ, ํŒŒ์ผ ์—…๋กœ๋“œ, ๊ฒ€์ƒ‰, ํŽ˜์ด์ง€๋„ค์ด์…˜, ๋ฒ„์ „ ๊ด€๋ฆฌ ๋“ฑ ์‹ค๋ฌด ์ˆ˜์ค€์˜ ๊ธฐ๋Šฅ์„ ๋ชจ๋‘ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

ํ•„์ˆ˜ ๊ธฐ๋Šฅ

  1. 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 - ํƒœ๊ทธ ๋ชฉ๋ก
  2. JWT ์ธ์ฆ ์‹œ์Šคํ…œ

    • ํ† ํฐ ์ƒ์„ฑ/๊ฒ€์ฆ (HS256 ์•Œ๊ณ ๋ฆฌ์ฆ˜)
    • Refresh Token ๊ตฌํ˜„
    • Authorization ํ—ค๋” ํŒŒ์‹ฑ
    • ํ† ํฐ ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ
  3. ๊ณ ๊ธ‰ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค๊ณ„

    • ๊ฒŒ์‹œ๊ธ€(posts), ์นดํ…Œ๊ณ ๋ฆฌ(categories), ํƒœ๊ทธ(tags) ํ…Œ์ด๋ธ”
    • ๋‹ค๋Œ€๋‹ค ๊ด€๊ณ„ (post_tags ์ค‘๊ฐ„ ํ…Œ์ด๋ธ”)
    • ๊ฒŒ์‹œ๊ธ€ ๋ฒ„์ „ ํžˆ์Šคํ† ๋ฆฌ (post_revisions)
    • ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ตœ์ ํ™”
    • ํŠธ๋žœ์žญ์…˜ ์ฒ˜๋ฆฌ
  4. ํŒŒ์ผ ์—…๋กœ๋“œ & ๋ฏธ๋””์–ด ๊ด€๋ฆฌ

    • ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ (multipart/form-data)
    • ์ธ๋„ค์ผ ์ž๋™ ์ƒ์„ฑ (GD ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ)
    • ํŒŒ์ผ ํƒ€์ž…/ํฌ๊ธฐ ๊ฒ€์ฆ
    • ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ API (GET /api/media)
  5. ์บ์‹ฑ ์‹œ์Šคํ…œ

    • ํŒŒ์ผ ๊ธฐ๋ฐ˜ ์บ์‹œ (cache/ ๋””๋ ‰ํ† ๋ฆฌ)
    • ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก/์ƒ์„ธ ์บ์‹ฑ
    • Cache-Control ํ—ค๋” ์„ค์ •
    • ์บ์‹œ ๋ฌดํšจํ™” (๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ •/์‚ญ์ œ ์‹œ)
  6. ์ „๋ฌธ ๊ฒ€์ƒ‰ & ํ•„ํ„ฐ๋ง

    • ์ œ๋ชฉ/๋‚ด์šฉ ์ „๋ฌธ ๊ฒ€์ƒ‰ (LIKE ๋˜๋Š” FULLTEXT INDEX)
    • ์นดํ…Œ๊ณ ๋ฆฌ/ํƒœ๊ทธ ํ•„ํ„ฐ๋ง
    • ๋‚ ์งœ ๋ฒ”์œ„ ๊ฒ€์ƒ‰
    • ๋ณตํ•ฉ ์ฟผ๋ฆฌ ๋นŒ๋” ๊ตฌํ˜„
  7. ๋ณด์•ˆ ๊ฐ•ํ™”

    • PDO Prepared Statements
    • ์ถœ๋ ฅ ์ด์Šค์ผ€์ดํ”„ (htmlspecialchars)
    • CORS ์„ค์ •
    • Rate Limiting (IP ๊ธฐ๋ฐ˜ ์š”์ฒญ ์ œํ•œ)
    • SQL Injection, XSS ๋ฐฉ์–ด
  8. ์—๋Ÿฌ ํ•ธ๋“ค๋ง & ๋กœ๊น…

    • JSON ์—๋Ÿฌ ์‘๋‹ต ํ‘œ์ค€ํ™” (RFC 7807 Problem Details)
    • ์ปค์Šคํ…€ Exception ํด๋ž˜์Šค
    • ์—๋Ÿฌ ๋กœ๊ทธ ํŒŒ์ผ (logs/error.log)
    • HTTP ์ƒํƒœ ์ฝ”๋“œ ์ •ํ™•ํžˆ ๋ฐ˜ํ™˜
  9. 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์—๊ฒŒ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๋ณต์‚ฌ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.

1
Step 1
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 ์‘๋‹ต์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

2
Step 2
์ด์ œ 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 ์—”๋“œํฌ์ธํŠธ์—์„œ ์œ ํšจํ•œ ํ† ํฐ์„ ๋ฐœ๊ธ‰๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ† ํฐ ๊ฒ€์ฆ ๋ฏธ๋“ค์›จ์–ด๊ฐ€ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.

3
Step 3
๊ฒŒ์‹œ๊ธ€ 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 ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

4
Step 4
์นดํ…Œ๊ณ ๋ฆฌ/ํƒœ๊ทธ 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๊ฐ€ ์ž‘๋™ํ•˜๊ณ , ๊ฒŒ์‹œ๊ธ€ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์ด ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค. ๊ฐ ์นดํ…Œ๊ณ ๋ฆฌ/ํƒœ๊ทธ์— ์—ฐ๊ฒฐ๋œ ๊ฒŒ์‹œ๊ธ€ ์ˆ˜๊ฐ€ ํ•จ๊ป˜ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

5
Step 5
ํŒŒ์ผ ์—…๋กœ๋“œ & ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ 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์ด ํ•จ๊ป˜ ๋ฐ˜ํ™˜๋ฉ๋‹ˆ๋‹ค.

6
Step 6
์บ์‹ฑ ์‹œ์Šคํ…œ๊ณผ 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 ์—๋Ÿฌ๊ฐ€ ๋ฐ˜ํ™˜๋ฉ๋‹ˆ๋‹ค. ์บ์‹œ ๋ฌดํšจํ™”๊ฐ€ ์ •์ƒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.

7
Step 7
๊ฒŒ์‹œ๊ธ€ ๋ฒ„์ „ ๊ด€๋ฆฌ์™€ ํ†ต๊ณ„ 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๊ฐ€ ์ž‘๋™ํ•˜์—ฌ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋ถ„ํฌ, ์ธ๊ธฐ ํƒœ๊ทธ ๋“ฑ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

8
Step 8
์—๋Ÿฌ ํ•ธ๋“ค๋ง, ๋กœ๊น…, 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 ํ˜ธ์ถœ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

9
Step 9
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๋กœ ์ด๋ฒคํŠธ๊ฐ€ ์ „์†ก๋ฉ๋‹ˆ๋‹ค.

10
Step 10
์„ฑ๋Šฅ ์ตœ์ ํ™”, ๋ณด์•ˆ ๊ฐ•ํ™”, ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

**์š”๊ตฌ์‚ฌํ•ญ:**

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)

๋กœ๊ทธ์ธ ํ›„ ์ œ์ถœ

์•„์ง ์ œ์ถœ๋œ ์ž‘ํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋„์ „์ž๊ฐ€ ๋˜์–ด๋ณด์„ธ์š”!

๋Œ“๊ธ€ (0)

๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!