오늘 리액트 컴파일러를 사용해 봤는데, 어땠을 것 같나요? 😉

한정(Han Jung)
30 min readJun 19, 2024

--

오픈소스화된 리액트 컴파일러를 사용해 이제 리액트에서 메모이제이션을 사용할 필요가 없는지 살펴봅니다.

원문: https://www.developerway.com/posts/i-tried-react-compiler

제가 생각해 낸 제목 중 가장 클릭을 유도하는 제목이지만, 요즘 리액트 커뮤니티에서 과장된 주제 중 하나에 대한 글이라면 그럴 만한 가치가 있다고 생각합니다 😅.

지난 2년 반 동안 리렌더링과 메모이제이션 관련 패턴을 언급하는 글을 공개하면 미래에서 온 방문자가 댓글로 방금 말한 모든 내용이 리액트 Forget(현재 리액트 컴파일러로 알려짐) 때문에 더 이상 신경 쓸 필요가 없다고 친절하게 알려주곤 했습니다.

이제 우리의 시간이 마침내 그들의 시간을 따라잡았습니다. 리액트 컴파일러가 실제로 실험적 기능으로 일반 대중에게 공개되었으니, 미래에서 온 방문자들의 말이 맞는지 조사하고 지금부터 리액트에서 메모이제이션을 잊을 수 있는지 직접 확인해 볼 차례입니다.

리액트 컴파일러란 무엇인가?

시작하기 전에 아주 간단하게 리액트 컴파일러는 무엇이며 어떤 문제를 해결하고 어떻게 시작할 수 있는지 살펴보겠습니다.

문제는 다음과 같습니다. 리액트의 리렌더링은 계단식입니다. 리액트 컴포넌트에서 상태를 변경할 때마다 컴포넌트 트리의 끝에 도달할 때까지 해당 컴포넌트, 그 안의 모든 컴포넌트 그리고 그 안의 컴포넌트의 리렌더링이 발생합니다.

이러한 다운스트림 리렌더링이 일부 작업이 많은 컴포넌트에 영향을 주거나 너무 자주 발생하면 앱의 성능 문제가 발생할 수 있습니다.

이러한 성능 문제를 해결하는 한 가지 방법은 이러한 리렌더링 연쇄를 방지하는 것입니다. 그리고 이를 위한 한 가지 방법으로 React.memo, useMemo, useCallback을 사용해 메모이제이션을 합니다. 일반적으로 컴포넌트를 React.memo로 감싸고, 모든 프로퍼티를 useMemouseCallback으로 감쌉니다. 그리고, 부모 컴포넌트가 다시 렌더링할 때 memo로 감싸진(즉, "메모이제이션 된") 컴포넌트는 다시 렌더링 되지 않습니다.

하지만 이러한 도구를 올바르게 사용하는 것은 매우 어렵습니다. 이 주제에 대해 몇 가지 글을 작성하고 몇 가지 동영상을 제작했으니, 여러분이 잘 알고 계신지 테스트해보세요.(useMemo와 useCallback 사용하기: 대부분 제거할 수 있습니다, 리액트 메모이제이션 마스터하기 — 고급 리액트 과정, 에피소드 5).

이때 리액트 컴파일러가 등장합니다. 컴파일러는 리액트 코어 팀에서 개발한 도구입니다. 컴파일러는 빌드 시스템에 연결하여 원래 컴포넌트의 코드를 가져와서 컴포넌트, 프로퍼티 및 훅의 의존성이 기본적으로 메모이제이션 된 코드로 변환하려고 시도합니다. 최종 결과는 모든 것을 memo, useMemo 또는 useCallback으로 감싸는 것과 비슷합니다.

물론 이것은 리액트 컴파일러를 이해하기 위한 대략적인 접근일 뿐입니다. 실제로는 훨씬 더 복잡한 변환 작업을 수행합니다. 실제 세부 사항을 알고 싶다면 Jack Herrington의 최근 영상(리액트 컴파일러: 리액트 컨퍼런스 2024를 넘어 깊게 알아보기)에서 개요를 잘 설명하고 있습니다. 또는, Sathya Gunasekaran이 컴파일러에 관해 설명하고 Mofei Zhang이 20분 만에 라이브 코딩을 하는 “리액트 컴파일러 딥다이브” 발표를 보면 완전히 정신이 혼미해지며 이 기술의 복잡성을 실감할 수 있을 것입니다 🤯.

