[Next.js] API ๋ผ์ฐํธ ๋ณดํธํ๊ธฐ - Unkey
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 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
์๋ณ์์ ์ด๋ฏธ Rate Limit์ ์ ์ฉํ๋๋ผ๋ Override ์ค์ ์ด ์ด๋ฅผ ๋ฎ์ด์ด๋ค. ์ฌ๋ฌ Override ๊ท์น์ด ์์ ๋ ์ ํํ ์ผ์นํ๋ ๊ท์น์ด ์์ผ๋์นด๋ ๊ท์น๋ณด๋ค ๋์ ์ฐ์ ์์๋ฅผ ๊ฐ์ง๋ค. ์๋ฅผ ๋ค์ด *@abc.com
, sales@abc.com
์ด ํจ๊ป ์ค์ ๋ ๊ฒฝ์ฐ, ๋ ๊ตฌ์ฒด์ ์ธ ๊ท์น์ธ sales@abc.com
์ด ์ฐ์ ์ ์ผ๋ก ์ ์ฉ๋๋ค.
Override ๋ณ๊ฒฝ์ฌํญ์ 60์ด ์ด๋ด์ ์ ์ธ๊ณ ์ฃ์ง ๋ก์ผ์ด์ ์ ์ ์ฉ๋๋ค. ์ฝ๋ ์์ → ๊ฒํ → ๋ฐฐํฌ ๊ณผ์ ์์ด ๋์๋ณด๋๋ฅผ ํตํด ๊ท์น์ ์์ ํ ์ ์์ด์ ์ด์ ํจ์จ์ฑ์ด ํฌ๊ฒ ํฅ์๋๋ค.
API Keys (๋ผ์ฐํธ ๋ณดํธ)
๊ธฐ๋ณธ ์ค์
โท 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 ์กฐํ ๋ก์ง์ ์ปค์คํ
ํ ์๋ ์๋ค.
- ํด๋ผ์ด์ธํธ ์์ฒญ
- Next.js ๋ฏธ๋ค์จ์ด - ์ฟ ํค์์ API Key ํ์ธ.
- ์ฟ ํค์ API Key ์์ผ๋ฉด ์์ฑ ํ ์๋ต ์ฟ ํค ์ค์
- ์์ฒญ ํค๋์ Authorization ํ๋ ์ถ๊ฐ
- 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 ์ต์
...
},
);
'๐ช Programming' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[HTTP] Cache-Control ํค๋ (0) | 2025.03.31 |
---|---|
[Next.js] dnd-kit์ ํ์ฉํ ์นธ๋ฐ(Kanban) ๋ณด๋ ๋๋๊ทธ ์ค ๋๋กญ ๊ตฌํ (0) | 2025.03.18 |
[UI] Shadcn DropdownMenu์์ Dialog ์๋ ๋ซํ ๋ฌธ์ ํด๊ฒฐ (0) | 2025.03.09 |
[CSS] :focus, :focus-visible ์ฐจ์ด์ (1) | 2025.03.06 |
[JS] ์๋ฐ์คํฌ๋ฆฝํธ ๋๋ค ์์(Random Color) ์์ฑํ๊ธฐ (0) | 2025.03.04 |
๋๊ธ
์ด ๊ธ ๊ณต์ ํ๊ธฐ
-
๊ตฌ๋
ํ๊ธฐ
๊ตฌ๋ ํ๊ธฐ
-
์นด์นด์คํก
์นด์นด์คํก
-
๋ผ์ธ
๋ผ์ธ
-
ํธ์ํฐ
ํธ์ํฐ
-
Facebook
Facebook
-
์นด์นด์ค์คํ ๋ฆฌ
์นด์นด์ค์คํ ๋ฆฌ
-
๋ฐด๋
๋ฐด๋
-
๋ค์ด๋ฒ ๋ธ๋ก๊ทธ
๋ค์ด๋ฒ ๋ธ๋ก๊ทธ
-
Pocket
Pocket
-
Evernote
Evernote
๋ค๋ฅธ ๊ธ
-
[HTTP] Cache-Control ํค๋
[HTTP] Cache-Control ํค๋
2025.03.31 -
[Next.js] dnd-kit์ ํ์ฉํ ์นธ๋ฐ(Kanban) ๋ณด๋ ๋๋๊ทธ ์ค ๋๋กญ ๊ตฌํ
[Next.js] dnd-kit์ ํ์ฉํ ์นธ๋ฐ(Kanban) ๋ณด๋ ๋๋๊ทธ ์ค ๋๋กญ ๊ตฌํ
2025.03.18 -
[UI] Shadcn DropdownMenu์์ Dialog ์๋ ๋ซํ ๋ฌธ์ ํด๊ฒฐ
[UI] Shadcn DropdownMenu์์ Dialog ์๋ ๋ซํ ๋ฌธ์ ํด๊ฒฐ
2025.03.09 -
[CSS] :focus, :focus-visible ์ฐจ์ด์
[CSS] :focus, :focus-visible ์ฐจ์ด์
2025.03.06