React 18에서 19로 넘어오면서 무엇이 달라졌을까요? 단순한 버전 업데이트가 아니라 React가 바라보는 방향 자체가 바뀐 건 아닐까요?

개념 정의

React 19는 2024년 12월에 정식 릴리스된 메이저 버전으로, use() 훅, Actions, React Compiler, ref as prop 등 개발 경험을 근본적으로 개선하는 변경사항들을 포함합니다.

왜 필요한가

React 18의 Concurrent 기능들이 저수준 도구에 가까웠다면, React 19는 이를 바탕으로 개발자가 직접 체감할 수 있는 고수준 기능 을 제공합니다. 특히 데이터 페칭, 폼 처리, 메모이제이션 같은 일상적 작업이 크게 간소화되었습니다.

use() 훅

기본 개념

use()Promise나 Context를 읽는 새로운 훅입니다. 가장 큰 특징은 다른 훅들과 달리 조건문이나 반복문 안에서 호출할 수 있다 는 것입니다.

Promise 읽기

JSX
import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  // Promise가 resolve될 때까지 가장 가까운 Suspense의 fallback을 보여줌
  const user = use(userPromise);

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

function App() {
  const userPromise = fetchUser(1); // Promise를 생성

  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

조건부 Context 읽기

JSX
function StatusMessage({ isSpecial }) {
  // 조건문 안에서 use 호출 가능 — 기존 useContext로는 불가능했음
  if (isSpecial) {
    const theme = use(ThemeContext);
    return <p style={{ color: theme.primary }}>특별 메시지</p>;
  }
  return <p>일반 메시지</p>;
}

Actions — 폼과 비동기 처리의 혁신

useActionState

비동기 액션의 상태(pending, 결과, 에러)를 선언적으로 관리합니다.

JSX
import { useActionState } from 'react';

function UpdateProfile() {
  const [state, submitAction, isPending] = useActionState(
    async (previousState, formData) => {
      const name = formData.get('name');
      try {
        await updateUser({ name });
        return { success: true, message: '프로필이 업데이트되었습니다' };
      } catch (error) {
        return { success: false, message: error.message };
      }
    },
    null // 초기 상태
  );

  return (
    <form action={submitAction}>
      <input name="name" placeholder="이름" />
      <button type="submit" disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
      {state?.message && (
        <p className={state.success ? 'success' : 'error'}>
          {state.message}
        </p>
      )}
    </form>
  );
}

useFormStatus

폼 내부의 자식 컴포넌트에서 부모 폼의 제출 상태를 읽을 수 있습니다.

JSX
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? '처리 중...' : '제출'}
    </button>
  );
}

function ContactForm() {
  async function handleSubmit(formData) {
    await sendMessage(formData);
  }

  return (
    <form action={handleSubmit}>
      <textarea name="message" />
      <SubmitButton /> {/* 폼 상태를 자동으로 인식 */}
    </form>
  );
}

useOptimistic

서버 응답을 기다리지 않고 UI를 먼저 업데이트 하는 낙관적 업데이트를 쉽게 구현합니다.

JSX
import { useOptimistic } from 'react';

function TodoList({ todos, addTodoAction }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodoText) => [
      ...currentTodos,
      { id: 'temp', text: newTodoText, sending: true },
    ]
  );

  async function handleSubmit(formData) {
    const text = formData.get('todo');
    addOptimisticTodo(text); // 즉시 UI 업데이트
    await addTodoAction(text); // 서버에 실제 저장
  }

  return (
    <div>
      <form action={handleSubmit}>
        <input name="todo" />
        <button type="submit">추가</button>
      </form>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.sending ? 0.5 : 1 }}>
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

ref as prop — forwardRef 불필요

React 19에서는 함수형 컴포넌트가 ref를 일반 prop으로 받을 수 있습니다.

JSX
// React 18 이전 — forwardRef 필수
const Input = forwardRef(function Input(props, ref) {
  return <input ref={ref} {...props} />;
});

// React 19 — 일반 prop으로 받기
function Input({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

// 둘 다 같은 방식으로 사용
function Form() {
  const inputRef = useRef(null);
  return <Input ref={inputRef} placeholder="입력" />;
}

forwardRef는 여전히 동작하지만, 향후 deprecated될 예정입니다.

React Compiler

React Compiler(이전 이름: React Forget)는 컴파일 타임에 자동으로 메모이제이션을 적용 합니다.

JSX
// React Compiler가 자동으로 최적화하므로
// 개발자가 직접 useMemo/useCallback을 작성할 필요가 줄어듦

// 이전: 수동 메모이제이션
function ProductList({ products, onSelect }) {
  const sorted = useMemo(
    () => products.sort((a, b) => a.price - b.price),
    [products]
  );
  const handleSelect = useCallback(
    (id) => onSelect(id),
    [onSelect]
  );
  return sorted.map(p => (
    <ProductItem key={p.id} product={p} onSelect={handleSelect} />
  ));
}

// React Compiler 사용 시: 그냥 작성하면 됨
function ProductList({ products, onSelect }) {
  const sorted = products.sort((a, b) => a.price - b.price);
  return sorted.map(p => (
    <ProductItem key={p.id} product={p} onSelect={() => onSelect(p.id)} />
  ));
}

React Compiler는 아직 선택적 도입(opt-in) 단계이며, 기존 코드와 점진적으로 호환됩니다.

Document Metadata

컴포넌트 내에서 <title>, <meta>, <link> 등을 렌더링하면 React가 자동으로 <head>로 호이스팅합니다.

JSX
function BlogPost({ post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <meta property="og:title" content={post.title} />
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

react-helmet이나 next/head 같은 서드파티 라이브러리 없이도 메타데이터를 관리할 수 있습니다.

기타 주요 변경사항

리소스 프리로딩

JSX
import { prefetchDNS, preconnect, preload, preinit } from 'react-dom';

function App() {
  preinit('https://cdn.example.com/main.js', { as: 'script' });
  preload('https://cdn.example.com/font.woff2', { as: 'font' });
  preconnect('https://api.example.com');

  return <div>앱 내용</div>;
}

에러 처리 개선

JSX
// 하이드레이션 에러 시 더 상세한 정보 제공
// 이전: "Text content does not match"
// React 19: 구체적인 불일치 내용과 위치를 표시

cleanup 함수를 반환하는 ref callback

JSX
function Component() {
  return (
    <div ref={(node) => {
      // 마운트 시
      node.style.opacity = '1';

      // 언마운트 시 cleanup — React 19 새 기능
      return () => {
        node.style.opacity = '0';
      };
    }}>
      내용
    </div>
  );
}

주의할 점

use()로 컴포넌트 내부에서 Promise를 생성하면 안 됨

use()에 전달하는 Promise는 컴포넌트 바깥 에서 생성해야 합니다. 렌더링 중에 use(fetch(...))처럼 새 Promise를 만들면, 매 렌더링마다 새 Promise가 생성되어 무한 Suspense 루프에 빠집니다.

JSX
// ❌ 렌더링 중 Promise 생성 — 무한 루프
function Bad({ id }) {
  const data = use(fetchUser(id)); // 매 렌더마다 새 Promise
}

// ✅ 부모에서 Promise를 생성하여 전달
function Parent({ id }) {
  const promise = useMemo(() => fetchUser(id), [id]);
  return <Child userPromise={promise} />;
}

마이그레이션 시 breaking changes

  • defaultProps는 함수형 컴포넌트에서 deprecated — 구조 분해 기본값 사용
  • propTypes는 런타임 체크에서 제거 — TypeScript 사용 권장
  • forwardRef는 당장 제거되지 않지만 점진적 마이그레이션 권장
  • StrictMode에서 이중 렌더링 시 console.log 음소거가 해제됨

React Compiler의 전제 조건

React Compiler는 Rules of React(순수한 렌더링, 훅 규칙 등)를 따르는 코드에서만 올바르게 동작합니다. 기존에 이 규칙을 위반하는 코드가 있으면 Compiler 도입 시 예기치 않은 동작이 발생할 수 있습니다.

정리

항목설명
use()Promise와 Context를 조건부로 읽는 새 훅 — 훅 규칙의 예외
useActionState비동기 액션의 pending/결과/에러를 선언적으로 관리
useFormStatus자식에서 부모 폼의 제출 상태를 읽기
useOptimistic서버 응답 전 UI를 먼저 업데이트하는 낙관적 업데이트
ref as propforwardRef 없이 ref를 일반 prop으로 전달
React Compiler컴파일 타임 자동 메모이제이션 (opt-in)
메타데이터 호이스팅<title>, <meta> 등을 컴포넌트에서 렌더링하면 자동으로 <head>로 이동

React 19는 복잡했던 패턴들을 내장 기능으로 흡수하는 방향입니다. 특히 Actions는 폼 처리의 보일러플레이트를 크게 줄여줍니다.

댓글 로딩 중...