컴파일러를 직접 사용해 보고 싶으시다면 https://react.dev/learn/react-compiler 문서를 참조하세요. 이미 아주 훌륭하며 모든 요구 사항과 방법에 대해 차례대로 나와 있습니다. 다만 이 기능은 아직 카나리아 버전의 리액트를 설치해야 하는 매우 실험적인 기능이므로 주의하세요.

준비는 여기까지입니다. 이제 실제로 무엇을 할 수 있고 어떻게 작동하는지 살펴봅시다.

컴파일러 사용해보기

이 글의 주된 목적은 컴파일러에 대한 우리의 기대가 현실과 일치하는지 조사하는 것이었습니다. 현재 우리가 기대하는 건 무엇인가요?

  • 컴파일러는 플러그 앤드 플레이 방식으로 설치만 하면 바로 작동하며, 기존 코드를 다시 작성할 필요가 없습니다.
  • 컴파일러를 설치한 후에는 React.memo, useMemo, useCallback에 대해 다시 생각할 필요가 없을 것입니다.

이러한 가정을 테스트하기 위해 몇 가지 간단한 예제를 구현해 컴파일러를 단독으로 테스트했습니다. 그리고, 제가 가지고 있는 세 가지 앱에서 실행해 보았습니다.

간단한 예: 컴파일러를 분리하여 테스트하기

모든 예제의 전체 코드는 여기에서 확인할 수 있습니다. https://github.com/developerway/react-compiler-test

컴파일러를 처음부터 시작하는 가장 쉬운 방법은 Next.js의 카나리아 버전을 설치하는 것입니다. 기본적으로 필요한 모든 것을 제공합니다.

npm install next@canary babel-plugin-react-compiler

그런 다음 next.config.js에서 컴파일러를 켤 수 있습니다.

const nextConfig = {
experimental: {
reactCompiler: true,
},
};

module.exports = nextConfig;

그리고 짜잔! 리액트 개발자 도구에서 자동 메모이제이션 된 컴포넌트를 즉시 볼 수 있습니다.

지금까지는 가정이 맞았습니다. 설치는 매우 간단하고 그냥 작동합니다.

이제 코드 작성을 시작하고 컴파일러가 어떻게 처리하는지 살펴봅시다.

첫 번째 예: 간단한 상태 변경

const SimpleCase1 = () => {
const [isOpen, setIsOpen] = useState(false);

return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
toggle dialog
</button>
{isOpen && <Dialog />}
<VerySlowComponent />
</div>
);
};

모달 대화 상자가 열려 있는지를 제어하는 isOpen 상태 변수와 동일한 컴포넌트에 렌더링 된 VerySlowComponent가 있습니다. 일반적인 리액트 동작은 isOpen 상태가 변경될 때마다 VerySlowComponent를 다시 렌더링 하여 대화 상자가 지연된 상태로 팝업 됩니다.

일반적으로 메모이제이션을 통해 이 상황을 해결하려면(물론 다른 방법도 있지만), VerySlowComponentReact.memo로 감싸면 됩니다.

const VerySlowComponentMemo = React.memo(VerySlowComponent);

const SimpleCase1 = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
...
<VerySlowComponentMemo />
</>
);
};

컴파일러를 사용하면 React.memo를 쓰지 않아도 개발 도구에서 VerySlowComponent가 메모이제이션되고 지연이 사라진 것을 확인할 수 있습니다. 또한, VerySlowComponent 안에 console.log를 통해 상태 변경 시 더 이상 리렌더링 되지 않는 것을 확인할 수 있습니다.

이 예제의 전체 코드는 여기에서 확인할 수 있습니다.

두 번째 예: 느린 컴포넌트의 프로퍼티

지금까지는 괜찮았지만, 앞의 예는 가장 간단한 예입니다. 좀 더 복잡하게 만들어서 프로퍼티를 도입해보죠.

VerySlowComponent에 함수 onSubmit 프로퍼티와 배열 data 프로퍼티가 있다고 가정해 봅시다.

const SimpleCase2 = () => {
const [isOpen, setIsOpen] = useState(false);

const onSubmit = () => {};
const data = [{ id: 'bla' }];
return (
<>
...
<VerySlowComponent onSubmit={onSubmit} data={data} />
</>
);
};

