본문으로 건너뛰기

React 19.3 실전 가이드: View Transitions·Fragment Refs·browser()로 바뀌는 UI 설계

React약 18분 읽기본문 8,959자

핵심 요약

이 글에서 확인할 내용

React 19.3은 작은 버전이지만 UI 경계를 크게 바꾼다 2026년 9월 9일 공개된 React 19.3은 메이저 버전처럼 대규모 마이그레이션을 요구하지 않습니다. 하지만 실무에서 반복하던 네 가지 우회 코드를 공식 API로 바꾼다는 점에서 의미가 큽니다.

  • React 19.3은 작은 버전이지만 UI 경계를 크게 바꾼다
  • 1. 업그레이드 전에 호환성 기준부터 세운다
  • 2. View Transitions는 애니메이션 라이브러리의 대체재가 아니다

React 19.3의 View Transitions, Fragment Refs, 서버와 브라우저 경계, Trusted Types를 표현한 대표 이미지

React 19.3은 작은 버전이지만 UI 경계를 크게 바꾼다

2026년 9월 9일 공개된 React 19.3은 메이저 버전처럼 대규모 마이그레이션을 요구하지 않습니다. 하지만 실무에서 반복하던 네 가지 우회 코드를 공식 API로 바꾼다는 점에서 의미가 큽니다.

화면 전환 애니메이션을 React의 렌더링 흐름과 함께 조정하는 <ViewTransition>, 불필요한 래퍼 DOM 없이 여러 노드를 제어하는 Fragment Refs, 브라우저에서만 의미 있는 UI를 Suspense 경계로 분리하는 browser(), 그리고 DOM 기반 XSS 방어를 위한 Trusted Types 지원이 핵심입니다.

React 19.3 공식 발표에 따르면 View Transitions와 Fragment Refs는 실험 단계를 마치고 안정화된 API가 되었습니다. React 공식 버전 페이지도 최신 안정 버전을 19.3으로 안내합니다.

따라서 새 프로젝트뿐 아니라 React 19.2를 운영 중인 팀도 기능별로 도입을 검토할 시점입니다.

이번 글에서는 기능 목록을 나열하는 대신 실제 제품 코드에서 어디에 적용하고 무엇을 조심해야 하는지 살펴봅니다. 애니메이션, DOM 접근, SSR 예외, 보안 정책은 서로 다른 문제처럼 보이지만 모두 React 트리와 브라우저 플랫폼 사이의 경계를 더 명시적으로 만든다는 공통점을 갖습니다.


1. 업그레이드 전에 호환성 기준부터 세운다

React 19.3 도입은 패키지 버전을 올리는 것부터 시작하지 않습니다. 먼저 애플리케이션과 프레임워크가 React 19.3을 지원하는지 확인하고, 라이브러리가 React 내부 구현이나 실험 API에 의존하지 않는지 점검해야 합니다.

특히 Server Components를 사용하는 프로젝트는 React와 프레임워크가 같은 조합을 검증했는지 확인하는 편이 안전합니다.

다음 순서로 작은 PR을 만드는 것이 좋습니다.

  1. react와 react-dom을 같은 19.3 버전으로 맞춘다.
  2. 타입 패키지를 별도로 고정했다면 호환 버전으로 함께 갱신한다.
  3. 기존 테스트와 프로덕션 빌드를 기능 변경 없이 먼저 통과시킨다.
  4. View Transition, Fragment Ref 같은 새 기능은 각각 별도의 PR에서 도입한다.
  5. 모션 감소 설정, 키보드 탐색, SSR 결과, CSP 위반 리포트를 회귀 항목에 넣는다.
npm install react@19.3 react-dom@19.3
npm run test
npm run build

한 번에 새 API를 모두 적용하면 렌더링 문제와 도입 실수를 구분하기 어렵습니다. 패키지 업그레이드 PR과 기능 적용 PR을 분리하면 문제가 생겼을 때 원인을 좁히기 쉽고, 롤백도 단순해집니다.


2. View Transitions는 애니메이션 라이브러리의 대체재가 아니다

<ViewTransition>은 자식이 추가되거나 제거되거나 위치와 스타일이 바뀔 때 브라우저 View Transition API와 React 업데이트를 연결합니다. React는 트리 변화를 enter, exit, update, share 네 종류로 구분합니다.

