안녕하세요. J4J입니다.
이번 포스팅은 React 19에 새롭게 추가된 문서 메타데이터와 리소스 프리로드 기능을 이용하여 react-helmet 없이 head를 관리하는 방법에 대해 알아보는 시간을 가져보려고 합니다.
react-helmet 없이 React 19에서 문서 메타데이터를 다뤄야 하는 이유
Next.js를 사용하지 않는 순수 React 프로젝트에서 페이지마다 title이나 meta 태그를 동적으로 바꾸려면 지금까지는 react-helmet-async 같은 별도 라이브러리를 설치해야 했습니다.
react-helmet-async는 Helmet 컴포넌트로 head에 들어갈 태그들을 감싸는 방식으로 동작합니다.
// ❌ react-helmet-async로 head를 관리하는 기존 방식
import { Helmet } from 'react-helmet-async'
export default function ProductPage({ product }: { product: Product }) {
return (
<>
<Helmet>
<title>{`${product.name} - MyShop`}</title>
<meta name="description" content={product.description} />
</Helmet>
<div>{product.name}</div>
</>
)
}
이렇게 작성하려면 react-helmet-async 패키지를 별도로 설치하고, 앱 최상단을 HelmetProvider로 감싸는 초기 설정도 함께 필요합니다.
// ✅ React 19 내장 기능으로 head를 관리하는 방식
export default function ProductPage({ product }: { product: Product }) {
return (
<>
<title>{`${product.name} - MyShop`}</title>
<meta name="description" content={product.description} />
<div>{product.name}</div>
</>
)
}
React 19부터는 title, meta, link 태그를 컴포넌트 트리 어디에서나 렌더링하기만 하면 React가 자동으로 head로 옮겨줍니다.
별도 라이브러리도, Provider 설정도 필요하지 않습니다.
다음과 같은 상황에서 React 19 내장 기능을 사용하는 것이 좋습니다.
- 별도 라이브러리 설치나 Provider 설정 없이 페이지별 title, meta를 관리하고 싶을 때
- 페이지마다 필요한 CSS를 그 페이지에 진입할 때만 로드하고 싶을 때
- 서드파티 스크립트를 특정 컴포넌트가 렌더링될 때만 삽입하고 싶을 때
- 다음에 이동할 화면에서 쓸 이미지, 폰트, API 서버를 미리 준비해 두고 싶을 때
다만 titleTemplate으로 전체 페이지의 title 형식을 일괄 정의하거나, htmlAttributes·bodyAttributes처럼 html/body 태그 자체의 속성을 제어해야 하는 경우에는 react-helmet-async가 여전히 필요합니다.
그러면 실제로 title, meta, link를 어떻게 렌더링하는지 살펴보겠습니다.
title, meta, link를 컴포넌트에서 직접 렌더링하기
title, meta, link는 컴포넌트 트리 어디에 렌더링하든 React가 자동으로 head 태그 안으로 옮겨주는 특수 처리 대상입니다.
import 없이 JSX 태그로 바로 사용할 수 있어, 조건에 따라 다른 상품 정보를 보여주는 페이지를 다음과 같이 구성해 볼 수 있습니다.
// pages/document-metadata/index.tsx
import { useState } from 'react'
interface Product {
id: number
name: string
description: string
}
const products: Product[] = [
{ id: 1, name: '무선 키보드', description: '저소음 무선 키보드 상세 설명입니다.' },
{ id: 2, name: '블루투스 마우스', description: '휴대성이 좋은 블루투스 마우스 상세 설명입니다.' },
]
export default function DocumentMetadataPage() {
const [selectedId, setSelectedId] = useState(products[0].id)
const selected = products.find((product) => product.id === selectedId)!
return (
<div className="flex flex-col gap-4 p-6">
<title>{`${selected.name} - MyShop`}</title>
<meta name="description" content={selected.description} />
<link rel="canonical" href={`https://myshop.example.com/products/${selected.id}`} />
<div className="flex gap-2">
{products.map((product) => (
<button
key={product.id}
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => setSelectedId(product.id)}
>
{product.name}
</button>
))}
</div>
<p className="text-sm text-gray-500">현재 선택된 상품: {selected.name}</p>
</div>
)
}
버튼을 클릭해 상품을 바꾸면 브라우저 탭의 title과 meta description이 즉시 함께 바뀌는 것을 확인할 수 있습니다.
다만 title의 children에는 문자열 하나만 전달할 수 있습니다.
// ❌ 텍스트와 표현식을 섞어서 children에 전달하는 경우
<title>Results page {pageNumber}</title>
이렇게 작성하면 children이 문자열이 아니라 배열로 전달되어 title이 의도한 대로 표시되지 않습니다.
// ✅ 템플릿 리터럴로 미리 하나의 문자열로 만드는 방식
<title>{`Results page ${pageNumber}`}</title>
그러므로 변수를 함께 표시해야 하는 title은 템플릿 리터럴로 문자열을 먼저 완성한 뒤 children으로 전달하는 것이 좋습니다.
또한 title과 meta는 hoist만 해줄 뿐 중복 제거는 해주지 않습니다.
두 개 이상의 컴포넌트가 동시에 title을 렌더링하면 React는 둘 다 head로 옮기기만 하고, 그 중 어떤 것이 브라우저에 최종 반영될지는 보장하지 않습니다.
react-router-dom과 함께 사용한다면, 라우트 전환 과정에서 이전 페이지가 채 사라지기 전에 새 페이지가 먼저 title을 렌더링하는 순간이 존재할 수 있습니다.
그러므로 title은 라우트 컴포넌트 최상단 한 곳에서만 렌더링하는 규칙을 프로젝트 안에서 직접 정해두는 것이 좋습니다.
다음으로는 이와 대조적으로 중복 제거가 자동으로 이루어지는 stylesheet를 살펴보겠습니다.
Stylesheet precedence로 로딩 순서 충돌 없이 관리하기
link 태그로 stylesheet를 렌더링할 때 precedence prop을 함께 지정하면, React가 로딩 순서와 중복 삽입을 직접 관리해 줍니다.
precedence prop이 없으면 이런 특별 처리가 전혀 동작하지 않고, 일반 DOM 요소처럼 렌더링된 위치 그대로 삽입됩니다.
개인적으로 precedence를 처음 접했을 때 low, medium, high 같은 이름 때문에 값 자체에 우선순위 등급이 내장되어 있다고 생각했습니다.
하지만 실제로는 precedence 값에 우선순위가 미리 정해져 있는 것이 아니라, 그 값이 트리에서 처음 등장한 순서대로 그룹의 위치가 정해집니다.
즉, 이름이 low든 high든 상관없이 나중에 처음 등장한 precedence 그룹이 head에서 더 뒤에 삽입되어 동일한 선택자에서는 더 우세하게 적용됩니다.
직접 두 개의 stylesheet로 확인해 보겠습니다.
// public/theme-low.css
.precedence-box {
background-color: #fca5a5;
}
// public/theme-high.css
.precedence-box {
background-color: #5eead4;
}
// pages/stylesheet-precedence/ThemeLink.tsx
interface ThemeLinkProps {
href: string
precedence: string
}
export default function ThemeLink({ href, precedence }: ThemeLinkProps) {
return <link rel="stylesheet" href={href} precedence={precedence} />
}
// pages/stylesheet-precedence/index.tsx
import ThemeLink from './ThemeLink'
export default function StylesheetPrecedencePage() {
return (
<div className="flex flex-col gap-4 p-6">
<ThemeLink href="/theme-low.css" precedence="low" />
<ThemeLink href="/theme-high.css" precedence="high" />
<div className="precedence-box rounded p-6 text-sm">
precedence="high"가 나중에 처음 등장했기 때문에 이 상자는 teal 색상으로 보입니다.
</div>
</div>
)
}
두 ThemeLink 모두 같은 precedence-box 선택자에 서로 다른 배경색을 정의하고 있지만, precedence="high" 그룹이 나중에 처음 등장했기 때문에 최종적으로 teal 색상이 적용됩니다.
다만 JSX에서 ThemeLink 두 개의 순서를 바꾸면 결과도 함께 달라지는데, precedence 그룹의 순위를 결정하는 것은 코드상 위치가 아니라 각 precedence 값이 처음 등장하는 시점이기 때문에 순서를 바꾸면 그 시점 자체가 뒤바뀝니다.
다만 같은 precedence 값을 가진 요소가 여러 번 렌더링되는 경우에는 주의가 필요합니다.
두 번째 이후에 등장하는 같은 precedence 값의 요소는 새 자리를 만들지 않고, 그 값이 처음 자리를 만들었던 위치에 그대로 합류합니다.
precedence="high"가 두 번, precedence="low"가 한 번 등장하는 경우로 직접 확인해 보겠습니다.
// precedence="high"가 두 번, precedence="low"가 한 번 등장하는 경우
<ThemeLink href="/a.css" precedence="high" />
<ThemeLink href="/b.css" precedence="low" />
<ThemeLink href="/c.css" precedence="high" />
high 자리는 a.css가 처음 등장할 때 만들어지므로, 이후에 등장하는 c.css는 새 자리를 만들지 않고 그 자리에 합류합니다.
low 자리는 b.css가 등장하는 시점에 비로소 만들어지는데, 이 시점이 high 자리보다 뒤이기 때문에 low 자리가 head에서 더 뒤에 위치합니다.
즉, 코드에서 마지막에 렌더링되는 요소는 precedence="high"인 c.css이지만, 실제로 화면에 적용되는 배경색은 low 쪽입니다.
그러므로 겹치는 stylesheet의 우선순위를 예측할 때는 코드에서 마지막에 등장하는 위치가 아니라, 각 precedence 값이 처음 등장하는 순서를 기준으로 판단하는 것이 좋습니다.
또한 link, script, style 태그는 한 번 렌더링된 이후 props를 바꾸어도 반영되지 않습니다.
precedence나 href 값을 상태에 따라 동적으로 바꾸고 싶다면 key를 다르게 주어 컴포넌트를 새로 마운트시키는 방식을 사용하는 것이 좋습니다.
다음으로는 script 태그를 컴포넌트 트리 어디서든 안전하게 렌더링하는 방법을 살펴보겠습니다.
Async Script를 컴포넌트 트리 어디서든 렌더링하기
script 태그도 src와 async={true}를 함께 지정하면 stylesheet와 마찬가지로 React가 특별하게 처리합니다.
여러 컴포넌트가 같은 src를 가진 script를 렌더링하더라도, React는 src를 기준으로 중복을 판단하여 실제로는 한 번만 삽입하고 한 번만 실행합니다.
앞서 살펴본 title, meta와 달리 script는 자동으로 중복이 제거된다는 점이 재밌는 차이입니다.
직접 컴포넌트를 여러 번 마운트해서 확인해 보겠습니다.
// public/mock-analytics.js
console.log('[mock-analytics] script executed')
// pages/async-script/AnalyticsScript.tsx
export default function AnalyticsScript() {
return <script async src="/mock-analytics.js" />
}
// pages/async-script/index.tsx
import { useState } from 'react'
import AnalyticsScript from './AnalyticsScript'
export default function AsyncScriptPage() {
const [showBanner, setShowBanner] = useState(false)
return (
<div className="flex flex-col gap-4 p-6">
<AnalyticsScript />
{showBanner && <AnalyticsScript />}
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => setShowBanner((prev) => !prev)}
>
배너 컴포넌트 {showBanner ? '숨기기' : '보이기'}
</button>
<p className="text-sm text-gray-500">콘솔을 확인하면 스크립트가 한 번만 실행된 로그를 볼 수 있습니다.</p>
</div>
)
}
배너 컴포넌트 버튼을 눌러 AnalyticsScript를 추가로 마운트해도, 콘솔에는 script executed 로그가 처음 한 번만 출력됩니다.
서로 다른 컴포넌트에서 같은 서드파티 스크립트가 필요할 때, 어느 한 곳에서만 로드되었는지 신경 쓰지 않고 각자 필요한 곳에 그대로 렌더링해도 되는 이유가 여기에 있습니다.
다음으로는 아직 렌더링되지 않은 화면의 리소스를 미리 준비해 두는 방법을 살펴보겠습니다.
리소스 프리로드 API로 다음 화면 준비하기
react-dom 패키지는 렌더링과 무관하게 리소스를 미리 준비해 둘 수 있는 4가지 함수를 제공합니다.
import { preload, preinit, preconnect, prefetchDNS } from 'react-dom'
[ preload ]
preload는 이미지, 폰트, 스크립트 등의 리소스를 다운로드만 미리 받아 두고 실행하지는 않는 함수입니다.
as 옵션은 필수이며 font, image, script, style 등 리소스 종류를 지정합니다.
[ preinit ]
preinit은 스크립트나 스타일시트를 다운로드한 뒤 즉시 실행(삽입)까지 함께 처리하는 함수입니다.
as 옵션은 script 또는 style만 지정할 수 있으며, style을 preinit할 때는 precedence도 함께 지정합니다.
[ preconnect / prefetchDNS ]
preconnect는 외부 도메인과 커넥션(TCP, TLS)까지 미리 맺어 두는 함수이고, prefetchDNS는 도메인의 DNS 조회만 미리 수행하는 함수입니다.
prefetchDNS는 href 하나만 인자로 받고, preconnect는 crossOrigin 옵션을 선택적으로 함께 전달할 수 있습니다.
실제 API를 호출하기 전에 연결을 미리 맺어 두고 싶을 때, preconnect가 prefetchDNS보다 더 강한 준비를 해 둡니다.
네 함수 모두 렌더링 중, useEffect, 이벤트 핸들러 어디에서 호출해도 동일하게 동작하므로, 버튼 클릭 시점에 호출하는 예제로 확인해 보겠습니다.
// public/preview.svg
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="28" fill="#38bdf8"/></svg>
// pages/resource-preload/index.tsx
import { preload, preinit, preconnect, prefetchDNS } from 'react-dom'
export default function ResourcePreloadPage() {
function handlePreloadImage() {
preload('/preview.svg', { as: 'image' })
}
function handlePreinitStyle() {
preinit('/theme-high.css', { as: 'style', precedence: 'high' })
}
function handlePreconnect() {
preconnect('https://api.example.com')
}
function handlePrefetchDNS() {
prefetchDNS('https://cdn.example.com')
}
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex gap-2">
<button className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50" onClick={handlePreloadImage}>
이미지 미리 받기
</button>
<button className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50" onClick={handlePreinitStyle}>
스타일시트 미리 적용하기
</button>
<button className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50" onClick={handlePreconnect}>
API 서버 미리 연결하기
</button>
<button className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50" onClick={handlePrefetchDNS}>
CDN 도메인 DNS 미리 조회하기
</button>
</div>
<p className="text-sm text-gray-500">버튼을 클릭한 뒤 개발자 도구의 Elements 패널에서 head 태그 내부를 확인하면 태그가 추가된 것을 볼 수 있습니다.</p>
</div>
)
}
버튼을 누르면 head 태그 내부에 link나 script가 즉시 추가되는 것을 개발자 도구에서 확인할 수 있습니다.
같은 href로 여러 번 호출해도 React가 이미 처리된 요청으로 인식하기 때문에 중복으로 리소스를 받아오지 않습니다.
다만 폰트를 preload할 때 crossOrigin 옵션을 함께 지정하지 않으면, 실제 폰트가 로드되는 시점에 브라우저가 다시 요청을 보내 중복 다운로드가 발생할 수 있습니다.
그러므로 폰트를 preload할 때는 실제 네트워크 탭에서 요청이 한 번만 발생하는지 직접 확인하며 사용하는 것이 좋습니다.
정리
지금까지 살펴본 내용을 정리하면 다음과 같습니다.
| 구분 | react-helmet-async | React 19 내장 기능 |
|---|---|---|
| 설치·설정 | 패키지 설치 + HelmetProvider 필요 | 불필요 — JSX 태그로 바로 사용 |
| title, meta 관리 | Helmet 컴포넌트로 감싸서 관리 | title, meta 태그를 직접 렌더링, 자동 hoist |
| 중복 처리 | 마지막에 렌더링된 값으로 라이브러리가 정리 | title·meta는 중복 제거 없음, script·link(stylesheet)는 src·precedence 기준으로 자동 제거 |
| 리소스 프리로드 | 지원하지 않음 | preload, preinit, preconnect, prefetchDNS 제공 |
| htmlAttributes·titleTemplate | 지원 | 미지원 — 여전히 react-helmet-async 필요 |
이상으로 React 19의 문서 메타데이터와 리소스 프리로드 기능에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > React' 카테고리의 다른 글
| React 19 useTransition, Actions로 비동기 pending과 에러 한 번에 처리하기 (0) | 2026.07.20 |
|---|---|
| React 19 ref as prop과 Context 렌더링으로 완성하는 forwardRef 없는 컴포넌트 설계 (0) | 2026.07.19 |
| React 19 useOptimistic, Server Action 없이 낙관적 업데이트를 처리하는 방법 (0) | 2026.07.13 |
| React 19 use API, Promise와 Context를 조건부로 읽는 방법 (1) | 2026.07.12 |
| Turborepo 원격 캐시 설정 방법 (Vercel / Self-hosted) (0) | 2026.02.01 |
댓글