카테고리 없음

최적화 기법 useRef, useCallback

잘먹는 개발자 에단 2025. 6. 30. 14:40

1. useRef

- useRef를 단순히 DOM 요소를 잡는 용도로만 알고 있다면, 30%만 알고 있는 것이다.

- useRef의 본질은 컴포넌트가 다시 렌더링 되어도 절대 사라지지 않는 나만의 '기억상자'라는 것이다. 이 상자의 내용물 ( .current)는 바뀐다고 해서 컴포넌트가 다시 렌더링 되지도 않는다.

 

이 때문에 3가지 활용법이 파생된다. 

 

활용법 1 : DOM 참조용, 가장 기본적인 사용법

const playerRef = useRef<ReactPlayer>(null);

- playerRef라는 기억 상자에 <ReactPlayer>라는 DOM 요소 또는 컴포넌트를 담아달라고 리액트에게 부탁하는 것. 

- 왜 쓰는가? 리액트의 상태관리 시스템을 통하지 않고, 특정 DOM 요소에게 직접 명령을 내리고 싶을 때 사용한다. 예를 들어 playerRef.current.seekTo(30) 처럼 동영상을 특정 시간으로 점프시킬 때 유용하다.

 

 

활용법 2 : 값 저장용 ( 인터벌/타이머 관리 )

const watchTimeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

- setInterval이나 setTimeout을 실행하면 돌려받는 ID값 ( 타이머를 멈추기 위해서 필요하다 )을 watchTimeIntervalRef 라는 기억 상자에 저장한다. 

- 왜 useState가 아니라 useRef인가? 

ㄴ 타이머 ID 값은 화면에 표시될 정보가 아니다. 만약에 이걸 useState로 관리하면, ID 값이 바뀔 때마다 불필요한 리렌더링이 발생한다. useRef는 리렌더링을 유발하지 않으므로, 이런 UI와 상관없는 값을 조용히 저장해두기에 완벽하다. 

 

 

활용법 3 : 최신상태 참조용 ( 클로저 문제 해결사 ) , useRef를 고급기법으로 만드는 핵심..

const syncStateRef = useRef({
	isPlaying, 
    //... 다른 상태들
});

// 최신 상태를 항상 동기화
useEffect(()=>{
	syncStateRef.current = {
    	isPlaying
    }
});

 

다음과 같은 코드는 제대로 동작하지 않는다. 그래서 syncStateRef가 필요한다.

useEffect의 클로저 문제 때문에 제대로 동작하지 않는다.

// ❌ 문제가 있는 코드
useEffect(() => {
  const interval = setInterval(() => {
    // 여기서 isPlaying은 맨 처음 렌더링 때의 값(false)으로 고정됨
    if (isPlaying) { // isPlaying이 true로 바뀌어도 여전히 false로 알고 있음
      console.log('재생 중'); // 절대 실행 안 됨
    }
  }, 1000);
  
  return () => clearInterval(interval);
}, []); // 빈 의존성 배열 [] 때문에 이 useEffect는 딱 한 번만 실행됨

 

이를 useRef로 해결할 수 있다. 

// ✅ 해결된 코드
useEffect(() => {
  const interval = setInterval(() => {
    // isPlaying을 직접 보지 않고, '실시간 게시판'을 봄
    const { isPlaying } = syncStateRef.current; 
    if (isPlaying) { // 게시판에는 항상 최신 정보가 있음
      console.log('재생 중'); // 정상적으로 실시간 상태 반영
    }
  }, 1000);
  
  return () => clearInterval(interval);
}, []);

 

 

** 근데 문득 이런게 궁금해졌다. 아무리 Ref를 최신화해서 쓰고 싶다고 하더라도 useEffect(()=>{});로 매 ref를 최신화할텐데 이거를 성능 최적화라고 할 수 있는지...

다음은 gemini의 답변이다.

- 그럼에도 불구하고 이것은 꽤 최적화된다고 한다.

- 관점을 전환해야한다는데, 이 패턴의 최적화 목표는 JS 변수 할당 연산의 실행횟수를 줄이는 것이 아니라, '훨씬 더 무겁고 비용이 비싼 작업의 실행을 막는 것' 이라고 한다.

- 그 무겁고 비싼 작업이라는 것은 바로, useEffect의 콜백함 수 전체, 특히 클린업 함수와 그로 인한 부수효과 side effect의 재실행이라고 한다.

예를 들어보자.

 

[접근1] useRef와 useEffect로 최신 상태 참조

// A. 상태 동기화용 useEffect
useEffect(() => {
  //  비용: JavaScript 객체에 대한 프로퍼티 할당. (매우 저렴)
  syncStateRef.current = { isPlaying, ... }; 
});

// B. 인터벌 설정용 useEffect
useEffect(() => {
  // 비용: setInterval 단 한번 생성. (상대적으로 비쌈)
  const interval = setInterval(() => {
    if (syncStateRef.current.isPlaying) {
      console.log('재생 중');
    }
  }, 1000);
  
  // 클린업 함수: 컴포넌트 소멸 시 단 한번 실행.
  return () => clearInterval(interval); 
}, []); // [] 덕분에 B는 딱 한번만 실행됨

