๋ฐ˜์‘ํ˜•

 

OpenAI ๊ฐ™์€ LLM(๋Œ€ํ˜• ์–ธ์–ด ๋ชจ๋ธ) ๊ธฐ๋ฐ˜ API๋กœ ๊ธฐ๋Šฅ์„ ๊ฐœ๋ฐœํ•˜๋‹ค ๋ณด๋ฉด, ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์‚ฌ์šฉ๋Ÿ‰ ์ œํ•œ์ด ํ•„์š”ํ•ด์ง„๋‹ค. Unkey๋ผ๋Š” API ๊ด€๋ฆฌ ํ”Œ๋žซํผ์„ ์ด์šฉํ•˜๋ฉด API ํ‚ค ๊ด€๋ฆฌ, ์š”์ฒญ ๋นˆ๋„ ์ œํ•œ, ์‚ฌ์šฉ๋Ÿ‰ ๋ถ„์„ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ์†์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค. API ์—”๋“œํฌ์ธํŠธ๋ณ„๋กœ ์š”์ฒญ๋Ÿ‰์„ ์ œํ•œํ•˜๊ฑฐ๋‚˜ ์‚ฌ์šฉ์ž๋งˆ๋‹ค ๋‹ค๋ฅธ ์ œํ•œ ์ •์ฑ…์„ ์„ค์ •ํ•  ์ˆ˜๋„ ์žˆ๋‹ค. Unkey ๋ฌด๋ฃŒ ์š”๊ธˆ์ œ(Free Tier)๋Š” API ํ‚ค 1000๊ฐœ, ์›” 15๋งŒ ๊ฑด์˜ ์š”์ฒญ๊นŒ์ง€ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ฐœ์ธ ํ”„๋กœ์ ํŠธ์— ์‚ฌ์šฉํ•˜๊ธฐ ๋”ฑ ์ข‹๋‹ค.

 

Next.js๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด Unkey์—์„œ ์ œ๊ณตํ•˜๋Š” SDK๋ฅผ ์ด์šฉํ•ด์„œ ๋” ํŽธํ•˜๊ฒŒ ํ†ตํ•ฉํ•  ์ˆ˜ ์žˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์„œ๋ฒ„ ์•ก์…˜์ด๋‚˜ API ๋ผ์šฐํŠธ์— ๋ฏธ๋“ค์›จ์–ด์ฒ˜๋Ÿผ ์ ์šฉํ•˜์—ฌ ์ธ์ฆ๋œ ์š”์ฒญ๋งŒ ์ฒ˜๋ฆฌํ•˜๊ฑฐ๋‚˜ ์‚ฌ์šฉ๋Ÿ‰ ์ œํ•œ์„ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

 

Unkey๋Š” ํฌ๊ฒŒ ๋ฐœ๊ธ‰๋œ Key๋กœ ์‚ฌ์šฉ์ž/์„œ๋น„์Šค๋ฅผ ๊ตฌ๋ถ„ํ•˜๋Š” API Key ๋ฐฉ์‹๊ณผ, IP ์ฃผ์†Œ/ID ๊ฐ™์€ ์ž„์˜ ์‹๋ณ„์ž๋ฅผ ์‚ฌ์šฉํ•˜๋Š” Ratelimit ๋ฐฉ์‹์œผ๋กœ ๋‚˜๋‰œ๋‹ค. ์ธ์ฆ ์—†๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋ณดํ˜ธํ•  ๋• Ratelimit, ํ‚ค๋ณ„ ์ ‘๊ทผ ๊ถŒํ•œ๊ณผ ํ• ๋‹น๋Ÿ‰์„ ๊ด€๋ฆฌํ•  ๋• API Key ๋ฐฉ์‹์ด ์ ํ•ฉํ•˜๋‹ค.

 

 

Ratelimiting (์‚ฌ์šฉ๋Ÿ‰ ์ œํ•œ)


๊ธฐ๋ณธ ์„ค์ •

โถ Unkey ๊ณ„์ • ์ƒ์„ฑ  

 

โท Unkey root key ์ƒ์„ฑ. ํ‚ค ์ƒ์„ฑ ํŽ˜์ด์ง€์—์„œ create_namespace, limit ๊ถŒํ•œ์— ์ฒดํฌํ•œ๋‹ค.

 

โธ @unkey/ratelimit ํŒจํ‚ค์ง€ ์„ค์น˜

pnpm install @unkey/ratelimit

 

โน Root Key ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ •

# .env.local
UNKEY_ROOT_KEY="..."

 

โบ ๋ผ์šฐํŠธ ๋ณดํ˜ธ ๋กœ์ง ์ž‘์„ฑ. Ratelimit ์ƒ์„ฑ์ž๋กœ limiter ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•œ ํ›„ limit ๋ฉ”์„œ๋“œ๋กœ ์‹๋ณ„์ž์— ๋Œ€ํ•œ ์š”์ฒญ ์ œํ•œ ์ƒํƒœ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. limit ๋ฉ”์„œ๋“œ๋Š” ์„ฑ๊ณต ์—ฌ๋ถ€(success), ๋‚จ์€ ์š”์ฒญ ํšŸ์ˆ˜(limit) ๋“ฑ์˜ ์ •๋ณด๋ฅผ ๋‹ด์€ RatelimitResponse ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์•„๋ž˜ ์˜ˆ์‹œ์—์„œ๋Š” IP ์ฃผ์†Œ๋ฅผ ์‹๋ณ„์ž๋กœ ์‚ฌ์šฉํ–ˆ์ง€๋งŒ ID, ์ด๋ฉ”์ผ ๋“ฑ ๋‹ค๋ฅธ ๊ณ ์œ ํ•œ ์‹๋ณ„์ž๋ฅผ ์ž์œ ๋กญ๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

