diff --git a/backend/package-lock.json b/backend/package-lock.json
index 5d36156..575cabd 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -12,6 +12,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
+ "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.0.0",
"@fastify/swagger": "^9.0.0",
"@scalar/fastify-api-reference": "^1.25.0",
@@ -1151,6 +1152,27 @@
"ipaddr.js": "^2.1.0"
}
},
+ "node_modules/@fastify/rate-limit": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
+ "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "fastify-plugin": "^5.0.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
diff --git a/backend/package.json b/backend/package.json
index b9764d2..fa71e5c 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -11,6 +11,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
+ "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.0.0",
"@fastify/swagger": "^9.0.0",
"@scalar/fastify-api-reference": "^1.25.0",
diff --git a/backend/src/app.js b/backend/src/app.js
index b9c3277..9479267 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -6,6 +6,7 @@ import fastifyCors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import fastifySwagger from '@fastify/swagger';
import multipart from '@fastify/multipart';
+import rateLimit from '@fastify/rate-limit';
import config from './config/index.js';
import * as schemas from './schemas/index.js';
@@ -50,6 +51,11 @@ export async function buildApp(opts = {}) {
},
});
+ // rate-limit 플러그인 등록 (특정 라우트에만 적용)
+ await fastify.register(rateLimit, {
+ global: false,
+ });
+
// 플러그인 등록 (순서 중요)
await fastify.register(dbPlugin);
await fastify.register(redisPlugin);
diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js
index 9280f6e..1fb6b49 100644
--- a/backend/src/routes/albums/index.js
+++ b/backend/src/routes/albums/index.js
@@ -183,7 +183,11 @@ export default async function albumsRoutes(fastify) {
if (part.type === 'file' && part.fieldname === 'cover') {
coverBuffer = await part.toBuffer();
} else if (part.fieldname === 'data') {
- data = JSON.parse(part.value);
+ try {
+ data = JSON.parse(part.value);
+ } catch {
+ return badRequest(reply, '잘못된 JSON 형식입니다.');
+ }
}
}
@@ -230,7 +234,11 @@ export default async function albumsRoutes(fastify) {
if (part.type === 'file' && part.fieldname === 'cover') {
coverBuffer = await part.toBuffer();
} else if (part.fieldname === 'data') {
- data = JSON.parse(part.value);
+ try {
+ data = JSON.parse(part.value);
+ } catch {
+ return badRequest(reply, '잘못된 JSON 형식입니다.');
+ }
}
}
diff --git a/backend/src/routes/albums/photos.js b/backend/src/routes/albums/photos.js
index 11ce1c3..3f6464a 100644
--- a/backend/src/routes/albums/photos.js
+++ b/backend/src/routes/albums/photos.js
@@ -97,7 +97,13 @@ export default async function photosRoutes(fastify) {
const buffer = await part.toBuffer();
files.push({ buffer, mimetype: part.mimetype });
} else if (part.fieldname === 'metadata') {
- metadata = JSON.parse(part.value);
+ try {
+ metadata = JSON.parse(part.value);
+ } catch {
+ reply.raw.write(`data: ${JSON.stringify({ error: '잘못된 metadata JSON 형식입니다.' })}\n\n`);
+ reply.raw.end();
+ return;
+ }
} else if (part.fieldname === 'startNumber') {
startNumber = parseInt(part.value) || null;
} else if (part.fieldname === 'photoType') {
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index 8078868..94c1ef3 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -11,6 +11,19 @@ export default async function authRoutes(fastify, opts) {
* 관리자 로그인
*/
fastify.post('/login', {
+ config: {
+ rateLimit: {
+ max: 5,
+ timeWindow: '1 minute',
+ continueExceeding: true, // 차단 중 시도하면 타이머 리셋 (마지막 시도 기준 1분)
+ keyGenerator: (request) => request.ip,
+ errorResponseBuilder: () => ({
+ statusCode: 429,
+ error: 'Too Many Requests',
+ message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.',
+ }),
+ },
+ },
schema: {
tags: ['auth'],
summary: '관리자 로그인',
@@ -37,6 +50,14 @@ export default async function authRoutes(fastify, opts) {
},
},
},
+ 429: {
+ type: 'object',
+ properties: {
+ statusCode: { type: 'integer' },
+ error: { type: 'string' },
+ message: { type: 'string' },
+ },
+ },
},
},
}, async (request, reply) => {
diff --git a/docs/improvements.md b/docs/improvements.md
index 05a2905..cd68831 100644
--- a/docs/improvements.md
+++ b/docs/improvements.md
@@ -539,8 +539,8 @@ await writeFile(dictPath, content, 'utf-8');
| 이슈 | 우선순위 | 상태 |
|------|---------|------|
-| 로그인 Rate Limit | Medium | ⬜ 미해결 |
-| Multipart JSON 파싱 | Medium | ⬜ 미해결 |
+| 로그인 Rate Limit | Medium | ✅ 해결됨 |
+| Multipart JSON 파싱 | Medium | ✅ 해결됨 |
## Phase 3: 외부 서비스 안정성
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7b7f7c3..715d8c0 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
-import { BrowserRouter } from 'react-router-dom';
+import { BrowserRouter, useLocation } from 'react-router-dom';
import { BrowserView, MobileView } from 'react-device-detect';
// 공통 컴포넌트
@@ -8,6 +8,9 @@ import { ScrollToTop } from '@/components/common';
// 라우트
import { PCPublicRoutes, PCAdminRoutes, MobileRoutes } from '@/routes';
+// 스토어
+import { useAuthStore } from '@/stores';
+
/**
* PC 환경에서 body에 클래스 추가하는 래퍼
*/
@@ -19,6 +22,26 @@ function PCWrapper({ children }) {
return children;
}
+/**
+ * PC 라우트 - admin 경로일 때만 AdminRoutes 렌더링
+ */
+function PCRoutes() {
+ const location = useLocation();
+ const isAdminPath = location.pathname.startsWith('/admin');
+ const { _hasHydrated } = useAuthStore();
+
+ // admin 경로에서 hydration 완료 전까지 빈 화면
+ if (isAdminPath && !_hasHydrated) {
+ return null;
+ }
+
+ return (
+
+ {isAdminPath ? : }
+
+ );
+}
+
/**
* 프로미스나인 팬사이트 메인 앱
* react-device-detect를 사용한 PC/Mobile 분리
@@ -30,10 +53,7 @@ function App() {
{/* PC 뷰 */}
-
-
-
-
+
{/* Mobile 뷰 */}
diff --git a/frontend/src/components/pc/admin/layout/Header.jsx b/frontend/src/components/pc/admin/layout/Header.jsx
index acdbfec..7681e09 100644
--- a/frontend/src/components/pc/admin/layout/Header.jsx
+++ b/frontend/src/components/pc/admin/layout/Header.jsx
@@ -3,15 +3,17 @@
* 모든 Admin 페이지에서 공통으로 사용하는 헤더
* 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함
*/
-import { Link } from 'react-router-dom';
+import { Link, useNavigate } from 'react-router-dom';
import { LogOut } from 'lucide-react';
import { useAuthStore } from '@/stores';
function AdminHeader({ user }) {
+ const navigate = useNavigate();
const logout = useAuthStore((state) => state.logout);
const handleLogout = () => {
logout();
+ navigate('/admin', { replace: true });
};
return (
diff --git a/frontend/src/components/pc/admin/layout/Layout.jsx b/frontend/src/components/pc/admin/layout/Layout.jsx
index 1ca9ccd..63059b5 100644
--- a/frontend/src/components/pc/admin/layout/Layout.jsx
+++ b/frontend/src/components/pc/admin/layout/Layout.jsx
@@ -4,10 +4,17 @@
* 헤더 고정 + 본문 스크롤 구조
*/
import { useLocation } from 'react-router-dom';
+import { useAuthStore } from '@/stores';
import Header from './Header';
function AdminLayout({ user, children }) {
const location = useLocation();
+ const { token } = useAuthStore();
+
+ // 토큰이 없으면 아무것도 렌더링하지 않음 (useAdminAuth에서 리다이렉트 처리)
+ if (!token) {
+ return null;
+ }
// 일정 목록 페이지만 내부 스크롤 처리 (하위 페이지는 레이아웃 스크롤 사용)
const isSchedulePage = location.pathname === '/admin/schedule';
diff --git a/frontend/src/hooks/pc/admin/useAdminAuth.js b/frontend/src/hooks/pc/admin/useAdminAuth.js
index 356c995..f048155 100644
--- a/frontend/src/hooks/pc/admin/useAdminAuth.js
+++ b/frontend/src/hooks/pc/admin/useAdminAuth.js
@@ -29,14 +29,14 @@ export function useAdminAuth(options = {}) {
staleTime: 1000 * 60 * 5, // 5분 캐시
});
- // 토큰 없거나 검증 실패 시 처리
- // 리다이렉트는 DOM 조작이므로 useEffect 사용 허용
+ // 토큰 검증 실패 시 (토큰 만료 등) 로그아웃 후 리다이렉트
+ // 참고: 토큰이 없는 경우는 라우트 가드(RequireAuth)에서 처리
useEffect(() => {
- if (required && (!token || isError)) {
+ if (required && isError) {
logoutRef.current();
- navigate(redirectTo);
+ navigate(redirectTo, { replace: true });
}
- }, [token, isError, required, navigate, redirectTo]);
+ }, [isError, required, navigate, redirectTo]);
return {
user: data?.user || user,
@@ -64,10 +64,12 @@ export function useRedirectIfAuthenticated(redirectTo = '/admin/dashboard') {
});
useEffect(() => {
- if (data?.valid) {
+ // token이 있고 검증이 성공한 경우에만 리다이렉트
+ // 로그아웃 시 캐시된 data가 남아있어도 token이 없으면 리다이렉트하지 않음
+ if (token && data?.valid) {
navigate(redirectTo);
}
- }, [data, navigate, redirectTo]);
+ }, [token, data, navigate, redirectTo]);
return {
isLoading: !!token && isLoading,
diff --git a/frontend/src/routes/pc/admin/index.jsx b/frontend/src/routes/pc/admin/index.jsx
index e18f321..afee464 100644
--- a/frontend/src/routes/pc/admin/index.jsx
+++ b/frontend/src/routes/pc/admin/index.jsx
@@ -1,7 +1,27 @@
-import { Routes, Route } from 'react-router-dom';
+import { Routes, Route, Navigate } from 'react-router-dom';
+import { useAuthStore } from '@/stores';
// 관리자 페이지
import AdminLogin from '@/pages/pc/admin/login/Login';
+
+/**
+ * 인증 필수 라우트 가드
+ * token이 없으면 로그인 페이지로 즉시 리다이렉트
+ */
+function RequireAuth({ children }) {
+ const { token, _hasHydrated } = useAuthStore();
+
+ // Hydration 완료 전에는 아무것도 렌더링하지 않음
+ if (!_hasHydrated) {
+ return null;
+ }
+
+ if (!token) {
+ return ;
+ }
+
+ return children;
+}
import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard';
import AdminMembers from '@/pages/pc/admin/members/Members';
import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit';
@@ -24,21 +44,21 @@ export default function AdminRoutes() {
return (
} />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
} />
);
diff --git a/frontend/src/stores/useAuthStore.js b/frontend/src/stores/useAuthStore.js
index db68731..f46ac0c 100644
--- a/frontend/src/stores/useAuthStore.js
+++ b/frontend/src/stores/useAuthStore.js
@@ -12,6 +12,12 @@ const useAuthStore = create(
token: null,
user: null,
isAuthenticated: false,
+ _hasHydrated: false,
+
+ // Hydration 완료 설정
+ setHasHydrated: (state) => {
+ set({ _hasHydrated: state });
+ },
// 로그인
login: (token, user) => {
@@ -38,6 +44,9 @@ const useAuthStore = create(
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
+ onRehydrateStorage: () => (state) => {
+ state?.setHasHydrated(true);
+ },
}
)
);