직접 메모이제이션 하는 경우, VerySlowComponentReact.memo로 감싸는 것 외에도 배열을 useMemo로 감싸고(어떤 이유로 외부로 옮길 수 없다고 가정해 보겠습니다.), onSubmituseCallback으로 감싸는 것이 필요합니다.

const VerySlowComponentMemo = React.memo(VerySlowComponent);

export const SimpleCase2Memo = () => {
const [isOpen, setIsOpen] = useState(false);
// 여기서 메모이제이션
const onSubmit = useCallback(() => {}, []);
// 여기서 메모이제이션
const data = useMemo(() => [{ id: 'bla' }], []);
return (
<div>
...
<VerySlowComponentMemo
onSubmit={onSubmit}
data={data}
/>
</div>
);
};

하지만 컴파일러를 사용하면 그렇게 할 필요가 없습니다! VerySlowComponent는 여전히 리액트 개발 도구에서 메모이제이션 된 상태로 나타나며, 그 안에 있는 "control" console.log는 여전히 실행되지 않습니다.

이 예제는 이 저장소에 있습니다.

세 번째 예: children으로써의 요소

자, 실제 앱을 테스트하기 전 세 번째 예시입니다. 거의 아무도 제대로 메모이제이션 할 수 없는 경우는 어떨까요? VerySlowComponent가 children을 사용한다면 어떨까요?

export const SimpleCase3 = () => {
const [isOpen, setIsOpen] = useState(false);

return (
<>
...
<VerySlowComponent>
<SomeOtherComponent />
</VerySlowComponent>
</>
);
};

머릿속에서 VerySlowComponent를 올바르게 메모이제이션 할 수 있는 방법이 떠오르나요?

대부분의 사람들은 VerySlowComponentSomeOtherComponent를 모두 React.memo로 감싸야한다고 생각할 것입니다. 이는 잘못된 생각입니다. 다음과 같이 <SomeOtherComponent /> 요소를 useMemo로 감싸는 것이 맞습니다.

const VerySlowComponentMemo = React.memo(VerySlowComponent);

export const SimpleCase3 = () => {
const [isOpen, setIsOpen] = useState(false);
// React.memo가 아닌 useMemo를 통해 메모를 작성합니다
const child = useMemo(() => <SomeOtherComponent />, []);
return (
<>
...
<VerySlowComponentMemo>{child}</VerySlowComponentMemo>
</>
);
};

왜 그런지 이해되지 않는다면 이 영상을 시청하면 도움이 될 것입니다. 이 영상에서는 메모이제이션에 대해 자세히 설명하고 있으며, 특히 이 패턴에 대해서도 다룹니다. (리액트 메모이제이션 마스터하기 — 리액트 심화 코스, 에피소드 5). 이 글 역시 유용할 수 있습니다. (리액트 엘리먼트, 자식, 부모, 리렌더링의 미스터리)

다행히도 리액트 컴파일러는 여기서도 여전히 마법 ✨을 부립니다! 모든 것이 메모이제이션 되어 있고, VerySlowComponentMemo도 렌더링되지 않습니다.

지금까지 모두 성공했네요, 상당히 인상적입니다! 하지만 그 예제들은 매우 단순합니다. 현실에서 그렇게 쉬운 경우가 있겠습니까? 이제 진짜 도전과제를 해보겠습니다.

실제 코드에서 컴파일러 테스트

컴파일러를 실제로 테스트하기 위해 제가 가지고 있는 세 가지 코드 베이스에서 실행해 보았습니다.

  • 앱 1: 몇 년 된 꽤 큰 규모의 앱으로, 여러 사람이 작성한 리액트, 리액트 라우터 및 웹팩 기반의 앱입니다.
  • 앱 2: 약간 더 최신이지만 여전히 꽤 큰 규모의 리액트 및 Next.js 앱으로 여러 사람이 작성했습니다.
  • 앱 3: 개인 프로젝트입니다. 최신의 Next.js와 기능을 활용하며 CRUD 작업이 포함된 적은 수의 화면이 있습니다.