// app/api/subtask/route.ts
import { Ratelimit } from '@unkey/ratelimit';

const limiter = new Ratelimit({
  // ์‚ฌ์šฉํ•  ๋„ค์ž„์ŠคํŽ˜์ด์Šค ์ด๋ฆ„
  namespace: 'kanban.subtask',
  // duration ๊ธฐ๊ฐ„ ๋™์•ˆ ํ—ˆ์šฉํ•  ์ตœ๋Œ€ ์š”์ฒญ ์ˆ˜
  limit: 30,
  // Unkey API ์ธ์ฆ์„ ์œ„ํ•œ ๋ฃจํŠธํ‚ค ์„ค์ •
  duration: '12h',
  // ๋ฃจํŠธํ‚ค ์„ค์ •
  rootKey: process.env.UNKEY_ROOT_KEY ?? '',
  // Unkey ์„œ๋น„์Šค์˜ ์„ฑ๋Šฅ ์ธก์ • ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ๋น„ํ™œ์„ฑํ™” (๊ธฐ๋ณธ๊ฐ’ false)
  disableTelemetry: true,
  // ์š”์ฒญ ์ œํ•œ์„ ๋น„๋™๊ธฐ์ ์œผ๋กœ ์ฒ˜๋ฆฌ. ์†๋„ ๋น ๋ฅด์ง€๋งŒ ์ •ํ™•๋„ ๋‹ค์†Œ ๋–จ์–ด์ง(98%)
  async: true,
});

export async function POST(req: NextRequest) {
  // ์š”์ฒญ ํ—ค๋”์—์„œ ํด๋ผ์ด์–ธํŠธ IP ์ฃผ์†Œ ์กฐํšŒ (IP ์ฃผ์†Œ๋ฅผ ์‹๋ณ„์ž๋กœ ์‚ฌ์šฉ)
  const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
  // limit ๋ฉ”์„œ๋“œ๋กœ ๋„˜๊ธด ์‹๋ณ„์ž์˜ ์š”์ฒญ ์ œํ•œ ์ƒํƒœ ํ™•์ธ
  const rateLimit = await limiter.limit(ip);
  // ์š”์ฒญ ํšŸ์ˆ˜๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉํ–ˆ์„ ๋•Œ
  if (!rateLimit.success) {
    return new Response('Rate limit exceeded', { status: 429 });
  }

  // ์š”์ฒญ ์ฒ˜๋ฆฌ ๊ณ„์†...
}

 

โป Unkey ๋Œ€์‹œ๋ณด๋“œ์˜ Ratelimit ๋ฉ”๋‰ด๋ฅผ ๋ณด๋ฉด ์ง€์ •ํ•œ ๋„ค์ž„์ŠคํŽ˜์ด์Šค๊ฐ€ ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋˜์–ด ์žˆ๋Š” ๊ฑธ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. ๋„ค์ž„์ŠคํŽ˜์ด์Šค๋ฅผ ํด๋ฆญํ•ด ๋ณด๋ฉด ์š”์ฒญ ์„ฑ๊ณต/์‹คํŒจ ์—ฌ๋ถ€์™€ ๊ฐ™์€ ์„ธ๋ถ€ ์ •๋ณด๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

 

๋™๊ธฐ vs ๋น„๋™๊ธฐ ๋ฐฉ์‹

๐Ÿ’ก ์—ฃ์ง€(Edge)๋Š” ์ธํ„ฐ๋„ท ์ธํ”„๋ผ์—์„œ ์‚ฌ์šฉ์ž์™€ ๊ฐ€๊นŒ์šด ์„œ๋ฒ„๋ฅผ ๊ฐ€๋ฆฌํ‚จ๋‹ค.

 

Ratelimit ์ƒ์„ฑ์ž์˜ async ์˜ต์…˜์€ ๊ธฐ๋ณธ์ ์œผ๋กœ false๋กœ ์ง€์ •๋˜์–ด ์žˆ์–ด์„œ ๋™๊ธฐ์ ์œผ๋กœ ์ž‘๋™ํ•œ๋‹ค. ์ด๋Š” limit ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœ(์š”์ฒญ ์ œํ•œ ์ƒํƒœ ํ™•์ธ)ํ•ด์„œ ์‘๋‹ต์„ ๋ฐ›์„ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ(๋ธ”๋กœํ‚น)ํ•ด์•ผ ํ•จ์„ ์˜๋ฏธํ•œ๋‹ค. Unkey๋Š” ์ง€์—ฐ(latency)์„ ์ค„์ด๊ธฐ ์œ„ํ•ด ์š”์ฒญ ์œ„์น˜์— ๋”ฐ๋ผ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์—ฃ์ง€๋กœ origin์„ ์žฌ๋ฐฐ์น˜ํ•˜์ง€๋งŒ ์ œํ•œ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜๋ ค๋ฉด ์—ฌ์ „ํžˆ origin๊ณผ์˜ ์š”์ฒญ/์‘๋‹ต ์™•๋ณต์ด ํ•„์š”ํ•˜๋‹ค.

 

