본문 바로가기

[리액트] Hook 렌더링 최적화 실무 적용기

최근에 Daily DevBlog에서 useCallback과 React.memo을 통한 렌더링 최적화에 관한 글을  읽었다.

 

글을 읽고 깨달은 것은 내가 지금까지 useCallback을 잘못 사용하고 있었다는 점이었다.

 

마침 챗봇 빌더의 설정 페이지를 시작으로 점차 렌더링 최적화를 적용해나갈 계획이었기 때문에 이를 수정하고자 한다.

 

더불어 리액트 훅 기반 프론트엔드 개발 시 주의할 점에서 state는 독립적으로 설정해야 하는 점 또한 적용할 것이다.


1. useCallback과 React.memo와의 관계성

우선 공식문서를 꼼꼼히 읽으면서 useCallback의 올바른 쓰임새부터 파악했다.

 

이것은, 불필요한 렌더링을 방지하기 위해 (예로 shouldComponentUpdate를 사용하여) 참조의 동일성에 의존적인 최적화된 자식 컴포넌트에 콜백을 전달할 때 유용합니다.

[출처] https://ko.reactjs.org/docs/hooks-reference.html#usecallback

 

useCallback은 최적화된, 즉 (React.memo로 최적화한) 자식 props로 내려주는 함수를 다시 생성하고 싶지 않을 때 사용하는 함수라는 점을 기억해야 한다.


2020.09.07. 첨언

Understanding the difference between useMemo and useCallback에서 잘 요약해주셨다.

useMemo keeps a function from being executed again if it didn’t receive a set of parameters that were previously used. It returns the results of a function. Use it when you want to prevent some heavy or costly operations from being called on each render.

useCallback keep a function from being re-created again, based on a list of dependencies. It returns the function itself. Use it when you want to propagate it to child components, and prevent from a costly function from re-running.


간단한 적용 예시를 우선적으로 구현해보았다.

 

다음의 부모 컴포넌트 Content.js에서 tabToggle을 useCallback으로 메모이제이션하여,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Content = _ => {
  const [activeTab, setActiveTab] = useState("list");
 
  const tabToggle = useCallback(tab => {
    if (activeTab !== tab) {
      setActiveTab(tab);
    }
  }, [activeTab]);
 
  return (
    <div>
      <BlockNavTabs activeTab={activeTab} tabToggle={tabToggle} />
      <TabContent activeTab={activeTab}>
      { 
        //... 
      }
      </TabContent>
    </div>
  );
};
 

 

최적화된 BlockNavTabs.js 자식 컴포넌트에 props로 해당 함수를 사용하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const BlockNavTabs = React.memo(({ activeTab, tabToggle }) => {
  const tabs = ["list""search"];
 
  const getIcon = tab => {
    switch (tab) {
      case "list":
        return <i className="icon-menu icons" />;
      case "search":
        return <i className="icon-magnifier icons" />;
      default:
        return null;
    }
  };
 
  return (
    <Nav tabs vertical>
      {tabs.map((tab, idx) => (
        <NavItem key={idx}>
          <NavLink active={activeTab === tab} onClick={_ => tabToggle(tab)}>
            {getIcon(tab)}
          </NavLink>
        </NavItem>
      ))}
    </Nav>
  );
});
 

이제  BlockNavTabs 컴포넌트는 activeTab의 변화만 감지하여 컴포넌트를 업데이트하게 될 것이다.

 

 

2. Setting 컴포넌트 개선

 

이제 본격적으로 설정 화면을 뜯어고칠 것이다.

 

우선 이전의 Setting 컴포넌트는 어떻게 되어있었는지, 문제는 무엇인지 파악했다.

 

그 전에 설정 화면의 UX를 소개하자면,

 

설정화면 UX

 

다음과 같이 네비게이션 탭을 두어 움직이는 형식이다.

 

상단 '저장' 버튼이 위치한 엘리먼트는 고정이다.

 

컴포넌트 트리를 그리면 다음처럼 되어있다.

설정 컴포넌트 트리 중 root에 해당하는 녀석이 모든 state를 가지고 있고, 이를 props로 해당 컴포넌트에 내려주고 있는 방식으로, setState하는 함수를 props로 넘겨주어 변경이 발생하면 props.saveFunction 으로 해당 데이터를 업데이트하고 있었다.

 

만약 '일반' 탭에서 봇 이름을 변경하면 그와 관련없는 나머지 탭들은 랜더링하지 않아야 최적화되었다고 할 수 있을 것이다. 

 

+ 여건상 백엔드 API까지 수정을 하기가 녹록치 않아 백엔드 API 수정 없이 적용해야 하는 것까지 고려해야 했다.

 

어떤 해당 값이 변경될 때 업데이트하는 컴포넌트들을 현재의 설정화면 상의 Profiler로 분석하면,

모든 컴포넌트들을 업데이트 하고 있다...

 

더불어 handleState와 같은 setState함수또한 state가 변경되었기 때문에 렌더링되었다.

 