모든 앱에 대해 진행한 내용은 다음과 같습니다.

  • 앱이 컴파일러에 대해 준비가 되었는지 초기 헬스 체크를 수행했습니다.
  • 컴파일러의 eslint 규칙을 활성화하고 전체 코드 베이스에 대해 실행했습니다.
  • 리액트 버전을 19 카나리아로 업데이트했습니다.
  • 컴파일러를 설치했습니다.
  • 컴파일러를 활성화하기 전에 불필요한 렌더링이 발생하는 몇 가지 가시적인 사례를 식별했습니다.
  • 컴파일러를 활성화하고 그 불필요한 렌더링 문제가 해결되었는지 확인했습니다.

앱 1 컴파일러 테스트 결과

이 앱의 리액트 코드는 약 15만 줄에 달하는 큰 규모입니다. 저는 이 앱에서 불필요한 리렌더링을 쉽게 발견할 수 있는 10가지 사례를 찾아냈습니다. 내부 버튼을 클릭할 때 전체 헤더 컴포넌트를 리렌더링하는 것처럼 사소한 것도 있었고, 입력 필드에 입력할 때 전체 페이지를 다시 렌더링 하는 것과 같이 큰 문제도 있었습니다.

  • 초기 헬스 체크: 컴포넌트의 97.7%가 컴파일 가능! 호환되지 않는 라이브러리 없음.
  • eslint 점검: 단 20개의 규칙 위반 발견
  • 리액트 19 업데이트: 몇 가지 사소한 문제가 발생했지만 주석 처리 후 앱이 정상적으로 작동하는 것 같았습니다.
  • 컴파일러 설치: 이 작업은 몇 번의 실수와 욕설을 동반했고, 웹팩이나 바벨 관련 작업을 한 지 꽤 오래되어 ChatGPT의 도움을 받아야 했습니다. 하지만 결국에는 해결되었습니다.
  • 앱 테스트: 불필요한 리렌더링 10건 중 단 2건만 컴파일러로 수정되었습니다 😢.

10점 만점에 2점은 꽤 실망스러운 결과입니다. 하지만, 이 앱에는 제가 수정하지 않은 몇 가지 eslint 위반 사항이 있었는데, 그래서 그런 것인지 의문이 들었습니다. 다음 앱을 살펴봅시다.

앱 2 컴파일러 테스트 결과

이 앱은 3만 줄 정도의 리액트 코드로 이전 앱보다 훨씬 더 작습니다. 여기서도 불필요한 리렌더링 10개를 발견했습니다.

  • 초기 헬스 체크: 동일한 결과, 97.7%의 컴포넌트를 컴파일할 수 있었습니다.
  • eslint 점검: 단 1개의 규칙 위반! 🎉 완벽한 후보군이었습니다.
  • 리액트 19 업데이트 및 컴파일러 설치: 이를 위해 Next.js를 카나리아 버전으로 업데이트해야 했고, 나머지는 컴파일러가 알아서 처리했습니다. 설치 후 바로 작동했고 웹팩 기반 앱을 업데이트하는 것보다 훨씬 쉬웠습니다.
  • 앱 테스트: 불필요한 리렌더링 10건 중 2건만 컴파일러에 의해 수정되었습니다 😢.

이번에도 10건 중 2건만 수정되었습니다! 완벽한 후보군이었지만… 이번에도 조금 실망스러운 결과였습니다. 이것이 바로 인위적인 “counter” 예제와 다른 실제 사례입니다. 무슨 일이 일어나고 있는지 디버깅하기 전에 세 번째 앱을 살펴봅시다.

앱 3 컴파일러 테스트 결과

이 앱은 주말 이틀 만에 작성된 가장 작은 앱입니다. 데이터 표와 표의 엔티티를 추가/편집/제거할 수 있는 기능이 포함된 몇 페이지에 불과합니다. 전체 앱이 너무 작고 단순해서 불필요한 리렌더링은 8개밖에 발견할 수 없었습니다. 모든 상호 작용에 대해 모든 것이 리렌더링 되며, 어떤 방식으로도 최적화하지 않았습니다.