๋งŒ์•ฝ ๋™์ผํ•œ ์‹๋ณ„์ž๊ฐ€ ์—ฌ๋Ÿฌ ์ง€์—ญ์—์„œ ๋™์‹œ์— ์š”์ฒญ์„ ๋ณด๋‚ธ๋‹ค๋ฉด ๋ฌผ๋ฆฌ์ ์œผ๋กœ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์ง€์—ญ์˜ ์‹๋ณ„์ž๊ฐ€ ์šฐ์„ ์ ์œผ๋กœ ์„ ํƒ๋œ๋‹ค. ๋‚˜๋จธ์ง€ ์ง€์—ญ์— ์žˆ๋Š” ์‹๋ณ„์ž๋Š” ์ƒ๋Œ€์ ์œผ๋กœ ๋” ๋จผ ๊ณณ์—์„œ ์„œ๋ฒ„์™€ ํ†ต์‹ ํ•ด์•ผ ํ•˜๋ฏ€๋กœ ์ง€์—ฐ์ด ๋” ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด์ฒ˜๋Ÿผ ๋™๊ธฐ ๋ฐฉ์‹์€ ๋‹ค์†Œ ๋Š๋ฆด ์ˆ˜ ์žˆ์ง€๋งŒ ๋ชจ๋“  ์š”์ฒญ์ด ํ•ญ์ƒ ๋™์ผํ•œ origin์„ ๊ธฐ์ค€์œผ๋กœ ๊ฒ€์ฆํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ •ํ™•์„ฑ์ด ๋†’์€ ์žฅ์ ์ด ์žˆ๋‹ค.

 

๋น„๋™๊ธฐ ๋ฐฉ์‹์€(async ์˜ต์…˜ true) ๋กœ์ปฌ ์—ฃ์ง€ ์บ์‹œ์˜ limit ์ƒํƒœ๋ฅผ ๋จผ์ € ์‚ฌ์šฉํ•œ ํ›„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋น„๋™๊ธฐ์ ์œผ๋กœ origin๊ณผ ๋™๊ธฐํ™”ํ•œ๋‹ค. ์ง€์—ฐ ์‹œ๊ฐ„์€ ํ›จ์”ฌ ์ค„์–ด๋“ค์ง€๋งŒ ์ •ํ™•๋„๊ฐ€ ๋‹ค์†Œ ๋–จ์–ด์ง€๋Š” ๋‹จ์ ์ด ์žˆ๋‹ค(์•ฝ 98%). ๋งŒ์•ฝ ๋™์ผํ•œ ์‹๋ณ„์ž๊ฐ€ ๋‹ค๋ฅธ ์ง€์—ญ์—์„œ ๋™์‹œ์— ์š”์ฒญ์„ ๋ณด๋‚ด์˜จ๋‹ค๋ฉด ์—ฃ์ง€์— ๋™๊ธฐํ™”๋˜๊ธฐ ์ „๊นŒ์ง€ ์š”์ฒญ ์ œํ•œ์„ ์ดˆ๊ณผํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ€๋Šฅ์„ฑ๋„ ์กด์žฌํ•œ๋‹ค.

 

๋น„๋™๊ธฐ ๋ฐฉ์‹์—์„œ RatelimitResponse ๊ฐ์ฒด์˜ remaining ๊ฐ’์€ ์ •ํ™•ํ•˜์ง€ ์•Š์„ ์ˆ˜๋„ ์žˆ์œผ๋ฏ€๋กœ, success ์†์„ฑ ๊ฐ’์„ ํ†ตํ•ด ์š”์ฒญ ํ—ˆ์šฉ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜๋Š” ๊ฒŒ ์ข‹๋‹ค. (์ฐธ๊ณ  ๋งํฌ)

 

Unkey ํŒ€์€ ์—„๊ฒฉํ•œ ์ •ํ™•๋„๋ฅผ ์š”๊ตฌํ•˜๋Š” ์ƒํ™ฉ์„ ์ œ์™ธํ•˜๊ณค ๋น„๋™๊ธฐ ๋ชจ๋“œ ์‚ฌ์šฉ์„ ๊ถŒ์žฅํ•˜๊ณ  ์žˆ๋‹ค.

 

Override

Override ๊ธฐ๋Šฅ์„ ์ด์šฉํ•˜๋ฉด ์ฝ”๋“œ ๋ณ€๊ฒฝ ์—†์ด ํŠน์ • ์กฐ๊ฑด(์ด๋ฉ”์ผ, ๋„๋ฉ”์ธ ๋“ฑ)์— ๋”ฐ๋ผ Rate Limit์„ ์œ ์—ฐํ•˜๊ฒŒ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. ์‹๋ณ„์ž์—๋Š” ์™€์ผ๋“œ์นด๋“œ(*)๋ฅผ ํ™œ์šฉํ•œ ํŒจํ„ด ๋งค์นญ์„ ์ง€์›ํ•œ๋‹ค. ์˜ˆ) *@sales.com, *.abc.com

