useRef대신 callbackRef 사용하기

한상우·2025년 4월 24일

리액트

목록 보기
17/24
post-thumbnail

React에서 useRef 대신 Callback Ref 사용하기: 성능 최적화

안녕하세요! 오늘은 React에서 DOM 요소에 접근할 때 흔히 사용하는 useRef와 useEffect 조합 대신, callback ref를 활용해 성능을 최적화하는 방법에 대해 알아보겠습니다.

목차

  1. 기존 방식: useRef + useEffect
  2. Callback Ref 소개
  3. 두 방식의 비교
  4. React 18.3+에서의 클린업 처리
  5. 실제 사용 예시
  6. 언제 Callback Ref를 사용해야 할까?
  7. 결론

기존 방식: useRef + useEffect

React에서 DOM 요소에 접근하기 위해 가장 일반적으로 사용되는 방식은 useRefuseEffect를 함께 사용하는 것입니다.

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

function AutoFocusInput() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    // 컴포넌트가 마운트된 후에 실행됩니다
    if (inputRef.current) {
      inputRef.current.focus();
      console.log('입력 필드에 포커스가 적용되었습니다!');
    }
  }, []);
  
  return <input ref={inputRef} placeholder="자동 포커스 입력 필드" />;
}

이 방식은 직관적이고 많은 개발자들이 익숙하게 사용하고 있습니다. 하지만 여기에는 한 가지 비효율이 숨어 있습니다. 바로 useEffect가 추가적인 렌더링 사이클을 필요로 한다는 점입니다.

Callback Ref 소개

Callback ref는 ref 속성에 객체 대신 함수를 직접 전달하는 방식입니다. 이 함수는 React가 DOM 요소를 연결하거나 연결 해제할 때 호출됩니다.

import React, { useCallback } from 'react';

function AutoFocusInput() {
  const inputRef = useCallback(node => {
    if (node !== null) {
      // DOM 요소가 연결됐을 때 실행할 코드
      node.focus();
      console.log('입력 필드에 포커스가 적용되었습니다!');
    }
  }, []);
  
  return <input ref={inputRef} placeholder="자동 포커스 입력 필드" />;
}

이 함수는 다음과 같은 상황에서 호출됩니다:

  1. DOM 요소가 생성될 때: 함수는 인자로 해당 DOM 요소를 받습니다.
  2. DOM 요소가 업데이트될 때: 함수는 이전 요소에 대해 null을 인자로 호출된 후, 새 요소에 대해 다시 호출됩니다.
  3. DOM 요소가 제거될 때: 함수는 인자로 null을 받습니다.

두 방식의 비교

실제 코드로 두 방식을 비교해 보겠습니다.

useRef + useEffect 방식

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

function FocusWithUseEffect() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.focus();
      console.log('useEffect: 입력 필드에 포커스가 적용되었습니다');
    }
  }, []);
  
  return <input ref={inputRef} placeholder="useEffect로 포커스" />;
}

Callback Ref 방식

import React, { useCallback } from 'react';

function FocusWithCallbackRef() {
  const inputRef = useCallback(node => {
    if (node) {
      node.focus();
      console.log('Callback Ref: 입력 필드에 포커스가 적용되었습니다');
    }
  }, []);
  
  return <input ref={inputRef} placeholder="Callback Ref로 포커스" />;
}

실행 순서 차이점

두 방식의 실행 순서를 비교해 보면

useRef + useEffect:
1. 컴포넌트 렌더링
2. DOM 업데이트
3. useEffect 실행 (비동기적)
4. DOM 조작 (포커스 적용)

Callback Ref:
1. 컴포넌트 렌더링
2. DOM 업데이트 (이 과정에서 ref 콜백 실행)
3. DOM 조작 (포커스 적용)

Callback Ref 방식이 한 단계 적은 것을 볼 수 있습니다. 특히 useEffect는 비동기적으로 실행되므로 추가적인 시간이 소요됩니다.

Callback Ref의 장점

1. 불필요한 useEffect 제거

가장 큰 장점은 useEffect가 필요 없다는 점입니다. DOM 요소가 실제로 생성되는 시점에 바로 함수가 호출되므로, 추가적인 렌더링 사이클 없이 DOM에 접근할 수 있습니다.

