안녕하세요. 데이블 Service Front-end Team(SF Team)의 이진호입니다.

리액트는 리액트만의 편리함과 확장성으로 프론트엔드 개발의 기본으로 자리 잡았습니다.

리액트 18은 배포된 지 벌써 2년이 지났고 이제 리액트 19가 저희를 기다리고 있습니다.

이에 맞춰 리액트 19에서 새롭게 선보이는 기능들과 개선 사항에 대해 요약해 보려 합니다.

새로운 기능

이번 리액트 19의 신규 기능은 크게 두 가지로 나눌 수 있습니다. 유저 액션을 간편하게 제어하는 기능과 렌더링에 필요한 자원을 편리하게 가져오는 use API입니다. 우선 유저 액션을 제어하는 신규 기능들에 대해 살펴보겠습니다.

Actions

프론트엔드 개발자들이 많이 다루는 것 중 하나는 입력 Form이라 생각합니다. 아래 예시와 같이 서비스에서 유저를 적절한 방향으로 유도하고 편의성을 개선하기 위해 많은 상태를 만들게 됩니다.

function UpdateName({}) {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);
    const error = await updateName(name);
    setIsPending(false);
    if (error) {
      setError(error);
      return;
    } 
    redirect("/path");
  };

  return (
    <div>
      <input value={name} onChange={(event) => setName(event.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        Update
      </button>
      {error && <p>{error}</p>}
    </div>
  );
}

하지만 위의 예시처럼 input이 1개인 경우는 매우 드뭅니다. 입력영역의 개수만큼 상태들을 선언하고 제어해야 합니다. 이런 불편한 점을 개선하기 위해 react-hook-form이나 TanStack Query 같은 라이브러리들을 사용하곤 합니다.

리액트 19에서는 useActionState, useOptimistic, useFormStatus를 통해 이를 해결하려고 합니다. 아래에서 3가지 hook들을 간단하게 설명하겠습니다.

useActionsState

아래는 리액트 공식문서에서 소개하고 있는 useActionState의 구조입니다.

const [error, submitAction, isPending] = useActionState(
  async (previousState, newName) => {
    const error = await updateName(newName);
    if (error) {
      // action의 결과로 모든 종류의 값을 반환할 수 있습니다.
      // 해당 Block에서는 에러를 반환합니다.
      return error;
    }

    // action 성공
    return null;
  },
  null,
);

구조를 살펴보면 TanStack Query의 useMutation과 유사한 것을 볼 수 있습니다. TanStack Query는 mutation을 더 정밀하게 제어하기 위한 여러 옵션과 상태들을 제공하지만 useActionState는 action을 제어하는 데 필요한 최소한의 설정과 상태만을 제공합니다. 그래서 전역으로 관리하지 않는 입력제어 양식에 간단하게 사용하기 좋을 것 같습니다.

useFormStatus, <form> action 속성

useActionState는 일반적인 action에서 범용적으로 사용하는 것과 달리 useFormStatus는 form에서 사용되는 hook입니다. form의 action 속성에 submit 함수를 지정하면 form 제출 시 action에 할당된 함수가 자동으로 실행됩니다.

form의 자식 컴포넌트에서 useFormStatus를 호출하면 부모 form의 제출 상태, action 등을 조회할 수 있습니다.

useOptimistic

게시글의 ‘좋아요’ 클릭과 같이 즉각적인 피드백이 중요한 액션에서 API 통신 완료 전에 미리 상태를 업데이트하는 것을 낙관적 업데이트라 합니다. 직접 구현하기에는 상태가 많이 필요하고 제어가 복잡합니다. 그래서 TanStack Query에서는 mutation key를 이용하여 낙관적 업데이트를 구현하곤 합니다. 하지만 TanStack Query도 mutation key 관리 등 구현이 쉽지만은 않습니다.

리액트 19에서는 해당 기능을 간단하게 구현할 수 있는 useOptimistic Hook을 제공합니다.

function ChangeName({currentName, onUpdateName}) {
  const [optimisticName, setOptimisticName] = useOptimistic(currentName);

  const submitAction = async formData => {
    const newName = formData.get("name");
    setOptimisticName(newName);
    const updatedName = await updateName(newName);
    onUpdateName(updatedName);
  };

  return (
    <form action={submitAction}>
      <p>Your name is: {optimisticName}</p>
      <p>
        <label>Change Name:</label>
        <input
          type="text"
          name="name"
          disabled={currentName !== optimisticName}
        />
      </p>
    </form>
  );
}

use API

공식 문서에서 use API를 아래와 같이 정의하고 있습니다.

use is a React API that lets you read the value of a resource like a Promise or context.

직역하면 Promise와 context를 읽는 데 도움을 주는 API임을 알 수 있습니다. 직역만으로는 알기 힘드니 공식 문서의 예시를 통해 조금 더 자세하게 살펴보겠습니다.

Promise

아래는 use API를 통해 Comments 컴포넌트를 구현한 예시입니다.

import {use} from 'react';

function Comments({commentsPromise}) {
  // `use` API는 Promise가 resolve 될 때까지 지연됩니다.
  const comments = use(commentsPromise);
  return comments.map(comment => <p key={comment.id}>{comment}</p>);
}

function Page({commentsPromise}) {
  // `use` API가 Comments 컴포넌트에서 지연되면 Suspense의 fallback이 렌더링 됩니다.
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  )
}