- 매 렌더링마다 syncStateRef.current에 객체를 할당한다. 이 작업은 순수 JS 연산으로 컴퓨터 입장에서 거의 무시할 만큼으로 비용이 저렴하다. 메모리에서 변수 값을 바꾸는 것은 비용이 거의 들지 않는다.

- 마운트 시에 딱 한번, setInterval을 생성하고 브라우저에 등록한다. 이것은 브라우저 api를 건드리는 작업으로 변수 할당보다는 훨씬 비싸다.

- ** 핵심 이득 : isPlaying 상태가 100번 바뀌어도 setInterval은 절대 clearInterval 되었다가 다시 생성되지 않고, 안정적으로 계속 유지된다.

 

[ 만약에 useRef 트릭을 쓰지 않는다면 ] 클로저 문제를 해결하기 위해서 useEffect의 의존성 배열에 isPlaying을 넣어야만한다.

// ❌ useRef를 안 썼을 때의 코드
useEffect(() => {
  const interval = setInterval(() => {
    if (isPlaying) { // isPlaying은 이제 최신 값을 참조함
      console.log('재생 중');
    }
  }, 1000);
  
  // isPlaying이 바뀔 때마다 여기가 실행됨!
  return () => clearInterval(interval); 
}, [isPlaying]); // isPlaying이 의존성 배열에 추가됨

- 발생하는 비용

- isPlaying 상태가 바뀔 때마다 

1. 클린업 함수가 실행된다. clearInterval(interval) 이 호출된다. ( 브라우저 api 호출 )

2. 콜백함수가 재실행된다. setInterval이 다시 생성되고 브라우저에 새로 등록된다. (브라우저 api 호출)

- 문제점 : 상태가 바뀔 때마다 타이머를 멈추고, 다시 시작하는 과정이 계속 반복된다. 이는 순수한 js 변수 할당과는 비교도 안될 정도로 훨씬 더 무겁고 비용이 비싼 작업이다. 만약 이게 타이머가 아니라 외부 라이브러리 구독 subscribe / unsubscribe이나 다른 복잡한 부수효과였다면 성능 저하는 더욱 심각해진다. 

 

그러니까 결론은 메모리에 있는 변수값을 바꾸는 것이 의존성 배열적으로 접근하는 방식보다 비용이 훨씬 저렴하기 때문에 syncstateRef를 쓰는 방식을 적극적으로 고려해야한다는 것이다. 

 

 

2. useCallback. 함수 재활용

- useCallback은 함수를 기억 Memoization 했다가 재사용하게 해주는 훅이다.

 

* 함수를 왜 재활용해야하는가?

- 리액트 컴포넌트는 리렌더링 될 때마다 그 안에 있는 함수들이 매번 새로 만들어진다. 코드 내용은 똑같아도 메모리 주소가 다른 완전히 새로운 함수가 되는 것이다.

이게 왜 문제냐면, 이 함수를 자식 컴포넌트에 props로 넘겨줄 때가 문제다. 자식 컴포넌트가 React.memo로 최적화되어있어도, props로 받는 함수가 매번 새로운 함수이니 '어? props가 바뀌었네?' 라고 착각하고 불필요한 리렌더링을 하게 된다.

 

useCallback은 이 함수를 재활용해서 자식 컴포넌트의 오해를 막아준다. 

 

활용법 1: 의존성이 없는 경우, 가장 기본적인 형태

const handlePlay = useCallback(()=>{
	setIsPlaying(true);
},[]) // 의존성 배열이 비어있음

- handleplay 함수는 외부의 어떤 변수도 사용하지 않는다.

- 이 함수는 컴포넌트가 살아있는 동안 단 한번만 만들고, 계속 재활용해줘 라는 뜻이다.

 

활용법 2: 의존성이 있는 경우, 일반적인 형태

const handleVideoChange = useCallback((index : number)=>{
	/* ... */
},
[course, videoProgress, /* ... */);

- handleVideoChange 함수는 course나 videoProgress 같은 상태 / props를 사용한다.

- 이 함수를 기억해두되, 의존성 배열 [] 안의 값들 중 하나라도 바뀌면 그 때는 어쩔 수 없이 함수를 새로 만들어줘. 하지만 그외의 이유로 리렌더링 되면 그냥 기억해둔 함수를 재활용해줘라는 뜻이다.

 

활용법 3: 의존성이 없는 비동기 함수

const syncVideoProgressToServer = useCallback(async()=>{
	/* ... */
},[]);

- 이 함수는 서버와 통신하는 비동기 함수지만, 함수 내부에서 사용하는 값들을 모두 매개변수로 받고 있어서 외부 상태에 의존하지 않는다. 

- 최적화 효과 : 이 syncVideoProgressToServer 함수는 단 한번만 생성된다. 따라서 이 함수를 여러 자식 컴포넌트에 props로 넘겨줘도, 자식 컴포넌트들이 불필요하게 리렌더링 되는 일을 완벽하게 막을 수 있다.