2. 더 정확한 타이밍

Callback ref는 DOM이 업데이트되는 정확한 시점에 동기적으로 호출됩니다. 따라서 타이밍 이슈가 발생할 가능성이 줄어듭니다.

// useRef + useEffect 방식
function ExampleWithUseEffect() {
  const divRef = useRef(null);
  
  useEffect(() => {
    console.log('DOM 업데이트 후 비동기적으로 실행');
    console.log('divRef.current:', divRef.current); // 이미 렌더링 완료
  }, []);
  
  return <div ref={divRef}>내용</div>;
}

// Callback Ref 방식
function ExampleWithCallbackRef() {
  const setDivRef = useCallback(node => {
    console.log('DOM 요소 생성과 동시에 동기적으로 실행');
    console.log('node:', node); // 방금 생성된 요소
  }, []);
  
  return <div ref={setDivRef}>내용</div>;
}

3. 의존성 관리 단순화

useEffect의 의존성 배열을 관리할 필요가 없어집니다. 필요한 의존성이 있다면 useCallback의 의존성 배열에만 추가하면 됩니다.

4. 조건부 로직 처리 용이

새 노드가 연결될 때와 이전 노드가 연결 해제될 때 각각 다른 처리를 쉽게 할 수 있습니다.

const setVideoRef = useCallback(node => {
  if (node) {
    // 새 노드가 연결될 때
    console.log('비디오 요소가 연결되었습니다');
    node.play();
  } else {
    // 이전 노드가 연결 해제될 때
    console.log('비디오 요소가 연결 해제되었습니다');
  }
}, []);

React 18.3+에서의 클린업 처리

React 18.3부터는 callback ref의 클린업 처리 방식이 개선되었습니다. 이전에는 요소가 제거될 때 callback ref 함수가 null을 인자로 호출되었지만, 이제는 함수가 반환하는 값이 클린업 함수로 사용됩니다. 이 변경 사항으로 클린업 처리가 더 간단하고 직관적으로 바뀌었습니다.

function ImprovedCallbackRef() {
  const setButtonRef = useCallback(node => {
    if (node) {
      console.log('버튼 요소가 연결되었습니다');
      
      // 이벤트 리스너 등록
      const handleClick = () => {
        console.log('버튼이 클릭되었습니다');
      };
      
      node.addEventListener('click', handleClick);
      
      // 클린업 함수 반환 - React 18.3+에서 지원됨
      return () => {
        console.log('클린업 함수가 실행됩니다');
        node.removeEventListener('click', handleClick);
      };
    }
  }, []);
  
  return (
    <div>
      <button ref={setButtonRef}>클릭하세요</button>
    </div>
  );
}

실제 사용 예시

예시 1: 자동 포커스 입력 필드

가장 간단한 예제로 자동 포커스를 적용하는 입력 필드입니다:

import React, { useState, useCallback } from 'react';

function AutoFocusInput() {
  const [count, setCount] = useState(0);
  
  const inputRef = useCallback(node => {
    if (node) {
      node.focus();
      console.log('입력 필드에 포커스가 적용되었습니다!');
    }
  }, []);
  
  return (
    <>
      <input ref={inputRef} placeholder="자동 포커스" />
      <button onClick={() => setCount(c => c + 1)}>
        카운트 증가: {count}
      </button>
    </>
  );
}

이 예제에서는 컴포넌트가 마운트될 때 입력 필드에 자동으로 포커스가 적용됩니다. 버튼을 클릭하여 상태를 변경해도 inputRef 콜백이 불필요하게 다시 호출되지 않습니다. 왜냐하면 동일한 DOM 요소에 대해서는 React가 ref 콜백을 다시 호출하지 않기 때문입니다.

React 성능 측면에서 이 방식이 왜 좋은지 살펴보겠습니다:

  1. useRefuseEffect를 사용했다면, 컴포넌트가 렌더링된 후 추가적인 비동기 실행 단계가 필요했을 것입니다.
  2. Callback ref는 DOM 요소가 생성되는 즉시 직접 호출되므로, 추가적인 렌더링 사이클이 발생하지 않습니다.
  3. 상태가 변경되어도 동일한 DOM 요소에 대해서는 콜백이 다시 호출되지 않기 때문에, 불필요한 작업이 발생하지 않습니다.

