diff --git a/package-lock.json b/package-lock.json index 1c7ea5e..e5fa27d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@prisma/client": "^6.17.0", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-devtools": "^5.90.2", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", @@ -1801,6 +1803,59 @@ "tailwindcss": "4.1.14" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", + "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", + "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", + "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.90.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.2", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.12", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", diff --git a/package.json b/package.json index b691428..1a06fd8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ }, "dependencies": { "@prisma/client": "^6.17.0", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-devtools": "^5.90.2", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/src/app/QueryProvider.tsx b/src/app/QueryProvider.tsx new file mode 100644 index 0000000..242d63c --- /dev/null +++ b/src/app/QueryProvider.tsx @@ -0,0 +1,16 @@ +"use client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useState } from "react"; + +export default function QueryProvider({ children }: { children: React.ReactNode }) { + const [client] = useState(() => new QueryClient()); + return ( + + {children} + + + ); +} + + diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 47e5e21..0bd7f46 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -2,8 +2,13 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { loginSchema } from "@/lib/validation/auth"; import { verifyPassword } from "@/lib/password"; +import { getClientKey, isRateLimited } from "@/lib/ratelimit"; export async function POST(req: Request) { + const key = getClientKey(req, "login"); + if (isRateLimited(key, 5, 60_000)) { + return NextResponse.json({ error: "Too Many Requests" }, { status: 429 }); + } const body = await req.json(); const parsed = loginSchema.safeParse(body); if (!parsed.success) diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index c6b8bab..3179d91 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -2,8 +2,13 @@ import { NextResponse } from "next/server"; import { loginSchema } from "@/lib/validation/auth"; import prisma from "@/lib/prisma"; import { verifyPassword } from "@/lib/password"; +import { getClientKey, isRateLimited } from "@/lib/ratelimit"; export async function POST(req: Request) { + const key = getClientKey(req, "login"); + if (isRateLimited(key, 5, 60_000)) { + return NextResponse.json({ error: "Too Many Requests" }, { status: 429 }); + } const body = await req.json(); const parsed = loginSchema.safeParse(body); if (!parsed.success) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c2d092b..6c3bcf8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import QueryProvider from "@/app/QueryProvider"; export const metadata: Metadata = { @@ -14,8 +15,10 @@ export default function RootLayout({ }>) { return ( - - {children} + + + {children} + ); diff --git a/src/lib/ratelimit.ts b/src/lib/ratelimit.ts new file mode 100644 index 0000000..0442412 --- /dev/null +++ b/src/lib/ratelimit.ts @@ -0,0 +1,23 @@ +type Key = string; + +const bucket: Map = new Map(); + +export function getClientKey(req: Request, extra?: string): string { + const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "local"; + return extra ? `${ip}:${extra}` : ip; +} + +export function isRateLimited(key: string, max: number, windowMs: number): boolean { + const now = Date.now(); + const windowStart = now - windowMs; + const arr = bucket.get(key)?.filter((t) => t >= windowStart) ?? []; + if (arr.length >= max) { + bucket.set(key, arr); // cleanup + return true; + } + arr.push(now); + bucket.set(key, arr); + return false; +} + + diff --git a/todolist.txt b/todolist.txt index 7efabef..68aa784 100644 --- a/todolist.txt +++ b/todolist.txt @@ -18,10 +18,10 @@ 3.3 세션/쿠키(HttpOnly/SameSite/Secure) 및 토큰 저장 전략 o 3.4 비밀번호 재설정 토큰 발급/검증/만료 o 3.5 보호 라우팅 미들웨어 및 인증 가드 o -3.6 로그인 시도 레이트리밋(정책은 보안/정책 참조) +3.6 로그인 시도 레이트리밋(정책은 보안/정책 참조) o [상태관리/데이터] -4.1 React Query 설치 및 Provider 구성 +4.1 React Query 설치 및 Provider 구성 o 4.2 공통 fetcher/에러 형식/재시도·백오프 설정 4.3 캐시 키/무효화 전략 수립 및 적용 4.4 낙관적 업데이트 패턴 적용(작성/수정)