React 19.2 찍먹하기

Kyle·2025년 10월 23일

React

목록 보기
3/5
post-thumbnail

React 1919.1에 이은 세 번째 업데이트 React 19.2가 npm에 공식 출시되었습니다. 이번 버전은 성능 최적화와 사용자 경험 개선에 초점을 맞춘 새로운 기능들을 다수 포함하고 있습니다.

이 글을 통해 React 19.2의 핵심 기능들이 기존의 불편함을 어떤 기술적 원리와 메커니즘으로 해결하게 되었는지를 중점적으로 살펴보겠습니다.

1. 새로운 React 기능: 컴포넌트 생명 주기와 이벤트 설계

<Activity />를 활용한 성능 최적화

기존의 isVisible && <Page /> 같은 조건부 렌더링은 컴포넌트를 완전히 언마운트하거나 마운트할 때 성능 저하를 유발합니다. 조건이 false가 되면 언마운트되고, true가 되면 다시 마운트되는 이 과정에서 비싼 연산이 포함되어 있다면 성능 저하를 우려할 수 있습니다. (물론, 비싼 연산이 없더라도 성능 저하에는 문제를 일으킵니다.)

<Activity/> 컴포넌트는 앱을 제어 및 우선순위를 지정할 수 있는 Activity 단위로 나눌 수 있게 합니다.

<Activity mode={...} />는 아래의 두 가지 모드를 지원합니다.

  • hidden: 자식 컴포넌트의 렌더링 결과는 유지하되, Side Effect만 언마운트하고 업데이트의 우선순위를 지연시킵니다. 즉 컴포넌트는 살아있지만 불필요하게 작동하지 않습니다.

  • visible: 일반적인 렌더링 및 업데이트를 처리합니다.

// After
<Activity mode={isVisible ? 'visible' : 'hidden'}>
  <Page />
</Activity>

이 기능을 활용하면 사용자가 다음으로 이동할 가능성이 높은 숨겨진 부분을 백그라운드에서 미리 렌더링하고 데이터 CSS 이미지를 미리 로드하여 탐색 속도를 높일 수 있습니다. 또한 사용자가 페이지를 이탈했다가 돌아올 때 입력 필드와 같은 상태를 유지할 수 있습니다.

hidden에서 Side Effect가 무엇인지, 어떻게 이것만 언마운트 하는지, 어떻게 우선순위를 지연시키는지 궁금해요

리액트는 모든 컴포넌트를 파이버 노드라는 자바스크립트 객체로 관리합니다. 각 파이버 노드에는 상태, 속성, DOM 노드 참조 정보 등이 있습니다. reconciliation 과정에서 기존의 파이버 노드에서 변화된 값들을 Side Effect List에 담아둡니다.

hidden 상태는 Reconciliation 과정에서 노드를 삭제하지 않습니다. 때문에 관련 정보들이 메모리에서 제거되지 않아 렌더링 결과를 유지할 수 있게 되는 것입니다.

이렇게 유지된 파이버 트리에 속한 useEffect의 클린업 함수가 커밋 단계에서 실행됩니다. 따라서 DOM을 그대로 둔 채 자바스크립트 레벨의 Side Effect만 언마운트 할 수 있게 되는 것이지요.

마지막으로 우선순위에 대해서는 동시성 렌더링과 함께 정리가 잘 된 글이 있어 함께 참고해보시면 좋을 것 같습니다.

코드 한 줄로 경험하는 React 동시성의 마법

useEffectEvent를 통한 명확한 이벤트 분리

useEffect 내에서 외부 시스템의 이벤트(예: 채팅방 연결 시 알림 표시)를 처리할 때 해당 Effect 로직과 무관한 값(아래 코드에서는 theme)이 종속성 배열에 포함되면 값이 변경될 때마다 Effect가 불필요하게 재실행됩니다.

useEffectEventEffect Event라는 함수를 반환하는데요, 항상 최신 Props와 State를 참조하는 Closure를 가지고 있지만, React의 종속성 검사에서는 무시되므로 Effect를 재실행하지 않습니다.

const ChatRoom = ({ roomId, theme }) => {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });
  
  useEffect(() => {
    // ... 연결 로직 ...
    connection.on('connected', () => {
      onConnected();
    });
    // ...
  }, [roomId]); // ✅ 'theme' 없이 roomId만 포함
}

이로써 Effect는 연결 로직 자체에 대한 종속성(roomId)만 유지하며 이벤트 처리 로직은 최신 데이터(theme)를 안전하게 사용할 수 있습니다. 단, Effect Event가 종속성 배열에 포함되어서는 안 된다는 규칙을 준수해야 합니다.

cacheSignal로 RSC 환경 캐시 관리

