렌더링 최적화, 어떤 hook을 사용할까

박상하·2025년 6월 19일

1년차

목록 보기
21/26

렌더링 최적화 어떤 걸 쓸까

React 기반 프로젝트를 개발하다보면 리렌더링이 일어났을 때 필요한 코드만 딱! 실행되게 하는게 중요하다고 많이 느낀다. 리렌더링에 따른 함수의 실행이나 렌더링을 제어할 수 있는 방법은 다양하다.

오늘은 그 방법들을 정리하고 내 나름 분석을 해보겠다.

useEffect+useState / useRef /useMemo / useCallback

자, 먼저 리렌더링이 될 때 내부 함수는 모두 재실행 된다. 결국 이 재실행되는 걸 대비해서
리액트는 다양한 훅을 제공한다..

useEffect + useState의 조합은 많은 개발자분들이 잘 알고있다.

먼저 React는 state의 변화를 감지하고 state의 변화가 생긴 부분은 rerendering 한다.
양날의 검이다. DOM에서는 딱 변화된 부분만 변경이 되지만 내부 함수는 그대로 실행이 된다.

const [open,setOpen]=useState(false)
const name='updown'
console.log('what?')

// 
const onClick=()=>{setOpen((current)=>!current)}


이런 함수가 있다면 click event를 실행할 때마다 name은 새롭게 할당되어 만들어지고
console에 what?이 실행된다. (클릭 때 마다)

이렇게 되면 서비스 성능은 떨어지고 또 다른 사이드 이펙트를 발생 시킬 수 있다.

먼저 useEffect를 사용하는 방법이 있다.

useEffect

다시 한 번 정리의 느낌으로 작성해보겠다.

useEffect를 사용하면 내부 스코프에 있는 함수가 의존성 배열을 기준으로 실행되고 안되고 조절할 수 있다.

const [open,setOpen]=useState(false)

useEffect(()=>{
  console.log('Hi')
  
},[])
console.log("Hello")

const onClick=()=>{setOpen((current)=>!current)}

Hi는 최초에 1번 (마운트된 후) 실행되고 Hello는 Click을 할 때마다 실행되게 된다.

그렇다면 의존성 배열에는 어떤걸 넣을 수 있을까
바로 렌더링에 영향을 주는 값이다. 주로 state라고 볼 수 있다.

const [open,setOpen]=useState(false)

useEffect(()=>{
  console.log('Hi')
  
},[open])
console.log("Hello")

const onClick=()=>{setOpen((current)=>!current)}

이렇게 open 이라는 state를 의존성 배열에 넣고 onClick을 실행하면 useEffect 내부 console.log('Hi')가 Click 횟수만큼 찍히게된다.

이런식으로 렌더링 시 지켜낼(?) 함수를 가두거나 필요한 값이 변경됐을 때 적절한 함수의 실행을 시도할 수 있다.

useRef

useRef는 값은 변경되지만 렌더링에 영향을 주지 않는다. 보존된다.

여기서 보존된다는 점이 일반 변수랑 차이가 된다.

만약 const let var 로 선언한 일반 변수는

"렌더링 시 초기화" 가 된다. 왜?? 앞서 말한바와 같이 재실행되어 재선언되는거니까. 즉, 메모리에 새롭게 들어간다.

그런데 useRef는 리렌더링이 되어도 그 값을 보존해준다.

주로 dom element 값을 가져올 때 많이 사용하는데 이는 useRef.current 속성값에 직접 DOM 요소와 연결할 수 있기 때문이다.

사실 주로 useEffect와 useRef를 사용해서 DOM 정보를 가져온다.

왜냐면

useEffect는 마운트, 즉 최초 컴포넌트의 렌더링이 이미 진행된 후에 실행된다.
그럼 DOM element 정보가 다 담겨있을 것이고 그 useEffect가 실행되는 스코프에 useRef.currnet 값에 접근하면 그 값을 가져올 수 있다.

const myInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
  // 마운트 후, 즉 렌더링 후 실행됨
const ref =  myInputRef.current
// 이거 가지고 뭐든 해
}, []);

return <input ref={myInputRef} />;

다시 본론으로 돌아와서, 그럼 useRef는 값이 변해도 렌더링이 일어나지 않는다.
어떤 경우에 이 훅을 사용하는게 좋을까?

정말 렌더링에 필요는 없지만 내 변경되는 값을 저장할 때 사용하면된다.

useEffect+useState / useRef 고민 사례

필자는 어제, 오늘 위 두 가지 방법을 놓고 고민한 경험이있다.

ChatForm 컴포넌트 관련 개발을 하면서

function Chat({ tokenInfo }: Props) {
 // 생략
  useConnectChat(roomRef, tokenInfo, setChatList, setAmIKick, setBroadcastEnd);
    const manageFn = useGetManageFn(roomRef, client, setChatList);

    return (
        <Container>>
            <ChatForm manageFn={manageFn} chat={chat} setChat={setChat} />
        </Container>
    );
}