리렌더링 상황을 획기적으로 개선할 수 있는 리액트 컴파일러의 완벽한 주제입니다!

  • 초기 헬스 체크: 100%의 컴포넌트가 컴파일할 수 있었습니다.
  • eslint 점검: 위반 사항이 없었습니다. 🎉
  • 리액트 19 업데이트 및 컴파일러 설치: 놀랍게도 이전 것보다 더 나빴습니다. 제가 사용한 라이브러리 중 일부는 아직 리액트 19와 호환되지 않았습니다. 경고를 없애기 위해 의존성을 강제로 설치해야 했습니다. 하지만 실제 앱과 모든 라이브러리는 계속 작동했으므로 큰 지장은 없었습니다.
  • 앱 테스트: 8가지의 불필요한 렌더링 사례 중에서 리액트 컴파일러가 해결한 건… 두구두구두구… 단 하나였습니다. 오직 한 개밖에 없었습니다! 🫠 이 시점에서 제가 거의 울먹일 뻔했습니다. 이 테스트에 대해 큰 기대를 했었습니다.

이것이 제 오래된 염세적 성향이 예상한 바입니다. 하지만 순진한 제 내면의 아이가 기대했던 바는 아닙니다. 저는 리액트 코드를 잘못 작성하고 있는 것일까요? 컴파일러에 의한 메모이제이션이 잘못된 이유를 조사하고, 수정할 수 있을까요?

컴파일러의 메모이제이션 결과 조사하기

이러한 문제를 유용한 방식으로 디버깅하기 위해 세 번째 앱의 페이지 중 하나를 자체 저장소로 추출했습니다. 제 생각의 흐름을 따라가며 코드를 작성하고 싶으시다면, 여기에서 확인할 수 있습니다.(https://github.com/developerway/react-compiler-test/) 디버깅 환경을 단순화하기 위해 가짜 데이터와 몇 가지 항목(예: SSR)을 제거했을 뿐 세 번째 앱에 있는 페이지 중 하나와 거의 똑같습니다.

국가 목록이 있는 표, 각 행에 대한 ‘삭제’ 버튼, 표 아래에 새 국가를 목록에 추가할 수 있는 입력 구성 요소가 있는 등 UI는 매우 간단합니다.

코드 관점에서 보면 하나의 상태, 쿼리 및 변이가 있는 하나의 컴포넌트일 뿐입니다. 전체 코드는 다음과 같습니다. 조사에 필요한 정보만 포함된 간소화된 버전은 다음과 같습니다.

export const Countries = () => {
// input에 저장하는 내용 저장
const [value, setValue] = useState("");

// React-Query 를 통해 모든 국가 리스트 가져오기 쿼리
const { data: countries } = useQuery(...);
// React-Query를 통해 국가 지우기 변이 실행
const deleteCountryMutation = useMutation(...);
// React-Query를 통해 국가 추가 변이 실행
const addCountryMutation = useMutation(...);
// "삭제" 버튼에 전달되는 콜백
const onDelete = (name: string) => deleteCountryMutation.mutate(name);
// "추가" 버튼에 전달되는 콜백
const onAddCountry = () => {
addCountryMutation.mutate(value);
setValue("");
};
return (
...
{countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
...
<TableCell className="text-right">
<!-- onDelete is here -->
<Button onClick={() => onDelete(name)} variant="outline">
Delete
</Button>
</TableCell>
</TableRow>
))}
...
<Input
type="text"
placeholder="Add new country"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button onClick={onAddCountry}>Add</button>
);
};

여러 상태(로컬 + 쿼리/변이 업데이트)를 가진 하나의 컴포넌트이기 때문에 모든 상호 작용에서 모든 것이 리렌더링 됩니다. 앱을 시작하면 불필요한 리렌더링이 발생하는 경우가 있습니다.

  • “새 국가 추가” 입력란에 입력하면 모든 것이 리렌더링 됩니다.
  • “삭제”를 클릭하면 모든 항목이 리렌더링 됩니다.
  • “추가”를 클릭하면 모든 것이 리렌더링 됩니다.

이와 같은 간단한 컴포넌트의 경우 컴파일러가 이 모든 것을 해결해 줄 것으로 기대합니다. 특히 리액트 개발자 도구에서는 모든 것이 메모이제이션 되어 있는 것 같다는 점을 고려하면 더욱 그렇습니다.

하지만 ‘컴포넌트 렌더링 시 업데이트 하이라이트’ 설정을 활성화하고 조명 쇼를 즐겨보세요.

테이블 내의 모든 컴포넌트에 console.log를 추가하면 헤더 컴포넌트를 제외한 모든 것이 모든 소스의 모든 상태 업데이트에서 여전히 리렌더링되는 것을 볼 수 있습니다.

그럼 그 이유를 조사하는 방법은 무엇일까요? 🤔