기본값은 부드러운 크로스페이드이며, CSS 클래스나 이벤트 프로퍼티로 세부 동작을 바꿀 수 있습니다.

중요한 점은 모든 상태 변경이 애니메이션을 일으키지 않는다는 것입니다. startTransition 안의 업데이트, Suspense가 콘텐츠를 공개하는 순간, useDeferredValue로 지연된 업데이트처럼 Transition으로 표시된 작업이 대상입니다. 입력창 타이핑처럼 즉시 반영해야 하는 긴급 업데이트까지 움직이지 않도록 React의 우선순위 모델과 연결한 설계입니다.

import { startTransition, useState, ViewTransition } from 'react';

export function ProductGallery() {
  const [selectedId, setSelectedId] = useState('camera');

  function selectProduct(id: string) {
    startTransition(() => {
      setSelectedId(id);
    });
  }

  return (
    <section>
      <ProductTabs selectedId={selectedId} onSelect={selectProduct} />
      <ViewTransition key={selectedId}>
        <ProductDetail id={selectedId} />
      </ViewTransition>
    </section>
  );
}

여기서 key가 바뀌면 이전 상세 화면이 제거되고 새 상세 화면이 추가됩니다. React는 이 변화를 전환 대상으로 묶어 브라우저에 전달합니다.

하지만 애니메이션을 붙였다는 이유로 느린 데이터 요청이 빨라지는 것은 아닙니다. 전환은 지연을 감추는 장식이 아니라 변화의 공간적 맥락을 설명하는 도구여야 합니다.

기존 View Transitions API 활용 가이드는 브라우저 API 자체와 SPA 라우팅을 다룹니다. React 19.3의 차이는 렌더링 트리와 Transition 우선순위를 React가 조정한다는 데 있습니다.

이미 직접 document.startViewTransition()을 호출하고 있다면 두 방식이 같은 업데이트를 중복으로 감싸지 않는지 먼저 확인해야 합니다.


3. 전환 원인을 addTransitionType으로 표현한다

같은 컴포넌트 변경이라도 사용자의 의도에 따라 움직임은 달라야 합니다. 캐러셀의 다음 버튼은 오른쪽에서 왼쪽으로, 이전 버튼은 반대로 움직이는 것이 자연스럽습니다. React 19.3의 addTransitionType은 상태 값이 아니라 업데이트가 발생한 이유를 전환에 기록합니다.

import {
  addTransitionType,
  startTransition,
  useState,
  ViewTransition,
} from 'react';

export function StoryCarousel({ stories }: { stories: Story[] }) {
  const [index, setIndex] = useState(0);

  function move(direction: 'next' | 'previous') {
    startTransition(() => {
      addTransitionType(direction);
      setIndex(current =>
        direction === 'next'
          ? (current + 1) % stories.length
          : (current - 1 + stories.length) % stories.length,
      );
    });
  }

  return (
    <ViewTransition
      key={stories[index].id}
      enter={{ next: 'from-right', previous: 'from-left' }}
      exit={{ next: 'to-left', previous: 'to-right' }}
    >
      <StoryCard story={stories[index]} onMove={move} />
    </ViewTransition>
  );
}

이 패턴은 애니메이션 조건을 전역 상태에 복제하지 않는다는 장점이 있습니다. 전환 타입은 해당 업데이트에 붙는 문맥이고, 비즈니스 상태는 여전히 현재 인덱스 하나로 유지됩니다. 브라우저에도 전환 타입이 전달되므로 :active-view-transition-type(...) 선택자로 CSS 범위를 좁힐 수 있습니다.

다만 전환 타입을 분석 이벤트처럼 남발하면 컴포넌트가 복잡해집니다. 사용자가 방향이나 계층 이동을 인식해야 하는 경우에만 붙이고, 단순 색상 변경이나 작은 토글은 CSS transition으로 처리하는 편이 낫습니다.


4. Suspense와 결합할 때는 폴백을 즉시 보여준다

View Transitions와 Suspense를 함께 쓰면 로딩 화면에서 최종 콘텐츠로 바뀌는 순간을 자연스럽게 연결할 수 있습니다. 그러나 폴백 자체까지 천천히 나타나게 만들면 사용자는 클릭이 먹히지 않았다고 느낄 수 있습니다.

