목차
개요
React 19가 정식 릴리스되면서 많은 프로젝트에서 마이그레이션을 고려하고 있을 것입니다. 이번 블로그 포스트에서는 7개의 앱과 공유 컴포넌트 라이브러리를 포함한 대규모 모노레포를 React 19로 업그레이드하면서 겪었던 실전 경험을 공유합니다.
약 70개 이상의 파일이 변경되었지만, 대부분은 @types/react@19의 타입 변경에 대한 대응이며, 런타임 동작이 바뀌는 코드는 거의 없었습니다.
주요 변경 사항 정리
forwardRef 제거 — ref가 일반 prop으로 승격
React 19에서 가장 눈에 띄는 변경은 forwardRef의 폐지입니다. 이제 ref를 일반 prop처럼 직접 전달할 수 있습니다.
Before (React 18)
import { forwardRef, memo } from 'react';
type ActionButtonProps = {
readonly label: string;
readonly onClick: () => void;
};
const _ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(
({ label, onClick }, ref) => {
return (
<button ref={ref} onClick={onClick}>
{label}
</button>
);
}
);
export const ActionButton = memo(_ActionButton);
After (React 19)
import { memo, type Ref } from 'react';
type ActionButtonProps = {
readonly ref?: Ref<HTMLButtonElement>;
readonly label: string;
readonly onClick: () => void;
};
const _ActionButton = ({ ref, label, onClick }: ActionButtonProps) => {
return (
<button ref={ref} onClick={onClick}>
{label}
</button>
);
};
export const ActionButton = memo(_ActionButton);
forwardRef 래퍼가 사라지면서 코드가 한결 깔끔해졌습니다. 컴포넌트 내부에서 분리되어 있던 _Component와 forwardRef 래핑 패턴이 단순한 함수 컴포넌트 + memo로 정리됩니다.
MutableRefObject → RefObject로 통합
@types/react@19에서 MutableRefObject가 폐지되고 RefObject로 통합되었습니다. 다음과 같이 타입 선언에서 단순히 치환하면 됩니다.
Before
import { type MutableRefObject } from 'react';
type TableContextType = {
readonly fetchError: MutableRefObject<boolean>;
readonly tableHeaderRefs: readonly MutableRefObject<unknown>[];
};
After
import { type RefObject } from 'react';
type TableContextType = {
readonly fetchError: RefObject<boolean>;
readonly tableHeaderRefs: readonly RefObject<unknown>[];
};
useRef 초기값 필수화 + RefObject 타입 변경
React 19에서는 useRef()에 초기값을 반드시 전달해야 합니다. 또한 RefObject<T>가 RefObject<T | null>로 변경되어, 타입에 null을 명시적으로 포함해야 합니다.
Before
// 초기값 없이 호출 (React 18에서는 허용)
const previousDepsRef = useRef<DependencyList>();
// RefObject<T>에 null 미포함
const stageRef: RefObject<Konva.Stage> = useRef(null);
After
// 초기값을 명시적으로 전달
const previousDepsRef = useRef<DependencyList | undefined>(undefined);
// RefObject<T | null>로 null 포함
const stageRef: RefObject<Konva.Stage | null> = useRef(null);
이 변경은 특히 Canvas/Stage 관련 훅이 많은 프로젝트에서 수십 개 파일에 영향을 미쳤습니다.
커스텀 훅의 제네릭 타입 추가
RefObject의 타입 변경에 따라 커스텀 훅도 제네릭 타입을 도입해야 했습니다. useIntersectionObserver의 사례를 살펴보겠습니다.
Before
export const useIntersectionObserver = (callback: () => void) => {
const observerRef = useRef<IntersectionObserver | null>(null);
const targetRef = useCallback((node: Element | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
}
if (node) {
observerRef.current = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) callbackRef.current();
});
observerRef.current.observe(node);
}
}, []);
return targetRef;
};
After
export const useIntersectionObserver = <T extends Element>(
callback: () => void
) => {
const observerRef = useRef<IntersectionObserver | null>(null);
const targetRef = useCallback((node: T | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
}
if (node) {
observerRef.current = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) callbackRef.current();
});
observerRef.current.observe(node);
}
}, []);
return targetRef;
};
이렇게 수정한 이후, 사용하는 쪽에서는 다음과 같이 제네릭 타입을 명시해야 합니다.
const targetRef = useIntersectionObserver<HTMLDivElement>(loadMore);
글로벌 JSX 네임스페이스 폐지
React 19에서 글로벌 JSX 네임스페이스가 폐지되고 React.JSX로 이동했습니다. 커스텀 Web Components를 사용하는 경우, 다음과 같이 타입 선언 방식을 변경해야 합니다.
Before
declare global {
namespace JSX {
interface IntrinsicElements {
readonly 'custom-header': unknown;
}
}
}
After
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
readonly 'custom-header': unknown;
}
}
}
ReactElement.props가 unknown으로 변경
@types/react@19에서 ReactElement의 props 타입이 any에서 unknown으로 변경되었습니다. isValidElement나 cloneElement를 사용할 때 다음과 같이 타입 인수를 명시해야 합니다.
Before
if (isValidElement(child)) {
return cloneElement(child, { index: columnIndex++ });
}
After
if (isValidElement<{ index: number }>(child)) {
return cloneElement(child, { index: columnIndex++ });
}
주변 라이브러리 업그레이드
React 19로 올리면 관련 라이브러리들도 함께 업그레이드해야 합니다. 주요 변경 사항은 다음과 같습니다.
| 라이브러리 | 변경 |
|---|---|
react / react-dom | ^19 |
@types/react / @types/react-dom | ^19 |
@testing-library/react | ^16.0.0 (React 19 지원) |
react-konva (Canvas 사용 시) | ^19 |
| Storybook | ^8.5.0 |
프로젝트별 영향도 분석
모노레포에서의 마이그레이션은 공유 패키지의 변경이 모든 앱에 영향을 미치기 때문에 영향 범위를 사전에 파악하는 것이 중요합니다.
| 변경 카테고리 | 영향 범위 | 파일 수 |
|---|---|---|
| 공유 컴포넌트 (forwardRef, RefObject 등) | 모든 앱 | ~30개 |
| Canvas/Stage 관련 훅 (RefObject null) | Canvas 사용 앱 | ~17개 |
| useIntersectionObserver 제네릭 | 무한스크롤 사용 앱 | ~11개 |
| JSX 네임스페이스 | Web Components 사용 앱 | 3개 |
| package.json | 모든 앱 | 7개 |
마이그레이션 전략
1단계: 공유 라이브러리부터
공유 컴포넌트 라이브러리를 먼저 수정합니다. forwardRef 제거, RefObject 타입 통합, 커스텀 훅의 제네릭 타입 추가 등 기반 작업을 완료합니다.
2단계: 앱별 순차 대응
공유 라이브러리 변경 후, 각 앱에서 발생하는 타입 에러를 순차적으로 수정합니다. 대부분은 기계적인 타입 수정이므로 IDE의 일괄 치환 기능을 활용할 수 있습니다.
3단계: 테스트 및 스냅샷 업데이트
forwardRef 제거로 인해 컴포넌트의 렌더링 결과가 미세하게 달라질 수 있으므로, 스냅샷 테스트를 재생성합니다.
마이그레이션 팁
-
TypeScript 에러를 가이드로 활용:
@types/react@19를 먼저 설치하면 변경이 필요한 모든 위치에서 타입 에러가 발생합니다. 이를 체크리스트로 활용하세요. -
forwardRef검색으로 범위 파악: 프로젝트 전체에서forwardRef를 검색하면 수정해야 할 컴포넌트를 한눈에 파악할 수 있습니다. -
MutableRefObject일괄 치환: 단순 치환이므로 IDE의 Find & Replace로 빠르게 처리할 수 있습니다. -
yarn.lock 변경은 크지만 걱정 없음: 이번 마이그레이션에서
yarn.lock에만 약 5,000줄의 변경이 있었지만, 이는 의존성 트리 재구성에 따른 자연스러운 결과입니다.
완료
React 19 마이그레이션의 핵심은 대부분의 변경이 타입 수준이라는 점입니다. 런타임 동작이 변경되는 코드는 거의 없으며, TypeScript 컴파일 에러를 하나씩 해결해 나가면 자연스럽게 마이그레이션이 완료됩니다.
forwardRef 폐지는 처음에는 큰 변화로 느껴질 수 있지만, 오히려 코드가 더 단순해지고 직관적으로 바뀌는 긍정적인 변화입니다. 모노레포 환경에서는 공유 패키지 → 개별 앱 순서로 진행하면 체계적으로 대응할 수 있습니다.
React 19가 가져오는 다른 새로운 기능들(Actions, use() 훅, Server Components 등)은 마이그레이션 이후 점진적으로 도입하는 것을 추천합니다. 먼저 안정적으로 업그레이드를 완료하고, 새로운 기능은 필요에 따라 하나씩 적용해 나가는 것이 안전한 접근법입니다.
React 19 업그레이드를 완료한 후에는 React Compiler 도입을 검토해 보시기 바랍니다. React Compiler를 사용하면 수동 메모이제이션 없이도 자동으로 렌더링이 최적화됩니다.
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.