use API에 promise 함수를 전달하면 Page 컴포넌트의 Suspense가 동작하고 promise가 resolve 되면 Comments 컴포넌트의 comments가 렌더링 됩니다.

위 예제로는 prop으로 전달된 commentsPromise 가 어떻게 생성된 것인지 알 수 없습니다. commentPromise는 상위 서버 컴포넌트에서 생성된 promise입니다. 아래 구체적인 예시를 통해 서버 컴포넌트에서 생성된 promise를 클라이언트 컴포넌트에서 전달받아 use에서 제어하는 패턴을 확인할 수 있습니다.

import { fetchMessage } from './lib.js';
import { Message } from './message.js';

export default function App() {
  const messagePromise = fetchMessage();
  return (
    <Suspense fallback={<p>waiting for message...</p>}>
      <Message messagePromise={messagePromise} />
    </Suspense>
  );
}
// message.js
'use client';

import { use } from 'react';

export function Message({ messagePromise }) {
  const messageContent = use(messagePromise);
  return <p>Here is the message: {messageContent}</p>;
}

서버 컴포넌트인 App 컴포넌트에서 messagePromise를 Message 컴포넌트로 전달하며 Suspense에서 해당 promise가 pending 상태일 때 fallback을 렌더링합니다. 또한 ErrorBoundary를 통해 promise가 reject 되는 상황도 다룰 수 있습니다.

마지막으로 서버와 클라이언트 사이의 정보 교환이기 때문에 resolved 된 값은 반드시 직렬화할 수 있어야 합니다. 함수와 같이 직렬화가 되지 않는 값은 Promise로 전달할 수 없습니다.

Context

전역 상태를 사용하거나 다양한 라이브러리를 설치하여 사용하려 하면 context를 설정해야 합니다. 그리고 컴포넌트 내에서 해당 context를 조회하기 위해 useContext hook을 사용합니다. 리액트 19에서는 use API를 통해 context를 더욱 쉽게 조회하여 사용할 수 있습니다.

import {use} from 'react';
import ThemeContext from './ThemeContext'

function Heading({children}) {
  if (children == null) {
    return null;
  }
  
  // early return 때문에 useContext를 사용하지 못합니다.
  const theme = use(ThemeContext);
  return (
    <h1 style=>
      {children}
    </h1>
  );
}

위의 예시에서 눈여겨 볼 만한 점은 use API는 early return을 지원한다는 것입니다. useContext는 hook이기 때문에 early return 구문 이후에 호출할 수 없습니다. 이에 반해 use API는 early return 구문 이후에도 호출할 수 있어 더 간편하게 구현할 수 있을 것 같습니다.

Server Component

리액트 19에서는 서버 컴포넌트를 본격적으로 지원합니다. 사용 방법은 Next.js 14버전의 서버 컴포넌트 사용법과 매우 유사합니다. (사실 같다고 봐도 무방할 것 같습니다) 또한 빌드 시에만 렌더링하는 SSG와 요청마다 렌더링하는 SSR을 모두 지원합니다.

저는 서버 컴포넌트 내용을 처음 읽은 후 CSR의 대표적인 라이브러리인 리액트에서 서버 컴포넌트를 지원한다는 것이 약간 어색했습니다. 하지만 조금 더 생각해 보니 서비스는 점점 복잡해지고 UX의 중요성이 계속 중요해 지면서 CSR로 모든 문제를 해결하는 것은 분명 한계점이 있습니다. 그래서 React에서도 이러한 변화에 맞춰 진화하는 것이 아니냐는 생각이 들었습니다.

Next.js는 분명 좋은 프레임워크이며 많은 개발자에게 선택받고 있습니다. 하지만 프레임워크 특성상 프레임워크의 규칙을 이해하고 따라야 합니다. 그래서 더 빠르고 자유롭게 앱을 구현하고 싶은 개발자들은 Next.js를 선택하기 어렵습니다. 이러한 문제를 리액트 19가 해결해 줄 수 있다고 생각합니다.

개선사항

ref as prop

리액트 19부터 함수 컴포넌트에서 ref를 prop으로 전달할 수 있습니다. forwardRef는 이제 필요하지 않습니다. forwardRef를 사용 중인 컴포넌트를 ref prop으로 전환하는 codemod를 지원할 예정입니다.

hydration 에러 간소화

리액트 18까지는 hydration 에러가 여러 개의 에러 로그로 개발자 도구에 표시되었지만, 리액트 19에서는 하나의 에러 로그로 표시됩니다.

<Context> as a provider

React 19에서는 <Context.Provider> 대신 <Context> 를 사용합니다. <Context.Provider> 는 deprecate 될 예정이며 <Context> 로 전환하는 codemod를 지원할 예정입니다.

ref를 위한 정리(cleanup) 함수

리액트 19에서는 ref callback 함수의 cleanup 함수를 지원합니다.

<input
  ref={(ref) => {
    // ref created

    // NEW: 요소가 DOM에서 제거될 때 ref를 재설정에 사용되는 정리 함수가 반환됩니다.
    return () => {
      // ref cleanup
    };
  }}
/>

컴포넌트가 언마운트 될 때, cleanup 함수가 호출됩니다. DOM ref, 클래스 컴포넌트, useImperativeHandle에서 동작합니다.

리액트 19에서는 cleanup 함수를 지원하기 때문에 함수가 아닌 다른 타입의 데이터를 반환하면 TypeScript 에러가 발생합니다. no-implicit-ref-callback-return codemode를 통해 쉽게 수정할 수 있습니다.

useDeferredValue 초깃값 설정 옵션

렌더링 최적화에 사용되는 useDeferredValue hook에 initialValue 옵션이 추가됩니다. 아래 예시와 같이 initialValue 옵션을 지정하면 첫 렌더링 시 initialValue로 렌더링 후 defferedValue가 return 하는 값으로 진행할 렌더링이 스케줄에 추가됩니다.

function Search({deferredValue}) {
  // 첫 렌더링 시 value는 '' 입니다.
  // deferredValue로 렌더링하는 작업이 스케줄에 등록됩니다.
  const value = useDeferredValue(deferredValue, '');
  
  return (
    <Results query={value} />
  );
}

Document 메타데이터 지원

SEO가 서비스에서 중요한 역할을 하면서 HTML 문서의 메타태그는 점점 중요해지고 있습니다. 하지만 리액트는 컴포넌트가 head 태그로부터 매우 멀리 위치하고 head 태그를 렌더링하지 않기 때문에 메타태그를 설정하는 것은 매우 어려웠습니다. 그래서 useEffect를 활용하여 수동으로 메타 태그를 삽입하거나 react-helmet 과 같은 라이브러리를 사용했습니다. 또는 Next.js를 바탕으로 작업하기도 했습니다.

하지만 리액트 19에서는 컴포넌트에서 메타태그를 렌더링할 수 있는 기능을 지원합니다.

function BlogPost({post}) {
  return (
    <article>
      <h1>{post.title}</h1>
      <title>{post.title}</title>
      <meta name="author" content="Josh" />
      <link rel="author" href="https://twitter.com/joshcstory/" />
      <meta name="keywords" content={post.keywords} />
      <p>
        Eee equals em-see-squared...
      </p>
    </article>
  );
}

위의 BlogPost 컴포넌트가 렌더링 될 때 <title> ,<link>, <meta> 태그들이 있는 것을 확인할 수 있습니다. 리액트는 해당 태그들을 자동으로 head 태그로 hoist합니다. 이러한 기능은 클라이언트 컴포넌트, 서버 컴포넌트에서 모두 동작합니다.

Stylesheets 지원

스타일 우선순위 규칙 때문에 스타일 관련 태그들의 DOM 내의 위치는 매우 중요합니다. 리액트는 head 컴포넌트 제어가 어렵기 때문에 컴포넌트 내부에 스타일 시트 구현이 어렵기 때문에 유저들은 컴포넌트들에서 필요한 모든 스타일을 index.html에서 모두 로드하거나 복잡한 스타일 라이브러리를 사용합니다.

리액트 19에서는 이러한 문제를 해결하기 위해 클라이언트 또는 서버 컴포넌트에서 스타일시트를 정밀하게 제어할 수 있는 기능들을 제공합니다. 만약 유저가 스타일 시트의 우선순위를 지정하면 우선순위에 맞게 DOM 내부에 스타일 시트를 삽입할 것입니다. 또한 컴포넌트가 노출되기 전에 스타일 시트가 로드되는 것을 보장합니다.

function ComponentOne() {
  return (
    <Suspense fallback="loading...">
      <link rel="stylesheet" href="foo" precedence="default" />
      <link rel="stylesheet" href="bar" precedence="high" />
      <article class="foo-class bar-class">
        {...}
      </article>
    </Suspense>
  )
}

function ComponentTwo() {
  return (
    <div>
      <p>{...}</p>
      <link rel="stylesheet" href="baz" precedence="default" />  <-- will be inserted between foo & bar
    </div>
  )
}

SSR 환경에서 리액트는 스타일 시트를 head 태그에 포함해 스타일 시트가 로드될 때까지 브라우저가 콘텐츠를 그리지 않도록 합니다. 만약 스타일 시트가 클라이언트 컴포넌트에서 발견되면, 리액트는 스타일 시트를 head 태그에 삽입하고 Suspense의 fallback을 렌더링합니다.

CSR 환경에서 리액트는 렌더링 이전에 새로운 스타일 시트를 로드하기 위해 기다릴 것입니다. 만약 해당 컴포넌트를 애플리케이션의 여러 군데에서 렌더링하려 하면 리액트는 스타일시트를 한 번만 로드할 것입니다.

비동기 스크립트 지원

앱을 만들고 운영하다 보면 스크립트가 필요한 경우가 많습니다. 외부 솔루션을 사용하거나 Google Tag Manager, Google analytics 등의 기능을 사용하기 위해 스크립트를 삽입해야 합니다. 이를 구현하기 위해서는 useEffect hook 내에서 script 태그를 수동으로 만들곤 합니다. 리액트 19에서는 script 태그를 지원하기 때문에 이런 불편함 점을 해결해 줄 것으로 기대합니다. 아래는 공식 문서에서 설명하는 비동기 스크립트에 관한 설명입니다.

HTML에서 일반 스크립트(<script src=“...”>)와 지연 스크립트(<script defer=“” src=“...”>)는 문서 순서대로 로드되므로 이러한 종류의 스크립트를 컴포넌트 트리의 깊은 곳에 렌더링하기가 어렵습니다. 그러나 비동기 스크립트(<script async=“” src=“...”>)는 임의의 순서로 로드됩니다.

리액트 19에서는 비동기 스크립트를 어느 곳에서나 렌더링할 수 있도록 지원합니다. 또한 비동기 스크립트를 포함하는 컴포넌트를 여러 군데에서 렌더링하여도 중복으로 실행되지 않도록 관리합니다.

SSR 환경에서는 비동기 스크립트는 head 태그에 포함될 것이며 중요한 스타일 시트, 이미지, 폰트처럼 중요한 자원이 로드되는 것을 막지 않도록 우선순위를 뒤로 미룰 것입니다.

자원 preloading 지원

첫 document 로드와 클라이언트 사이드에서의 업데이트 동안, 브라우저에 먼저 로드해야할 자원들을 알려주는 것은 페이지 성능에 매우 큰 영향을 미칩니다.

리액트 19에서는 자원 로드와 관련된 새로운 API들을 제공하여 자원들을 효율적으로 로드할 수 있도록 지원합니다.

import { prefetchDNS, preconnect, preload, preinit } from 'react-dom'
function MyComponent() {
  preinit('https://.../path/to/some/script.js', {as: 'script' }) // 스크립트를 로드 및 실행합니다.
  preload('https://.../path/to/font.woff', { as: 'font' }) // 폰트를 미리 로드합니다.
  preload('https://.../path/to/stylesheet.css', { as: 'style' }) // stylesheet를 미리 로드합니다.
  prefetchDNS('https://...') // 해당 주소로부터 어떤 요청도 하지 않을 것 같을 때.
  preconnect('https://...') // 해당 주소로부터 무언가 요청할 것 같을 때.
}

<!-- the above would result in the following DOM/HTML -->
<html>
  <head>
    <!-- links/scripts are prioritized by their utility to early loading, not call order -->
    <link rel="prefetch-dns" href="https://...">
    <link rel="preconnect" href="https://...">
    <link rel="preload" as="font" href="https://.../path/to/font.woff">
    <link rel="preload" as="style" href="https://.../path/to/stylesheet.css">
    <script async="" src="https://.../path/to/some/script.js"></script>
  </head>
  <body>
    ...
  </body>
</html>

이러한 API들을 통해 스타일 시트에 포함된 폰트와 같은 부가 자원들을 미리 로드할 수 있습니다. 또한 이동하고자 하는 페이지에서 필요한 자원의 목록을 prefetch하고 링크를 클릭하거나 호버할 때 preload 하여 빠른 화면전환을 구현할 수 있습니다.

Compatibility with third-party scripts and extensions

third-party 스크립트와 브라우저 확장 기능들의 hydration이 개선되었습니다.

hydrating이 진행될 때, 서버에서 제공한 html 포함되어 있지 않은 요소가 발견되면 리액트는 이를 바로잡기 위해 강제로 re-render 할 것입니다. 이전에는 third-party 스크립트나 브라우저 extension에 의해 삽입된 요소가 있으면 mismatch 에러가 발생했습니다.

리액트 19에서는, head 태그나 body 태그 내부에 예상하지 못한 요소가 발견되어도 에러를 반환하지 않고 지나칩니다. 만약 리액트가 hydration 불일치로 인해 전체 문서를 다시 렌더링 한다면, third party 스크립트와 브라우저 extension이 삽입한 스타일 시트들을 그대로 유지합니다.

Better error reporting

리액트 19 이전에는 Error Boundary에서 에러를 두 번 반환했습니다. (원래 에러 1번, 자동 복구 실패한 후 1번) 그래서 console.error를 두 번 호출하여 개발자 도구에 에러가 2번 출력되었습니다.

하지만 리액트 19에서는 1개의 에러로 출력됩니다. 그리고 onRecoverableError를 보완하기 위해 두 가지 새로운 root 옵션이 추가되었습니다.

  • onCaughtError: React가 에러 바운더리에서 에러를 포착할 때 호출됩니다.
  • onUncaughtError: 에러가 발생했지만 에러 바운더리에서 잡히지 않았을 때 호출됩니다.
  • onRecoverableError: 에러가 발생하고 자동으로 복구될 때 호출됩니다.

Support for Custom Elements

리액트 19에서는 Custom Element들을 완벽하게 지원하며 Custom Elements Everywhere 의 모든 테스트를 통과 하였습니다.

리액트 19 이전에는, 리액트가 인식하지 못한 props를 속성(property)이 아닌 특성(attribute)으로 처리했기 때문에 Custom Element를 만들기 어려웠습니다. 리액트 19에서는 아래 전략을 통해 CSR와 SSR에서 모두 속성을 제어할 수 있도록 하였습니다.

  • SSR : Custom Element로 전달되는 props가 원시 타입의 자료형이거나 값이 true면 특성으로 할당됩니다. objectsymbolfunction 같은 원시 타입 자료형이 아니거나 값이 false면 특성에서 제외됩니다.
  • CSR : Custom Element instance의 속성에 포함되는 props는 속성으로 할당되며 포함되지 않은 props는 특성으로 할당됩니다.

속성(property)과 특성(attribute)의 차이

  1. Property (속성):
    • DOM 객체의 실제 속성을 말합니다.
    • 객체이기 때문에 동적으로 값을 읽고 쓸 수 있습니다.
    • 자바스크립트로 직접 접근할 때는 property를 사용합니다.
    • 예: document.querySelector('input').value = "new value"는 property를 수정하는 예입니다.
  2. Attribute (특성):
    • HTML 태그에 명시적으로 작성되는 값입니다.
    • 문자열로 저장되며 초깃값으로만 동작합니다.
    • 예: <input type="text" value="initial">에서 value는 attribute입니다.

마무리

리액트 19에는 여러 새로운 기능들과 개선 사항이 있을 것으로 예상됩니다. 제가 생각하는 리액트 19의 방향성은 아래와 같습니다.

  1. Action을 통한 복잡한 상태관리의 단순화
  2. 서버 컴포넌트를 통한 SSR 구현
    1. use API
    2. 메타태그, 스타일 시트, 스크립트 등의 지원

특히 CSR의 대표 라이브러리였던 리액트가 SSR을 지원하면서 기존 리액트 생태계에 어떤 영향을 미칠지 기대됩니다. SSR을 구현하기 위해서 다른 프레임워크나 라이브러리가 필수였는데 리액트 19만으로 해결 가능하다면 개발자들에게 선택지가 넓어질 것 같습니다.

끝까지 읽어주셔서 감사합니다!

출처

해당 글은 https://react.dev/blog/2024/04/25/react-19 를 바탕으로 작성되었습니다.