Server Action의 이점과 API Routes와의 차이
Server Action은 기존의 API Routes와 비교했을 때 여러 장점을 제공합니다. API Routes가 별도의 엔드포인트를 관리해야 했던 것과 달리, Server Action은 컴포넌트와 더 가깝게 위치하여 개발 경험을 향상시킵니다.
Server Action의 기본 사용법
Server Action을 사용하기 위해서는 'use server' 지시어를 함수 상단이나 파일 상단에 추가해야 합니다. 이 지시어는 해당 함수나 파일 내의 모든 내보낸 함수가 서버에서 실행됨을 명시합니다.
// 방법 1: 파일 상단에 'use server' 지시어 추가 'use server' export async function updateData(formData) { // 서버에서 실행되는 로직 } // 방법 2: 함수 본문 상단에 'use server' 지시어 추가 export async function createItem(data) { 'use server' // 서버에서 실행되는 로직 }
Server Action과 API Routes의 차이점
Server Action과 API Routes의 주요 차이점은 다음과 같습니다.
첫째, Server Action은 별도의 Wrapper가 불필요합니다. API Routes를 사용할 때는 fetch 함수로 감싸야 했지만, Server Action은 직접 함수를 호출하는 방식으로 사용합니다.
// API Routes 방식 // 1. API 라우트 정의 (/api/updateUser.js) export async function POST(request) { try { // 요청 본문 파싱 const body = await request.json(); // 데이터 처리 로직 const result = await updateUserInDB(body); return Response.json({ success: true, data: result }); } catch (error) { return Response.json( { success: false, error: error.message }, { status: 500 } ); } } // 2. 클라이언트 컴포넌트에서 API 호출 'use client' import { useState } from 'react'; export default function UserForm() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); async function handleSubmit(e) { e.preventDefault(); setLoading(true); try { const response = await fetch('/api/updateUser', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }), }); const data = await response.json(); setResult(data); } catch (error) { console.error('Error:', error); } finally { setLoading(false); } } return ( <form onSubmit={handleSubmit}> <div> <label htmlFor="name">이름:</label> <input id="name" type="text" value={name} onChange={(e) => setName(e.target.value)} required /> </div> <div> <label htmlFor="email">이메일:</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <button type="submit" disabled={loading}> {loading ? '제출 중...' : '제출'} </button> {result && <p>결과: {JSON.stringify(result)}</p>} </form> ); }
// Server Action 방식 // 1. Server Action 정의 'use server' export async function updateUser(formData) { // FormData 객체에서 값 추출 const name = formData.get('name'); const email = formData.get('email'); // 데이터 처리 로직 const result = await updateUserInDB({ name, email }); return result; } // 2. 클라이언트 컴포넌트에서 사용 'use client' import { updateUser } from './actions'; import { useFormStatus } from 'react-dom'; // 제출 버튼 컴포넌트 (로딩 상태 표시용) function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? '제출 중...' : '제출'} </button> ); } export default function UserForm() { return ( <form action={updateUser}> <div> <label htmlFor="name">이름:</label> <input id="name" name="name" type="text" required /> </div> <div> <label htmlFor="email">이메일:</label> <input id="email" name="email" type="email" required /> </div> <SubmitButton /> </form> ); }
둘째, Form Action을 기본적으로 지원합니다. HTML form 요소에 직접 Server Action을 연결할 수 있어 개발이 간편해집니다.
Form Action 지원의 주요 장점:
- 프로그레시브 인핸스먼트(Progressive Enhancement) 지원: JavaScript가 비활성화되어 있거나 로드되지 않은 상황에서도 폼이 작동합니다. 이는 사용자 경험을 크게 향상시키며, 앱의 안정성을 높입니다.
- 상태 관리 간소화: 폼 제출 상태(로딩, 성공, 오류)를 React의 useFormStatus 훅을 통해 쉽게 관리할 수 있습니다.
- 코드 감소: 기존 방식에서 필요했던 onSubmit 핸들러, preventDefault(), fetch 요청 등의 코드가 불필요해져 코드량이 크게 줄어듭니다.
셋째, Server Action은 항상 POST 요청으로 처리됩니다. 이는 보안과 일관성을 위한 설계입니다.
POST 요청으로만 처리하는 주요 보안 이점:
- CSRF 보호: POST 요청은 최신 브라우저에서 기본으로 설정된 SameSite 쿠키 정책과 함께 대부분의 CSRF(Cross-Site Request Forgery) 취약점으로부터 보호됩니다.
- 출처 검증: Next.js는 추가적인 보호 계층으로 Origin 헤더와 Host 헤더(또는 X-Forwarded-Host)를 비교하여 일치하지 않으면 요청을 중단합니다. 이는 Server Action이 호스팅된 페이지와 동일한 호스트에서만 호출될 수 있음을 의미합니다.
Server Action Security
Next.js의 Server Action은 RSC Payload를 통해 직렬화 하여 Clinet로 전송됩니다. 이로 인해 발생하는 다양한 보안 문제를 다음과 같이 해결합니다.
다양한 내부 보안 메커니즘을 통해 CSRF(Cross-Site Request Forgery) 공격으로부터 보호합니다.
Server Action의 CSRF 보호 내부 구현
- POST 메서드 강제: Server Action은 항상 POST 메서드로만 호출되도록 내부적으로 강제됩니다. API Routes에서는 개발자가 HTTP 메서드를 선택할 수 있지만, Server Action에서는 POST만 허용됩니다.
- 자동화된 출처 헤더 검증: Next.js는 추가적인 보호 계층으로 Origin 헤더와 Host 헤더(또는 X-Forwarded-Host)를 자동으로 비교합니다. API Routes에서는 이러한 검증을 개발자가 직접 구현해야 하지만, Server Action에서는 프레임워크 차원에서 기본 제공됩니다.
- 클로저 변수 암호화: Server Action이 컴포넌트 내부에 정의되면 외부 스코프의 변수에 접근할 수 있습니다(클로저). 이 변수들은 클라이언트로 전송되어야 하므로, Next.js는 민감한 데이터 노출을 방지하기 위해 자동으로 암호화합니다. 각 빌드마다 새로운 프라이빗 키가 생성되며, 이는 특정 빌드에서만 액션이 호출될 수 있음을 의미합니다.
export default function Page() { const publishVersion = await getLatestVersion(); // 클로저 변수 async function publish() { 'use server' // publishVersion이 암호화되어 클라이언트로 전송됨 if (publishVersion !== await getLatestVersion()) { throw new Error('버전이 변경되었습니다'); } } return <button action={publish}>Publish</button>; }
- 비결정적 ID 생성: Next.js는 Server Action을 위해 암호화된 비결정적(non-deterministic) ID를 생성하여 클라이언트가 Server Action을 참조하고 호출할 수 있게 합니다. 이러한 ID는 보안 강화를 위해 빌드 간에 주기적으로 재계산됩니다.
- 서명을 통한 무결성 보장: 암호화된 클로저 변수는 추가로 서명되어 공격자가 입력값을 조작하기 어렵게 만듭니다. 이는 데이터의 무결성을 보장하고 변조를 방지합니다.
보안 고려사항
- .bind() 사용 시 주의: JavaScript의
.bind()
메서드를 사용한 경우 암호화가 적용되지 않습니다. 이는 성능을 위한 의도적인 설계이지만 보안상 주의가 필요합니다.
// 위험한 예시 async function deleteUser(userId) { 'use server' await deleteUserFromDB(userId); // 검증 없이 바로 삭제 } const boundDelete = deleteUser.bind(null, currentUserId);
- 멀티 서버 환경(로드 밸런서를 사용하는 분산 서버 시스템 등): 여러 서버에서 Next.js를 호스팅하는 경우, 각 서버 인스턴스가 다른 암호화 키를 가질 수 있습니다. 일관성을 위해
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
환경 변수를 사용하여 모든 서버가 동일한 키를 사용하도록 설정할 수 있습니다.
SSR과 TanStack Query 활용하기
Provider 설정하기
TanStack Query를 사용하기 위해서는 QueryClientProvider를 설정해야 합니다. 클라이언트 환경에서는 새로운 QueryClient를 생성하고, 서버 환경에서는 한 번만 생성되도록 하는 것이 중요합니다.
// providers.tsx 파일 'use client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' export default function Providers({ children }) { const [queryClient] = useState(() => new QueryClient()) return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) }
App Router와 Pages Router에서의 사용법
Next.js의 App Router와 Pages Router 모두에서 TanStack Query를 사용할 수 있습니다. 두 방식 모두 hydration과 dehydration 과정이 필요하지만, 구현 방법에 차이가 있습니다.
1. App Router에서 사용법
App Router에서는
<QueryClientProvider>
가 initialData와 <Hydrate>
prefetching 방식 모두에 필요합니다.방법 1: initialData 사용
// app/page.tsx - 서버 컴포넌트에서 데이터 가져오기 import Posts from './posts' export default async function Page() { // 서버에서 데이터 가져오기 const initialPosts = await getPosts() return <Posts initialPosts={initialPosts} /> } // app/posts.tsx - 클라이언트 컴포넌트에서 useQuery 사용 'use client' import { useQuery } from '@tanstack/react-query' export default function Posts({ initialPosts }) { const { data: posts } = useQuery({ queryKey: ['posts'], queryFn: getPosts, initialData: initialPosts, // 서버에서 가져온 데이터를 초기값으로 사용 }) return ( <div> {posts.map(post => ( <div key={post.id}>{post.title}</div> ))} </div> ) }
방법 2: Hydration 사용
// app/layout.tsx - Provider 설정 import Providers from './providers' export default function RootLayout({ children }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> ) } // app/providers.tsx 'use client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' export default function Providers({ children }) { const [queryClient] = useState(() => new QueryClient()) return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) } // 서버 컴포넌트에서 데이터 prefetch import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' export default async function Page() { const queryClient = new QueryClient() // 서버에서 데이터 prefetch await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts, }) // 직렬화된 dehydratedState 생성 const dehydratedState = dehydrate(queryClient) return ( <HydrationBoundary state={dehydratedState}> <Posts /> </HydrationBoundary> ) }
2. Pages Router에서 사용법
Pages Router에서는 getServerSideProps나 getStaticProps와 함께 사용할 수 있습니다:
// pages/_app.js - Provider 설정 import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Hydrate } from '@tanstack/react-query' import { useState } from 'react' export default function MyApp({ Component, pageProps }) { const [queryClient] = useState(() => new QueryClient()) return ( <QueryClientProvider client={queryClient}> <Hydrate state={pageProps.dehydratedState}> <Component {...pageProps} /> </Hydrate> </QueryClientProvider> ) } // pages/index.js import { dehydrate, QueryClient } from '@tanstack/react-query' export async function getServerSideProps() { const queryClient = new QueryClient() // 서버에서 데이터 prefetch await queryClient.prefetchQuery(['posts'], getPosts) return { props: { // 직렬화된 dehydratedState를 props로 전달 dehydratedState: dehydrate(queryClient), }, } } export default function HomePage() { return ( <div> <Posts /> </div> ) }
결론
Next.js에서의 데이터 페칭은 Server Actions와 TanStack Query를 통해 더욱 강력하고 효율적으로 이루어질 수 있습니다. Server Actions는 서버와 클라이언트 간의 경계를 더욱 자연스럽게 만들어주며, TanStack Query는 복잡한 상태 관리 로직을 단순화해줍니다. 이 두 기술을 적절히 활용하면 사용자 경험을 향상시키는 동시에 개발자 경험도 개선할 수 있습니다.