안녕하세요! 오늘은 React에서 DOM 요소에 접근할 때 흔히 사용하는 useRef와 useEffect 조합 대신, callback ref를 활용해 성능을 최적화하는 방법에 대해 알아보겠습니다.
React에서 DOM 요소에 접근하기 위해 가장 일반적으로 사용되는 방식은 useRef와 useEffect를 함께 사용하는 것입니다.
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는 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="자동 포커스 입력 필드" />;
}
이 함수는 다음과 같은 상황에서 호출됩니다:
null을 인자로 호출된 후, 새 요소에 대해 다시 호출됩니다.null을 받습니다.실제 코드로 두 방식을 비교해 보겠습니다.
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로 포커스" />;
}
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는 비동기적으로 실행되므로 추가적인 시간이 소요됩니다.
가장 큰 장점은 useEffect가 필요 없다는 점입니다. DOM 요소가 실제로 생성되는 시점에 바로 함수가 호출되므로, 추가적인 렌더링 사이클 없이 DOM에 접근할 수 있습니다.
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>;
}
useEffect의 의존성 배열을 관리할 필요가 없어집니다. 필요한 의존성이 있다면 useCallback의 의존성 배열에만 추가하면 됩니다.
새 노드가 연결될 때와 이전 노드가 연결 해제될 때 각각 다른 처리를 쉽게 할 수 있습니다.
const setVideoRef = useCallback(node => {
if (node) {
// 새 노드가 연결될 때
console.log('비디오 요소가 연결되었습니다');
node.play();
} else {
// 이전 노드가 연결 해제될 때
console.log('비디오 요소가 연결 해제되었습니다');
}
}, []);
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>
);
}
가장 간단한 예제로 자동 포커스를 적용하는 입력 필드입니다:
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 성능 측면에서 이 방식이 왜 좋은지 살펴보겠습니다:
useRef와 useEffect를 사용했다면, 컴포넌트가 렌더링된 후 추가적인 비동기 실행 단계가 필요했을 것입니다.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 함수에서 클린업 함수를 직접 반환하여 리소스 정리를 간단하게 처리할 수 있습니다. 이 방식의 장점은:
useRef로 클린업 함수를 별도로 관리할 필요가 없어 코드가 간결해집니다.Callback ref는 다음과 같은 상황에서 특히 유용합니다:
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를 활용하여:
useEffect를 사용했다면 DOM 요소가 렌더링된 후 비동기적으로 처리해야 했지만, callback ref를 통해 DOM 요소가 생성되는 즉시 작업을 수행할 수 있습니다. 이는 사용자 인터랙션이 중요한 UI 컴포넌트에서 특히 유용합니다.
이제 callback ref의 개념, 장점, 사용 방법, 그리고 실제 예시까지 살펴보았습니다. 이 기술을 활용하면 React 애플리케이션의 성능을 향상시키고, 더 직관적이고 깔끔한 코드를 작성할 수 있습니다.
여러분도 프로젝트에서 불필요한 useEffect를 제거하고 callback ref를 활용해 더 효율적인 코드를 작성해 보세요. 작은 최적화의 차이가 대규모 애플리케이션에서는 큰 성능 향상으로 이어질 수 있습니다!
Happy Coding! 😊