[면접준비!] React19에서 useRef는 뭐가 달라졌을까?

타락한스벨트전도사·2025년 7월 3일
54
post-thumbnail

리액트19 useRef 바뀐 소식 아직도 모르시는건아니죠? 🤔

리액트 19에서는 다양한 업데이트가 있었습니다. 당연히 이걸 잘 알아야 리액트를 잘 쓸 수 있는거겠죠? 이번업데이트에서 ref 관련해서는 정말 큰 변화들이 있었어요!

오늘은 React 19에서 달라진 ref의 핵심 변화와 많은 분들이 놓치고 있는 흔한 오해들에 대해 깊이 있게 알아보겠습니다.

🚀 React 19의 핵심 변화들

forwardRef 이제 안녕! - ref as prop

React 19 이전에는 함수 컴포넌트에 ref를 전달하려면 forwardRef가 필수였어요:

// React 18 이전 방식
const Input = forwardRef<HTMLInputElement, { placeholder: string }>(
  ({ placeholder }, ref) => {
    return <input ref={ref} placeholder={placeholder} />;
  }
);

React 19에서는 ref를 일반 prop처럼 사용할 수 있어요:

// React 19 방식 - 훨씬 간단!
function Input({
  placeholder,
  ref,
}: {
  placeholder: string;
  ref?: React.Ref<HTMLInputElement>;
}) {
  return <input ref={ref} placeholder={placeholder} />;
}

forwardRef의 보일러플레이트가 완전히 사라졌죠!

cleanup 함수 지원 - React 19 정식 등장!

React 19에서 ref 콜백에서 cleanup 함수를 반환할 수 있게 되었어요. 이 기능은 React 19에서 정식으로 등장했습니다!

const handleRef = (node: HTMLDivElement | null) => {
  if (!node) return;

  console.log("요소 마운트!");

  const handleClick = () => console.log("클릭!");
  node.addEventListener("click", handleClick);

  // cleanup 함수 반환 - 돔이 사라질 때 실행되어요!
  return () => {
    console.log("cleanup 실행!");
    node.removeEventListener("click", handleClick);
  };
};

💡 흔한 오해들과 인사이트

오해 1: "useRef는 DOM만 할당한다"

많은 분들이 useRef를 DOM 참조 전용으로 생각하시는데, 사실 그냥 값 저장하는 훅이에요!

// DOM 참조뿐만 아니라
const domRef = useRef<HTMLDivElement>(null);

// 어떤 값이든 저장 가능
const timerRef = useRef<NodeJS.Timeout | null>(null);
const countRef = useRef(0);
const objectRef = useRef({ name: "React", version: 19 });

더 놀라운 건, useCallback과 useMemo도 useRef로 구현할 수 있어요! (실제 구현체는 다르지만요)

// useCallback을 useRef로 구현
function useMyCallback<T extends (...args: any[]) => any>(
  callback: T,
  deps: React.DependencyList
): T {
  const ref = useRef<{ callback: T; deps: React.DependencyList }>({
    callback,
    deps,
  });

  if (!shallowEqual(deps, ref.current.deps)) {
    ref.current = { callback, deps };
  }

  return ref.current.callback;
}

왜냐하면 결국 모든 훅들은 fiber 노드의 memoizedState에 저장되는 값들일 뿐이거든요!

오해 2: "ref에는 useRef만 쓸 수 있다"

이것도 틀렸어요! ref의 진짜 스펙은 "DOM 노드를 받는 콜백 함수"입니다:

// useRef 사용
<div ref={myRef} />

// 콜백 함수 직접 사용
<div ref={(node) => console.log('마운트!', node)} />

// 커스텀 함수도 가능
const handleRef = (node: HTMLDivElement | null) => {
  // 원하는 로직
};
<div ref={handleRef} />

⚡ ref 콜백의 실행 타이밍

여기서 중요한 건 ref 콜백이 언제 실행되느냐예요. useEffect와 비교해볼까요?

function TimingExample() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("useEffect 실행");
  }, []);

  const refCallback = useCallback(() => {
    console.log("ref 콜백 실행");
  }, []);

  return (
    <div>
      <div ref={refCallback}>카운트: {count}</div>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

⚠️ 주의: ref에 할당되는 함수가 달라지면 리렌더링할 때마다 실행되니 주의해주세요! 그래서 useCallback으로 감싸주는 것이 좋습니다.

https://fe-resume.coach

https://fe-resume.coach

실행 순서:

  1. 컴포넌트 렌더링
  2. ref 콜백 실행 ← DOM이 마운트되는 순간!
  3. useEffect 실행

ref 콜백은 DOM의 생명주기와 직접 연결되어 있어서:

  • useEffect보다 더 빠르게 동작
  • 리렌더링과 관계없이 DOM이 실제로 마운트될 때만 실행

🔥 심화!! 실전 활용법

이제 이런 지식을 바탕으로 어떤 멋진 것들을 만들 수 있는지 보여드릴게요!

useOutsideClick - 더 이상 useEffect 안녕!

밖에 클릭하면 닫히는 거, 매번 useEffect로 구현하시나요?

// 기존 방식 - 번거로워요 😵
useEffect(() => {
  const handleClick = (e) => {
    if (ref.current && !ref.current.contains(e.target)) {
      setIsOpen(false);
    }
  };
  document.addEventListener("mousedown", handleClick);
  return () => document.removeEventListener("mousedown", handleClick);
}, []);

✨ 이벤트 핸들러는 DOM 가까이에 있어야 자연스럽죠!

const onOutsideClick = useOutsideClick();

<div ref={onOutsideClick(() => setIsOpen(false))}>메뉴 내용</div>;

https://fe-resume.coach

구현은 이렇게:

import { useEffect, useRef, useCallback } from "react";

type OutsideClickCallback = () => void;

export const useOutsideClick = () => {
  // 현재 요소와 콜백을 저장
  const elementRef = useRef<HTMLElement | null>(null);
  const callbackRef = useRef<OutsideClickCallback>(() => {});

  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      const element = elementRef.current;
      if (!element) return;

      if (!element.contains(e.target as Node)) {
        callbackRef.current();
      }
    };

    // 모바일도 고려!
    document.addEventListener("mousedown", handleClick);
    document.addEventListener("touchstart", handleClick, { passive: true });

    return () => {
      document.removeEventListener("mousedown", handleClick);
      document.removeEventListener("touchstart", handleClick);
    };
  }, []);

  // stable한 ref 콜백 - useEvent 패턴 활용
  const stableCallback = useCallback((element: HTMLElement | null) => {
    elementRef.current = element;

    if (!element) return;

    // React 19 cleanup 활용!
    return () => {
      elementRef.current = null;
    };
  }, []);

  // 콜백을 업데이트하고 stable ref를 반환하는 함수
  return (callback: OutsideClickCallback) => {
    callbackRef.current = callback;
    return stableCallback;
  };
};

💡 useEvent 패턴: 콜백 함수는 stable하게 유지하되, 최신 값(옵션)에는 접근할 수 있는 패턴이에요. React 팀이 RFC로 제안한 useEvent 훅과 같은 아이디어로, "latest ref pattern"이라고도 불립니다.

핵심 아이디어:

  • 등록된 요소들을 Map에 저장
  • 전역 클릭 이벤트에서 !element.contains(e.target)이면 콜백 실행
  • cleanup으로 자동 정리

useFade - 애니메이션까지!

DOM이 사라질 때 애니메이션을 부여하려면? 당연히 다시 DOM을 원래 위치에 삽입하고, 사라지는 효과 주고, 다시 제거하면 됩니다!

ref 콜백으로 이걸 통제하는 방법을 보여드릴게요!

https://fe-resume.coach

import { useRef, useCallback } from "react";

export type FadeOptions = {
  duration?: number;
};

export const useFade = () => {
  const containerRef = useRef<HTMLElement | null>(null);
  const optionsRef = useRef<FadeOptions>({});

  // stable한 ref 콜백 - useEvent 패턴 활용
  const stableCallback = useCallback((element: HTMLElement | null) => {
    const { duration = 300 } = optionsRef.current;
    if (!element) return;

    // fadeIn 효과
    element.style.opacity = "0";
    element.style.transition = `opacity ${duration}ms ease-out`;
    containerRef.current = element.parentElement;

    requestAnimationFrame(() => {
      element.style.opacity = "1";
    });

    // cleanup - 사라질 때 fadeOut!
    return () => {
      if (!containerRef.current || !element) return;

      // 1. 복사본을 원래 위치에 삽입
      const clone = element.cloneNode(true) as HTMLElement;
      clone.style.opacity = "1";
      clone.style.transition = `opacity ${duration}ms ease-out`;
      containerRef.current.appendChild(clone);

      // 2. fadeOut 효과
      requestAnimationFrame(() => {
        clone.style.opacity = "0";
      });

      // 3. 애니메이션 후 제거
      setTimeout(() => {
        clone.remove();
      }, duration);
    };
  }, []);

  // 옵션을 업데이트하고 stable 콜백을 반환하는 함수
  return (options: FadeOptions = {}) => {
    optionsRef.current = options;
    return stableCallback;
  };
};

💡 useEvent 패턴: 여기서도 같은 패턴을 사용했어요. ref 콜백은 stable하게 유지하되, 최신 콜백 함수에는 접근할 수 있도록 했습니다. 이는 Kent C. Dodds가 소개한 "latest ref pattern"으로, React 팀이 RFC로 제안한 useEvent 훅과 같은 아이디어입니다.

ref 어떻게 여러개 쓸건데? - mergeRefs

하나의 요소에 여러 ref를 적용하고 싶다면? mergeRefs 유틸리티로 금방이죠:

export function mergeRefs<T = any>(
  ...refs: Array<React.Ref<T> | undefined>
): React.RefCallback<T> {
  return (element: T | null) => {
    const cleanups: Array<(() => void) | undefined> = [];

    refs.forEach((ref) => {
      if (!ref) return;

      if (typeof ref === "function") {
        const cleanup = ref(element);
        if (typeof cleanup === "function") {
          cleanups.push(cleanup);
        }
      } else if ("current" in ref) {
        (ref as React.MutableRefObject<T | null>).current = element;
      }
    });

    // 클린업 함수들이 있으면 합쳐서 반환
    if (cleanups.length > 0) {
      return () => {
        cleanups.forEach((cleanup) => {
          if (cleanup) cleanup();
        });
      };
    }
  };
}

🎯 마무리

이런 활용법 뭔가 참신하신가죠? 사실 이건 제 아이디어가 아니라 스벨트를 참고한거에요. (제 닉네임을 확인 ㅎㅎ;;)

Svelte 문법에는 이미 이런걸로 애니메이션이나 outside click을 구현하는 스펙이 있거든요. 타 프레임워크의 인사이트가 React에서도 적용이 되네요!

https://fe-resume.coach

스벨트로 만든 데모페이지

React 19의 ref 변화는 단순한 문법 개선이 아니라 더 나은 개발 경험을 제공합니다:

  • forwardRef 제거로 보일러플레이트 감소
  • cleanup 함수 지원으로 더 강력한 리소스 관리
  • DOM 생명주기와의 밀접한 연동으로 더 정확한 타이밍 제어

여러분도 React 19의 새로운 ref 기능들을 활용해서 더 선언적이고 효율적인 코드를 작성해보세요! 🚀


💼 이력서 서비스 운영합니다!
개발자 이력서 작성에 도움이 필요하시다면: fe-resume.coach/ai-resume

profile
기부하면 코드 드려요

0개의 댓글