뒤로가기
React 19에서 달라지는 것들

September 4, 2023

reactfrontend

React 19 RC가 나왔을 때 팀 리드가 슬랙에 링크를 던졌다. "이거 한번 보세요." 릴리즈 노트를 쭉 읽고 나서 처음 든 생각은 "이번엔 진짜 많이 바뀌는구나"가 아니었다. "React가 어디로 가려는 거지?"였다.

React 18의 Concurrent Features는 솔직히 실무에서 체감이 크지 않았다. useTransition을 써본 적은 있지만, "이거 없으면 안 되는" 상황은 드물었다. 근데 19는 느낌이 다르다. 개별 API의 추가가 아니라, React가 생각하는 "웹 개발의 방향" 자체가 드러나는 업데이트다.

이 글은 "뭐가 새로 나왔는지"를 나열하는 글이 아니다. React 19의 변화들이 어떤 철학을 반영하는지, 그리고 그게 우리 실무 코드에 어떤 의미인지를 생각해본 글이다.

Actions가 말해주는 것: 서버 우선 사고방식#

React 19에서 가장 큰 변화를 하나만 꼽으라면 Actions다. 표면적으로는 폼 처리 방식의 개선이지만, 깊이 보면 React의 철학적 전환점이다.

이전까지 폼을 처리하던 방식을 먼저 보자.

tsx
// React 18까지의 전형적인 패턴
function CreatePostForm() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);
    try {
      await createPost({ title, content });
    } catch (err) {
      setError('저장에 실패했습니다');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={e=> setTitle(e.target.value)} />
      <textarea value={content} onChange={e=> setContent(e.target.value)} />
      <button disabled={isSubmitting}>
        {isSubmitting ? '저장 중...' : '저장'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

상태 3개, 이벤트 핸들러 1개, try-catch 1개. 폼 하나에 이 정도 보일러플레이트가 필요했다. React 19에서는 이렇게 바뀐다.

tsx
function CreatePostForm() {
  const [error, submitAction, isPending] = useActionState(
    async (prevState: string | null, formData: FormData) => {
      try {
        await createPost({
          title: formData.get('title') as string,
          content: formData.get('content') as string,
        });
        return null;
      } catch {
        return '저장에 실패했습니다';
      }
    },
    null
  );

  return (
    <form action={submitAction}>
      <input name="title" />
      <textarea name="content" />
      <button disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

controlled input이 사라졌다. FormData에서 바로 값을 꺼낸다. isSubmittingisPending으로 대체됐다. 필드 10개짜리 폼을 만들어보면 체감이 확 온다.

그런데 진짜 중요한 건 보일러플레이트 감소가 아니다. <form action={submitAction}>이라는 패턴 자체가 중요하다. HTML의 네이티브 <form action>과 같은 형태다. JavaScript가 비활성화된 환경에서도 기본적인 폼 제출이 가능하다는 뜻이다. React가 "브라우저의 기본 동작을 덮어쓰는 것"에서 "브라우저의 기본 동작을 활용하는 것"으로 이동하고 있다.

이건 React 코어 팀의 한 멤버가 쓴 "The Two Reacts"라는 글과 연결된다. UI = f(data)(state)라는 공식. UI가 서버의 데이터와 클라이언트의 상태, 두 가지로 결정된다는 관점이다. Actions는 "클라이언트에서 상태를 조작하는 것"이 아니라 "서버에 데이터를 보내는 것"에 초점을 맞춘다. 서버 우선(server-first) 사고방식이 React의 기본 방향이 되고 있다는 신호다.

ref-as-prop: 사소해 보이지만 중요한 단순화#

tsx
// 이전
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />;
});

// React 19
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}

forwardRef가 필요 없어졌다. "그래서 뭐?"라고 할 수 있다. 코드 몇 줄 줄어드는 것뿐인데.

근데 이게 중요한 이유가 있다. React의 역사에서 forwardRef는 대표적인 "설계 실수를 패치한 API"였다. ref를 prop으로 넘길 수 없다는 제약 때문에 래퍼 함수가 필요했고, 그 래퍼 때문에 타입 추론이 복잡해지고, DevTools에서 컴포넌트 이름이 이상하게 보이고, 초보자가 이해하기 어려운 코드가 됐다.

React 19에서 ref가 그냥 prop이 된 건, "기술 부채를 해소했다"는 의미 이상이다. React 팀이 "API 표면을 줄이는 것"을 의식적으로 추구하고 있다는 뜻이다. 새 기능을 추가하는 것만큼, 기존의 불필요한 복잡성을 제거하는 것도 중요하게 보고 있다.

이건 더 넓은 패턴의 일부다. use(Context)useContext를 대체할 수 있게 된 것, 메타데이터를 컴포넌트 안에서 직접 선언할 수 있게 된 것, react-helmet 같은 별도 라이브러리가 필요 없어진 것. 전부 "외부 의존성이나 별도 API 없이, React 자체로 할 수 있는 것"을 늘리는 방향이다.

use() 훅: 유연성과 복잡성 사이#

use()는 가장 파격적인 신규 API다. 기존 훅들과 달리 조건문 안에서 호출할 수 있다.

tsx
function UserProfile({ userPromise, showDetails }: {
  userPromise: Promise<User>;
  showDetails: boolean;
}) {
  const user = use(userPromise);

  if (showDetails) {
    const details = use(fetchUserDetails(user.id));
    return <DetailView user={user} details={details} />;
  }

  return <SimpleView user={user} />;
}

이전에는 불가능했던 패턴이다. 훅은 무조건 컴포넌트 최상단에서 호출해야 했으니까. 조건부 데이터 페칭을 하려면 별도 컴포넌트로 쪼개거나 useEffect 안에서 처리해야 했다.

use()는 Suspense와 함께 동작한다. Promise를 넘기면 resolve될 때까지 가장 가까운 Suspense boundary의 fallback이 보인다.

여기서 주의할 점이 있다. use()의 편리함에 취해서 무분별하게 쓰면, Suspense boundary 설계가 꼬인다. 컴포넌트 트리의 여기저기서 use(Promise)를 호출하면, 의도하지 않은 곳에서 로딩 스피너가 뜬다. "이 부분만 로딩 중이어야 하는데 왜 페이지 전체가 스피너로 바뀌지?"라는 상황이 생긴다.

팀에서 도입하려면 Suspense boundary 규칙을 먼저 정해야 한다. "페이지 레벨 Suspense는 여기, 컴포넌트 레벨 Suspense는 저기"라는 가이드라인 없이 쓰면 혼란스럽다.

한 React 생태계의 저명한 개발자가 흥미로운 관점을 제시한 적이 있다. "사용하는 모든 도구는 실제로 우리가 가진 문제를 해결해야 한다"고. use()도 마찬가지다. 기존의 useEffect + useState 패턴이 충분한 곳에서 굳이 use()로 바꿀 필요는 없다. 진짜 조건부 데이터 페칭이 필요한 곳, Suspense로 로딩 상태를 선언적으로 관리하고 싶은 곳에서 쓰는 게 맞다.

useOptimistic: 작지만 실용적인 변화#

tsx
function LikeButton({ postId, initialLikes }: {
  postId: string;
  initialLikes: number;
}) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, _: null) => current + 1
  );

  async function handleLike() {
    addOptimisticLike(null);
    await likePost(postId);
  }

  return (
    <button onClick={handleLike}>
      {optimisticLikes}
    </button>
  );
}