๋Œ€์‹œ๋ณด๋“œ - Ratelimit ๋ฉ”๋‰ด - ์šฐ์ธก ์ƒ๋‹จ Override Identifer ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ์˜ค๋ฒ„๋ผ์ด๋“œ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

์‹๋ณ„์ž์— ์ด๋ฏธ Rate Limit์„ ์ ์šฉํ–ˆ๋”๋ผ๋„ Override ์„ค์ •์ด ์ด๋ฅผ ๋ฎ์–ด์“ด๋‹ค. ์—ฌ๋Ÿฌ Override ๊ทœ์น™์ด ์žˆ์„ ๋• ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ๊ทœ์น™์ด ์™€์ผ๋“œ์นด๋“œ ๊ทœ์น™๋ณด๋‹ค ๋†’์€ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๊ฐ€์ง„๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด *@abc.com, sales@abc.com์ด ํ•จ๊ป˜ ์„ค์ •๋œ ๊ฒฝ์šฐ, ๋” ๊ตฌ์ฒด์ ์ธ ๊ทœ์น™์ธ sales@abc.com์ด ์šฐ์„ ์ ์œผ๋กœ ์ ์šฉ๋œ๋‹ค.

 

Override ๋ณ€๊ฒฝ์‚ฌํ•ญ์€ 60์ดˆ ์ด๋‚ด์— ์ „ ์„ธ๊ณ„ ์—ฃ์ง€ ๋กœ์ผ€์ด์…˜์— ์ ์šฉ๋œ๋‹ค. ์ฝ”๋“œ ์ˆ˜์ • → ๊ฒ€ํ†  → ๋ฐฐํฌ ๊ณผ์ • ์—†์ด ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ํ†ตํ•ด ๊ทœ์น™์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์–ด์„œ ์šด์˜ ํšจ์œจ์„ฑ์ด ํฌ๊ฒŒ ํ–ฅ์ƒ๋œ๋‹ค.

 

 

API Keys (๋ผ์šฐํŠธ ๋ณดํ˜ธ)


๊ธฐ๋ณธ ์„ค์ •

โถ Unkey ๊ณ„์ • ์ƒ์„ฑ  

 

โท Unkey ๋Œ€์‹œ๋ณด๋“œ์—์„œ API ์ƒ์„ฑ

 

โธ UNKEY_API_ID ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ถ”๊ฐ€

# .env.local
UNKEY_API_ID="..."

 

โน @unkey/nextjs ํŒจํ‚ค์ง€ ์„ค์น˜

pnpm add @unkey/nextjs

 

โบ ๋ผ์šฐํŠธ ๋ณดํ˜ธ ๋กœ์ง ์ถ”๊ฐ€. ๊ธฐ์กด Next.js ๋ผ์šฐํŠธ๋ฅผ withUnkey ํ•จ์ˆ˜๋กœ ๊ฐ์‹ธ๋ฉด ์š”์ฒญ์„ ๋ฐ›์„ ๋•Œ๋งˆ๋‹ค ๋‚ด๋ถ€์ ์œผ๋กœ API ํ‚ค๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค. ํ‚ค ๊ฒ€์ฆ์„ ์„ฑ๊ณตํ•˜๋ฉด ๋ผ์šฐํŠธ ๋ณธ๋ฌธ์ด ์‹คํ–‰๋˜๊ณ , ์‹คํŒจํ•˜๋ฉด handleInvalidKey ๋ฉ”์„œ๋“œ๊ฐ€ ์‹คํ–‰๋œ๋‹ค.

// app/api/subtask/route.ts
import { withUnkey } from '@unkey/nextjs';

export const POST = withUnkey(
  async (req) => {
    /* API ํ‚ค ๊ฒ€์ฆ ์„ฑ๊ณต์‹œ ๋ผ์šฐํŠธ ๋ณธ๋ฌธ ์‹คํ–‰ */
  },
  {
    // Unkey ์„œ๋น„์Šค์˜ ์„ฑ๋Šฅ ์ธก์ • ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ๋น„ํ™œ์„ฑํ™” (๊ธฐ๋ณธ๊ฐ’ false)
    disableTelemetry: true,
    // ๊ฒ€์ฆ ๋Œ€์ƒ API ํ‚ค๊ฐ€ ์†ํ•œ API ID
    apiId: process.env.UNKEY_API_ID,
    // API ํ‚ค ๊ฒ€์ฆ ์‹คํŒจ ์‹œ ์—๋Ÿฌ ์‘๋‹ต ์ปค์Šคํ…€
    handleInvalidKey(_req, res) {
      console.error('API key validation failed:', res?.code);
      return new Response('Unauthorized', { status: 401 });
    },
  },
);

 

โป ์ƒˆ๋กœ์šด API Key๋ฅผ ์ƒ์„ฑํ•˜๊ณ  Next.js ๋ผ์šฐํŠธ๋กœ ์š”์ฒญ ํ…Œ์ŠคํŠธ๋ฅผ ๋ณด๋‚ด๋ณธ๋‹ค.

curl -XPOST 'http://localhost:3000/api/<PATH>' \
  -H "Authorization: Bearer <YOUR_KEY>"

 

Next.js ๋ฏธ๋“ค์›จ์–ด ์„ค์ •

