diff --git a/deploy/k8s/overlays/prod/kustomization.yaml b/deploy/k8s/overlays/prod/kustomization.yaml
index 0e4cb92..f0dea25 100644
--- a/deploy/k8s/overlays/prod/kustomization.yaml
+++ b/deploy/k8s/overlays/prod/kustomization.yaml
@@ -13,7 +13,7 @@ commonLabels:
# 이미지 태그 설정
images:
- name: ghcr.io/mayne0213/todo
- newTag: main-sha-be3027d173376dd5e5522c1e4379d72b9c4aa51e
+ newTag: main-sha-40d453dfd169d1ccefbfbaf774f50645500ace32
patchesStrategicMerge:
- deployment-patch.yaml
diff --git a/services/nextjs/app/api/auth/password/route.ts b/services/nextjs/app/api/auth/password/route.ts
new file mode 100644
index 0000000..8cb9958
--- /dev/null
+++ b/services/nextjs/app/api/auth/password/route.ts
@@ -0,0 +1,84 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { corsHeaders, handleCorsPreFlight } from '@/shared/lib/cors'
+
+const PASSWORD = '5364'
+const SESSION_COOKIE_NAME = 'todo-auth-session'
+
+// OPTIONS - CORS preflight 처리
+export async function OPTIONS() {
+ return handleCorsPreFlight()
+}
+
+// POST - 비밀번호 인증
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+ const { password } = body
+
+ if (!password) {
+ const response = NextResponse.json(
+ { success: false, error: '비밀번호를 입력해주세요' },
+ { status: 400 }
+ )
+ Object.entries(corsHeaders()).forEach(([key, value]) => {
+ response.headers.set(key, value)
+ })
+ return response
+ }
+
+ if (password === PASSWORD) {
+ // 인증 성공 - 세션 쿠키 설정
+ const response = NextResponse.json(
+ { success: true, message: '인증 성공' },
+ { status: 200 }
+ )
+
+ // HttpOnly 쿠키로 세션 저장 (보안 강화)
+ response.cookies.set(SESSION_COOKIE_NAME, 'authenticated', {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24, // 24시간
+ path: '/',
+ })
+
+ Object.entries(corsHeaders()).forEach(([key, value]) => {
+ response.headers.set(key, value)
+ })
+ return response
+ } else {
+ const response = NextResponse.json(
+ { success: false, error: '비밀번호가 일치하지 않습니다' },
+ { status: 401 }
+ )
+ Object.entries(corsHeaders()).forEach(([key, value]) => {
+ response.headers.set(key, value)
+ })
+ return response
+ }
+ } catch (error) {
+ console.error('Password auth error:', error)
+ const response = NextResponse.json(
+ { success: false, error: '서버 오류가 발생했습니다' },
+ { status: 500 }
+ )
+ Object.entries(corsHeaders()).forEach(([key, value]) => {
+ response.headers.set(key, value)
+ })
+ return response
+ }
+}
+
+// GET - 인증 상태 확인
+export async function GET(request: NextRequest) {
+ const session = request.cookies.get(SESSION_COOKIE_NAME)?.value
+
+ const response = NextResponse.json(
+ { success: true, authenticated: session === 'authenticated' },
+ { status: 200 }
+ )
+ Object.entries(corsHeaders()).forEach(([key, value]) => {
+ response.headers.set(key, value)
+ })
+ return response
+}
diff --git a/services/nextjs/app/page.tsx b/services/nextjs/app/page.tsx
index 2c1be76..110fe9a 100644
--- a/services/nextjs/app/page.tsx
+++ b/services/nextjs/app/page.tsx
@@ -1,6 +1,11 @@
import { TodoApp } from '@/src/widgets/todo-app'
+import PasswordModal from '@/src/features/password-auth/ui/PasswordModal'
export default function Home() {
- return
+ return (
+
+
+
+ )
}
diff --git a/services/nextjs/middleware.ts b/services/nextjs/middleware.ts
new file mode 100644
index 0000000..22c000f
--- /dev/null
+++ b/services/nextjs/middleware.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { isAuthenticated, createUnauthorizedResponse } from '@/shared/lib/auth'
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl
+
+ // 공개 경로 (인증 불필요)
+ const publicPaths = [
+ '/api/auth/password', // 비밀번호 인증 API
+ '/api/health', // 헬스체크
+ ]
+
+ // 공개 경로는 통과
+ if (publicPaths.some(path => pathname.startsWith(path))) {
+ return NextResponse.next()
+ }
+
+ // API 라우트 보호
+ if (pathname.startsWith('/api/')) {
+ if (!isAuthenticated(request)) {
+ return createUnauthorizedResponse()
+ }
+ return NextResponse.next()
+ }
+
+ // 페이지 라우트 보호
+ if (pathname === '/' || pathname.startsWith('/app')) {
+ if (!isAuthenticated(request)) {
+ // 인증되지 않은 경우, 클라이언트에서 처리하도록 그대로 진행
+ // (PasswordModal이 표시됨)
+ return NextResponse.next()
+ }
+ return NextResponse.next()
+ }
+
+ return NextResponse.next()
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public folder
+ */
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
+ ],
+}
+
diff --git a/services/nextjs/src/features/password-auth/index.ts b/services/nextjs/src/features/password-auth/index.ts
new file mode 100644
index 0000000..0683994
--- /dev/null
+++ b/services/nextjs/src/features/password-auth/index.ts
@@ -0,0 +1,2 @@
+export { default as PasswordModal } from './ui/PasswordModal'
+
diff --git a/services/nextjs/src/features/password-auth/ui/PasswordModal.tsx b/services/nextjs/src/features/password-auth/ui/PasswordModal.tsx
new file mode 100644
index 0000000..19bb930
--- /dev/null
+++ b/services/nextjs/src/features/password-auth/ui/PasswordModal.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import { useState, ReactNode, useEffect } from 'react'
+import { Input, Button } from '../../../shared/ui'
+
+interface PasswordModalProps {
+ children: ReactNode
+}
+
+export default function PasswordModal({ children }: PasswordModalProps) {
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [isAuthenticated, setIsAuthenticated] = useState(false)
+ const [isChecking, setIsChecking] = useState(true)
+
+ // 컴포넌트 마운트 시 인증 상태 확인
+ useEffect(() => {
+ checkAuthStatus()
+ }, [])
+
+ const checkAuthStatus = async () => {
+ try {
+ const response = await fetch('/api/auth/password', {
+ method: 'GET',
+ credentials: 'include',
+ })
+ const data = await response.json()
+
+ if (data.success && data.authenticated) {
+ setIsAuthenticated(true)
+ }
+ } catch (error) {
+ console.error('인증 상태 확인 실패:', error)
+ } finally {
+ setIsChecking(false)
+ }
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+
+ try {
+ const response = await fetch('/api/auth/password', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ password }),
+ })
+
+ const data = await response.json()
+
+ if (data.success) {
+ setIsAuthenticated(true)
+ setPassword('')
+ } else {
+ setError(data.error || '비밀번호가 일치하지 않습니다.')
+ setPassword('')
+ }
+ } catch (error) {
+ console.error('인증 요청 실패:', error)
+ setError('서버 오류가 발생했습니다. 다시 시도해주세요.')
+ setPassword('')
+ }
+ }
+
+ // 인증 상태 확인 중일 때 로딩 표시
+ if (isChecking) {
+ return (
+
+ )
+ }
+
+ if (isAuthenticated) {
+ return <>{children}>
+ }
+
+ return (
+
+
+
+ 비밀번호 입력
+
+
+ Todo 앱에 접근하려면 비밀번호를 입력하세요.
+
+
+
+
+ )
+}
+
diff --git a/services/nextjs/src/shared/lib/auth.ts b/services/nextjs/src/shared/lib/auth.ts
new file mode 100644
index 0000000..e8c1942
--- /dev/null
+++ b/services/nextjs/src/shared/lib/auth.ts
@@ -0,0 +1,27 @@
+import { NextRequest } from 'next/server'
+
+export const SESSION_COOKIE_NAME = 'todo-auth-session'
+
+/**
+ * 요청이 인증되었는지 확인
+ */
+export function isAuthenticated(request: NextRequest): boolean {
+ const session = request.cookies.get(SESSION_COOKIE_NAME)?.value
+ return session === 'authenticated'
+}
+
+/**
+ * 인증되지 않은 요청에 대한 응답 생성
+ */
+export function createUnauthorizedResponse() {
+ return new Response(
+ JSON.stringify({ success: false, error: '인증이 필요합니다' }),
+ {
+ status: 401,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ )
+}
+