RSC 환경에서 cache()를 사용하면 데이터 요청을 중복 제거할 수 있지만 렌더링이 실패하거나 취소되는 경우 진행 중인 비동기 작업(예: fetch)을 수동으로 중단할 방법이 없어 불필요하게 서버 리소스와 시간을 소비할 수 있습니다.

cacheSignalcache()의 수명 주기가 끝날 때 (렌더링 완료, 중단 또는 오류 시)를 알려주는 AbortControllerSignal을 제공합니다. 개발자는 이 cacheSignalfetch와 같은 비동기 작업에 연결하여 React가 캐시 결과를 더 이상 사용하지 않기로 결정했을 때 해당 비동기 작업을 즉시 중단(Abort)할 수 있게 되었습니다.

import { cache, cacheSignal, Suspense } from 'react'

/**
 * ✅ 사용자 정보를 가져오는 캐시된 fetch 함수
 *
 * - React가 이 함수를 처음 호출할 때 AbortController를 생성
 * - 렌더링이 실패하면 React가 .abort()를 호출하여 fetch를 중단
 */
const getUser = cache(async (id: number) => {
  const url = `https://api.example.com/users/${id}`

  try {
    // React가 관리하는 AbortController의 signal을 반환
    const signal = cacheSignal()

    // Suspense fallback 표시
    const response = await fetch(url, { signal })

    // fetch가 정상적으로 완료된 경우
    // React 렌더링이 계속 진행되며, 이 데이터는 캐시에 저장
    const data = await response.json()

    // React 렌더링이 끝나면 fetch 결과는 그대로 유지
    // 이 결과는 다음 렌더링 시 재사용
    return data
  } catch (error: any) {
    // 만약 렌더링 중 ErrorComponent 같은 컴포넌트가 에러를 던지면
    // React는 렌더링을 중단 후 AbortController.abort()를 호출
    // 그 즉시 fetch가 중단되며 AbortError가 발생
    if (error.name === 'AbortError') {
      console.warn(`Fetch 중단`)
    } else {
      console.error(`Fetch 오류`)
    }
    throw error
  }
})

/**
 * ✅ 사용자 정보를 표시하는 컴포넌트
 * 
 * - Suspense 환경에서 호출되며 getUser가 데이터를 반환할 때까지 대기
 */
async function UserProfile({ id }: { id: number }) {
  const user = await getUser(id)
  return <div>{user.name}</div>
}

/**
 * ❌ 의도적으로 에러를 던지는 컴포넌트
 * 
 * - React가 렌더링을 중단할 때 getUser의 fetch가 
 *   어떻게 중단되는지 확인
 */
function ErrorComponent() {
  throw new Error('렌더링 오류')
}

/**
 * 🧩 전체 렌더링 흐름
 *
 * 1. Suspense가 렌더링 시작
 * 2. UserProfile이 getUser를 호출하고 fetch는 대기 상태
 * 3. 이어서 ErrorComponent가 렌더링 도중 에러를 throw
 * 4. React(Fizz)는 렌더링을 실패로 간주하고 AbortController.abort()를 호출
 */
async function App() {
  return (
    <div>
      <Suspense fallback={<div>로딩 중...</div>}>
        <UserProfile id={1} />
        <ErrorComponent />
      </Suspense>
    </div>
  )
}

cacheSignal() 그럼 언제 써요?

빠른 사용자 전환이라는 상황을 하나 가정해보겠습니다.

  1. 사용자가 프로필 페이지에서 UserProfile(id=1)을 보고 있음

  2. 곧바로 다른 유저를 클릭해서 UserProfile(id=2) 렌더링이 시작됨

  3. 이때 첫 번째 렌더링(id=1)은 아직 fetch가 끝나지 않음

  4. React는 이전 렌더링을 abort하고 새 렌더링으로 전환

이때 cacheSignal()을 사용하면 아래와 같은 장점이 있습니다.

  1. 이전 렌더링(id=1)에 연결된 AbortController.abort() 호출

  2. getUser(1)fetchAbortError를 throw

  3. 네트워크 리소스를 즉시 해제

  4. 새 렌더링(id=2)에 대해서 새로운 signal을 생성하고 fetch 시작

결과적으로!

  • 오래 걸리는 네트워크 요청을 불필요하게 유지하지 않습니다.
  • CPU와 메모리 낭비를 줄일 수 있습니다.

2. 새로운 React DOM 기능: SSR 및 성능 최적화

Partial Pre-rendering을 통한 정적 동적 분리

기존 SSR에서는 서버에서 모든 데이터를 가져오고 HTML을 생성한 다음 클라이언트가 모든 JS를 다운로드할 때까지 Hydration을 기다려야 했습니다. 이로 인해 동적 콘텐츠가 많은 페이지는 초기 로딩 시간이 지연되는 문제가 있었습니다.