๐Ÿ’ก Next.js ๋ฏธ๋“ค์›จ์–ด๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ Edge ๋Ÿฐํƒ€์ž„์—์„œ ์‹คํ–‰๋œ๋‹ค. Edge ๋Ÿฐํƒ€์ž„์—์„  ์š”์ฒญ๋งˆ๋‹ค ๋…๋ฆฝ์ ์ธ ์‹คํ–‰ ํ™˜๊ฒฝ์„ ๊ฐ€์ง„๋‹ค(์š”์ฒญ๋งˆ๋‹ค ๋ณ„๋„ ์ธ์Šคํ„ด์Šค).

 

๋งŒ์•ฝ ํšŒ์›๊ฐ€์ž…/๋กœ๊ทธ์ธ์ด ์—†๋Š” ๊ฐ„๋‹จํ•œ ํ”„๋กœ์ ํŠธ๋ผ๋ฉด ๋งŒ๋ฃŒ์ผ์ด ์ง€์ •๋œ API Key๋ฅผ ์ฟ ํ‚ค์— ์ €์žฅํ•ด ๋‘๊ณ , ์š”์ฒญ๋ฐ›์„ ๋•Œ๋งˆ๋‹ค Next.js ๋ฏธ๋“ค์›จ์–ด์—์„œ ์ฟ ํ‚ค๋ฅผ ์กฐํšŒํ•˜์—ฌ Authorization ํ—ค๋”์— ์ถ”๊ฐ€ํ•  ์ˆ˜๋„ ์žˆ๋‹ค. ์ฐธ๊ณ ๋กœ withUnkey ํ•จ์ˆ˜๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ Authorization ํ—ค๋”์—์„œ API Key๋ฅผ ์ฐพ๋Š”๋‹ค. ์›ํ•œ๋‹ค๋ฉด getKey ํ•จ์ˆ˜๋ฅผ ์ด์šฉํ•ด์„œ API Key ์กฐํšŒ ๋กœ์ง์„ ์ปค์Šคํ…€ํ•  ์ˆ˜๋„ ์žˆ๋‹ค.

 

  1. ํด๋ผ์ด์–ธํŠธ ์š”์ฒญ
  2. Next.js ๋ฏธ๋“ค์›จ์–ด - ์ฟ ํ‚ค์—์„œ API Key ํ™•์ธ.
    • ์ฟ ํ‚ค์— API Key ์—†์œผ๋ฉด ์ƒ์„ฑ ํ›„ ์‘๋‹ต ์ฟ ํ‚ค ์„ค์ •
    • ์š”์ฒญ ํ—ค๋”์— Authorization ํ•„๋“œ ์ถ”๊ฐ€
  3. Next.js ๋ผ์šฐํŠธ์—์„œ API Key ๊ฒ€์ฆ ํ›„ ์‘๋‹ต

 

 

๋ฏธ๋“ค์›จ์–ด ํ•จ์ˆ˜๋กœ ์ „๋‹ฌ๋˜๋Š” Request ๊ฐ์ฒด๋Š” ์ฝ๊ธฐ ์ „์šฉ์ด๋ฏ€๋กœ ์š”์ฒญ ํ—ค๋”๋ฅผ ์ˆ˜์ •ํ•˜๋ ค๋ฉด ํ—ค๋” ๋ณต์ œ/์ˆ˜์ • → ์‘๋‹ต ๊ฐ์ฒด์˜ ํ—ค๋”๋ฅผ ๋ฎ์–ด์“ฐ๋Š” ์ž‘์—…์ด ํ•„์š”ํ•˜๋‹ค. NextResponse.next()๋ฅผ ์ด์šฉํ•˜์—ฌ ์š”์ฒญ ํ—ค๋”๋ฅผ ์ˆ˜์ •ํ•˜๋ฉด, ์ˆ˜์ •๋œ ํ—ค๋”๊ฐ€ API ๋ผ์šฐํŠธ ํ•ธ๋“ค๋Ÿฌ์˜ Request ๊ฐ์ฒด์— ๋ฐ˜์˜๋œ๋‹ค. response.cookie.set()์„ ํ†ตํ•ด ์‘๋‹ต ์ฟ ํ‚ค๋ฅผ ์„ค์ •ํ•˜๋ฉด Set-Cookie ํ—ค๋”๊ฐ€ ์ถ”๊ฐ€๋˜์–ด ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ดํ›„ ์š”์ฒญ์—์„œ ํ•ด๋‹น ์ฟ ํ‚ค๋ฅผ ์ž๋™์œผ๋กœ ํฌํ•จ์‹œํ‚จ๋‹ค.

// src/middleware.ts
import { type NextRequest, NextResponse } from 'next/server';
import { retrieveSubtaskUnkey, setUnkeySessionCookie } from '@/lib';