자, 먼저 useConnectChat은 chatRoom과 연결해주는 hook이다. 이때, roomRef에 해당 chatRoom 과 연결이 된다.

그럼 그 값을 manageFn에 전달해준다.

manageFn은 2가지 방법으로 개발해보았다.

1. useEffect+useState

function useGetManageFn(roomRef, client, setChatList) {
    const [manageFn, setManageFn] = React.useState<ManageFnType | undefined>(undefined);

    React.useEffect(() => {
        if (roomRef.current) {
            const manageFn = createChatManager({
                room: roomRef.current,
                client,
                setChatList,
            });

            setManageFn(manageFn);
        }
    }, []);
    return manageFn;
}

export { useGetManageFn };

2. useRef

function useGetManageFn(...) {
  const manageFnRef = React.useRef();

  if (!manageFnRef.current && roomRef.current) {
    manageFnRef.current = createChatManager({
      room: roomRef.current,
      client,
      setChatList,
    });
  }

  return manageFnRef.current;
}

두 코드 모두 1번만 내부 함수가 실행된다는 공통점이 있다.

참고로 useConnectChat은 비동기 함수가 없다. 순차대로 실행한다면 roomRef는 room이 존재하는 저장소가 된다.

그렇다면 둘 다 room이 있다는 가정 하에 실행이 되는데 그때, 일단 두 코드 모두 manager를 정상적으로 연결한다.

그런데 차이는 다음과 같다.

    return (
        <Container>
            <ChatList chatList={chatList} />
            <Notification />
            <Product />
            <ChatForm manageFn={manageFn} chat={chat} setChat={setChat} />
        </Container>
    );

ChatForm에 managaFn에 값이 넘어가는 과정이다.

1번인 useEffect + useState를 예시로 보면

최초 처음 렌더링 할 때 어떤 값이 넘어갈까? 바로 useState에 담긴 기본값이다.
즉, undefined

대신 차이를 보면 useEffect+useState는 최초렌더링 이후 실행되어 state값이 변경되고 다시 한 번 return 하게된다. 왜냐면 아까 말했듯이 동일함수 내 useEffect 외부에 존재하는 실행문은 다시 실행이된다. 그래서 return도 다시 된다.

그럼 Chat 컴포넌트에서 manageFn은 새로운 값으로 채워지고
그 값은 state이기 때문에 렌더링에 영향을 준다.

결국 ChatForm을 재실행하는 trigger가 된다.

2번인 useRef를 살펴보자

useRef는 함수의 흐름대로 실행이된다. 즉, 컴포넌트 렌더링 함수 이전에 실행이 된다.
대신 useRef도 초기값이 있다. 그럼? props에 어떤 값이 들어갈까
아무리 중간에 값이 변해도 props에는 useRef의 초기값이 들어간다.

그리고 useGetManageFn에 의해 manageFn.current 에는 manageFn 함수가 들어가게 된다.
그런데 렌더링에 무관하기 때문에 Props는 그대로 존재한다. 즉, ChatForm이 재실행되지 않는다.

대신 다른 state에 의해 ChatForm이 재실행된다면 그때 props를 평가할 때 변경된 Ref값으로 들어가며 정상적으로 ManageFn이 들어간다.

결국 이 차이를 알고 사용하는게 중요한거 같다.

정리하면 useEffect,useState는 렌더링에 영향을 주고 Props로 전달한다면 이게 적절하고
useRef는 렌더링에 영향을 안주는 Props로 전달할 때도 ref값은 비추천이다. 물론 그게 필요한 상황이 있을 수 있지만 ! 개발에 정답은 없으니 말이다.

useMemo / useCallback

이 외에도 useMemo useCallback Hook이 존재한다.

간단히 말해서

useMemo는 값을 기억해준다.(의존성배열이 바뀔 때만 다시 계산)
useCallback은 함수를 기억해준다.

컴포넌트가 리렌더링 되면 내부 함수가 모두 재실행되는데 이때 함수의 재선언, 값의 계산을 막는 Hook이라고 생각하면 좋다.

useMemo
무엇 -> 값을 기억 + 의존성 배열이 바뀌면 다시 계산
언제 -> 계산 비용이 큰 연산 + 무거운 필터링

const filteredList = useMemo(() => {
  return bigList.filter(item => item.includes(keyword));
}, [bigList, keyword]);
// bigList나 keyword가 바뀌지 않으면 이전 filter 결과를 재사용!

useCallback
무엇 -> 함수를 기억 + 의존성 배열이 바뀔 때만 새롭게 만듦
언제 -> 자식 컴포넌트에 함수를 props로 넘길 때 불 필요한 리렌더 방지

const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

0개의 댓글