React 팀도 폴백은 즉시 표시하고, 폴백에서 완료 콘텐츠로 바뀌는 update만 애니메이션하는 패턴을 권장합니다.

import { Suspense, ViewTransition } from 'react';

export function RecommendationPanel() {
  return (
    <ViewTransition update="reveal" default="none">
      <Suspense fallback={<RecommendationSkeleton />}>
        <RecommendationList />
      </Suspense>
    </ViewTransition>
  );
}

default="none"은 enter와 exit를 포함한 기본 전환을 끄고, update만 허용합니다. 사용자가 행동한 직후 스켈레톤은 바로 보이며, 데이터와 이미지가 준비되면 최종 카드로 부드럽게 바뀝니다. 이미 캐시에 있는 UI는 즉시 표시되므로 불필요한 모션도 줄어듭니다.

이미지와 폰트 로딩도 View Transition 안에서 Suspense에 참여시킬 수 있지만, 모든 리소스를 하나의 거대한 경계에 넣으면 가장 느린 파일 때문에 전체 화면이 기다립니다. 상품 제목과 가격처럼 핵심 정보는 먼저 보여주고, 갤러리나 추천 영역처럼 시각적 완결성이 중요한 부분만 별도 경계로 나누는 것이 좋습니다.

또한 prefers-reduced-motion: reduce 사용자를 위한 스타일을 반드시 준비해야 합니다. 제품의 의미를 애니메이션 방향에만 의존시키지 말고, 포커스와 읽기 순서가 전환 전후에 유지되는지도 키보드로 검증해야 합니다.


5. Fragment Refs는 래퍼 div 없이 DOM 그룹을 제어한다

React Fragment는 여러 형제 노드를 묶되 실제 DOM을 추가하지 않습니다. 그동안 이 특성 덕분에 레이아웃은 깨끗하게 유지할 수 있었습니다.

하지만 형제 그룹 전체에 포커스 이동이나 관찰자를 붙이려면 불필요한 div를 추가하거나 각 자식 ref를 배열로 관리해야 했습니다. React 19.3에서는 <Fragment ref={...}>로 FragmentInstance를 얻을 수 있습니다.

import { Fragment, useEffect, useRef, type FragmentInstance } from 'react';

export function KeyboardRegion({ children }: { children: React.ReactNode }) {
  const regionRef = useRef<FragmentInstance | null>(null);

  useEffect(() => {
    const region = regionRef.current;
    if (!region) return;

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === 'Home') region?.focus();
      if (event.key === 'End') region?.focusLast();
    }

    region.addEventListener('keydown', handleKeyDown);
    return () => region.removeEventListener('keydown', handleKeyDown);
  }, []);

  return <Fragment ref={regionRef}>{children}</Fragment>;
}

FragmentInstance는 자식 DOM을 배열처럼 그대로 노출하지 않고, 그룹에 필요한 제한된 작업을 제공합니다. 이벤트 등록과 해제, focus()·focusLast()·blur(), IntersectionObserver나 ResizeObserver 연결, 사각형 측정, 스크롤 이동 등이 포함됩니다.

자식 컴포넌트가 ref를 전달하지 않아도 동작하고 DOM 구조도 바뀌지 않습니다.

좋은 사용 사례는 CSS Grid의 직계 자식 관계를 유지해야 하는 카드 묶음, 테이블 행 그룹, 여러 제목으로 이루어진 목차 구간, 관찰 가능한 섹션입니다. 반대로 실제 의미가 있는 영역이라면 section, nav, fieldset 같은 시맨틱 요소를 없애서는 안 됩니다. Fragment Ref는 래퍼를 피하는 도구이지 HTML 의미 구조를 지우는 도구가 아닙니다.


6. Observer 생명주기를 Fragment에 맡긴다

목록의 여러 노드가 화면에 들어왔는지 확인하려고 카드마다 옵서버를 생성하면 메모리 사용량과 정리 코드가 늘어납니다. FragmentInstance의 observeUsing과 unobserveUsing을 사용하면 하나의 옵서버를 그룹에 연결하고 같은 단위로 해제할 수 있습니다.