이제 이것을 앞서 말했던 최적화를 진행해야 했다.

 

우선, state가 한 객체로 정의된 것을 쪼개는 작업부터 시작했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 이전 state
const [form, setValues] = useState({
    _id: void 0,
    name"",
    sessionTimeout: "",
    accessKey: "",
    accessSecret: "",
    apiUrl: "",
    token: "",
    sessionTimeOutMessage: "",
    welcome: {},
    fallback: [],
    imageUrl: null,
    file: null
});
 

 

위의 state(덩어리)를 각 state로 분리하면,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const [name, setName] = useState(bot.name);
const [sessionTimeout, setSessionTimeout] = useState(bot.sessionTimeout);
const [sessionTimeOutMessage, setSessionTimeOutMessage] = useState(bot.sessionTimeOutMessage);
 
const welcomeResponse = setDefaultBlock(bot.welcome.response);
const [welcome, setWelcome] = useState({
    ...welcomeResponse,
    utterance: bot.welcome.utterance
});
 
const fallbackResponse = setDefaultBlock(bot.fallback);
const [fallback, setFallback] = useState(fallbackResponse);
const [imageUrl, setImageUrl] = useState(bot.imageUrl);
const [file, setFile] = useState(null);
 
const __id = useRef(bot._id);
const _apiUrl = useRef(bot.apiUrl);
const _accessKey = useRef(bot.accessKey);
const _accessSecret = useRef(bot.accessSecret);
 

useRef로 설정한 이유는 변경이 되지 않는 값들이기 때문에 한번만 선언하면 되서 ref로 설정하였다.

 

이제 이 값들과 setState 함수들을 각각의 컴포넌트로 내려주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// In SettingLayout.js
return (
  <Suspense fallback=Loader }>
    <AsideContainerFrame
    firstTab="general"
    AddFrontComp={ AddFrontComp }
    data={
      [
        {
          title: "일반",
          type: "link",
          children: {
            tabId: "general",
            id: "general",
            component: <General
              name={name}
              id={_id}
              apiUrl={apiUrl}
              accessKey={accessKey}
              accessSecret={accessSecret}
              imageUrl={imageUrl}
              setName={setName}
              setImageUrl={setImageUrl}
              setFile={setFile}
            />
          }
        },
        {
          title: "기본 블록 설정",
          type: "group",
          children: [
            {
              subtitle: "웰컴 블록",
              tabId: "defaults_welcome",
              id: "welcome",
              component: <WelcomeBlock
                id={_id}
                block={welcome}
                handleState={handleDefaultBlock}
              />
            },
            {
              subtitle: "폴백 블록",
              tabId: "defaults_fallback",
              id: "fallback",
              component: <FallbackBlock
                id={_id}
                block={fallback}
                handleState={handleDefaultBlock}
              />
            }
          ]
        },
        {
          title: "대화 유효시간",
          type: "link",
          children: {
            tabId: "ttl",
            id: "ttl",
            component: <AliveTime
              sessionTimeout={sessionTimeout}
              sessionTimeOutMessage={sessionTimeOutMessage}
              setSessionTimeout={setSessionTimeout}
              setSessionTimeOutMessage={setSessionTimeOutMessage}
            />
          }
        }
      ]
    } />
  </Suspense>
)
 

(* AsideContainerFrame 컴포넌트: 사이드 바가 있는 레이아웃을 정의한 템플릿)

 

마지막으로 각각의 컴포넌트를 React.memo로 메모이제이션하였다.

 

만약에 setState 외 함수를 props로 내려주어야 할 경우에는 useCallback으로 감싸주었다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const AliveTime = React.memo(({ 
  sessionTimeout,
  sessionTimeOutMessage,
  setSessionTimeout,
  setSessionTimeOutMessage
 }) => {
  return (
    <>
      {
        // ...
      }
    </>
  );
});

 

개선된 구조 상에서 이전과 같이 데이터 하나만을 변경하는 작업을 했을 때의 profiler 결과이다.

관련없는 탭들과 함수는 전부 랜더링되지 않았다!

 

+ 렌더링 시간도 더 축소된 것을 볼 수 있다.

 

추가로, 예전 코드를 뜯어고치는 과정에서 필요없는 상태값이나 스토어 값들은 제거하고, 필요없는 함수나 라이프 사이클을 수정하기도 했다.

 

그리고 굳이 dummy 초기값을 준 다음에 useEffect로 다시 설정하는 과정을 거칠 필요 없이 리듀서에서 select한 값을 초기값으로 그대로 활용할걸 그랬다 U_U흑흑..

 

그리고 예전 코드들을 보고 개선하는 과정에서 내가 만든건데도 모르겠는 함수나 변수명, 로직들은 다른 사람이 와서 봐도 모르겠다 싶어서 변경했다. 미리 알아서 너무 다행이다...

 

 

최적화하면서 얻은 교훈: 시간이 허락할 때마다 주기적으로 코드를 보고 개선하자. + 작년의 나를 믿지말자 ^^