리액트 개발자 도구는 추가 정보를 제공하지 않습니다. 컴파일러 플레이그라운드에 컴포넌트를 복사해서 붙여 넣고 무슨 일이 일어나는지 볼 수는 있지만… 하지만 결과물을 보세요! 😬 이건 잘못된 방향으로 나아가는 것 같고, 솔직히 말해서 절대 하고 싶지 않은 일입니다.

떠오르는 유일한 방법은 해당 테이블을 점진적으로 메모이제이션하고 컴포넌트나 의존성에 이상한 일이 일어나고 있는지 확인하는 것입니다.

직접 메모이제이션 하며 조사하기

이 부분에서는 직접 메모이제이션을 하는 것이 어떻게 작동하는지 완전히 이해하신 분들을 위한 것입니다. React.memo, useMemo 또는 useCallback에 대해 불안감을 느끼신다면 이 동영상을 먼저 보시길 추천합니다.

또한 로컬에서 코드를 열고(https://github.com/developerway/react-compiler-test) 코드 따라 하기 연습을 해보시면 아래의 과정을 훨씬 쉽게 따라갈 수 있을 것입니다.

입력 시 input 요소 리렌더에 대해 조사하기

이번에는 전체 표를 다시 한번 살펴보겠습니다.

<Table>
<TableCaption>Supported countries list.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[400px]">Name</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>

확실히 헤더 컴포넌트가 메모이제이션되었다는 사실은 컴파일러가 무엇을 했는지 힌트를 줍니다. 아마도 모든 컴포넌트를 React.memo 와 동등한 것으로 감싸고, TableBody 내부는 useMemo 와 동등한 것으로 메모이제이션되었을 것입니다. 그리고 useMemo에 상응하는 코드의 의존성에 매 렌더링 때마다 업데이트되는 것이 포함되어 있어, 결과적으로 TableBody 자체를 포함한 TableBody 내부의 모든 것이 리렌더링되게 만들었습니다. 적어도 이는 테스트해볼 만한 좋은 가설입니다.

그 내용 부분의 메모이제이션을 복제해 보면 몇 가지 단서를 얻을 수 있을 것 같습니다.

// TableBody 컨텐츠를 전부 메모이제이션 합니다.
const body = useMemo(
() =>
countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
)),
// 여기가 코드에서 사용되는 의존성입니다.
// eslint 고마워요!
[countries, onDelete],
);

이제 이 전체 부분이 데이터의 countries 배열과 onDelete 콜백에 의존한다는 것을 분명히 알 수 있습니다. countries 배열은 쿼리에서 가져오기 때문에 다시 렌더링할 때마다 다시 만들 수 없으며, 이를 캐싱하는 것이 라이브러리의 주요 책임 중 하나입니다.

onDelete 콜백은 다음과 같습니다.

const onDelete = (name: string) => {
deleteCountryMutation.mutate(name);
};

의존성 항목에 들어가려면 해당 항목도 메모이제이션 해야 합니다.

const onDelete = useCallback(
(name: string) => {
deleteCountryMutation.mutate(name);
},
[deleteCountryMutation],
);

그리고 deleteCountryMutation은 다시 리액트 쿼리에서 변이 된 것이므로 괜찮을 것입니다.

const deleteCountryMutation = useMutation({...});

마지막 단계는 TableBody를 메모이제이션하고 메모이제이션 된 자식을 렌더링 하는 것입니다. 모든 것이 올바르게 메모이제이션 되면 input에 입력할 때 행과 셀을 다시 렌더링 하는 것이 중지될 겁니다.

const TableBodyMemo = React.memo(TableBody);

// Countries 내부 렌더링
<TableBodyMemo>{body}</TableBodyMemo>;

작동하지 않았습니다. 🤦🏻‍♀️ 이제 점점 어딘가로 다가가고 있습니다. 제가 의존성을 잘못 처리했고, 컴파일러 역시 똑같이 잘못했을 것입니다. 하지만 무엇을 잘못했을까요? countries 외에는 deleteCountryMutation 하나의 의존성만 있습니다. 안전할 것이라고 가정했지만, 정말 그랬을까요? 그 내부에는 실제로 무엇이 있을까요? 다행히도 소스 코드를 볼 수 있었습니다 useMutation은 여러 작업을 수행하는 훅이며, 다음과 같은 것을 반환합니다.