React 19.2에서 앱의 일부를 미리 렌더링하고 나중에 렌더링을 재개하는 Partial Pre-rendering 이 도입되었습니다. 이 기능은 정적 부분을 미리 렌더링하고 CDN에서 제공한 다음 렌더링을 재개하여 동적 콘텐츠를 채웁니다.

import { prerender, Suspense } from 'react'

/**
 * ✅ [정적 영역] prelude
 * 
 * 이 부분은 사전 렌더링 시 HTML로 미리 생성되어 CDN에 캐싱
 * StaticHeader → prerender → prelude 생성 → CDN 캐싱
 */

function StaticHeader() {
  return (
    <header>
      <h1>React 19.2 Partial Pre-rendering</h1>
      <p>이 영역은 prelude로서 정적으로 캐시됩니다</p>
    </header>
  )
}

/**
 * ✅ [동적 영역] postponed
 * 
 * 데이터 fetch가 완료될 때까지 렌더링을 연기(postpone)
 * prerender 과정 -> 이 컴포넌트가 중단점
 * resume 시점에 React가 여기서부터 렌더링을 이어감
 */
async function DynamicUserInfo() {
  const res = await fetch('https://api.example.com/user/1', { cache: 'no-store' })
  const user = await res.json()

  return (
    <section>
      <h2>Dynamic User Info</h2>
      <p>이름: {user.name}</p>
    </section>
  )
}

/**
 * ✅ prerender()
 * 
 * 이 함수는 정적 prelude와 postponed 상태를 생성
 * React는 Suspense 경계를 만나면 렌더링을 잠시 멈추고
 * postponed 상태로 기록
 */

export const App = prerender(async function Page() {
  return (
    <main>
      <StaticHeader />
      <Suspense fallback={<p>사용자 정보를 불러오는 중...</p>}>
        <DynamicUserInfo />
      </Suspense>
    </main>
  )
})

동적 영역과 정적 영역을 어떻게 구분해요? 제가 직접해요?

아니요 아니요..
prerender를 실행할 때 모든 것은 정적이라고 가정하고 렌더링을 시작하되, 일시 중단되면 postponed로 인식이 됩니다.

그럼 언제 일시 중단이 되나요?

async/await을 사용하는 경우가 대표적일 것 같습니다. 추가로 use(promise) 이런 식으로 사용해도 일시 중단이 됩니다. 즉, Promise를 사용할 경우 기다리는 동안 렌더링을 중단하게 됩니다. 중단이 되면 컴포넌트 트리를 탐색해 가장 가까운 <Suspense/> 경계를 찾는데 이 경계가 postponed의 시발점이 됩니다.

이거 그럼 RSC랑 비슷하네요!

사용하는 목적이 다릅니다.

  • RSC: 클라이언트에 JS를 거의 보내지 않고 서버에서 렌더링한 컴포넌트를 전달
  • PPR(Partial Pre-rendering): 렌더링 결과 중 일부를 정적으로 캐시하고 일부를 나중에 이어서 완성
항목RSCPPR
렌더링 단위컴포넌트 단위페이지 단위
목적클라이언트로 JS를 최소 전송정적/동적 콘텐츠 분리 및 캐싱
실행 시점서버가 클라이언트 요청 시마다 실행사전 렌더링 시점에서 실행
데이터 로딩 방식서버에서 데이터 fetchprerender 과정 중 async 경계에서 중단
결과물클라이언트에서 다시 조합되는 React Element 트리prelude(HTML) + postponed(렌더링 상태 스냅샷)
공통점서버에서 React 코드 실행서버에서 React 코드 실행

한 번 더 정리하자면, RSC는 서버에서 렌더링하는 단위이고 PPR은 정적, 동적 렌더링의 시점을 분리하기 위함입니다.

Performance Tracks로 개발자 경험 향상

React의 Concurrent Mode는 작업을 여러 우선순위로 나누어 처리하는 복잡한 내부 스케줄링 메커니즘을 사용합니다. 기존에는 이러한 React 내부의 스케줄러가 어떤 작업을 어떤 우선순위로 처리하고 있는지 외부 도구로 상세하게 파악하기 어려웠습니다.

Chrome DevTools 성능 프로파일에 새로운 사용자 지정 트랙 세트인 Performance Tracks가 추가되었습니다.

  • Scheduler 트랙: React가 blocking하거나 transition과 같은 다양한 우선순위에 대해 어떤 작업을 수행하는지 보여주어 작업의 순서와 시간을 이해하는 데 도움을 줍니다. (사진에서 빨간색)

  • Components 트랙: 컴포넌트의 마운트 시점 효과 실행 시간 등 컴포넌트 단위의 작업 시간을 보여주어 성능 문제 식별에 도움을 줍니다. (사진에서 파란색)

이는 개발자가 애플리케이션의 성능 문제를 시각적으로 명확하게 파악하고 최적화할 수 있도록 지원하여 개발자 경험을 향상시킵니다.