export async function middleware(req: NextRequest) {
  // ...

  // ์ฟ ํ‚ค์—์„œ API Key๋ฅผ ํ™•์ธํ•˜๊ณ  ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑํ•˜๋Š” ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜
  const { unkeyValue, isNewKey } = await retrieveSubtaskUnkey(req);
  // ์š”์ฒญ ํ—ค๋” ๋ณต์ œ
  const headers = new Headers(req.headers);
  // ๋ณต์ œํ•œ ์š”์ฒญ ํ—ค๋”์— Authorization ํ•„๋“œ ์ถ”๊ฐ€
  headers.set('Authorization', `Bearer ${unkeyValue}`);
  // ๋ณต์ œํ•œ ํ—ค๋”๋ฅผ ํฌํ•จํ•œ ์š”์ฒญ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ๋‹ค์Œ ์ฒ˜๋ฆฌ ๋‹จ๊ณ„๋กœ ์ „๋‹ฌ
  const response = NextResponse.next({ request: { headers } });
  // API Key๋ฅผ ์ƒˆ๋กœ ์ƒ์„ฑํ–ˆ๋‹ค๋ฉด ์‘๋‹ต ์ฟ ํ‚ค์— ์ €์žฅ(๋‹ค์Œ ๋ฒˆ ์š”์ฒญ ๋•Œ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด)
  if (isNewKey && unkeyValue) setUnkeySessionCookie(response, unkeyValue);

  // ...
  return response;
}

// '/api' ๊ฒฝ๋กœ ์ดํ•˜์˜ ๋ชจ๋“  ์š”์ฒญ์— ๋ฏธ๋“ค์›จ์–ด ์ ์šฉ
export const config = { matcher: ['/api/:path*'] };

 

Next.js๋Š” ํ”„๋กœ์ ํŠธ๋‹น 1๊ฐœ์˜ middleware.ts ํŒŒ์ผ๋งŒ ํ—ˆ์šฉํ•œ๋‹ค. ๋•Œ๋ฌธ์— ์ฃผ์š” ๋กœ์ง์€ ์•„๋ž˜์ฒ˜๋Ÿผ ๋ณ„๋„์˜ ๋ชจ๋“ˆ๋กœ ๊ตฌ์„ฑํ•œ ๋’ค ๋ฏธ๋“ค์›จ์–ด์—์„œ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐฉ์‹์„ ๊ถŒ์žฅํ•œ๋‹ค. ์ฟ ํ‚ค๋ฅผ ์„ค์ •ํ•  ๋• httpOnly, secure, sameSite ์˜ต์…˜์„ ์ง€์ •ํ•˜๋Š” ๊ฒƒ์ด ๋ณด์•ˆ์ƒ ์œ ๋ฆฌํ•˜๋‹ค. API ํ‚ค ์ƒ์„ฑ์€ @unkey/api ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•œ ํ›„, unkey.keys.create() ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๋œ๋‹ค. ์ด๋•Œ ๋งŒ๋ฃŒ ์‹œ๊ฐ„(expires), ์š”์ฒญ ์ œํ•œ(ratelimit), remaining ์ดˆ๊ธฐํ™”(refill), ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(meta) ๋“ฑ์„ ์œ ์—ฐํ•˜๊ฒŒ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

// lib/unkey.ts
export const UNKEY_COOKIE_NAME = 'unkey_session';
export const UNKEY_EXPIRY_HOURS = 72;
export const UNKEY_SUBTASK_LIMIT = 30;
export const UNKEY_NAMESPACE = { SUBTASK: 'kanban.subtask' } as const;

// ์ฟ ํ‚ค์— API Key๋ฅผ ์„ค์ •ํ•˜๋Š” ํ•จ์ˆ˜
export const setUnkeySessionCookie = (
  response: NextResponse,
  unkeyValue: string,
) => {
  response.cookies.set({
    name: UNKEY_COOKIE_NAME,
    value: unkeyValue,
    httpOnly: true, // ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๋กœ ์ฟ ํ‚ค ์ ‘๊ทผ ์ œํ•œ(document.cookie)
    secure: !isDev(), // HTTPS ์—ฐ๊ฒฐ์—์„œ๋งŒ ์ฟ ํ‚ค ์ „์†ก
    maxAge: 60 * 60 * UNKEY_EXPIRY_HOURS, // ์ฟ ํ‚ค ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (์ดˆ ๋‹จ์œ„)
    sameSite: 'strict', // ๋™์ผ ์‚ฌ์ดํŠธ ์š”์ฒญ์—์„œ๋งŒ ์ฟ ํ‚ค ์ „์†ก
    path: '/api', // /api ๊ฒฝ๋กœ์—์„œ๋งŒ ์ฟ ํ‚ค ์ „์†ก
  });
};

// ์ฟ ํ‚ค์—์„œ API Key๋ฅผ ํ™•์ธํ•˜๊ณ  ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜
export const retrieveSubtaskUnkey = async (req: NextRequest) => {
  let isNewKey = false;
  let unkeyValue = req.cookies.get(UNKEY_COOKIE_NAME)?.value ?? null;

  if (!unkeyValue) {
    isNewKey = true;
    const clientInfo = getClientInfo(req);
    unkeyValue = await createUnkey(clientInfo);
  }

  return { unkeyValue, isNewKey };
};

