React 19.2 공식 출시: Activity와 useEffectEvent가 해결하는 상태 보존 및 의존성

Maru

@maru

React 19.2 공식 출시: Activity와 useEffectEvent가 해결하는 상태 보존 및 의존성 지옥

React 19.2 공식 출시: Activity와 useEffectEvent가 해결하는 상태 보존 및 의존성 지옥

React 19.2 마이너 업데이트는 오랫동안 개발자들을 괴롭혔던 프론트엔드의 고질적인 한계들을 해결하는 중대한 전환점입니다. 이번 릴리스의 핵심은 단순히 새로운 편의 기능을 추가하는 수준을 넘어, 컴포넌트의 생명주기와 데이터 흐름을 다루는 React 런타임의 근본적인 제약을 극복하는 데 있습니다.

그동안 웹 애플리케이션을 개발할 때 상태를 보존하면서 화면에서 요소를 잠시 숨기거나, 불필요한 의존성 배열 때문에 이펙트가 무한히 재실행되는 현상, 그리고 서버 컴포넌트 환경에서 이미 취소된 요청이 백그라운드 서버 자원을 낭비하는 문제 등은 명확한 정답 없이 임시방편적인 해결책에 의존해야 했습니다.

React 19.2는 이러한 고질적인 엔지니어링 병목을 해결하기 위해 세 가지 강력한 도구를 안정적인 스펙으로 공식 도입했습니다. 화면에서 보이지 않는 컴포넌트의 상태를 안전하게 보존하는 Activity, 부수 효과 내부에서 의존성 배열의 제약 없이 최신 값을 안전하게 참조하게 돕는 useEffectEvent, 그리고 서버 렌더링 과정에서 중도 포기된 불필요한 네트워크 요청을 자동으로 중단하는 cacheSignal이 그 주인공입니다.

이 가이드에서는 새로운 API들이 설계된 배경과 작동 원리를 자세히 뜯어보며, 이 도구들이 실제 프로덕션 환경의 사용자 경험과 아키텍처를 어떻게 혁신적으로 개선하는지 단계별로 살펴보겠습니다.

탭 전환과 사이드바 상태 유실을 해결하는 Activity 컴포넌트

기존 웹 애플리케이션에서 탭을 전환하거나 사이드바를 접고 펼 때, 개발자들은 언제나 성능과 사용자 경험 사이에서 불합리한 타협을 해야 했습니다. 화면에서 보이지 않는 요소를 처리하는 기존의 두 가지 방식은 각각 치명적인 한계를 가지고 있었습니다.

  • 표준 React 언마운트 방식: 화면에서 컴포넌트를 완전히 제거하는 방식입니다. 메모리와 DOM 트리에서 완전히 사라지기 때문에 불필요한 렌더링 리소스를 아낄 수 있다는 장점이 있습니다. 하지만 컴포넌트가 가지고 있던 스크롤 위치, 입력 필드의 텍스트, 로컬 상태가 모두 초기화되어 사용자가 다시 해당 화면으로 돌아왔을 때 이전 작업 흐름이 완전히 끊어집니다.
  • CSS display: none 방식: 컴포넌트를 메모리와 DOM에 그대로 유지한 채 시각적으로만 숨기는 방식입니다. 상태는 보존되지만, 보이지 않는 백그라운드 상태에서도 React의 가상 DOM 트리에 남아 불필요한 렌더링 연산을 계속 수행합니다. 특히 컴포넌트 내부에서 백그라운드로 실행 중인 타이머, 이벤트 리스너, 소켓 연결 같은 이펙트가 정리되지 않고 백그라운드에서 무한히 작동하여 전체 애플리케이션의 성능을 크게 좀먹습니다.

React 19.2에서 마침내 안정적인 스펙으로 도입된 <Activity /> 컴포넌트는 이 고질적인 문제를 프레임워크 런타임 수준에서 우아하게 해결합니다. 이 컴포넌트는 visiblehidden 두 가지 모드를 제공하며, 개발자가 복잡한 상태 복원 로직을 작성하지 않고도 두 방식의 장점만을 누릴 수 있도록 돕습니다.