예시 2: Canvas 애니메이션

Canvas 요소에 접근하여 애니메이션을 구현하는 예제입니다.

import React, { useState, useCallback } from 'react';

function CanvasAnimation() {
  const [isRunning, setIsRunning] = useState(true);
  
  const setCanvasRef = useCallback(canvas => {
    if (!canvas) return;
    
    const ctx = canvas.getContext('2d');
    let frameId;
    let angle = 0;
    
    // 애니메이션 함수
    const render = () => {
      // 캔버스 지우기
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      
      // 회전하는 사각형 그리기
      ctx.save();
      ctx.translate(canvas.width / 2, canvas.height / 2);
      ctx.rotate(angle);
      ctx.fillStyle = 'coral';
      ctx.fillRect(-50, -50, 100, 100);
      ctx.restore();
      
      // 각도 업데이트
      angle += 0.01;
      
      // 애니메이션이 실행 중이면 다음 프레임 요청
      if (isRunning) {
        frameId = requestAnimationFrame(render);
      }
    };
    
    // 애니메이션 시작
    frameId = requestAnimationFrame(render);
    
    // React 18.3+에서는 함수를 반환하여 클린업 처리
    return () => {
      console.log('캔버스 애니메이션 정리');
      cancelAnimationFrame(frameId);
    };
  }, [isRunning]); // isRunning 상태가 변경될 때 콜백 함수 재생성
  
  return (
    <div>
      <canvas 
        ref={setCanvasRef}
        width={300}
        height={300}
        style={{ border: '1px solid black' }}
      />
      <div>
        <button onClick={() => setIsRunning(!isRunning)}>
          {isRunning ? '일시 정지' : '재생'}
        </button>
      </div>
    </div>
  );
}

이 예제에서는 Canvas에 회전하는 사각형을 그리는 애니메이션을 구현했습니다. callback ref를 사용하여 Canvas 요소에 접근하고, requestAnimationFrame을 통해 애니메이션을 실행합니다.

React 18.3+에서는 callback ref 함수에서 클린업 함수를 직접 반환하여 리소스 정리를 간단하게 처리할 수 있습니다. 이 방식의 장점은:

  1. 클린업 로직이 설정 로직과 함께 위치하여 코드의 응집성이 높아집니다.
  2. useRef로 클린업 함수를 별도로 관리할 필요가 없어 코드가 간결해집니다.
  3. 컴포넌트 언마운트나 ref 변경 시 자동으로 클린업 함수가 호출됩니다.

언제 Callback Ref를 사용해야 할까?

Callback ref는 다음과 같은 상황에서 특히 유용합니다:

  1. 성능이 중요한 경우: 불필요한 렌더링을 최소화하고 싶을 때
  2. DOM 요소의 생성/제거 시점이 중요한 경우: 정확한 타이밍에 동작해야 할 때
  3. DOM 요소에 즉시 접근해야 하는 경우: 요소가 생성되는 즉시 작업을 수행해야 할 때
  4. 외부 라이브러리 통합 시: DOM 요소에 대한 외부 라이브러리 초기화가 필요할 때
  5. 애니메이션이나 사용자 경험과 관련된 즉각적인 반응이 필요한 경우: 지연 없이 DOM 조작이 필요할 때

결론

Callback ref는 React에서 DOM 요소에 접근하는 더 효율적인 방법입니다. useRef와 useEffect 조합보다 성능이 좋고, 정확한 타이밍에 DOM 조작이 가능하다는 장점이 있습니다.

React 18.3 이상에서는 클린업 처리도 더 간단해져, callback ref 함수에서 직접 클린업 함수를 반환할 수 있습니다. 이로 인해 코드의 응집성이 높아지고 리소스 관리가 더 쉬워졌습니다.

프론트엔드 개발자로서 이러한 최적화 기법을 익히고 적절히 활용한다면, 더 반응성 좋고 효율적인 React 애플리케이션을 만들 수 있을 것입니다. 면접에서도 이런 최적화 지식을 어필하면 React에 대한 깊은 이해도를 보여줄 수 있습니다.

