리액트 성능 최적화의 비밀 무기: useRef보다 강력한 Ref 콜백 사용법
리액트 개발자라면 memo
나 useCallback
같은 도구를 사용해 불필요한 렌더링을 막는 데 익숙할 것입니다.
하지만 만약, 이 렌더링 자체를 완전히 건너뛰고, 오직 성능만을 위해 DOM을 직접 제어해야 하는 극한의 상황이 온다면 어떻게 해야 할까요.
세계적인 데이터 그리드 라이브러리인 AG Grid의 개발팀은 실제 제품에서 바로 이런 문제에 부딪혔습니다.
그리고 그들이 찾아낸 해답은, 많은 리액트 개발자들이 잘 알지 못하는 숨겨진 기능, 바로 'Ref 콜백'에 있었습니다.
오늘은 렌더링의 굴레에서 벗어나 리액트의 성능을 한계까지 끌어올리는 이 강력한 비밀 무기에 대해 깊이 있게 알아보겠습니다.
1. 문제의 시작: 미세한 움직임이 거대한 렉을 만들다
문제는 AG Grid의 컬럼(열) 크기를 조절하는 아주 간단한 기능에서 시작되었습니다.
사용자가 마우스로 컬럼의 경계선을 잡고 드래그하면, 수많은 셀들의 너비와 위치가 실시간으로 변경되어야 하는데요.
내부에 들어가는 셀 컴포넌트가 조금이라도 복잡하고 무거운 로직을 담고 있을 경우, 이 부드러워야 할 움직임이 뚝뚝 끊기는, 끔찍한 사용자 경험으로 이어진 것입니다.
리액트 개발자 도구로 프로파일링을 해보니 원인은 명확했습니다.
마우스가 1픽셀 움직일 때마다 수백 개의 셀 컴포넌트가 state
변경으로 인해 불필요하게 다시 렌더링되고 있었던 것입니다.
memo
를 사용하면 어느 정도 개선은 되겠지만, 근본적인 해결책은 아니었습니다.
핵심은 '렌더링을 줄이는 것'이 아니라, '렌더링을 아예 하지 않는 것'에 있었습니다.
2. 첫 번째 시도: useRef
와 useEffect
의 함정
렌더링을 피하려면, 리액트의 상태(state)를 통하지 않고 실제 DOM 요소의 스타일을 직접 바꿔야 합니다.
이를 위해 우리는 자연스럽게 useRef
를 떠올립니다.
DOM 요소를 담을 수 있는 그릇을 만들고, 그 그릇을 이용해 직접 스타일을 조작하는 것입니다.
하지만 여기서 첫 번째 함정을 만나게 됩니다.
function CellComponent({ left }) {
const cellRef = useRef(null);
// 이 코드는 위험합니다!
useEffect(() => {
// cellRef.current가 null일 수도 있고,
// 이 효과는 브라우저가 화면을 그린 '후'에 비동기적으로 실행됩니다.
if (cellRef.current) {
cellRef.current.style.left = `${left}px`;
}
}, [left]);
return <div ref={cellRef}>...</div>;
}
useEffect
는 브라우저가 화면을 그리고 난 '후'에 실행됩니다.
그 결과, 셀들이 처음에는 위치 정보 없이 렌더링되었다가, useEffect
가 실행되는 순간 제자리로 '순간이동'하는 깜빡임(Flicker) 현상이 발생합니다.
사용자 경험 측면에서 이것은 결코 좋은 해결책이 아닙니다.
3. 두 번째 시도: useLayoutEffect
, 강력하지만 위험한 망치
이 깜빡임 문제를 해결하기 위해 등장하는 것이 useLayoutEffect
입니다.
이 훅은 useEffect
와 거의 똑같이 생겼지만, 결정적인 차이가 있습니다.
바로 브라우저가 화면을 그리기 '전'에 동기적으로 실행된다는 점입니다.
덕분에 우리는 화면이 그려지기 전에 DOM 요소의 스타일을 완벽하게 설정할 수 있고, 깜빡임 문제는 해결됩니다.
하지만 useLayoutEffect
는 성능에 영향을 줄 수 있는 무거운 망치와 같습니다.
브라우저의 렌더링을 막고 실행되기 때문에, 이 안에서 복잡한 작업을 하면 오히려 성능이 저하될 수 있습니다.
또한, 컴포넌트의 라이프사이클에 묶여있어, 조건부 렌더링과 엮이면 의존성 배열 관리가 매우 복잡해지는 단점이 있습니다.
4. 최종 해답: DOM 요소와 직접 대화하는 'Ref 콜백'
바로 이 지점에서 오늘의 주인공, 'Ref 콜백'이 등장합니다.
우리가 흔히 ref
속성에는 useRef
가 반환한 객체만 넣을 수 있다고 생각하지만, 사실은 '함수'도 전달할 수 있습니다.
이것이 바로 Ref 콜백입니다.
Ref 콜백은 컴포넌트가 아닌, 실제 DOM 요소의 라이프사이클에 완벽하게 동기화되어 작동하는 특별한 함수입니다.
- DOM 요소가 화면에 마운트(생성)될 때: 콜백 함수가 해당 DOM 요소를 인자로 받아 '한 번' 호출됩니다.
- DOM 요소가 화면에서 언마운트(제거)될 때: 콜백 함수가
null
을 인자로 받아 '한 번' 호출됩니다. (리액트 19부터는 클린업 함수를 반환하는 방식으로 변경됩니다.)
이것이 왜 강력할까요.
이 콜백은useLayoutEffect
처럼 화면이 그려지기 전에 동기적으로 실행되면서도, 라이프사이클이 컴포넌트가 아닌 DOM 요소 자체에 묶여있어 훨씬 직관적이고 안전합니다.
import { useCallback, useState } from 'react';
function CellComponent({ left, isVisible }) {
const [cellElement, setCellElement] = useState(null);
// 셀의 위치를 직접 업데이트하는 로직 (AG Grid 내부 로직이라고 가정)
// cellElement가 준비되면 이 로직이 실행됩니다.
useCustomPositioning(cellElement, left);
// Ref 콜백 함수
const measuredRef = useCallback(node => {
// node는 실제 div DOM 요소입니다.
if (node !== null) {
// DOM 요소가 준비되었을 때 상태에 저장합니다.
setCellElement(node);
}
// node가 null이면(언마운트되면) 별도의 처리를 할 수도 있습니다.
}, []); // 의존성 배열이 비어있어, 함수는 단 한번만 생성됩니다.
if (!isVisible) return null;
// 이제 ref 속성에 useRef 객체 대신 함수를 전달합니다.
return <div ref={measuredRef}>...</div>;
}
이제 CellComponent
는 left
프롭스가 변경되어도 더 이상 리렌더링되지 않습니다.
대신 useCustomPositioning
과 같은 외부 로직이 cellElement
를 직접 참조하여, 필요할 때마다 style.left
속성만 직접 변경합니다.
우리는 렌더링을 완벽하게 회피하면서도, 원하는 성능을 얻게 된 것입니다.
위대한 힘에는 위대한 책임이 따릅니다
Ref 콜백은 리액트의 선언적인 패러다임을 벗어나, DOM을 직접 제어하는 매우 강력하고 낮은 수준의 도구입니다.
이것은 모든 곳에 사용해야 하는 기술이 절대 아닙니다.
대부분의 애플리케이션에서는 useState
와 memo
만으로도 충분합니다.
하지만 여러분이 AG Grid처럼 고성능 라이브러리를 만들거나, 복잡한 애니메이션, 드래그 앤 드롭 기능을 구현하며 1밀리초의 성능까지 쥐어짜야 하는 상황에 놓인다면, Ref 콜백은 그 어떤 방법보다도 확실하고 우아한 해결책이 되어줄 것입니다.
이 비밀 무기를 여러분의 도구함에 잘 보관해두십시오.
언젠가 반드시 필요한 순간이 올 것입니다.