// Unkey API ํ‚ค๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜.
export async function createUnkey(meta: ClientInfo) {
  const unkey = new Unkey({
    rootKey: getEnv('UNKEY_ROOT_KEY'),
    disableTelemetry: true,
  });

  try {
    const ownerId = nanoid(10);
    const { result, error } = await unkey.keys.create({
      apiId: getEnv('UNKEY_API_ID'),
      prefix: UNKEY_NAMESPACE.SUBTASK, // ํ‚ค์— ์ถ”๊ฐ€๋  ์ ‘๋‘์‚ฌ
      ownerId: ownerId, // ์œ ์ € ์‹๋ณ„์„ ์œ„ํ•œ ID
      name: meta.realIp ?? meta.ip ?? 'unknown',
      meta: { createdAt: new Date().toISOString(), ...meta },
      expires: addHours(new Date(), UNKEY_EXPIRY_HOURS).getTime(),
      ratelimit: { duration: 1000, limit: 2, async: true }, // 1์ดˆ๊ฐ„ 2๋ฒˆ ์š”์ฒญ ํ—ˆ์šฉ
      remaining: UNKEY_SUBTASK_LIMIT,
      refill: { interval: 'daily', amount: UNKEY_SUBTASK_LIMIT }, // ์ž์ •๋งˆ๋‹ค amount ๋งŒํผ remaining ๋ฆฌ์…‹
      enabled: true,
    });

    if (error) {
      console.error(error.message);
      return null;
    }

    console.log(`Created new Unkey key for user ${ownerId}`);
    return result.key;
  } catch (error) {
    console.error('Failed to create Unkey key', error);
    return null;
  }
}

 

์ฐธ๊ณ ๋กœ Next.js์—์„œ ์ฟ ํ‚ค๋ฅผ ์ฝ๊ณ (read) ์“ธ ์ˆ˜ ์žˆ๋Š”(write) cookies()๋ผ๋Š” ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋„ ์ œ๊ณตํ•œ๋‹ค. ์ฟ ํ‚ค ํŠน์„ฑ์ƒ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„  ์ฝ๊ธฐ๋งŒ ๊ฐ€๋Šฅํ•˜๊ณ , ์„œ๋ฒ„ ์•ก์…˜์ด๋‚˜ ๋ผ์šฐํŠธ ํ•ธ๋“ค๋Ÿฌ์—์„  ์ฝ๊ธฐ/์“ฐ๊ธฐ๋ฅผ ๋ชจ๋‘ ์ง€์›ํ•œ๋‹ค.

 

๋ฉ”ํƒ€๋ฐ์ดํ„ฐ

๐Ÿ’ก ํ”„๋ก์‹œ(์ค‘๊ฐ„์—์„œ ์š”์ฒญ์„ ์ „๋‹ฌํ•˜๋Š” ์„œ๋ฒ„)๋ฅผ ์„œ๋น„์Šค ์•ž์— ๋ฐฐ์น˜ํ•˜๋ฉด ์‹ค์ œ ์‚ฌ์šฉ์ž IP ์‹๋ณ„์ด ์–ด๋ ค์›Œ์ง„๋‹ค. ๊ทธ๋ž˜์„œ X-Forwarded-For ํ—ค๋”๋ฅผ ํ†ตํ•ด ์›๋ณธ ์‚ฌ์šฉ์ž IP๋ฅผ ์ „๋‹ฌํ•œ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ Vercel์€ IP ์Šคํ‘ธํ•‘ ๊ณต๊ฒฉ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ์™ธ๋ถ€์—์„œ ์ „์†ก๋œ X-Forwarded-For ํ—ค๋” ๊ฐ’์„ ๋ฌด์‹œํ•˜๊ณ , ํด๋ผ์ด์–ธํŠธ์˜ ์‹ค์ œ IP ์ฃผ์†Œ๋กœ ๋ฎ์–ด์“ด๋‹ค. IP ์Šคํ‘ธํ•‘(Spoofing)์ด๋ž€ ๊ณต๊ฒฉ์ž๊ฐ€ ๋„คํŠธ์›Œํฌ ์ƒ์—์„œ ์ž์‹ ์˜ ์‹ค์ œ IP ์ฃผ์†Œ๋ฅผ ์€ํํ•˜๊ณ  ๋‹ค๋ฅธ IP ์ฃผ์†Œ๋กœ ์œ„์žฅํ•˜์—ฌ ํ†ต์‹ ํ•˜๋Š” ๊ธฐ๋ฒ•์ด๋‹ค.

 

ํ”„๋กœ์ ํŠธ๋ฅผ Vercel์— ๋ฐฐํฌํ–ˆ๋‹ค๋ฉด ๋™์  ์š”์ฒญ(Dynamic Request)์€ Vercel Function(์„œ๋ฒ„๋ฆฌ์Šค ํ•จ์ˆ˜)์„ ํ†ตํ•ด ์ฒ˜๋ฆฌ๋˜๋ฉฐ, ์ด๋•Œ Request ๊ฐ์ฒด๋ฅผ ํ†ตํ•ด ๋‹ค์–‘ํ•œ ํ—ค๋” ์ •๋ณด๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ๋‹ค. Unkey API Key๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ์ด๋Ÿฌํ•œ ํ—ค๋” ์ •๋ณด๋ฅผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์— ์ €์žฅํ•ด ๋‘๋ฉด ์ดํ›„ ๋กœ๊ทธ๋ฅผ ๋ถ„์„ํ•˜๊ฑฐ๋‚˜ ํ†ต๊ณ„๋ฅผ ์ˆ˜์ง‘ํ•  ๋•Œ ์œ ์šฉํ•˜๊ฒŒ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