만약, 위의 사진을 직접 보고 싶다면 아래의 코드를 참고해보세요!
(단, 리액트 19.2로 업그레이드를 하셔야지 탭이 보일 거에요.)

import React, { useState, useTransition, useEffect, Suspense } from "react";

// ✅ 무거운 연산을 시뮬레이션하는 함수
function heavyCalculation(num: number) {
  const start = performance.now();
  while (performance.now() - start < 80) {} // 80ms CPU 점유
  return num * 2;
}

// ✅ transition 우선순위로 실행될 컴포넌트
function ExpensiveList({ count }: { count: number }) {
  const items = [];
  for (let i = 0; i < count; i++) {
    items.push(<li key={i}>계산 결과: {heavyCalculation(i)}</li>);
  }

  // 이펙트 실행 시점을 Components 트랙에서 확인 가능
  useEffect(() => {
    console.log("ExpensiveList 렌더 완료");
  }, [count]);

  return <ul>{items}</ul>;
}

// ✅ transition과 blocking을 구분해서 트래킹할 수 있는 상위 컴포넌트
export default function PerformanceDemo() {
  const [input, setInput] = useState("");
  const [count, setCount] = useState(10);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setInput(value); // 🔹 즉시 실행 (blocking 우선순위)

    // 🔸 transition 우선순위: React가 낮은 우선순위로 예약 처리
    startTransition(() => {
      setCount(Number(value) || 0);
    });
  }

  return (
    <div style={{ padding: "1rem", fontFamily: "sans-serif" }}>
      <h1>React 19.2 Performance Tracks 예시</h1>
      <input
        type="number"
        placeholder="리스트 길이 입력"
        value={input}
        onChange={handleChange}
        style={{ padding: "0.5rem", fontSize: "1rem", width: "200px" }}
      />
      {isPending && <p>Transition 작업 진행 중...</p>}

      <Suspense fallback={<p>리스트를 렌더링 중...</p>}>
        <ExpensiveList count={count} />
      </Suspense>
    </div>
  );
}

3. 추가적인 변경 사항

SSR Suspense Boundary 일괄 처리 개선

이전에는 SSR 스트리밍 중 데이터가 준비되는 즉시 Suspense 콘텐츠가 대체(fallback) 콘텐츠를 순차적으로 즉시 대체했습니다. 이로 인해 UX가 부자연스럽게 느껴지거나, 클라이언트 렌더링의 일괄 처리 방식과 시각적으로 다르게 동작하는 문제가 있었습니다.

React 19.2부터는 서버 렌더링된 Suspense Boundary의 표시를 짧은 시간 동안 일괄 처리(Batching)하여 더 많은 콘텐츠가 함께 표시되도록 합니다. 이 일괄 처리 덕분에 일관된 UX를 제공하고 View Transition 기반 애니메이션을 더 잘 지원할 수 있는 기반이 마련되었습니다.

SSR Web Streams 지원 및 Node Streams 권장

Web Streams은 브라우저 환경에서 사용되었으며 Node.js 환경에서는 스트리밍 SSR을 위한 별도의 API만 지원했습니다.

하지만, Web Streams이 추가되었음에도 불구하고 Node.js 환경에서는 성능이 훨씬 빠르고 기본적으로 압축을 지원하는 Node Streams API (renderToPipeableStream 등)를 사용하는 것을 여전히 강력히 권장합니다.

eslint-plugin-react-hooks v6 업데이트

React Compiler 기반 규칙을 사용하거나 새로운 ESLint Flat Config를 적용하려면 린터 구성을 수동으로 업데이트해야 하는 번거로움이 있었습니다.

eslint-plugin-react-hooks의 최신 버전이 출시되었으며 기본적으로 Flat Config를 지원합니다. 새로운 React Compiler 기반 규칙에 대한 선택적 옵트인이 포함되어 미래 기술 스택을 준비할 수 있습니다. 레거시 구성을 유지하려면 다음과 같이 recommended-legacy로 변경해야 합니다.

- extends: ['plugin:react-hooks/recommended']
+ extends: ['plugin:react-hooks/recommended-legacy']

useId 접두사 변경

이전 useId 접두사에는 :r: 또는 «r»과 같이 View Transitionsview-transition-name 속성값이나 XML 1.0 이름 규칙에 유효하지 않은 특수 문자가 포함되어 있었습니다. 이는 향후 기능과의 통합에 문제를 일으킬 수 있었습니다.

useId의 기본 접두사가 _r_로 변경되었습니다. 이 접두사는 View TransitionsXML 1.0 이름에 모두 유효한 문자열이므로 호환성을 확보합니다.

참조

profile
불편함을 고민하는 프론트엔드 개발자, 박민철입니다.

0개의 댓글