useCallback은 위와 같이 두 개의 인자를 받습니다. 첫번째 인자는 메모이제이션할 콜백함수를, 두번째 인자는 useEffect, useMemo와 같이 의존성 배열이 전달됩니다. useCallback은 메모이제이션된 콜백함수를 반환하며, 이 콜백함수는 의존성 배열의 값이 변경되었을 경우에만 갱신됩니다. 만약 의존성 배열의 값이 동일하다면, 해당 함수를 사용하는 컴포넌트가 리렌더링되더라도 새로운 함수가 생성되지 않고 기존 함수가 반환됩니다.
위의 두 코드는 동일합니다. useCallback은 useMemo를 기반으로 만들어졌기 때문입니다. useCallback은 useMemo에서 값이 아닌 함수를 사용할 때에 편의성을 증진시킨 Hook입니다. 두 Hook은 성능 최적화라는 목적에서는 공통점을 갖지만, 특정 값을 재사용할 것인지 혹은 특정 함수를 재사용할 것인지에 따라 선택적으로 사용할 수 있습니다. useMemo는 값의 재사용을 위해 전달된 함수를 실행하고 그 결과를 메모이제이션하지만, useCallback은 함수의 재사용을 위해 전달된 함수 자체를 메모이제이션합니다.
7.2 useCallback을 사용하는 이유
useCallback을 사용하면 매번 함수를 생성하지 않고 기억하게 해두었다가 필요할 때마다 함수를 재사용할 수 있게 됩니다. 함수를 생성하는 것만으로는 메모리나 CPU의 리소스를 많이 차지하는 작업이라고 할 수 없습니다. 하지만 props가 바뀌지 않았을 때, React의 가상 돔(Virtual DOM)에 리렌더링을 하지 않고 이전 결과를 사용하면 렌더링 시간을 줄일 수 있습니다.
useCallback이 사용되는 경우는 대표적으로 두 가지가 있습니다. 첫 번째는 이벤트 핸들러 함수를 재사용할 때, 두 번째는 참조 동일성이 유지되지 않아 생기는 문제를 해결할 때입니다.
7.2.1 useCallback으로 이벤트 핸들러 함수 재사용하기
기본적으로 컴포넌트 안에서 state나 props가 업데이트되면 컴포넌트는 리렌더링 되는데, 만약 아래 코드처럼 사용자로부터 입력받는 input 태그같이 업데이트가 잦은 state가 있는 컴포넌트라면 매번 새롭게 이벤트 핸들러 함수가 생성됩니다.
이를 개선하기 위해 반복해서 생성되는 이벤트 핸들러 함수를 useCallback으로 감싸주면 함수를 메모이제이션하여, 컴포넌트가 리렌더링 되더라도 의존하는 값들이 바뀌지 않는 한 기존 함수를 계속해서 반환합니다.
import React, { useCallback, useState } from "react";
function InputId() {
const [id, setId] = useState("");
// useCallback을 사용한 예제
const onChangeId = useCallback( e => {
setId(e.target.value);
}, []);
return <input onChange={onChangeId} />;
}
export default InputId;
7.2.2 useCallback으로 참조 동일성 문제 해결하기
useEffect 의 의존성 배열에 함수가 들어가면, 함수도 객체 타입이기 때문에 챕터6(실습2)에서 봤던 참조 동일성 문제가 발생합니다. 앞에서는 값을 재사용하기 위해서 useMemo를 통해 메모이제이션 했다면 이번에는 함수를 재사용해야 하므로 useCallback을 사용해보겠습니다.
위 코드를 보면, useEffect의 의존성 배열에 fetchUserInfo 함수가 들어있으므로 fetchUserInfo가 변경될 때 API를 호출합니다. 그런데 객체 타입인 fetchUserInfo는 컴포넌트가 렌더링되면 새로운 참조 값으로 변경됩니다. fetchUserInfo 의 참조 값이 변경되면 useEffect가 다시 실행되고, state인 user 값이 업데이트되어 컴포넌트가 리렌더링 됩니다. 이로 인해 발생하는 무한 루프를 useCallback으로 해결해보겠습니다.
import React, { useState, useEffect, useCallback } from "react";
function UserProfile({ id }) {
const [user, setUser] = useState(null);
// useCallback을 사용한 예제
const fetchUserInfo = useCallback(
() =>
fetch(`https://user-api.com/users/${id}`)
.then((response) => response.json())
.then(({ user }) => user),
[id]
);
useEffect(() => {
fetchUserInfo().then((user) => setUser(user));
}, [fetchUserInfo]);
...
}
useCallback으로 함수를 감싸주면 해당 함수는 이미 메모이제이션 되었기 때문에 의존성 배열로 넣어준 id를 변경하지 않는 한 컴포넌트는 리렌더링되지 않습니다.
7.3. (실습1) useCallback 사용해보기
useCallback의 사용법과 사용시기를 확인해보고자 간단한 예제를 통해 useCallback을 사용하지 않았을 때와 사용했을 때를 비교해보겠습니다. 해당 예제에서는 토글 버튼을 사용하여 bool 값에 따라 라이캣의 외출 상태를 변경시키는데, 상태값이 true일 경우 ‘집에있어요!’, false일 경우 ‘외출했어요!’라는 문구와 이미지로 쉽게 라이캣의 상태를 확인할 수 있습니다.
위 예제는 라이캣 이동! 버튼을 눌러 값이 변경될 때마다 locationToggle 변수에 변경된 값을 반영해주고, 현재 getLocation값 버튼을 눌렀을 때 licatLocation 함수를 호출하여 콘솔에 상태 값을 출력해줍니다. 만약 상황에 따라 licatLocation 함수에 변화가 있을 때만 실행되는 useEffect를 추가해야 하는 경우가 생긴다면, 어떤 결과를 보여줄까요?
그림 7-1
처음 페이지가 렌더링되면, useEffect가 마운트됩니다. 콘솔창에 보이는 메세지는 useEffect가 실행될 때마다 출력됩니다. 이제 라이캣의 외출 상태를 변경하기 위해 라이캣 이동! 버튼을 눌러보겠습니다.
그림 7-2
분명 라이캣 이동! 버튼만 클릭했기 때문에 licatLocation 함수에는 영향이 없어 licatLocation 함수를 의존하는 useEffect도 실행되지 않을 거라고 예상하고 있었을 겁니다. 그런데 왜 useEffect도 같이 실행되고 있는 걸까요?
그 답은 React 리렌더링 조건 중 state 값이 변하면 컴포넌트 전체가 리렌더링되고, 함수가 다시 생성되어 새로운 주소 값을 참조하는 참조 동일성 문제를 통해 찾을 수 있습니다. 지금 예제처럼 간단한 상황에서는 리렌더링이 계속 발생해도 성능에 크게 영향이 없지만, 만약 호출하는 함수가 매우 무거운 로직을 가지고 있다면 수없이 반복되는 리렌더링은 매우 비효율적일 것입니다.
이럴 때 useCallback을 사용합니다. 재사용할 함수를 useCallback의 콜백함수로 넣어 메모이제이션 해주면, 이제 state 값이 변해도 licatLocation 함수에 변화가 없을 땐 useEffect도 실행되지 않아 리렌더링이 일어나지 않게 됩니다.
7.3.2 useCallback을 사용할 때
const licatLocation = useCallback(() => {
console.log(
locationToggle
? "📍 현재 getLocation값은 true(집에있어요!)"
: "📍 현재 getLocation값은 false(외출했어요!)"
);
return;
}, []); // --- ① 빈 배열을 넣었을 때
❗
useCallback 사용시 주의할 점
그림 7-3
실행화면에서 볼 수 있듯이 useCallback으로 licatLocation 함수를 감싸준 결과 useEffect는 더 이상 실행되지 않지만, 현재 locationToggle 에 저장된 state 값은 true임에도 불구하고 licatLocation 함수는 계속해서 false 값을 보여주고 있습니다.
이런 상황은 ①처럼 의존성 배열을 빈 배열로 주게 될 때 만날 수 있습니다. 의존성 배열에 아무 조건도 주지 않는다면, 처음 렌더링될 때의 state 값이 그대로 메모이제이션됩니다. 리렌더링을 해줄 조건이 없어서 계속 처음 저장된 상태 그대로를 가져오기 때문입니다. 위의 상황도 콜백함수가 메모이제이션 될 때 locationToggle 값이 false로 저장된 상황이었습니다. 그렇기 때문에 useCallback을 사용할 때는 의존성 배열에 리렌더링을 위한 조건을 반드시 넣어줘야 합니다.
아래 ②처럼 리렌더링을 위한 조건인 locationToggle 까지 넣어주고, 다시 코드를 실행시켜 봅시다.
const licatLocation = useCallback(() => {
console.log(
locationToggle
? "📍 현재 getLocation값은 true(집에있어요!)"
: "📍 현재 getLocation값은 false(외출했어요!)"
);
return;
}, [locationToggle]); // --- ② 값을 넣었을 때
그림 7-4
그림 7-5
콘솔 결과를 확인해보면, 라이캣 이동! 버튼을 클릭했을 때마다 licatLocation 함수 호출! 메세지가 출력됩니다. 또한 state 값에 따라 왼쪽 화면에 출력되는 state 값과 licatLocation 함수가 가지고 있는 state 값이 일치하는 것을 볼 수 있습니다.
7.4. (실습2) useCallback 사용해 보기
부모 컴포넌트가 렌더링 되면 자식 컴포넌트도 렌더링 됩니다. 만약 props가 같은 주소일 경우, 주소를 비교한 후 리렌더링하는 React.memo로 감싸 성능을 향상시킬 수 있습니다. 이번 실습에서는 React.memo와 useCallback을 사용하여 성능 최적화 및불필요한 리렌더링을 줄이는 것에 목적을 두었으며, 코드의 가독성을 위해 스타일링 코드는 제외하고 살펴보겠습니다.
* 7.4.3 최종 코드에 스타일링 코드가 포함되어 있습니다.
* 이미지 출처 - WeNiv
그림 7-6 : 실습2 결과물
2배 성장!🪄 버튼을 누르면, RealLicat은 2배로 크기가 커지고 <h2> 요소에 현재 상태 값인 licatSize가 화면에 나타납니다. 🟦의 인사 또는 🟨의 인사 버튼을 누르면, Boolean 값이 true일 때 “안녕!” 문구가 나타납니다. React.memo와 useCallback을 사용하지 않았을 때, 버튼을 누르면 콘솔 창에서 어떤 일들이 벌어지는지 확인해 보겠습니다.
각각의 버튼을 눌렀을 때, useCallback의 의존성 배열에 넣은 해당 state가 변했을 때만 리렌더링 되는 것을 볼 수 있습니다. 콘솔 창에서 보이지는 않지만, setLicatSize 함수에 useCallback을 사용함으로써 관련 없는 버튼을 눌렀을 때 이미지가 함께 리렌더링 되지 않습니다.
useCallback 챕터를 끝마치며, 성능 최적화에는 코드의 복잡성, 유지보수의 어려움, 메모리 증가 등의 단점이 있을 수 있기 때문에 장단점을 비교하여 useCallback을 올바르게 사용해야 합니다.