동작 메커니즘은 매우 직관적이면서도 강력합니다. <Activity mode="hidden">으로 설정되면 React는 내부적으로 CSS의 display: none을 적용하여 DOM 노드를 유지합니다. 하지만 단순히 시각적으로만 숨기는 데 그치지 않고, 다음과 같은 강력한 최적화 작업을 동시에 수행합니다.

  1. 이펙트 정리 및 일시 중지: 컴포넌트 내부에서 실행 중이던 모든 이펙트의 정리 함수를 실행합니다. 즉, 백그라운드에서 불필요하게 리소스를 낭비하던 소켓 연결이 끊어지고 타이머나 구독이 안전하게 일시 중지됩니다.
  2. 렌더링 우선순위 강하: 숨겨진 컴포넌트 트리 내의 상태 업데이트 우선순위를 최하위로 낮춥니다. 이로 인해 사용자가 직접 눈으로 보고 상호작용하는 활성 화면의 렌더링에만 브라우저의 모든 연산 자원을 집중하도록 만듭니다.

이후 사용자가 탭을 다시 열어 mode="visible"로 전환하면, React는 보존되어 있던 DOM 상태를 그대로 화면에 다시 드러냅니다. 이와 동시에 일시 중지되었던 이펙트들을 다시 마운트하여 실시간 연결이나 리액티브한 동작을 자연스럽게 재개합니다. 개발자는 복잡한 수동 상태 관리 로직을 구현할 필요 없이 완벽한 성능 최적화와 부드러운 사용자 경험을 모두 챙길 수 있게 되었습니다.

이펙트 의존성 지옥을 탈출하는 useEffectEvent

React 개발을 하다 보면 누구나 한 번쯤 이펙트 내부에서 최신 상태값을 읽어야 하는데, 이 값 때문에 이펙트가 불필요하게 재실행되는 현상으로 골머리를 앓게 됩니다. 이른바 의존성 지옥이라 불리는 이 문제는 React의 이펙트 설계 구조상 특정 값을 내부에서 참조하면 반드시 의존성 배열에 포함해야 한다는 규칙 때문에 발생합니다.

가장 대표적인 사례가 바로 실시간 연결을 관리하는 웹소켓입니다. 소켓 연결은 오직 연결 대상 주소가 바뀔 때만 새로 맺어져야 합니다. 하지만 만약 소켓으로 메시지를 수신했을 때 사용자가 현재 설정한 테마 상태를 함께 활용해야 한다면 상황이 복잡해집니다.

기존에는 이 문제를 해결하기 위해 다음과 같이 작성할 수밖에 없었습니다.

useEffectEvent 도입 이전: 불필요한 재연결의 악순환

javascript
useEffect(() => {
  const socket = connectToSocket(url);

  socket.on('message', (msg) => {
    // 메시지 수신 시점의 최신 테마를 로그에 남기고 싶습니다.
    showNotification(msg, theme);
  });

  return () => socket.disconnect();
}, [url, theme]); // theme이 바뀔 때마다 웹소켓 연결이 끊어지고 다시 생성됩니다!

위 코드에서 테마 상태는 단순한 UI 설정일 뿐 소켓 연결의 수명주기와는 아무런 관련이 없습니다. 하지만 이펙트 내부에서 이 값을 참조하고 있기 때문에 의존성 배열에 추가해야만 합니다. 그 결과 사용자가 화면 스타일을 바꾸기만 해도 소켓 연결이 끊어졌다가 새로 연결되는 불필요한 네트워크 비용이 발생하게 됩니다.

이 문제를 우회하기 위해 useRef를 사용해 수동으로 최신 값을 동기화하는 패턴을 쓰기도 했지만, 이는 코드를 복잡하게 만들고 동기화 타이밍 문제로 인한 버그를 유발하기 쉬웠습니다.