실용적인 예시: 인터랙티브 슬라이더 컴포넌트

마지막으로, 실제 프로젝트에서 사용할 수 있는 인터랙티브 슬라이더 컴포넌트 예시를 살펴보겠습니다. 이 예시는 callback ref를 사용하여 슬라이더의 너비를 측정하고 드래그 이벤트를 처리합니다.

import React, { useState, useCallback } from 'react';

function Slider() {
  const [sliderWidth, setSliderWidth] = useState(0);
  const [value, setValue] = useState(50);
  const [isDragging, setIsDragging] = useState(false);
  
  // slider 트랙에 대한 callback ref
  const setSliderRef = useCallback(node => {
    if (!node) return;
    
    // 슬라이더 너비 측정
    const { width } = node.getBoundingClientRect();
    setSliderWidth(width);
    
    // 이벤트 핸들러
    const handleMouseDown = (e) => {
      setIsDragging(true);
      updateValue(e.clientX, node);
      
      // 전역 이벤트 핸들러 설정
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
    };
    
    const handleMouseMove = (e) => {
      if (isDragging) {
        updateValue(e.clientX, node);
      }
    };
    
    const handleMouseUp = () => {
      setIsDragging(false);
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
    
    // 값 업데이트 헬퍼 함수
    const updateValue = (clientX, sliderNode) => {
      const { left } = sliderNode.getBoundingClientRect();
      const newX = clientX - left;
      const percentage = Math.max(0, Math.min(100, (newX / sliderWidth) * 100));
      setValue(Math.round(percentage));
    };
    
    // 이벤트 리스너 추가
    node.addEventListener('mousedown', handleMouseDown);
    
    // React 18.3+에서는 클린업 함수 반환
    return () => {
      node.removeEventListener('mousedown', handleMouseDown);
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
  }, [isDragging, sliderWidth]);
  
  return (
    <div className="slider-container">
      <div 
        className="slider-track"
        ref={setSliderRef}
        style={{ position: 'relative', width: '100%', height: '6px', backgroundColor: '#eee', borderRadius: '3px' }}
      >
        <div 
          className="slider-fill"
          style={{ 
            position: 'absolute', 
            width: `${value}%`, 
            height: '100%', 
            backgroundColor: '#007bff',
            borderRadius: '3px'
          }}
        />
        <div 
          className="slider-thumb"
          style={{ 
            position: 'absolute', 
            left: `${value}%`, 
            top: '50%', 
            transform: 'translate(-50%, -50%)',
            width: '16px',
            height: '16px',
            backgroundColor: '#007bff',
            borderRadius: '50%',
            cursor: isDragging ? 'grabbing' : 'grab'
          }}
        />
      </div>
      <div className="slider-value" style={{ marginTop: '10px' }}>
        현재 값: {value}
      </div>
    </div>
  );
}

export default Slider;

이 컴포넌트는 callback ref를 활용하여:

  1. 슬라이더 트랙의 너비를 측정합니다.
  2. 마우스 이벤트 리스너를 등록하고 정리합니다.
  3. 슬라이더의 현재 값을 계산합니다.

useEffect를 사용했다면 DOM 요소가 렌더링된 후 비동기적으로 처리해야 했지만, callback ref를 통해 DOM 요소가 생성되는 즉시 작업을 수행할 수 있습니다. 이는 사용자 인터랙션이 중요한 UI 컴포넌트에서 특히 유용합니다.


마무리

이제 callback ref의 개념, 장점, 사용 방법, 그리고 실제 예시까지 살펴보았습니다. 이 기술을 활용하면 React 애플리케이션의 성능을 향상시키고, 더 직관적이고 깔끔한 코드를 작성할 수 있습니다.

여러분도 프로젝트에서 불필요한 useEffect를 제거하고 callback ref를 활용해 더 효율적인 코드를 작성해 보세요. 작은 최적화의 차이가 대규모 애플리케이션에서는 큰 성능 향상으로 이어질 수 있습니다!

Happy Coding! 😊

profile
안녕하세요

0개의 댓글