// lib/utils.ts
import { type NextRequest, userAgent } from 'next/server';

export const getClientInfo = (req: NextRequest) => {
  // Next.js์—์„œ ์ œ๊ณตํ•˜๋Š” userAgent ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ OS, ๊ธฐ๊ธฐ ๋“ฑ ์ •๋ณด ์ถ”์ถœ
  const { browser, os, device, isBot } = userAgent(req);
  // ์ถ”์ถœํ•œ ์ •๋ณด๋ฅผ agent ๊ฐ์ฒด์— ์ €์žฅ
  const agent = { browser, os, device, isBot };
  // ํด๋ผ์ด์–ธํŠธ์˜ ์›๋ž˜ IP ์ฃผ์†Œ์™€ ์š”์ฒญ์ด ๊ฑฐ์ณ์˜จ ํ”„๋ก์‹œ ์„œ๋ฒ„๋“ค์˜ IP ์ฃผ์†Œ๋ฅผ ์ถ”์ ํ•˜๋Š” ํ‘œ์ค€ ํ—ค๋”(์ฝค๋งˆ๋กœ ๊ตฌ๋ถ„)
  const ip = req.headers.get('x-forwarded-for');
  // ํด๋ผ์ด์–ธํŠธ์˜ ์‹ค์ œ IP ์ฃผ์†Œ
  const realIp = req.headers.get('x-real-ip');
  // ํด๋ผ์ด์–ธํŠธ์˜ ๊ตญ๊ฐ€ ์ •๋ณด ์˜ˆ) KR
  const country = req.headers.get('x-vercel-ip-country');
  // ํด๋ผ์ด์–ธํŠธ์˜ ๋„์‹œ ์ •๋ณด ์˜ˆ) Seocho-gu
  const city = req.headers.get('x-vercel-ip-city');
  // ๋ฆฌํผ๋Ÿฌ ์ •๋ณด
  const referrer =
    req.headers.get('referer')?.replace(/https?:\/\/([^/]+).*/i, '$1') ??
    'direct';

  return { agent, ip, realIp, country, city, referrer };
};

 

Next.js์˜ userAgent ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋Š” HTTP ์š”์ฒญ ํ—ค๋”์˜ User-Agent ๋ฌธ์ž์—ด์„ ๋ถ„์„ํ•ด์„œ isBot, os, browser ๊ฐ™์€ ๋‹ค์–‘ํ•œ ํด๋ผ์ด์–ธํŠธ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.

 

 

์ „์ฒด ์š”์ฒญ ์ œํ•œ

๋ผ์šฐํŠธ์— ๋‹จ์ผ API Key๋ฅผ ์„ค์ •ํ•˜์—ฌ ์ „์ฒด ์š”์ฒญ ํšŸ์ˆ˜(remaining)๋ฅผ ํ†ตํ•ฉ ๊ด€๋ฆฌํ•˜๊ณ , Ratelimit์„ ํ†ตํ•ด ๊ฐ๊ฐ์˜ ์‚ฌ์šฉ๋Ÿ‰์„ ์ œํ•œํ•˜๋ฉด API Key ์ˆ˜์ค€์—์„œ ์ด์‚ฌ์šฉ๋Ÿ‰์„ ์ œ์–ดํ•˜๋ฉด์„œ ๊ฐœ๋ณ„ ์‹๋ณ„์ž์˜ ๊ณผ๋„ํ•œ ์š”์ฒญ๋„ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ๊ฐœ๋ณ„ ์‹๋ณ„์ž์˜ ์š”์ฒญ ํšŸ์ˆ˜๊ฐ€ ์•„์ง ๋‚จ์•„ ์žˆ๋”๋ผ๋„ API Key์˜ ์ „์ฒด ์š”์ฒญ ํšŸ์ˆ˜๋ฅผ ๋ชจ๋‘ ์†Œ์ง„ํ–ˆ๋‹ค๋ฉด ์š”์ฒญ์ด ์ฐจ๋‹จ๋œ๋‹ค.

 

withUnkey๋ฅผ ํ†ตํ•ด API Key์˜ ๋‚จ์€ ์š”์ฒญ ํšŸ์ˆ˜๋ฅผ ๊ฒ€์‚ฌํ•œ ํ›„, ๊ฐœ๋ณ„ ์‹๋ณ„์ž์˜ Ratelimit์„ ๊ฒ€์‚ฌํ•˜๋Š” ์˜ˆ์‹œ.

// app/api/subtask/route.ts
// API Key์˜ ๋‚จ์€ ์š”์ฒญ ํšŸ์ˆ˜ ๊ฒ€์‚ฌ
export const POST = withUnkey(
  async (req) => {
    const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
    const rateLimit = await limiter.limit(ip);
    // ๊ฐœ๋ณ„ ์‹๋ณ„์ž์˜ ๋‚จ์€ ์š”์ฒญ ํšŸ์ˆ˜ ๊ฒ€์‚ฌ
    if (!rateLimit.success) {
      return new Response('Rate limit exceeded', { status: 429 });
    }

    // ์š”์ฒญ ์ฒ˜๋ฆฌ ๊ณ„์†...
  },
  {
    // withUnkey ์˜ต์…˜...
  },
);
๋ฐ˜์‘ํ˜•