import { Fragment, useEffect, useRef, type FragmentInstance } from 'react';

export function ImpressionGroup({ children, onVisible }: Props) {
  const fragmentRef = useRef<FragmentInstance | null>(null);

  useEffect(() => {
    const fragment = fragmentRef.current;
    if (!fragment) return;

    const observer = new IntersectionObserver(entries => {
      const visibleIds = entries
        .filter(entry => entry.isIntersecting)
        .map(entry => (entry.target as HTMLElement).dataset.impressionId)
        .filter(Boolean) as string[];

      if (visibleIds.length > 0) onVisible(visibleIds);
    }, { threshold: 0.5 });

    fragment.observeUsing(observer);
    return () => fragment.unobserveUsing(observer);
  }, [onVisible]);

  return <Fragment ref={fragmentRef}>{children}</Fragment>;
}

여기서도 관찰 이벤트를 곧바로 네트워크 요청으로 보내면 스크롤 중 과도한 호출이 발생합니다. 동일 ID 중복 제거, 일정 시간 이상의 노출 조건, 페이지가 백그라운드로 전환됐을 때 중지하는 정책은 애플리케이션 레이어에서 별도로 설계해야 합니다. Fragment Ref는 관찰 대상 관리만 단순하게 해줍니다.


7. browser()는 mounted 상태 패턴을 Suspense로 바꾼다

서버 렌더링 환경에서 localStorage, 사용자 시간대, 브라우저 전용 SDK에 접근하는 컴포넌트는 오랫동안 mounted 상태나 typeof window !== 'undefined' 검사에 의존했습니다. 이 코드는 서버와 첫 클라이언트 렌더의 결과가 달라져 하이드레이션 오류를 만들기 쉽고, 로딩 UI를 컴포넌트마다 제각각 구현하게 합니다.

React 19.3의 browser()는 react-dom에서 가져와 use(browser()) 형태로 사용합니다. 서버에서는 가장 가까운 Suspense 경계를 일시 중단하고, 브라우저에서는 렌더링을 계속합니다.

import { Suspense, use } from 'react';
import { browser } from 'react-dom';

function LocalTimeZone() {
  use(browser());
  const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
  return <strong>{zone}</strong>;
}

export function EventTimeZone() {
  return (
    <Suspense fallback={<span>시간대 확인 중…</span>}>
      <LocalTimeZone />
    </Suspense>
  );
}

이 API의 장점은 브라우저 전용 조건을 React 렌더링 모델 안으로 가져온다는 것입니다. 서버 HTML에는 명시적인 폴백이 들어가고, 하이드레이션 후 브라우저에서 실제 값이 렌더링됩니다.

테스트에서도 mounted 타이밍을 임의로 기다리는 대신 Suspense 경계를 기준으로 상태를 검증할 수 있습니다.

그러나 browser()를 SSR을 끄는 만능 스위치로 사용하면 안 됩니다. 상품명, 가격, 문서 본문처럼 서버가 렌더링할 수 있는 콘텐츠까지 제외하면 초기 표시와 검색 접근성이 나빠집니다. 브라우저 API가 반드시 필요한 가장 작은 하위 컴포넌트에만 적용하고, 가능하면 서버가 기본값을 넘겨주는 방식을 우선합니다.


8. 조건부 browser()로 서버 데이터가 있을 때는 렌더한다

use는 일반 Hook과 달리 조건문이나 조기 반환 이후에도 호출할 수 있습니다. 이를 활용하면 서버가 초기 데이터를 제공한 경우에는 그대로 HTML을 만들고, 데이터가 없을 때만 브라우저 렌더링으로 넘길 수 있습니다.

import { use } from 'react';
import { browser } from 'react-dom';

function ClientOnlyChart({ initialData }: { initialData?: Point[] }) {
  if (initialData) {
    return <Chart points={initialData} />;
  }

  use(browser());
  const cached = readChartCache();
  return <Chart points={cached} />;
}

이 패턴은 같은 컴포넌트를 서버 데이터가 있는 상세 페이지와 브라우저 캐시를 쓰는 대시보드에서 재사용할 때 유용합니다. 다만 조건이 서버와 클라이언트에서 일관되어야 합니다. 서버에서는 initialData가 없는데 클라이언트 첫 렌더에서 갑자기 값이 생기는 구조라면 경계 밖의 마크업까지 달라지지 않는지 확인해야 합니다.