const mutate = React.useCallback(...)

return { ...result, mutate, mutateAsync: result.mutate }

메모이제이션 되지 않은 객체가 반환되었습니다!!! 의존성으로만 사용할 수 있을 거라고 생각한 제 가정이 틀렸습니다.

하지만 mutate 자체는 메모이제이션 되어 있습니다. 따라서 이론적으로는 대신 의존성에 전달하면 됩니다.

// 반환된 객체에서 mutate 분리
const { mutate: deleteCountry } = useMutation(...);

// 의존성으로 전달
const onDelete = useCallback(
(name: string) => {
// 여기서 직접 사용
deleteCountry(name);
},
// 메모이제이션 된 의존!
[deleteCountry],
);

이 단계가 끝나면 마지막으로 직접 수동으로 메모이제이션 한 것이 작동합니다.

이제 이론상으로는 수동 메모이제이션을 모두 제거하고 mutate 수정 사항을 그대로 두면 리액트 컴파일러가 이를 인식할 수 있어야 합니다.

그리고 실제로도 그렇게 동작했습니다! 무언가를 입력해도 테이블 행과 셀이 더 이상 다시 렌더링 되지 않습니다 🎉.

그러나 국가 ‘추가’ 및 ‘삭제’에 대한 리렌더링은 여전히 존재합니다. 이 부분도 수정해 보죠.

‘추가’ 및 ‘삭제’ 리렌더링 조사하기

TableBody 코드를 다시 살펴보겠습니다.