버튼을 누르면 서버 응답을 기다리지 않고 바로 숫자가 올라간다. 실패하면 자동으로 롤백된다. 이전에는 상태를 두 개 유지하면서 직접 롤백 로직을 짜야 했다.

이것도 같은 흐름이다. 개발자가 직접 관리하던 상태(낙관적 값, 원본 값, 롤백 로직)를 React가 대신 해준다. 보일러플레이트를 줄이는 방향.

"그래서 지금 당장 도입해야 하나?"#

솔직한 답을 하자면, "상황에 따라 다르다."

React 19로 업그레이드한다고 기존 코드를 전부 뜯어고칠 필요는 없다. 하위 호환성은 유지된다. 기존 forwardRef 코드도, controlled input도, useContext도 전부 계속 동작한다.

새로 작성하는 코드에서는 적용할 수 있다. forwardRef 대신 ref prop을 쓰고, 단순한 폼에서 useActionState를 써보고, useContext 대신 use(Context)를 써본다. 점진적으로.

다만 프레임워크 의존도가 깊어지는 건 인지하고 있어야 한다. Server Components, Actions, use() 전부 Next.js 같은 프레임워크와의 통합을 전제로 설계된 측면이 있다. 순수 SPA에서 이런 기능들의 이점을 100% 누리기는 어렵다. React가 "라이브러리"에서 "프레임워크 생태계의 코어"로 점점 이동하고 있다는 것을 받아들일 필요가 있다.

React의 미래 방향에 대한 생각#

React 19를 보면서 느끼는 큰 그림이 있다. React가 "두 개의 세계"를 연결하려 한다는 거다.

한쪽은 클라이언트다. UI = f(state). 사용자의 인터랙션에 즉각 반응하는 세계. 카운터를 누르면 즉시 숫자가 올라가고, 드래그하면 즉시 요소가 움직인다.

다른 한쪽은 서버다. UI = f(data). 데이터베이스에서 데이터를 읽고, 마크다운을 파싱하고, 무거운 연산을 처리하는 세계. 클라이언트에서는 불가능하거나 비효율적인 작업들.

React 19의 변화들은 이 두 세계를 자연스럽게 이어주려는 시도다. Actions는 클라이언트의 폼 인터랙션을 서버로 연결하고, use()는 서버에서 시작된 데이터를 클라이언트에서 소비하는 패턴을 개선하고, Server Components는 서버에서만 실행되는 컴포넌트를 허용한다.

UI = f(data, state). 서버의 데이터와 클라이언트의 상태를 합치는 공식. 이게 React가 가려는 방향이다.

동시에 걱정도 있다. React가 원래 가졌던 "그냥 JavaScript" 철학과의 긴장이 느껴진다. Server Components를 이해하려면 번들러 통합을 알아야 하고, Actions를 제대로 쓰려면 프레임워크 규칙을 따라야 한다. "React는 뷰 레이어일 뿐"이라던 시절의 단순함은 이미 지나갔다.

이건 좋고 나쁘고의 문제가 아니다. 웹 개발의 현실이 복잡해졌고, React가 그 복잡성을 해결하려 하는 거다. 중요한 건 새 API가 해결하려는 문제를 이해하고, 우리 프로젝트에 그 문제가 있는지 판단하고, 있을 때만 도입하는 거다. 모든 프로젝트가 Server Components를 필요로 하지 않고, 모든 폼이 Actions를 필요로 하지 않는다. 도구는 문제가 있을 때 가치가 있다.