Next.js 같은 프레임워크가 제공하는 동적 import()나 Server Component 경계와 역할이 겹칠 수도 있습니다. 번들 자체에서 브라우저 전용 모듈을 제외해야 한다면 프레임워크 기능이 필요하고, 같은 번들 안에서 렌더 시점만 Suspense로 조정하려면 browser()가 적합합니다. 두 도구의 책임을 구분해야 합니다.


9. Trusted Types 지원은 보안 정책을 대신하지 않는다

Trusted Types는 innerHTML 같은 DOM 주입 지점에 일반 문자열 대신 정책을 거쳐 생성한 TrustedHTML 등의 객체만 전달하도록 강제합니다. 이를 통해 DOM 기반 XSS 위험을 줄일 수 있습니다.

React 19.3 이전에는 React가 값을 DOM API에 넘기기 전에 문자열로 강제 변환해 Trusted Types 객체가 다시 평범한 문자열이 되는 문제가 있었습니다. 19.3부터는 객체를 그대로 전달해 브라우저가 정책을 검증할 수 있습니다.

const policy = window.trustedTypes?.createPolicy('article-html', {
  createHTML(input) {
    return sanitizeHtml(input);
  },
});

const safeHtml = policy?.createHTML(untrustedHtml);

이 변화가 dangerouslySetInnerHTML을 자동으로 안전하게 만드는 것은 아닙니다. 어떤 입력을 허용하고 어떤 태그와 속성을 제거할지는 여전히 애플리케이션의 콘텐츠 정제 정책에 달려 있습니다.

CSP의 require-trusted-types-for 'script'와 trusted-types 정책 허용 목록을 Report-Only 모드로 먼저 배포하고, 위반 로그를 정리한 뒤 강제 모드로 전환하는 단계적 접근이 안전합니다.

서드파티 에디터, 분석 SDK, 오래된 위젯이 직접 DOM을 조작하면 정책 적용 후 깨질 수 있습니다. React 코드만 검사하지 말고 실제 사용자 흐름에서 발생하는 CSP 리포트와 브라우저 콘솔 위반을 수집해야 합니다. Trusted Types는 정제 도구 자체가 아니라 검증되지 않은 문자열이 위험한 API에 도달하지 못하게 하는 타입 경계입니다.


10. Server Components에서 Context 래퍼가 줄어든다

React 19.3은 Server Component가 Client Component 모듈에서 만든 Context를 직접 렌더할 수 있게 했습니다. Server Component가 Context를 생성할 수 있게 된 것은 아닙니다. 'use client' 파일에서 createContext로 만든 값을 가져와 별도의 Provider 래퍼 컴포넌트 없이 사용할 수 있다는 의미입니다.

// user-context.tsx
'use client';

import { createContext } from 'react';

export const UserContext = createContext<User | null>(null);
// layout.tsx - Server Component
import { UserContext } from './user-context';

export default async function Layout({ children }: LayoutProps) {
  const user = await getCurrentUser();

  return (
    <UserContext value={user}>
      {children}
    </UserContext>
  );
}

이전에는 값을 전달하기만 하는 UserProvider Client Component를 따로 만들어야 했습니다. 이제 경계의 목적이 코드에 더 직접적으로 드러나고 파일 하나를 줄일 수 있습니다.

다만 Context 값이 자주 바뀌거나 큰 객체라면 클라이언트 하위 트리의 렌더링 비용은 그대로입니다. Context 설계 원칙까지 달라진 것은 아니므로 상태를 무조건 한 Context에 모으지 않아야 합니다.

React Server Components 아키텍처 가이드에서 다룬 것처럼 서버와 클라이언트 경계는 직렬화 가능성, 번들 크기, 상호작용 필요성을 기준으로 나눠야 합니다. 새 문법은 불필요한 래퍼를 제거하지만 경계 설계 책임을 없애지는 않습니다.


11. 테스트는 시각 효과보다 불변 조건을 검증한다