useEffectEvent 도입 이후: 반응형 데이터와 비반응형 이벤트의 완벽한 분리

React 19.2에서 안정화 스펙으로 정식 도입된 useEffectEvent 훅은 이 문제를 완전히 해결합니다. 이 훅은 이펙트 안에서 사용되지만, 상태 변화에 반응하지 않는 비반응형 이벤트 로직을 분리해 내는 특수한 도구입니다.

useEffectEvent로 감싼 함수는 렌더링 시점의 가장 최신 상태와 속성값에 제약 없이 접근할 수 있으면서도, 이펙트의 의존성 배열에서는 완벽하게 제외됩니다.

javascript
// 상태 변화에 따라 이펙트를 재실행하지 않는 이벤트 함수를 정의합니다.
const onMessageReceived = useEffectEvent((msg) => {
  showNotification(msg, theme);
});

useEffect(() => {
  const socket = connectToSocket(url);

  socket.on('message', (msg) => {
    // 이벤트 함수를 호출하여 안전하게 최신 테마 값을 참조합니다.
    onMessageReceived(msg);
  });

  return () => socket.disconnect();
}, [url]); // 이제 의존성 배열에서 theme이 완전히 제거되었습니다!

이 구조 덕분에 이제 소켓은 오직 url이 바뀔 때만 재연결됩니다. 사용자가 테마를 변경하더라도 onMessageReceived는 항상 렌더링 시점의 가장 최신 테마 값을 올바르게 참조하여 동작하기 때문에 소켓 재연결 없이도 원하는 비즈니스 로직을 완벽하게 수행할 수 있습니다.

즉, useEffectEvent는 이펙트 내에서 반응해야 하는 대상인 url과, 반응하지 않고 값만 읽어와야 하는 대상인 테마 상태의 경계를 명확히 나누어 줍니다. 덕분에 개발자는 최신 데이터 접근성을 유지하면서도 불필요한 부작용을 원천적으로 차단할 수 있게 되었습니다.

서버 컴포넌트의 중도 포기된 요청을 취소하는 cacheSignal

서버 사이드 렌더링과 서버 컴포넌트 환경에서는 사용자가 페이지가 완전히 로딩되기도 전에 다른 링크를 클릭해 이동하는 일이 매우 흔하게 일어납니다. 클라이언트 측에서는 이미 이전 페이지의 정보가 필요 없어졌지만, 서버는 그 사실을 알지 못한 채 백그라운드에서 불필요해진 API 호출이나 데이터베이스 조회를 끝까지 수행하는 고질적인 자원 낭비 문제가 있었습니다.

React 19.2는 이러한 리소스 낭비를 방지하기 위해 cacheSignal API를 안정적인 스펙으로 도입했습니다. cacheSignal은 현재 실행 중인 서버 컴포넌트의 렌더링 컨텍스트와 연동된 표준 AbortSignal 객체를 반환하는 간결한 API입니다. 이 시그널은 사용자가 페이지 로딩 도중 다른 곳으로 이동하여 서버 렌더링이 중단되거나, 서버 측 데이터의 캐시 만료 시점에 도달하면 자동으로 취소 상태가 됩니다.

이 API를 활용하는 방법은 매우 직관적입니다. 아래 예시처럼 표준 fetch 함수의 signal 옵션에 cacheSignal이 반환하는 시그널을 전달하기만 하면 됩니다.

javascript
import { cacheSignal } from 'react';

async function ProductDetails({ productId }) {
  // 현재 서버 컴포넌트 요청의 생명주기에 연동된 AbortSignal을 가져옵니다.
  const signal = cacheSignal();

  // fetch 요청에 시그널을 주입하여 연동합니다.
  const response = await fetch(`https://api.example.com/products/${productId}`, {
    signal,
    next: { revalidate: 3600 }
  });
  const product = await response.json();

  return (
    <section>
      <h2>{product.title}</h2>
      <p>{product.description}</p>
    </section>
  );
}

