[React] 대규모 모노레포에서 React 19 마이그레이션 실전 가이드

2026-03-03 hit count image

7개의 앱과 공유 컴포넌트 라이브러리를 포함한 대규모 모노레포를 React 19로 업그레이드하면서 겪었던 실전 경험을 공유합니다. forwardRef 제거, RefObject 타입 통합, useRef 초기값 필수화, JSX 네임스페이스 변경 등 주요 변경 사항과 마이그레이션 전략을 다룹니다.

react

개요

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 래퍼가 사라지면서 코드가 한결 깔끔해졌습니다. 컴포넌트 내부에서 분리되어 있던 _ComponentforwardRef 래핑 패턴이 단순한 함수 컴포넌트 + 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에서 ReactElementprops 타입이 any에서 unknown으로 변경되었습니다. isValidElementcloneElement를 사용할 때 다음과 같이 타입 인수를 명시해야 합니다.

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 제거로 인해 컴포넌트의 렌더링 결과가 미세하게 달라질 수 있으므로, 스냅샷 테스트를 재생성합니다.

마이그레이션 팁

  1. TypeScript 에러를 가이드로 활용: @types/react@19를 먼저 설치하면 변경이 필요한 모든 위치에서 타입 에러가 발생합니다. 이를 체크리스트로 활용하세요.

  2. forwardRef 검색으로 범위 파악: 프로젝트 전체에서 forwardRef를 검색하면 수정해야 할 컴포넌트를 한눈에 파악할 수 있습니다.

  3. MutableRefObject 일괄 치환: 단순 치환이므로 IDE의 Find & Replace로 빠르게 처리할 수 있습니다.

  4. yarn.lock 변경은 크지만 걱정 없음: 이번 마이그레이션에서 yarn.lock에만 약 5,000줄의 변경이 있었지만, 이는 의존성 트리 재구성에 따른 자연스러운 결과입니다.

완료

React 19 마이그레이션의 핵심은 대부분의 변경이 타입 수준이라는 점입니다. 런타임 동작이 변경되는 코드는 거의 없으며, TypeScript 컴파일 에러를 하나씩 해결해 나가면 자연스럽게 마이그레이션이 완료됩니다.

forwardRef 폐지는 처음에는 큰 변화로 느껴질 수 있지만, 오히려 코드가 더 단순해지고 직관적으로 바뀌는 긍정적인 변화입니다. 모노레포 환경에서는 공유 패키지 → 개별 앱 순서로 진행하면 체계적으로 대응할 수 있습니다.

React 19가 가져오는 다른 새로운 기능들(Actions, use() 훅, Server Components 등)은 마이그레이션 이후 점진적으로 도입하는 것을 추천합니다. 먼저 안정적으로 업그레이드를 완료하고, 새로운 기능은 필요에 따라 하나씩 적용해 나가는 것이 안전한 접근법입니다.

React 19 업그레이드를 완료한 후에는 React Compiler 도입을 검토해 보시기 바랍니다. React Compiler를 사용하면 수동 메모이제이션 없이도 자동으로 렌더링이 최적화됩니다.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS