useRef

useRef를 간단하게 이야기 하자면 화면을 바꾸지 않는 데이터라고 할 수 있습니다. useRef는 값이 변경되어도 화면이 리렌더링되지 않습니다. React에서는 document를 이용해 DOM 요소에 접근하는 방식을 비추천하기 때문에 document를 이용하지 않고 DOM 요소에 쉽게 접근을 하기 위해 useRef를 사용합니다.

 

document 객체 사용을 지양하는 이유는 document를 이용하면 DOM을 직접 조작하여 화면을 변경할 수 있는데, 이렇게 되면 화면 변경에 대해서 React를 통해 화면을 바꾸는 것과 document로 DOM을 직접 조작하여 바꾸는 것 이렇게 두 가지로 나뉘어 관리하기가 매우 어려워집니다.

 

하지만 document를 이용하지 않는다면, 원하는 요소를 focus하고 스크롤을 옮기는(scrollIntoView) 등의 편리한 작업을 하기 어렵워집니다. 그래서 React에서 useRef를 제공하는 것입니다. document.getElementById나 document.querySelector 등을 이용해 요소를 가져오는 것을 원하는 요소의 ref 속성에 useRef로 정의한 값을 넣어주고 .current로 그 요소를 바로 가져올 수 있도록 해 줍니다.

 

그런데 useRef를 사용하면 왜 하면이 리렌더링 되지 않을까요? useRef는

const idRef = useRef(null)

이런식으로 사용하는데, 이때 null은 내부 속성인 current에 null을 넣어주는 것입니다. 즉 객체인 것이죠 useRef의 값이 바뀌어도 객체 내부 메소드인 current가 변경되는 것이기 때문에 참조가 바뀌지 않고, 얕은 복사를 이용하는 React 특성상 감지하지 못하는 것입니다.

 

그리고 useRef는 이렇게 요소를 가져오기 위한 방법 뿐만 아니라 컴포넌트가 언마운트되기 전까지 값을 보존하기 때문에 직접 값을 넣어줘서 로깅용으로 사용하거나 UI와 무관한 값을 저장 및 라이브러리를 사용할 때 사용하기도 합니다.

 

사용 예시들을 보면 쉽게 이해하실 수 있습니다.

 

1. DOM 요소 포커싱

import { useRef } from 'react';

function LoginForm () {
	const emailRef = useRef(null);
    
    const handleSubmit = () => {
    	if (!emailRef.current.value.includes('@')) {
        	emailRef.current.focus(); // 유효성 검사 실패 시 포커싱
            return;
        }
        // ...
    }
    
    return (
    	<form>
          <input ref={emailRef} type='email' />
          <button>로그인</button>
        </form>
    )
}

 

2. 애니메이션 / 미디어 제어

function VideoPlayer () {
	const videoRef = useRef(null);
    
    const play = () => videoRef.current.play();
    const pause = () => videoRef.current.pause();
    
    return (
    <div>
      <video ref={videoRef} src="video.mp4" />
      <button onClick={play}>재생</button>
      <button onClick={pause}>일시정지</button>
    </div>
    )
}

 

3. 이전 state 값 추적

fucntion Counter () {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef(0);
  
  useEffect(() => {
    prevCountRef.current = count; // 이전 값 저장
  }, [count]);
  
  return (
    <div>
      현재 값: {count}, 이전 값: {prevCountRef.current}
    </div>
  );
}

 

4. 라이브러리 연동

DOM을 직접 조작하는 외부 라이브러리 (D3.js, Chart.js, Fabric.js 등)와 통합할 때 필수적으로 사용합니다.

function Chart() {
  const chartRef = useRef(null);
  
  useEffect(() => {
    //D3.js로 차트 생성
    const svg = d3.select(chartRef.current);
    svg.append("circle").attr("r", 50);
  },[]);
  
  return <svg ref={chartRef} />
}

 

 

그리고 React 라이프사이클 동안 값을 유지하는 useRef를 이용해 클로저 문제를 해결하기도 합니다.

 

클로저 문제

  • 함수형 컴포넌트에서 비동기 콜백(setTimeout, setInterval 등)을 사용할 때,
  • 콜백이 만들어진 시점의 state, props, 변수 값이 캡처되어 나중에 실행해도 그 값만 참조하게 됩니다.
  • 즉, 최신 값이 아닌 옛날 값을 참조하며, 원하는 작업을 수행할 수 없는 문제가 발생하는 것입니다.
const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setTimeout(() => {
    alert(count)
  }, 1000);
  
  return () => clearTimeout(timer); // 메모리 누수 방지를 위해 언마운트시 clear
},[]);

위와 같이 코드를 작성하면 클로저 문제가 발생합니다.

※ 사실 count를 의존성 배열에 추가하면 클로저 문제가 발생하지 않지만, 사용 방식을 쉽게 보여주고자 해당 코드에서 useRef를 활용해 클로저 문제를 해결하는 방식을 보여드리겠습니다. 이후 실질적으로 useRef를 사용해야 해결할 수 있는 클로저 문제 발생 코드 사례도 작성할 예정입니다.

 

const [count, setCount]  useState(0);
const countRef = useRef(count);

// count의 값이 변경될 때마다 countRef에 최신값 반영
useEffect(() => {
  countrRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setTimeout(() => {
    alert(countRef.current); // countRef를 이용해 최신값 불러오기
  }, 1000);
  
   return () => clearTimeout(timer);
},[]);

클로저 문제가 발생했을 경우 위와 같이 countRef를 만들고 최신 값을 계속 갱신하여 setTimer에서 countRef의 current를 불러오면 됩니다. 하지만 해당 코드의 경우 setTimeout이 있는 useEffect의 의존성 배열에 count를 추가하면 해결되는 간단한 문제여서 실질적으로 useRef가 필요한 클로저 문제 발생 코드와 어떻게 해결하면 되는지에 대한 사례도 바로 작성하겠습니다.

 

 

클로저 문제 발생 코드

import { useState, useEffect } from 'react';

function App () {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const handleResize = () => {
      console.log('창 크기 변경된 횟수 :', count); // 클로저 문제 발생 (최초 count만 불러와짐)
    }
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
    <div>
      <p> Count: {count} </p>
      <button onClick={() => setCount(count + 1)}> + </button>
    </div>
  );
}

해당 코드는 이벤트 핸들러가 최초 count만 기억하고 불러오기 때문에 count가 변화되어도 창 크기 변경 시 항상 0만 출력이 됩니다. 의존성 배열에 count를 넣으면 해결되겠지만, count가 변경될 때마다 이벤트 등록이 계속 발생하며 성능 저하 및 메모리 누수 위험이 있습니다 그렇기에 이런 경우 useRef를 이용합니다.

 

import {useState, useEffect, useRef} from 'react';

export default function App () {
  const [count, setCount] = useState(0);
  const countRef = useRef(count); // 이벤트 핸들러에서 count 값을 불러오기 위해 countRef 정의
  
  useEffect(() => {
    countRef.current = count;
  }, [count]); // count 변경 시마다 업데이트
  
  useEffect(() => {
    const handleResize = () => {
      console.log('창 크기 변경 횟수:', countRef.current); // countRef를 이용해 count 값 호출
    };
    
    window.addEventListener('resize', handleResize);
    
    return () => window.removwEventListener('resize', handleResize);
  }, []);
  
  return (
    <div>
      <p> Count: {count} </p>
      <button onClick={() => setCount(count + 1)}> + </button>
    </div>
  );
}

countRef.current 값은 컴포넌트 생명주기 동안 유지되기에 countRef를  count와 동기화하여 count가 바뀔 때마다 countRef.current 값을 갱신해 줍니다. 그리고 해당 countRef.current를 이벤트 핸들러 내에서 사용하여 클로저 문제를 해결할 수 있습니다.

 

WebSocket 연결 과정에서도 해당 방식을 이용할 수 있습니다.

export deafult function Chat () {
  const [message, setMessage] = useState('');
  const messageRef = useRef('');
  
  useEffect(() => {
    messageRef.current = message;
  }, [message]);
  
  useEffect(() -> {
    const socket = new WebSocket(''wss://...');
    socket.onmessage = (event) => {
      console.log('받은 메시지:', messageRef.current);
    };
    
    return () => socket.close();
  }, []);
  
  ...
}

 

 

ref.current 사용 시 주의점 (feat. side effect)

 

그리고 ref.current는 렌더링 중에 읽으면 안됩니다! React는 컴포넌트가 순수 함수처럼 동작하길 원합니다. 순수 함수란 동일한 입력에 대해 동일한 결과를 반환하는 함수를 말합니다. 즉 컴포넌트에 동일한 입력(props, state, context)가 주어지면 동일한 결과(JSX)를 반환하기를 원하는 것이죠. 하지만 렌더링 중에 ref.current를 읽거나 쓰게 되면 컴포넌트가 동일한 입력에도 매번 다른 결과를 내거나 외부 상태를 바꿀 가능성이 있습니다.

 

그렇기에 ref.current는 이벤트 핸들러 혹은 useEffect 내부에서 사용해야 합니다.

useEffect나 이벤트 핸들러는 렌더링과 별개로 실제 DOM이 만들어진 뒤 실행하기 때문에 side effect를 안전하게 처리할 수 있기 때문입니다.

 

side effect에 대해서도 자세하게 설명하면 좋을거 같습니다.

side effect는 함수가 본래의 목적 이외에 외부 세계에 영향을 주거나, 외부 상태에 의존하거나, 변경하는 모든 행위를 의미합니다. 즉, 함수가 동일한 입력을 받아도 외부 요인에 따라 결과가 달라지거나 함수 실행이 외부에 영향을 미친다면 해당 함수는 side effect가 존재한다고 이야기합니다.

이러한 side effect는 함수형 프로그래밍에서 기본적으로 피하거나 최소화해야 합니다. React에서는 side effect를 컴포넌트의 렌더링 로직과 명확하게 분리해야 하며, useEffect 내부에서 side effect를 다루는 것으로 실현합니다.

 

 

React 19, ref 콜백 클린업 함수

React 19에서는 ref 콜백 함수에서 클린업 함수를 반환할 수 있는 기능이 추가됐습니다.

이를 이용해 DOM의 노드가 마운트될 때 필요한 리소스(이벤트 리스너, 옵저버 등)를 설정하고, 언마운트되거나 노드가 변경될 때 자동으로 정리(cleanup)하는 작업을 더욱 간편하게 처리할 수 있게 되었습니다.

 

기존 ref 콜백은 어떻게 동작했는가?

- 마운트 하는 경우: ref 콜백이 DOM 노드를 인자로 받아 호출합니다.

- 언마운트 하는 경우: ref 콜백이 null을 인자로 받아 한 번 더 호출되기 때문에 이 시점에 클린업 작업을 직접 구현해야 했습니다.

const refCallback = (node) => {
  if (node) {
    // 노드 마운트
  } else {
    // 노드 언마운트 -> 클린업 작업
  }
};

return <div ref={refCallback} />

 

이제 React 19에서는 ref 콜백 클린업 함수를 반환할 수 있기 때문에 null일 때 작업을 하는 것이라니라 return을 이용해 작업을 할 수 있게 됐습니다. 코드로 확인해보면

const refCallback = (node) => {
  if (node) {
    // 노드가 마운트 됨: 리소스 설정
    const handler = () => { //원하는 작업 }
    node.addEventListener('click', handler);
    
    // 클린업 함수 반환
    return () => {
      node.removeEventLisener('click', handler);
      console.log('cleanup', node);
    };
  }
};

return <div ref={refCallback} />

이렇게 else 뒤에 작업을 하는것이 아니라 return으로 클린업 함수를 반환할 수 있습니다.

이를 통해 코드를 명확하고 직관적으로 볼 수 있고 리소스 정리(이벤트 해체, 옵저버 해제)를 자동화할 수 있어 메모리 누수 방지에 효과적입니다.

 

결과적으로 컴포넌트 전체의 생명주기에 대한 작업은 useEffect를 이용하고, 특정 DOM 노드의 생명주기에 대한 작업은 ref 콜백 함수를 이용할 수 있습니다.

 

그런데 위의 예시는 click 이벤트를 다루는 경우입니다. 사실 이런 경우는 그냥 onClick을 사용하면 됩니다. 그렇다면 어떤 상황에서 ref 콜백 함수를 사용할까요?

  • React에서 지원하지 않는 이벤트에 대한 작업이 필요한 경우
  • ResizeObserver, IntersectionObserver, D3.js 등 DOM 노드에 직접 접근해 등록/해제해야 하는 리소스 관리가 필요한 경우
  • 동적으로 여러 개의 ref가 필요한 경우
  • DOM 노드에 대해서 마운트/언마운트될 때의 추가 작업이 필요한 경우
  • 조건부 렌더링 혹은 동적 노드 교체가 자주 일어날 경우

보통 위와 같은 경우에 콜백 ref를 사용하는 것이 일반적입니다.

저는 콜백 ref를 사용한 경험이 없어서 사용 사례들을 찾아보며 이해를 했는데, 그 부분도 정리해 보겠습니다.

 

A. transition 이벤트 관리

const refCallback = (node) => {
  if (node) {
    const handler = () => console.log('transition start');
    node.addEventListener('transitionstart', handler);
    
    return () => {
      node.removeEventListener('trasitionstart', handler);
      console.log('cleanup', node);
    };
  }
};

return <div ref={refCallback}>...</div>

React 19부터 onTransitionStart 및 지원하지 않던 이벤트를 지원해주기 시작했습니다. 하지만 대부분 React 18을 사용중일거라 예상되는데, 위처럼 React에서 지원하지 않는 이벤트에 대해서 처리하고 싶을 때 ret 콜백 함수를 사용하면 됩니다.

 

B. ResizeObserver 등의 리소스 관리

const refCallback = (node) => {
  if (node) {
    const observer = new ResizeObserver(() => { //원하는 작업 });
    observer.observe(node);
    
    return () => observer.disconnect();
  }
}

 

 

C. 동적으로 여러 개의 ref가 필요한 경우

리스트의 각 아이템에 ref를 달아야 하는 경우 ref 객체를 배열이안 Map으로 직접 관리해야 합니다.

이 때 콜백 ref를 쓰면 각 DOM 노드를 원하는 방식으로 저장 및 관리할 수 있습니다.

const itemsRef = useRef(null);
if (itemsRef.current === null) {
  itemsRef.current = new Map();
}
// 위의 코드는 const itemsRef = useRef(new Map());으로 사용할 수 있지만,
// 공식 문서에 따르면 위의 방식은 최초 렌더링 시에만 new Map()을 사용하지만,
// 이후의 렌더링마다 호출 자체는 이루어지기 때문에 성능 저하 가능성이 있습니다.
// 그래서 위의 패턴을 사용합니다. ref.current를 렌더링 중에 쓰거나 읽는 것은 허용되지 않지만,
// 이 경우는 결과가 항상 동일하고 초기화 중에만 조건이 실행되어 충분히 예측이 가능하기에 허용됩니다.

const setItemRef = (id, node) => {
  const map = itemsRef.current;
  if (node) map.set(id, node);
  else map.delete(id);
}

return items.map(item => (
  <div key={item.id} ref={node => setItemRef(item.id, node)}>{item.name}<div>
))

 

 

D. DOM 노드가 마운트/언마운트 될 때 추가 작업이 필요한 경우

이벤트 리스너 등록/해제, 외부 라이브러리 초기화/정리가 DOM 노드의 마운트/언마운트 시점에 필요한 경우 콜백 ref를 사용하여 각 시점에 맞춰 원하는 작업을 할 수 있습니다. React 19 이전에는 마운트 시에는 node가 언마운트 시에는 null이 전달되는 것을 이용해 if문으로 처리했고, React 19부터는 클린업 함수를 함께 이용하면 됩니다.

보통 이벤트 리스너나 외부 라이브러리에 대한 작업은 useEffect를 이용하지만, 동적 DOM 노드 혹은 조건부 렌더링과 같은 특정 상황에서는 ref 콜백 함수를 이용하는 것이 더 적합합니다.

import {useState, useCallback} from 'react';

export default function DynamicComponent() {
  const [isShown, setIsShown] = useState(false);
  
  const refCallback = useCallback((node: HDMLDivElement | null) => {
    if (node) {
      // 노드 마운트
      const handleClick = () => console.log("Clicked !!");
      node.addEventListener("click", handleCLick);
      
      return () => {
        // 노드 언마운트, React 19의 클린업 함수 이용
        node.remveEventListener("click", handleClick);
      }
    }
  }, []);
  
  return (
    <div>
      <button onClick={() => setIsShown(!isShown)}>Toggle</button>
      {isShown && <div ref={refCallback}>동적 노드</div>}
    </div>
  );
}

위의 경우도 click 이벤트는 onClick이 있으니 onClick을 사용하는 것이 더 React스럽고, React에서 사용하지 않는 이벤트나 외부 라이브러리 및 옵저버 등의 등록/해제하는 패턴을 구현해야 할 때 ref 콜백 함수를 사용하는 것이 적합합니다. 

 

E. DOM 노드의 생명주기를 세밀하게 제어애햐 할 경우

const refCallback = (node) => {
  if (node) {
    node.startAnimation();
    return () => node.stopAnimation(); // 애니메이션 정리
  }
};

 

생각보다 정리글이 길어졌네요. 다음 포스팅에 이어서 작성하겠습니다!

+ Recent posts