위 코드에서 만약 클라이언트가 서버 응답을 모두 받기 전에 다른 페이지로 이동해 버리면, 리액트 서버 런타임은 즉시 해당 요청의 cacheSignal을 취소합니다. 이에 따라 진행 중이던 외부 API 호출인 fetch 역시 즉시 중단되어, 서버의 CPU 성능과 백그라운드 네트워크 대역폭을 모두 보호할 수 있게 됩니다.

특히 cacheSignal은 리액트의 독자적인 서버 캐싱 아키텍처 및 중복 제거 메커니즘과 긴밀하게 맞물려 작동합니다. 여러 서버 컴포넌트가 동일한 API를 호출할 때 리액트는 이를 단일 요청으로 처리하는 등의 최적화를 수행하는데, cacheSignal은 이러한 캐시의 유효 기한 만료나 캐시 재생성 과정에서 발생하는 렌더링 중단 시점까지 정밀하게 추적합니다. 유효하지 않은 캐시를 다시 가져오기 위해 백그라운드에서 갱신 작업을 하던 도중이라도, 렌더링 중단이 감지되는 순간 갱신 작업을 깔끔하게 종료해 주는 것입니다.

이러한 자동 취소 메커니즘은 복잡한 마이크로서비스 아키텍처나 대규모 데이터베이스 조회가 필요한 대형 프로덕션 서버 환경에서 시스템 안정성을 유지하는 핵심 장치 역할을 합니다. 개발자가 일일이 수동으로 AbortController를 제어하는 수고로움을 덜어주고, 리액트 엔진 자체의 생명주기에 맞추어 취소 로직을 완전히 일원화했다는 점에서 매우 뛰어난 개선사항입니다.

React 19.2 안착을 위한 준비 작업과 실무 도입 제언

React 19.2에서 정식 스펙으로 합류한 도구들은 단순히 코드를 조금 더 깔끔하게 작성할 수 있도록 돕는 편의 기능이 아닙니다. 그동안 개발자가 컴포넌트 생명주기와 부수 효과를 다루면서 감내해야 했던 고질적인 제약 사항들을 프레임워크 수준에서 근본적으로 해결하는 설계의 전환점입니다. 상태 유실 없는 컴포넌트 제어, 의존성 배열의 제약에서 벗어난 이벤트 처리, 그리고 무의미한 서버 자원 낭비를 막는 요청 취소는 현대 웹 애플리케이션의 안정성과 사용자 경험을 한 차원 높여주는 뼈대가 됩니다.

이러한 기능들을 실제 프로덕션 환경에 안전하게 도입하기 위해서는 도구의 도움을 받아 올바른 설계 규칙을 강제하는 것이 좋습니다. 특히 useEffectEvent는 강력한 최적화 기능을 제공하지만, 오직 이펙트 내부에서만 호출되어야 하며 외부 컴포넌트의 프롭스로 전달해서는 안 된다는 엄격한 규칙이 존재합니다.

이 문제를 방지하기 위해 React 19.2 출시와 함께 배포된 eslint-plugin-react-hooks v6 린터 플러그인을 활성화해야 합니다. 이 플러그인은 useEffectEvent가 잘못된 위치에서 호출되거나 프롭스로 유출되는 안티 패턴을 정적 분석 단계에서 정교하게 감지하고 차단하여 잠재적인 런타임 오류를 방지해 줍니다.

단순한 버그 패치나 마이너 업그레이드를 넘어, 기존의 복잡했던 우회책들을 걷어내고 더 선언적이고 명확한 아키텍처를 구축할 수 있는 기회입니다. 실무 프로젝트의 성격에 맞춰 점진적으로 새로운 API들을 도입하고, 보다 견고한 개발 환경을 구축해 보길 권장합니다.