View Transition을 도입한 뒤 테스트가 애니메이션 프레임과 클래스 이름에 과도하게 결합하면 작은 디자인 변경에도 깨집니다. 컴포넌트 테스트에서는 사용자의 클릭 뒤 올바른 콘텐츠와 포커스가 나타나는지, startTransition 동안 긴급 입력이 막히지 않는지 검증합니다.

방향과 시간 같은 시각적 품질은 대표 브라우저의 E2E 및 스크린샷 테스트로 분리하는 편이 안정적입니다.

Fragment Ref는 다음 조건을 확인합니다.

  • 추가 래퍼가 생기지 않아 CSS Grid나 table 구조가 유지되는가
  • 옵서버와 이벤트 리스너가 언마운트될 때 정리되는가
  • focus()와 focusLast()의 키보드 순서가 DOM 읽기 순서와 일치하는가
  • 자식이 조건부로 바뀌어도 오래된 DOM 노드를 참조하지 않는가

browser()는 서버 렌더 결과에 폴백이 들어가는지, 하이드레이션 이후 브라우저 값으로 교체되는지 두 단계를 나눠 검증합니다. Trusted Types는 CSP Report-Only 환경과 강제 환경을 모두 준비하고, 사용자 입력 HTML뿐 아니라 서드파티 스크립트 경로까지 포함해야 합니다.


12. 도입 우선순위는 문제의 크기로 정한다

React 19.3의 모든 기능을 동시에 사용할 필요는 없습니다. 화면 전환 애니메이션이 이미 복잡한 제품이라면 View Transitions가 가장 큰 효과를 냅니다. 불필요한 래퍼 때문에 CSS Grid와 접근성이 자주 깨진다면 Fragment Refs를 먼저 검토할 수 있습니다.

브라우저 API 의존 컴포넌트가 하이드레이션 예외를 많이 만든다면 browser()가 우선입니다. 보안팀이 엄격한 CSP를 운영한다면 Trusted Types 호환성이 업그레이드의 직접적인 이유가 됩니다.

권장 순서는 다음과 같습니다.

단계적용 대상성공 기준
1React 19.3 패키지 업그레이드기존 테스트·빌드·하이드레이션 경고 변화 없음
2브라우저 전용 컴포넌트 한 곳mounted 우회 제거, 서버 폴백 명확화
3목록 또는 갤러리 한 곳전환 중 입력 지연 없음, 모션 감소 설정 통과
4Fragment 옵서버 한 곳래퍼 제거, 정리 로직과 포커스 테스트 통과
5Trusted Types Report-Only위반 출처 분류와 정책 소유자 지정

기능 수가 아니라 제거한 우회 코드와 줄어든 오류를 측정해야 합니다. 하이드레이션 경고 수, 애니메이션 관련 레이아웃 이동, 중복 옵서버 수, CSP 위반 건수처럼 변경 전후를 비교할 수 있는 지표를 정하면 도입 효과가 분명해집니다.


마치며 React와 브라우저의 경계가 더 명시적으로 보인다

React 19.3은 화려한 문법보다 경계의 품질을 높이는 릴리스입니다. View Transitions는 React 업데이트 우선순위와 브라우저 애니메이션을 연결하고, Fragment Refs는 DOM 구조를 바꾸지 않은 채 노드 그룹을 제어합니다.

browser()는 SSR 불가능한 부분을 Suspense로 드러내며, Trusted Types 지원은 문자열과 안전한 DOM 값의 경계를 보존합니다.

좋은 도입은 새 API를 많이 쓰는 것이 아니라 기존 코드의 의도를 더 정확하게 표현하는 것입니다. 먼저 19.3 업그레이드 자체를 안정화하고, 실제로 우회 코드가 많은 경계 한 곳씩 교체해 보세요. 전환은 더 자연스러워지고, DOM은 더 단순해지며, 서버와 브라우저의 책임은 이전보다 분명해질 것입니다.


함께 읽으면 좋은 글

작성·검증 정보

이 글은 본문에 연결된 외부 참고 링크 1개와 코드 예제를 바탕으로 작성했습니다. 기술 버전과 지원 범위는 바뀔 수 있으므로 적용 전 연결된 공식 자료의 최신 내용을 함께 확인해 주세요.

발행일 · 작성·정정 원칙 보기

← Waylog Blog 홈으로 돌아가기