<TableBody>
{countries?.map(({ name }, index) => (
<TableRow key={index}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>

전체 내용은 리스트에서 국가를 추가하거나 제거할 때마다 리렌더링 됩니다. 다시 같은 전략을 적용해 보죠. 수동으로 이러한 컴포넌트를 메모이제이션 하려면 어떻게 했을까요?

동적 리스트이므로, 다음과 같이 해야 합니다.

첫째, “key” 프로퍼티가 배열의 위치가 아닌 국가와 일치하도록 해야 합니다. index는 적절하지 않습니다. 리스트 앞부분에서 국가를 제거하면 아래 행의 인덱스가 모두 변경되어 메모이제이션에 상관없이 강제로 리렌더링 되기 때문입니다. 실제로는 각 국가에 대한 id와 같은 것을 도입해야 할 것입니다. 단순하게 하기 위해 name을 사용하고 중복 이름이 추가되지 않도록 합시다. key는 유일해야 합니다.

{
countries?.map(({ name }) => (
<TableRow key={name}>...</TableRow>
));
}

둘째, TableRowReact.memo로 래핑합니다. 간단합니다.

const TableRowMemo = React.memo(TableRow);

셋째, useMemoTableRowchildren을 메모이제이션 합니다.

{
countries?.map(({ name }) => (
<TableRow key={name}>
... // useMemo로 여기에 있는 모든 것을 메모해야 합니다.
</TableRow>
));
}

렌더링 내부와 배열 내부에 있기 때문에 불가능합니다. 훅은 렌더링 함수 외부의 컴포넌트 상단에서만 사용할 수 있습니다.

이 작업을 수행하려면 콘텐츠가 포함된 전체 TableRow를 컴포넌트로 추출해야 합니다.

const CountryRow = ({ name, onDelete }) => {
return (
<TableRow>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
);
};

그리고 데이터를 프로퍼티로 전달합니다.

<TableBody>
{countries?.map(({ name }) => (
<CountryRow
name={name}
onDelete={onDelete}
key={name}
/>
))}
</TableBody>

그리고, CountryRowReact.memo로 대신 래핑합니다. onDelete는 이미 고쳤기 때문에 올바르게 메모이제이션 되었습니다.

수동으로 메모이제이션을 구현할 필요도 없었습니다. 컴포넌트에 해당 행을 추출하자마자 컴파일러가 즉시 인식하고 리렌더링이 중단되었습니다 🎉. 인간과 기계의 대결에서 2:0으로 승리했습니다.

흥미롭게도 컴파일러는 CountryRow 컴포넌트 내부의 모든 것을 포착할 수 있지만 컴포넌트 자체는 포착하지 못합니다. 수동으로 메모이제이션을 제거하고 keyCountryRow 변경 사항을 유지하면 추가/삭제 시 셀과 행은 다시 렌더링 되지 않지만 CountryRow 컴포넌트 자체는 계속 다시 렌더링 됩니다.

이 시점에서는 컴파일러로 이 문제를 해결할 아이디어가 없고 이미 글에 충분한 자료가 있으므로 그냥 리렌더링 하도록 놔두겠습니다. 내부의 모든 내용이 메모이제이션 되어 있으므로 그렇게 큰 문제는 아닙니다.

그래서 결론이 뭔가요?

컴파일러는 간단한 케이스와 간단한 컴포넌트에서 놀라운 성능을 발휘합니다. 세 개 중 세 개는 맞습니다! 하지만 현실 세계는 조금 더 복잡합니다.

제가 컴파일러를 사용해 본 세 앱 모두에서 810개의 불필요한 리렌더링 중 눈에 띄는 12개의 케이스만 수정할 수 있었습니다.

하지만 조금의 추론적 사고와 추측을 통해, 사소한 코드 변경으로 그 결과를 개선할 수 있을 것 같습니다. 그러나 이를 조사하는 것은 결코 사소하지 않으며, 창의적인 사고와 리액트 알고리즘 및 기존 메모이제이션 기술에 대한 숙련도가 필요합니다.

컴파일러가 작동하기 위해 기존 코드를 변경해야 했습니다.

  • useMutation 훅의 반환값에서 mutate를 추출하여 코드에서 직접 사용해야 했습니다.
  • TableRow와 그 안의 모든 것을 분리된 컴포넌트로 추출합니다.
  • “key”를 index에서 name으로 변경합니다.

이전이후의 코드를 확인하고 앱을 직접 사용해 볼 수 있습니다.

제가 가정했던 기준으로 설명드려보겠습니다.

“그냥 작동”하나요? 기술적으로는 그렇습니다. 전원을 켜기만 하면 아무 문제도 없는 것 같습니다. 하지만 리액트 개발자 도구에서 메모이제이션 것으로 표시되기는 하지만 모든 것을 올바르게 메모이제이션 하지는 않습니다.

컴파일러를 설치한 후 memo, useMemo, useCallback은 잊어버려도 되나요? 절대 안 됩니다! 적어도 현재 상태에서는 아닙니다. 오히려 지금보다 더 잘 알고 있어야 하며 컴파일러에 최적화된 컴포넌트를 작성하기 위한 육감적인 감각을 키워야 합니다. 아니면 수정하려는 리렌더링 부분을 디버깅하는 데 사용하세요.

물론 이는 우리가 문제를 고치고 싶다는 전제하에 하는 말입니다. 제 생각에는 이렇게 될 것 같습니다. 컴파일러가 프로덕션에 사용할 수 있게 되면 모두 컴파일러를 켜게 될 것입니다. 개발 도구에서 모든 “memo ✨”를 보면 안정감을 느낄 수 있으므로 모두가 리렌더링에 대한 걱정을 덜고 기능 작성에 집중할 수 있을 것입니다. 대부분의 리렌더링은 어차피 성능에 미치는 영향이 미미하기 때문에 리렌더링의 절반이 여전히 존재한다는 사실을 아무도 눈치채지 못할 것입니다.

리렌더링이 실제로 성능에 영향을 미치는 경우가 있습니다. 이런 경우에는 합성을 사용하면 문제를 더 쉽게 해결할 수 있습니다. 합성의 예로는 상태를 아래로 이동하거나, 요소를 children 또는 프로퍼티로 전달하거나, 분할된 프로바이더를 사용하는 Context로 추출하거나, 메모이제이션 된 선택자를 허용하는 외부 상태 관리 도구를 사용하는 것 등이 있습니다. 그리고 가끔씩 수동으로 React.memouseCallback을 사용해야 할 때도 있습니다.

미래에서 온 방문자들은 이제 평행 우주에서 온 사람들이라고 확신합니다. 그곳은 리액트가 악명 높은 자바스크립트보다 더 구조화된 언어로 작성되고, 컴파일러가 실제로 모든 경우를 100% 해결할 수 있는 놀라운 곳일 겁니다.

--

--

한정(Han Jung)
한정(Han Jung)

Written by 한정(Han Jung)

개인용 블로그로 사용하고 있습니다. 좋은 개발자가 꿈입니다. > https://www.notion.so/Han-Jung-c43f4bcd2b3f4b3d85b93aee41c5e098

No responses yet