0. 들어가기
최근 실시간 구현 과제를 받고, 코드를 작성한 뒤 질의 응답을 받은 적이 있었다.
useMemo, useCallback에 대해 알고 있냐는 질문을 받았는데, 면접 대비를 했기 때문에 대답 자체는 무난하게 했다고 생각했다.
하지만 잘 알고 있었다면 해당 과제에 이 두 훅이 적용됐어야 한다는 것을 뒤늦게 알게 됐다.
즉 머리로만 알고 실제로 코드에는 적용하지 못하는 어리석은 모습을 보였던 것 같다.
약 1년 가량 코딩을 하면서 useMemo와 useCallback을 적용한 적이 거의 없는 것 같다.
위에서 말한대로 이론으로만 알고 있기 때문에, 아니 오히려 이론으로도 모른다는 걸 인지했다.
해당 개념을 알게 된 것도 거의 1년 전이라 기억이 안 나기도 했고... 🥲
며칠 전 빌려본 책에서 useMemo와 useCallback에 대한 내용이 이해하기 좋게 나와서 정리해보려고 한다.
1. 훅의 기본 원리
훅을 이해하기 전에 변수의 유효 범위라는 것을 먼저 이해해보자.
모든 프로그래밍 언어에서 변수는 유효한 범위가 있다.
{
const local = 1;
}
위 코드에서 local은 블록 안에서만 유효하고 블록을 벗어나면 자동으로 소멸된다.
소멸이란 local 변수의 선언 -> 1로 값을 할당 -> 함수 종료 시 블록을 벗어나 local 변수는 메모리에서 유효하지 않게 됨을 의미한다.
여러 프로그래밍 언어에서 중괄호 안쪽의 범위를 블록 범위라고 하며, 이 범위 안쪽의 변수를 지역 변수라고 한다.
지역 변수는 아래처럼 함수에서도 동일하게 적용된다.
function func() {
const local = 1;
return local
}
함수 func의 몸통은 중괄호로 작성되어 있는데,
중괄호는 블록 범위이기 때문에 return문을 만나 블록을 벗어나면 그 안에 선언한 변수가 자동으로 소멸된다.
리액트의 함수 컴포넌트 또한 함수이기 때문에 변수는 위와 똑같은 유효 범위를 갖는다.
export default function UseOrCreate() {
const local = 1
return <p>{local}</p>
}
위에서 소멸이 무엇인지 설명한 과정과 동일하게, local 변수는 함수가 호출될 때마다 값이 1로 할당된다.
함수 컴포넌트가 리렌더링 될 때마다 이 UseOrCreate 함수는 다시 실행되며 local은 다시 1로 초기화된다.
local 변수가 블록 범위 안에서 선언되었기에 함수가 실행할 때만 존재하고, 함수가 끝나면 소멸된다.
상태란 변수의 유효 범위와 무관하게 계속 유지하는 값을 의미한다.
상태는 한 번 설정되면 이후로 값을 변경할 수 없는 '읽기 전용'의 개념을 가진 불변 상태와
아무 때나 값을 변경하고 계속 유지할 수 있는 가변 상태로 나뉜다.
하지만 함수 컴포넌트는 블록 범위라는 개념 때문에 상태를 가질 수 없다.
함수 컴포넌트가 상태를 가질 수 있는 유일한 방법은 상태를 담은 변수를 함수 몸통 바깥에 꺼내 블록 범위의 영향을 받지 않게 하는 것이다.
const global = 1;
export default function UseOrCreate() {
return <p>{global}</p>
}
이처럼 global 변수는 함수 몸통 바깥에 있어 소멸하지 않고 계속 1로 유지되며, 이런 변수를 전역 변수라고 한다.
리액트 훅은 상태를 갖지 못하는 함수 컴포넌트가 마치 상태를 가진 것처럼 동작하게 한다.
이런 개념을 이용해 캐시를 전역 변수 형태로 만들어 구현할 수 있다.
캐시란 데이터나 값을 미리 복사하는 임시 저장소를 의미하는데,
원본 데이터에 접근하는 시간이 오래 걸리거나 다시 계산하는 시간을 절약하고 싶을 때 주로 사용한다.
이제 캐시를 구현해보면서 useMemo에 대해 알아보자!
2. Cache
const cache: Record<string, any> = {};
export const useOrCreate = <T>(key: string, callback: () => T): T => {
if (!cache[key]) cache[key] = callback();
return cache[key] as T;
};
Record 타입은 TS에서 객체의 키와 값의 타입을 지정하는 유틸리티 타입이다.
주로 Record<K, T> 형태로 사용하며, K에는 객체의 키에 해당되는 타입 / T에는 해당 키에 매핑할 값의 타입을 할당한다.
위 코드에서 cache 변수를 전역 변수로 선언했다.
만약 cache[key]에 설정한 값이 없다면 callback 함수를 호출해 cache[key]에 저장한 값을 생성하고 저장한다.
값이 없다면 호출해서 생성, 저장하고, 값이 있다면 그냥 그대로 반환하면 되는 캐시의 개념을 그대로 구현했다.
import { Title, Avatar } from '../components';
import * as D from '../data';
import { useOrCreate } from './useOrCreate';
export default function UseOrCreateTest() {
const headTexts = useOrCreate('headTests', () => ['No.', 'Name', 'Job Title', 'Email']);
const users = useOrCreate('users', () => D.makeArray(100).map(D.makeRandomUser));
const head = useOrCreate('head', () =>
headTexts.map(text => <th key={text}>{text}</th>)
);
const body = useOrCreate('children', () =>
users.map((user, index) => (
<tr key={user.uuid}>
<th>{index + 1}</th>
<td className="flex items-center">
<Avatar src={user.avatar} size="1.5rem" />
<p className="ml-2">{user.name}</p>
</td>
<td>{user.jobTitle}</td>
<td>{user.email}</td>
</tr>
))
);
headText를 보면, key로 headTests를 사용하고 콜백 함수로 뒤의 익명 화살표 함수를 사용하고 있다.
처음 호출될 때는 'headTests'에 해당하는 캐시가 없기에 콜백 함수가 실행돼 배열이 생성되고 캐시에 저장된다.
하지만 이후에 동일한 key로 호출하면 캐시에 저장된 배열을 반환해준다.
users, head, body도 동일하게 동작한다.
users의 경우 100개의 사용자 데이터를 만들었는데, 이런 경우 캐싱을 하는 게 좋다.
또한 head는 headTexts에 종속되어 있어, headTexts가 캐시된 배열이라면 다시 생성될 일이 없어진다.
body도 users를 map으로 전개하고 있기 때문에 head와 동일하게 user에 종속되어 있다.
이런 차트가 100개까지 만들어진다.
캐시된 값은 어떤 상황이 일어나면 값을 갱신해주어야 한다.
이처럼 캐시를 갱신하게 만드는 상황이나 요소를 의존성이라고 하며, 이러한 의존성으로 구성된 배열을 의존성 목록이라고 한다.
3. 데이터를 캐시하는 useMemo
useMemo는 데이터를 캐시하는 용도로 사용된다.
위에서 캐시를 구현하는데 사용한 코드를 useMemo로 최적화 해보자.
const 캐시된_데이터 = useMemo(콜백_함수, [의존성1, 의존성2...])
콜백_함수 = () => 원본_데이터
종속성 목록이 바뀔 때마다 콜백 함수를 자동으로 호출하게 된다.
import { Title, Avatar } from '../components';
import * as D from '../data';
import { useMemo } from 'react';
export default function Meme() {
const headTexts = useMemo(() => ['No.', 'Name', 'Job Title', 'Email'], []);
const users = useMemo(() => D.makeArray(100).map(D.makeRandomUser), []);
const head = useMemo(
() => headTexts.map(text => <th key={text}>{text}</th>),
[headTexts]
);
const body = useMemo(
() =>
users.map((user, index) => (
<tr key={user.uuid}>
<th>{index + 1}</th>
<td className="flex items-center">
<Avatar src={user.avatar} size="1.5rem" />
<p className="ml-2">{user.name}</p>
</td>
<td>{user.jobTitle}</td>
<td>{user.email}</td>
</tr>
)),
[users]
);
위에서 언급한대로 head는 headTexts를 사용하기 때문에 이 내용이 변하면 head도 같이 바뀌어야 한다. (=의존성이 있다.)
body 역시 users를 종속성 목록에 넣어야 한다.
4. 콜백 함수를 캐시하는 useCallback
useCallback은 함수 몸통을 캐시한다.
const 캐시된_콜백_함수 = useCallbak(원본_콜백_함수, 의존성_목록)
useCallback의 타입을 알아보면, 타입 변수 T는 함수어야 한다는 제약이 붙어 있다.
function useCallback<T extends Function>(callback: T, deps: DependencyList): T;
그렇기 때문에 아래와 같이 작성하는 것을 권장한다.
옳지 않은 예시처럼 구현하면 callback 함수가 항상 새로 만들어지기 때문에 사용하는 의미가 퇴색된다.
// 옳지 않은 예
const callback = () => alert('button clicked');
const onClick = useCallback(callback, []);
// 옳은 예
const onClick = useCallback(() => alert('button clicked'), []);
버튼을 클릭하면 onClick 콜백 함수가 호출되도록 구현한 코드를 보자.
import { useMemo, useCallback } from 'react';
import { Title } from '../components';
import { Button } from '../theme/daisyui';
import * as D from '../data';
export default function Callback() {
const onClick = useCallback(() => alert('Button Clicked'), []);
const buttons = useMemo(
() =>
D.makeArray(3)
.map(D.randomName)
.map((name, index) => (
<Button className="btn-primary btn-wide btn-xs" key={index} onClick={onClick}>
{name}
</Button>
)),
[onClick]
);
return (
<section className="mt-4">
<Title className="text-5xl font-bold text-center">Callback</Title>
<div className="flex mt-4 justify-evenly">{buttons}</div>
</section>
);
}
랜덤으로 생성된 유저의 이름이 적힌 버튼이 3개 있고, 버튼을 클릭하면 alert이 호출된다.
하지만 어떤 유저의 버튼을 눌렀는지는 알 수 없고 그냥 버튼이 눌렸다는 alert만 확인할 수 있다.
이 때 name을 받아 alert에 띄워주도록 해보자.
4-1. 고차 함수
JS는 함수를 일급 객체로 취급한다.
이 말은 함수와 변수를 차별하지 않고, 변수에 함수를 할당하거나 함수를 다른 함수의 인자로 전달할 수 있다는 것이다.
후자를 통해 다른 함수를 반환하는 함수, 고차 함수를 만들 수 있다.
리액트 개발에서 고차 함수는 주로 콜백 함수에 어떤 정보를 추가로 전달하려고 할 때 사용된다.
// 고차 함수 예시
const onClick = useCallback((name: string) => () => (alert(`${name} clicked`), [])
위 코드와 같이 onClick은 name을 매개변수로 받는 함수지만, 뒤에 익명 화살표 함수를 반환하는 고차 함수가 된다.
그런데 onClick 이벤트 속성은 () => void 타입의 콜백 함수를 설정해야 하기 때문에 이처럼 내부에서 name 변수를 전달할 수 없다.
하지만 고차 함수로 구현하면 onClick이 요구하는 () => void 타입 함수를 반환하면서, 동시에 name 값을 전달 받을 수 있다!
import { useMemo, useCallback } from 'react';
import { Title } from '../components';
import { Button } from '../theme/daisyui';
import * as D from '../data';
export default function HighOrderCallback() {
const onClick = useCallback((name: string) => () => alert(`${name} clicked`), []);
const buttons = useMemo(
() =>
D.makeArray(3)
.map(D.randomName)
.map((name, index) => (
<Button
className="btn-primary btn-wide btn-xs"
key={index}
onClick={onClick(name)}>
{name}
</Button>
)),
[onClick]
);
return (
<section className="mt-4">
<Title className="text-5xl font-bold text-center">Callback</Title>
<div className="flex mt-4 justify-evenly">{buttons}</div>
</section>
);
}
이렇게 onClick을 호출하면서 name을 전달해주면 원하는대로 어떤 버튼을 눌렀는지 유저의 이름을 알 수 있게 된다.
5. useMemo와 useCallback을 비교한 결론
useMemo | useCallback | |
목적 | 값을 메모이제이션 (계산된 값, 객체, 배열 등) | 함수를 메모이제이션 |
return 값 | 메모이제이션된 값 | 메모이제이션된 함수 |
사용 예시 | 계산 비용이 높은 값이나 객체 계산 | 자식 컴포넌트에 전달하는 함수 최적화, 함수가 의존성에 따라 달라지지 않게 할 때, 이벤트 핸들러나 비동기 함수, 상태 변경 등 |
의존성 | 의존성 배열에 따라 값이 다시 계산될지 여부 결정 | 의존성 배열에 따라 함수가 다시 생성될지 여부 결정 |
개념을 다시 이해하니까 조금 용례가 명확해지는 것 같다.
이전까지는 그냥 모호하게 메모이제이션한다! 고만 알고 있었기 때문에 어떨 때 써야 하는지 감이 안 왔다.
아마 내가 개발하면서 놓친 부분들이 많았을 것 같기에... 지금부터는 조금 고민해보면서 사용해봐도 좋을 것 같다.
**참고
리액트로 웹앱 만들기 with 타입스크립트
'⚛️ React' 카테고리의 다른 글
[React] useState 훅과 용례 (1) | 2024.09.24 |
---|---|
[React] import React from 'react'와 이별하기 (feat. React 17, rafce) (0) | 2024.04.21 |
[React] React의 렌더링 방식과 웹 브라우저의 동작 (0) | 2024.03.28 |
[React] Vite의 환경 변수 (cf. CRA) (0) | 2024.02.16 |
[React] useState (0) | 2024.02.03 |