React 19 Actions API · `use` 훅 · React Compiler — useEffect 보일러플레이트를 절반으로 줄이는 폼·데이터 페칭 실전 가이드
이 글의 대상 독자: React 기본 훅(
useState,useEffect)을 사용해본 경험이 있는 분을 대상으로 합니다.
React로 개발을 해왔다면, 데이터를 불러오는 코드가 얼마나 판에 박힌 모양을 반복하는지 잘 알고 있을 것입니다. useState 세 개(데이터·로딩·에러), useEffect 안의 비동기 처리, cleanup 함수, 의존성 배열 관리... 이 패턴은 기능적으로는 완전하지만, 핵심 로직보다 상태 관리 코드가 더 많아지는 아이러니를 낳았습니다.
React 19는 이 구조적 반복을 프레임워크 수준에서 해결합니다. Actions API, use 훅, React Compiler — 이 세 가지는 각각의 역할이 있지만 함께 설계된 하나의 패러다임으로, 폼 처리와 데이터 페칭 코드를 눈에 띄게 간결하게 만들어줍니다.
기존 useEffect 기반 패턴을 완전히 대체하는 것이 목표가 아닙니다. 새로운 API가 명확히 유리한 상황이 있고, 여전히 기존 방식이 적합한 상황도 있습니다. 이 글에서는 세 기둥이 실제 코드에서 어떻게 작동하는지 살펴보고, 마지막에 "언제 새 API를, 언제 기존 useEffect를 선택할 것인가"에 대한 판단 기준도 정리합니다.
React 19 버전 정보
React 19는 2024년 12월 stable로 출시되었으며, 이후 2025년 10월 19.2까지 발전했습니다. React Compiler는 2025년 10월 v1.0 stable을 기준으로 설명합니다.
핵심 개념
React Compiler — 메모이제이션을 직접 작성하지 않아도 되는 이유
React Compiler는 빌드 타임에 컴포넌트 코드를 정적 분석해 useMemo, useCallback, React.memo를 자동으로 삽입하는 컴파일러입니다. Next.js 16에서는 next.config.ts에 한 줄로 활성화할 수 있습니다.
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
export default nextConfig;컴파일러는 컴포넌트가 "Rules of React"(순수 함수, 훅 규칙 등)를 준수하는 경우에만 최적화를 적용합니다. 위반 코드가 있는 컴포넌트는 해당 컴포넌트만 건너뛰고 나머지를 계속 컴파일하므로, 기존 코드베이스에 점진적으로 도입할 수 있습니다.
핵심: Compiler가 도입되었다고 해서
useMemo/useCallback이 완전히 사라지는 것은 아닙니다. 서드파티 라이브러리와 복잡하게 결합된 컴포넌트, Rules of React를 위반하는 레거시 코드에서는 여전히 수동 메모이제이션이 필요합니다.
Actions API와 useTransition — 폼과 비동기 변이의 새로운 패러다임
기존 React에서 폼 제출을 처리하려면 onSubmit 핸들러에서 직접 상태를 관리해야 했습니다. React 19는 <form action={asyncFn}> 형태로 비동기 함수를 HTML 요소의 action prop에 직접 전달하는 패턴을 도입했습니다.
Actions API는 내부적으로 useTransition을 사용합니다. useTransition은 UI를 차단하지 않고 비동기 상태 전환을 관리하는 하위 레벨 원시 API로, Actions가 바로 이 위에 구축되어 있습니다. 폼 없이 직접 비동기 작업을 Transition으로 감싸야 할 때는 useTransition을 직접 활용할 수 있습니다.
// useTransition으로 직접 비동기 액션을 제어하는 경우
const [isPending, startTransition] = useTransition();
function handleUpdate() {
startTransition(async () => {
await updateSomething();
// 완료 후 상태 업데이트
});
}Actions API와 함께 설계된 관련 훅 세 가지입니다.
| 훅 | 반환값 | 역할 |
|---|---|---|
useActionState |
[state, dispatch, isPending] |
이전 상태 + formData를 받아 다음 상태를 반환하는 비동기 함수를 캡슐화 |
useFormStatus |
{ pending, data, method, action } |
폼 트리 내부 컴포넌트에서 상위 폼의 pending 상태를 prop drilling 없이 읽기 |
useOptimistic |
[optimisticState, addOptimistic] |
서버 응답 전 낙관적 UI를 즉시 표시, 응답 후 자동 롤백 |
use 훅 — 렌더 타임의 Promise와 Context 읽기
use는 기존 훅과 근본적으로 다른 두 가지 특성을 가집니다.
- Promise를 직접 인자로 받아 해당 컴포넌트를 suspend 시킬 수 있습니다.
- 조건문이나 반복문 안에서도 호출이 가능합니다 (기존 훅 규칙 예외).
아래는 기존 패턴과 use 훅을 사용한 패턴의 비교입니다.
// 기존 패턴 — useState 3개 + useEffect
function UserProfile({ id }: { id: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(id)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage />;
return <div>{user.name}</div>;
}// React 19 패턴 — use() + Suspense + ErrorBoundary
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Promise가 resolve될 때까지 suspend
return <div>{user.name}</div>;
}
function App({ id }: { id: string }) {
// 중요: Promise는 렌더마다 재생성되면 안 됩니다.
// useMemo로 메모이제이션해 id가 바뀔 때만 새 Promise를 생성합니다.
const userPromise = useMemo(() => fetchUser(id), [id]);
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorMessage />}>
<UserProfile userPromise={userPromise} />
</ErrorBoundary>
</Suspense>
);
}Suspense: React의 선언적 로딩 상태 처리 메커니즘입니다.
<Suspense fallback={...}>하위 트리에서 어떤 컴포넌트가 Promise를 기다리는 동안, React는 자동으로 해당 트리를 fallback UI로 대체합니다.use훅은 이 Suspense 메커니즘과 직접 연결됩니다.
ErrorBoundary:
use(promise)패턴에서 Promise가 reject되면 가장 가까운 ErrorBoundary가 에러를 잡아 fallback UI를 표시합니다.react-error-boundary라이브러리를 활용하면 함수형으로 사용할 수 있습니다.
실전 적용
세 가지 핵심 개념이 실제 코드에서 어떻게 조합되는지, 네 가지 시나리오를 통해 살펴보겠습니다.
예시 1: Server Action + useActionState로 폼 처리하기
Next.js App Router 환경에서 사용자 이름 수정 폼을 구현하는 예시입니다. Server Action이 유효성 검사, DB 업데이트, 캐시 무효화를 담당하고, 클라이언트는 useActionState로 상태를 관리합니다.
// app/actions.ts — Server Action
'use server'
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { getSession } from '@/lib/auth'; // 사용 중인 auth 라이브러리에 맞게 교체
const schema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
});
type State = {
success: boolean;
name?: string;
error?: string;
};
export async function updateUsername(
prevState: State | null,
formData: FormData
): Promise<State> {
// schema.safeParse: Zod 유효성 검사 결과.
// result.success가 false면 result.error에 오류 정보가 담깁니다.
const result = schema.safeParse({ name: formData.get('name') });
if (!result.success) {
return { success: false, error: result.error.errors[0].message };
}
const session = await getSession(); // 세션에서 현재 사용자 ID를 가져옵니다
await db.user.update({
where: { id: session.userId },
data: { name: result.data.name },
});
revalidatePath('/profile');
return { success: true, name: result.data.name };
}// app/profile/page.tsx — Client Component
'use client'
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom'; // react가 아닌 react-dom에서 임포트
import { updateUsername } from '../actions';
// useFormStatus는 상위 <form>의 자식 컴포넌트 안에서만 동작합니다.
// SubmitButton을 별도 컴포넌트로 분리하는 이유가 바로 여기에 있습니다.
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '저장 중...' : '저장'}
</button>
);
}
export default function ProfileForm() {
const [state, dispatch, isPending] = useActionState(updateUsername, null);
return (
<form action={dispatch}>
<input
name="name"
defaultValue={state?.name}
aria-invalid={!!state?.error}
/>
{state?.error && <p role="alert">{state.error}</p>}
{state?.success && <p>저장되었습니다!</p>}
<SubmitButton />
</form>
);
}| 코드 포인트 | 설명 |
|---|---|
useActionState(updateUsername, null) |
Server Action을 감싸 [state, dispatch, isPending]을 반환 |
<form action={dispatch}> |
네이티브 form submit이 dispatch를 호출 |
useFormStatus — react-dom 임포트 |
react가 아닌 react-dom에서 가져와야 함에 주의 |
SubmitButton 분리 |
상위 폼의 pending 상태를 읽으려면 별도 컴포넌트로 분리 필수 |
prevState 파라미터 |
Server Action의 첫 번째 인자로 이전 상태가 자동 주입됨 |
예시 2: useOptimistic으로 즉각 반응하는 좋아요 버튼
서버 응답을 기다리지 않고 UI를 먼저 업데이트하고, 실패 시 자동으로 원래 상태로 롤백하는 패턴입니다. 자동 롤백이 되더라도 사용자에게 오류 사실을 알리는 처리도 함께 포함합니다.
import { useOptimistic, useState } from 'react';
function LikeButton({ post }: { post: Post }) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(currentLikes: number, increment: number) => currentLikes + increment
);
async function handleLike() {
setErrorMessage(null);
addOptimisticLike(1); // 즉시 UI 반영
try {
await likePost(post.id); // 서버 요청
} catch {
// useOptimistic은 이 액션이 완료(또는 실패)되면 자동으로 서버 값으로 롤백됩니다.
// 자동 롤백 외에, 사용자에게 실패를 알리는 메시지를 별도로 표시하는 것이 좋습니다.
setErrorMessage('좋아요 처리 중 오류가 발생했습니다. 다시 시도해주세요.');
}
}
return (
<div>
<button onClick={handleLike} aria-label={`좋아요 ${optimisticLikes}개`}>
♥ {optimisticLikes}
</button>
{errorMessage && (
<p role="alert" style={{ fontSize: '0.875rem', color: '#ef4444' }}>
{errorMessage}
</p>
)}
</div>
);
}낙관적 업데이트(Optimistic Update): 서버 요청의 성공을 미리 가정하고 UI를 먼저 변경하는 UX 패턴입니다.
useOptimistic은 pending 상태에서는 낙관적 값을 사용하고, Action이 완료되면 자동으로 실제 서버 값으로 교체합니다. 자동 롤백이 되더라도, 실패 사실을 사용자에게 별도로 알려주는 에러 표시 로직이 필요합니다.
예시 3: use 훅과 조건부 Context 읽기
기존 useContext와 달리 use(Context)는 조건문 안에서 호출할 수 있습니다. 조건에 따라 Context 값이 필요 없는 경우, 조건을 먼저 평가해 불필요한 구독을 건너뛸 수 있습니다.
import { createContext, use } from 'react';
const ThemeContext = createContext<Theme | null>(null);
function ThemeIcon({ showTheme }: { showTheme: boolean }) {
// 기존 useContext라면 이 조건문 전에 훅을 호출해야 합니다.
// use()는 조건문 안에서 호출할 수 있어, showTheme이 false일 때 Context 구독을 건너뜁니다.
if (!showTheme) return null;
const theme = use(ThemeContext);
if (!theme) return null;
return <Icon color={theme.primary} />;
}이 특성은 Context 구독 자체가 조건부인 컴포넌트에서 가장 의미가 있습니다. 기존 useContext를 사용했다면 조건과 상관없이 항상 훅을 최상단에서 호출해야 했지만, use는 실제로 필요한 경우에만 구독할 수 있어 렌더링 로직을 더 자연스럽게 표현할 수 있습니다.
예시 4: 하이브리드 패턴 — Server Component + TanStack Query
사전 지식 안내: 이 예시는 Next.js App Router와 TanStack Query v5에 대한 기본적인 이해가 있는 분을 대상으로 합니다.
2025년 기준으로 업계에서 안착하고 있는 패턴입니다. Server Component에서 초기 데이터를 prefetch해 클라이언트 waterfall을 없애고, 클라이언트에서는 TanStack Query의 캐싱과 revalidation을 재사용합니다.
// app/products/page.tsx — Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ProductList } from './ProductList';
import { fetchProducts } from '@/lib/api';
// 서버 환경의 fetchProducts는 DB 또는 내부 서비스를 직접 호출합니다.
// 브라우저 환경과 달리 절대 URL이나 인증 헤더 처리가 필요할 수 있습니다.
export default async function ProductsPage() {
const queryClient = new QueryClient();
// 서버에서 미리 데이터를 가져와 queryClient에 채웁니다.
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
);
}// app/products/ProductList.tsx — Client Component
'use client'
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from '@/lib/api';
// 주의: 클라이언트에서 호출되는 fetchProducts는 브라우저 환경에서 동작하는
// HTTP 요청이어야 합니다. (baseURL, 인증 헤더 등 클라이언트 환경에 맞게 설정 필요)
export function ProductList() {
// 서버에서 prefetch된 캐시를 그대로 재사용 — 첫 렌더에 추가 네트워크 요청 없음
// 이후 staleTime이 지나거나 refetch 조건에 해당하면 클라이언트에서 갱신됩니다.
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
return (
<ul>
{products?.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}이 패턴의 핵심은 서버와 클라이언트가 동일한 queryKey로 캐시를 공유한다는 점입니다. 서버에서 미리 채운 캐시가 직렬화되어(dehydrate) 클라이언트로 전달(HydrationBoundary)되고, 클라이언트는 이를 받아 추가 요청 없이 즉시 렌더링합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 보일러플레이트 제거 | 로딩·에러·낙관적 상태를 위한 useState 3개 패턴이 사라짐 |
| 선언적 UI | use(promise)로 데이터 페칭 로직이 렌더 흐름에 자연스럽게 통합 |
| 자동 최적화 | React Compiler가 메모이제이션을 처리해 성능 버그 발생 여지 감소 |
| 폼 UX 향상 | useActionState + useFormStatus 조합으로 제출 상태 UI가 구조적으로 처리됨 |
| 점진적 채택 | 기존 코드를 한 번에 재작성하지 않아도 새 컴포넌트부터 적용 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Promise 재생성 문제 | use(fetchUser(id))를 컴포넌트 내부에서 직접 호출하면 매 렌더마다 새 Promise 생성 → 무한 Suspense 루프 |
클라이언트: useMemo로 메모이제이션. 서버: cache() 함수 사용 |
| Compiler 적용 제외 | Rules of React 위반 컴포넌트, 복잡한 서드파티 결합 시 컴파일 최적화 미적용 | 해당 컴포넌트에 한해 수동 useMemo/useCallback 유지 |
| useEffect의 역할 잔존 | 이벤트 리스너, WebSocket 구독, DOM 동기화 등에는 여전히 useEffect가 적절 |
데이터 페칭과 사이드이펙트를 명확히 구분해 적용 |
| 서버/클라이언트 경계 | Server Actions는 직렬화 가능한 인자만 받음 (함수, 클래스 인스턴스 전달 불가) | DTO 패턴으로 전달 데이터를 원시 타입으로 설계 |
| 학습 곡선 | Actions + Suspense + ErrorBoundary 트리플 조합의 첫 설계에 적응 시간 필요 | 작은 폼 하나부터 시작해 패턴을 점진적으로 익히는 것을 권장 |
cache()함수란:import { cache } from 'react'로 가져오는 React Server Components 전용 메모이제이션 함수입니다. 동일한 인자로 호출된 함수를 같은 렌더 요청 안에서 캐싱하며,use(promise)패턴에서 Server Component의 Promise 재생성 문제를 해결하는 데 사용됩니다. 클라이언트에서는useMemo가 같은 역할을 합니다.
실무에서 가장 흔한 실수
use(fetchData())를 컴포넌트 본문 안에서 직접 호출하는 것: 렌더마다 새 Promise가 생성되어 무한 Suspense 루프에 빠집니다. 클라이언트 컴포넌트에서는useMemo로 메모이제이션하고, Server Component에서는cache()를 활용해야 합니다.useFormStatus를 폼과 같은 컴포넌트 안에서 호출하는 것:useFormStatus는 상위<form>의 자식 컴포넌트 안에서만 동작합니다. Submit 버튼을 별도 컴포넌트로 분리해야 하며, 임포트는react가 아닌react-dom에서 해야 합니다.useEffect를 완전히 제거하려는 시도: Actions API와use훅은 데이터 페칭과 폼 변이에 특화되어 있습니다. DOM 이벤트 구독, WebSocket, 외부 시스템 동기화 같은 사이드이펙트는 여전히useEffect가 올바른 도구입니다.
새 API vs 기존 useEffect — 언제 무엇을 선택할 것인가
| 상황 | 권장 접근 |
|---|---|
| 폼 제출, 데이터 변이(create/update/delete) | useActionState + Server Action |
| 서버 응답 전 즉각 UI 반응이 필요한 버튼 (좋아요, 팔로우 등) | useOptimistic |
| 컴포넌트 렌더 시 데이터를 선언적으로 표시 | use(promise) + Suspense + ErrorBoundary |
| 클라이언트 캐싱, revalidation, 무한 스크롤이 필요한 경우 | TanStack Query (React 19와 병행 사용 가능) |
| DOM 이벤트 구독, WebSocket 연결, 외부 시스템 동기화 | useEffect — 여전히 적절한 도구 |
| 서드파티 라이브러리 초기화, 브라우저 API 접근 | useEffect |
| Compiler가 적용되지 않는 복잡한 컴포넌트의 성능 최적화 | useMemo/useCallback 수동 유지 |
새로운 API는 **"데이터를 가져오거나 변경하는 코드"**에 특화되어 있습니다. useEffect는 "컴포넌트 생명주기에 맞춰 부수 효과를 실행해야 하는 코드"에 여전히 유효합니다. 두 영역은 겹치지 않으므로 하나의 프로젝트 안에서 자연스럽게 공존할 수 있습니다.
마치며
React 19는 "무엇을 보여줄 것인가"에만 집중할 수 있도록 상태 관리 보일러플레이트를 프레임워크 수준으로 흡수한 버전입니다. Actions API는 폼과 비동기 변이를, use 훅은 데이터 페칭의 선언적 표현을, React Compiler는 성능 최적화 코드 작성 부담을 각각 줄여줍니다.
새 API를 한꺼번에 익힐 필요는 없습니다. 아래 세 단계로 순서대로 경험해보시면 자연스럽게 패턴이 체화됩니다.
- 기존 프로젝트의 폼 하나를
useActionState로 전환해보는 것을 권장합니다.pnpm add react@19 react-dom@19로 업그레이드 후,useState+onSubmit패턴의 폼 컴포넌트를 찾아useActionState와<form action={dispatch}>로 변환해보시면 차이를 바로 느낄 수 있습니다.useFormStatus로 Submit 버튼을 분리하는 단계까지 완성하면 Actions API의 전체 구조가 한눈에 들어옵니다. - 좋아요, 팔로우처럼 즉각 반응해야 하는 버튼에
useOptimistic을 적용해보시면 서버 응답 대기 없이 UX가 얼마나 달라지는지 직접 확인할 수 있습니다. 에러 처리 로직(롤백 후 사용자 메시지 표시)도 함께 작성해보시면 실무 적용에 준비가 됩니다. use훅 패턴은Suspense+ErrorBoundary기초를 먼저 다진 후 도입하시는 것이 좋습니다.react-error-boundary라이브러리(pnpm add react-error-boundary)를 추가하고, 데이터 페칭 컴포넌트 하나를use(promise)패턴으로 전환해보시면 Suspense 경계 설계를 익힐 수 있습니다. 이때 Promise를useMemo로 메모이제이션하는 것을 잊지 마시고, 에러 케이스도 반드시 테스트해보시기를 권장합니다.Suspense와ErrorBoundary를 어느 계층에 배치할지는 처음에 가장 고민되는 부분인데, "로딩/에러 상태를 어느 범위에서 표시할 것인가"를 기준으로 생각하시면 설계 결정이 수월해집니다.
다음 글: TanStack Query v5와 React Server Components의 하이브리드 아키텍처 심화 — 이번 글에서 소개한 prefetch 패턴을 기반으로, 중첩 레이아웃에서의 캐시 계층 설계,
staleTime전략, Waterfall 없는 병렬 데이터 페칭 구현까지 다룰 예정입니다.
참고 자료
- React v19 공식 릴리즈 노트 | react.dev
- useActionState 공식 문서 | react.dev
- useTransition 공식 문서 | react.dev
- React 19.2 릴리즈 노트 | react.dev
- React Compiler: Say Goodbye to useMemo and useCallback | Certificates.dev
- React 19 use() Hook Deep Dive | DEV Community
- React 19 Suspense for Data Fetching | Syncfusion Blogs
- Stop Using useEffect for Data Fetching | Medium
- React Server Components + TanStack Query: 2026 Data-Fetching Power Duo | DEV Community
- TanStack Query Advanced SSR 공식 문서 | tanstack.com
- The Complete Developer Guide to React 19: Async Handling | Callstack