React 19는 forwardRef랑 작별하고, 메타태그를 컴포넌트에서 박는다
React 19가 바꾼 것, ref와 Actions 그리고 네이티브 메타태그
#React #프론트엔드 #리팩토링
얼마 전에 SPA 메타태그를 서버에서 직접 박아넣는 글을 썼다. 그 글을 쓰고 나서 React 19 문서를 보다가, React가 이제 메타태그를 컴포넌트에서 직접 지원한다 는 걸 알게 됐다. 내가 서버 쪽에서 고생해서 만든 걸 React가 언어 차원에서 해준다니, 한 번 제대로 봐야겠다 싶었다. 이 글에서는 React 19가 18에서 뭘 바꿨는지, 그리고 그게 내 코드를 어떻게 줄여줄 수 있는지 정리해본다. 마지막에는 그런데도 왜 아직 내 프로젝트를 19로 못 올렸는지도 적어둔다. 메타태그를 컴포넌트에서 그냥 렌더한다 이게 제일 먼저 와닿았다. React 19에선 컴포넌트 안에서 title 이나 meta 를 그냥 렌더하면, React가 알아서 그걸 문서의 head 로 끌어올린다 . react-helmet 같은 라이브러리를 안 써도 된다. function BlogPost({ post }) { return ( article title {post.title} | Jaeyonging 블로그 /title meta name="description" content={post.descr} / meta property="og:title" content={post.title} / h1 {post.title} /h1 {/* ... 본문 ... */} /article ); } 근데 여기서 헷갈리면 안 되는 게 있다. 이건 자바스크립트가 돌고 난 뒤에 박히는 거다. 즉 크롤러나 카톡 봇은 여전히 못 본다. 그래서 내가 서버에서 메타를 주입한 작업(지난 글)이 여전히 필요하다 . 둘은 경쟁이 아니라 보완이다. 사람이 쓰는 브라우저 쪽은 React 19가 맡고, 크롤러 쪽은 서버 주입이 맡는다. 내 블로그로 치면 이렇다. 지금은 라우트를 이동할 때마다 CanonicalManager라는 컴포넌트가 document.head를 직접 뒤져서 canonical과 og:url을 고쳐 쓰고 있다. React 19라면 그 코드가 태그 몇 줄로 줄어든다. forwardRef랑 작별한다 React 18까지는 자식 컴포넌트한테 ref를 넘기려면 forwardRef 로 감싸야 했다. 이게 은근 거추장스러웠다. React 19부턴 ref를 그냥 일반 prop처럼 받는다. // React 18: forwardRef로 감싸야 했다 const Input = forwardRef((props, ref) = { return input ref={ref} {...props} / ; }); // React 19: 그냥 prop으로 받으면 끝 function Input({ ref, ...props }) { return input ref={ref} {...props} / ; } forwardRef로 감싸던 컴포넌트들을 풀고 나면 코드가 한 겹씩 얇아진다. 별거 아닌 것 같아도, 이런 래퍼가 쌓이면 컴포넌트 트리를 읽기 힘들어진다. forwardRef의 제네릭 타입은 쓸 때마다 찾아봐야 하는 종류라, 이건 생각보다 작지 않은 변화다. Actions, 폼의 로딩과 에러 상태를 React가 들고 있어준다 이게 18에서 19로 오면서 제일 크게 바뀐 부분이다. 예전엔 폼을 제출할 때 isLoading 이랑 error 상태를 내가 직접 useState로 만들고, try/catch로 일일이 켜고 껐다. React 19는 비동기 함수를 폼의 action에 그냥 넘기면, pending(처리 중)이랑 error를 자동으로 관리 해준다. useActionState, useFormStatus, useOptimistic 같은 훅이 같이 들어왔다. // 제출 중 상태를 내가 안 만들어도 된다 const [error, submitAction, isPending] = useActionState( async (prev, formData) = { const res = await save(formData); if (res.error) return res.error; return null; }, null ); // form action={submitAction} 만 걸면 isPending이 알아서 바뀐다 form action={submitAction} input name="title" / button disabled={isPending} {isPending ? '저장 중' : '저장'} /button {error p {error} /p } /form 내가 손으로 관리하던 "제출 중이니 버튼 비활성화" 같은 코드가 통째로 사라진다. 특히 useOptimistic은, 서버 응답을 기다리기 전에 화면을 먼저 바꿔놓고(낙관적 업데이트) 실패하면 되돌리는 걸 쉽게 해준다. 댓글 달기처럼 성공할 확률이 높은 동작에 잘 맞는다. use() 훅 promise를 렌더 중에 직접 풀 수 있는 use() 도 들어왔다. Suspense랑 같이 쓰면 데이터를 await 하듯 받아올 수 있다. 조건문 안에서도 호출할 수 있어서, 기존 훅 규칙보다 유연하다. import { use, Suspense } from 'react'; // promise를 컴포넌트 밖에서 만들어 두고 const postPromise = fetch('/api/getBlog?bid=66').then((r) = r.json()); function Post() { const post = use(postPromise); // 렌더 중에 바로 푼다. 아직 안 끝났으면 Suspense가 받는다 return h1 {post.title} /h1 ; } function Page() { return ( Suspense fallback={ p 불러오는 중 /p } Post / /Suspense ); } 주의할 건 promise를 렌더 안에서 새로 만들면 안 된다는 점이다. 렌더할 때마다 fetch가 새로 나가고 Suspense가 계속 걸린다. 그래서 위처럼 밖에서 만들어 두거나, 캐시가 있는 라이브러리(React Query 같은 것)와 같이 쓰는 게 보통이다. 이 예시는 문서를 보고 정리한 것이고, 내 프로젝트에 실제로 넣어본 건 아니다. 올리려면 나 혼자로는 안 된다 19가 좋아 보여도 바로 못 올리는 이유가 있다. forwardRef를 없앤 건 내 코드 얘기고, 내가 쓰는 라이브러리들이 아직 forwardRef로 짜여 있으면 소용이 없다 . UI 라이브러리, 애니메이션, 에디터 같은 건 대부분 내부에서 ref를 넘긴다. 그 중 하나라도 19를 아직 지원 안 하면 경고가 콘솔에 잔뜩 뜨거나 아예 안 돈다. 내 블로그만 해도 react-quill, lottie-react, framer-motion이 걸려 있어서 이것들이 먼저 준비돼야 한다. 그래서 내 프로젝트들은 아직 전부 18에 있다. 이 글을 다시 손보는 2026년 9월에 확인해보니 블로그, 포트폴리오, 코인, 모니터링은 18.2.0이고, 제일 최근에 만든 여행 플래너와 이음도 18.3.1이다. 새로 만든 것조차 19로 시작하지 않은 건, 위에서 말한 라이브러리 문제를 미리 피하려는 쪽에 가깝다. 코드 몇 줄 줄이자고 의존성 전체가 따라올 때까지 기다리는 중이다. 그래서 지금은 분기마다 한 번씩 주요 의존성이 19를 지원하는지 확인만 해두고 있다. 준비가 되면 올리는 건 하루면 끝날 일이다.