코로 넘어져도 헤딩만 하면 그만
리액트 심화 및 최적화(커스텀 훅, lazy) 본문
최근 들어 리액트의 심화된 동작 방식과 훅에 관해 조금 더 깊은 학습을 해보았습니다. 직접 커스텀 컴포넌트 훅을 만들어보고, 이후 React.lazy를 써서 코드를 최적화 시키는 작업을 수행했습니다.
개인적으로 코드를 최적화시킨다는 개념이 굉장히 재밌어서 다른 챕터보다도 즐겁게 공부했는데요. 반복되는 로직은 컴포넌트를 훅으로 만들어 사용하며, 데이터를 초기에 데이터를 받는 속도를 감소시키는 방식들이 매우 효율적으로 느껴졌기 때문입니다.
🚩React와 가상돔(Virtual Document Object Model)
리액트를 설명할 때 가상의 문서 객체 모델인 Virtual DOM을 빼놓을 순 없을 겁니다. 가상돔은 실제 돔의 가벼운 사본인데, 리액트는 가상돔 객체에 접근해서 이전의 가상돔과 비교한 뒤, 바뀐 부분들만 인식하여 바꿔주게 됩니다. 본래 HTML문서가 있다면 브라우저는 이를 읽으면서 트리 구조의 DOM 객체를 생성합니다. 이후 여러 돔 API들을 사용해서 문서의 요소들을 조작할 수 있게 되지요. 하지만 돔의 변경 및 업데이트는 브라우저에게 잦은 리플로우 및 리페인트 과정을 요구하고, 바꿀 필요가 없는 부분들까지 변경하며 성능이 점차 떨어지게 되었습니다.
과거와 같이 가벼운 웹이었다면 모를까, 현재의 대부분의 웹은 많은 돔 조작을 요구합니다. 따라서 이렇게 필요 없는 부분까지 매번 새로 그리는 방식은 몹시 비효율적입니다(frame drop와 같은 문제가 발생).
바로, 이 때문에 React는 가상 돔이라는 개념을 도입하게 된 것입니다.
리액트는 가상돔을 실제돔과 동기화 한 뒤, 상태가 바뀔 때마다 가상돔을 재생성하여 이전 상태와 비교합니다. 왜 리액트에서 '상태'가 중요한지 알 수 있겠죠? 무조건 '상태'가 바뀌어야만 리액트에서는 뭔가 바뀌었다고 인지한다고 했잖아요. 이렇게 바뀐 부분만 실제 돔에 업데이트(재조정Reconciliation)하기 때문에, 전체 구조를 매번 다시 그려줄 필요가 없게 된 것입니다. 또 가상돔은 실제를 조작하는 게 아니라서 비교적 가볍기 때문에 화면 업데이트 시간과 비용도 절약할 수 있습니다.
🚩React Diffing 알고리즘
이러한 이유로 리액트가 이전의 가상돔과 새로운 가상돔을 비교할 때, 효율적으로 UI를 갱신하는 과정이 필요했습니다. 이를 효율적으로 하기 위해 리액트는 다음 두 가지 가정을 비교 알고리즘에 사용하게 됩니다.
1) 각기 다른 두 요소는 다른 트리를 구축할 것이다.
2) key프로퍼티를 가지면, 여러 번 렌더링을 해도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.
실제로 리액트에서는 기존 가상돔 트리와 변경된 가상돔 트리를 비교할 때, 레벨 순서대로 순회하며(너비 우선 탐색BFS) 탐색을 합니다. 위에서 아래로 순차적으로 비교하며 바뀐 점을 찾게 되는데 바로 이 때문에 리스트의 처음에 엘리먼트를 추가할 시 아래처럼 마지막에 추가하는 것보다 훨씬 나쁜 성능을 보이게 됩니다.
<ul>
<li>first</li>
<li>second</li>
</ul>
//자식 엘리먼트의 끝에 새로운 자식 엘리먼트를 추가함
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
//맨 처음에 새로운 자식 엘리먼트를 추가함
<ul>
<li>zero</li>
<li>first</li>
<li>second</li>
</ul>
맨 처음에 자식 엘리먼트를 새로 추가한 경우, 리액트는 맨 첫 번째 노드가 다르다는 것을 인지하자마자 리스트 전체가 바뀌었다고 인식하고 맙니다. 즉 first와 second까지 아예 새로 렌더링을 해버리는 비효율이 발생하죠. 이를 막기 위해 리액트는 key를 준비합니다. 리액트는 key 속성을 통해 위치만 이동한 엘리먼트들은 두고 새로 생성된 엘리먼트만 추가해줍니다. key값이 없다면 새 생성을 아예 인지하지 못하니 비효율적으로 동작하는 경우가 있어 주의해야 하는 것이죠. 특히 map을 돌릴 때 key를 꼭 넣어주는 것을 잊지 말아야 합니다.
key로 쓸 수 있는 것은 형제 엘리먼트들 사이에서 유일한 '유니크한 값'이어야 하고, 대개 id를 사용합니다. id가 없을 때 부득이하면 index를 쓸 수도 있지만, 이는 배열이 다르게 정렬되는 경우 리액트가 새 돔 트리를 구축해버리기 때문에 비효율적일 수 있습니다. 그러니 되도록이면 유니크한 id와 같은 값을 사용하는 것이 좋겠습니다.(uuid라는 걸 쓸 수도 있다고 들었어요)
🚩React 커스텀 Hooks
리액트에서는 이미 여러 훅을 쓰고 있습니다. 클래스형 컴포넌트에서 함수형 컴포넌트를 지향하기 시작하면서 useEffect, useState와 같은 훅들은 거의 필수가 되었죠. 그런데 이 훅을 필요에 따라 사용자가 '스스로 로직을 뽑아 커스텀하여' 사용할 수도 있습니다. 바로 이런 훅을 커스텀 훅이라고 부릅니다. 주로 여러 곳에서 반복되는 로직을 뽑을 때 사용합니다.
커스텀 훅을 정의할 때에는 다음과 같은 규칙을 따릅니다.
1) 함수 이름 앞에 use를 붙이는 것이 규칙이다.
2) 대개 프로젝트 내 hooks디렉토리에 위치시킨다.
3) 함수는 조건부 함수여서는 안 된다(return 하는 값이 조건부일 수 없다).
이렇게 만들어둔 커스텀 훅은 여러 컴포넌트에서도 쓸 수 있는데, 이때 같은 상태를 공유하지는 않습니다. 로직만 공유하고 상태는 각 컴포넌트 내에 독립적으로 정의되어 있다고 보면 좋을 것 같습니다.
🚩코드 분할, 번들 분할 (lazy 사용)
코드 분할을 하게 되면 당장 필요한 코드가 아닌 부분을 따로 분리해두고 필요할 때 불러오게 됩니다. 이를 통해 대규모의 앱에서도 첫 페이지 로딩 속도를 개선할 수 있죠. 특히 리액트는 SPA라서 모든 컴포넌트를 한번에 불러오는데, 처음 불러와야 하는 데이터의 양이 방대하다면 로딩 속도가 오래 걸릴 것이고, 사용자 입장에서 결코 좋은 현상이 아닙니다. 첫 페이지에서 오래 걸린다면 얼마나 답답하겠어요?
이를 방지하기 위해 코드 분할을 사용할 수 있습니다. 또 무거운 라이브러리를 import 했는데 모든 기능을 다 쓰는 게 아니라면, 이럴 때에도 일부만 들고 와서 쓰는 번들 분할을 통해 용량을 현저히 줄일 수 있겠습니다.
/* 이렇게 lodash 라이브러리를 전체를 불러와서 그 안에 들은 메서드를 꺼내 쓰는 것은 비효율적입니다.*/
import _ from 'lodash';
_.find([]);
/* 이렇게 lodash의 메서드 중 하나를 불러와 쓰는 것이 앱의 성능에 더 좋습니다.*/
import find from 'lodash/find';
find([]);
위와 같이 lodash라는 라이브러리에서도 일부만 불러와 개발할 수 있는 것입니다.
코드 분할이든 번들 분할이든 어쨌든, 중요한 건 당장 쓰지 않는 부분은 빼거나 나중에 불러오도록 미루어서 용량과 속도에 최적화를 시킨다는 점입니다. 우선 코드 분할부터 한번 살펴봅시다.
리액트 최상위에서 import 지시자를 통해 불러오는 방법은 기존까지 static import정적 불러오기 라고 했습니다. 하지만 코드 분할을 통해서는 dynamic import동적 불러오기를 실행 할 수 있습니다. 동적 불러오기는 맨 위가 아니라 코드 중간에도 불러올 수 있는데, 이때 then함수를 사용해 필요한 코드만 가져옵니다. 또 가져온 코드에 대한 모든 호출은 해당 함수 내에 있어야 하고요.
이러한 동적 불러오기는 React.lazy와 함께 사용할 수 있는데요. lazy는 아래와 같이 Suspense와 함께 쓰입니다. 정확히는 Suspense로 묶은 내부에서 lazy가 쓰여야 lazy를 통해 데이터를 뒤늦게 불러오는 동안(처음 한번에 다 받아온 게 아니니까요. 결국 조삼모사의 개념이라서 이 방법을 쓰는 게 나을지 개발자가 장단점을 고려한 뒤 선택해야 합니다. 처음 오래 시간을 들여서 불러오거나... 동적 불러오기 때문에 나중에 로딩이 조금씩 걸리더라도, 처음 불러오는 시간을 줄이거나...)Suspense의 fallback 부분이 공통적으로 보이게 됩니다. 데이터를 불러오는 동안 로딩 페이지를 보여주고 싶다면 반드시 아래처럼 Suspense로 묶어줘야 하는 것이죠.
아래 코드처럼 Route단계에서 코드를 분할해주는 것이, 일종의 '분기점'에 해당하기 때문에 유리합니다. 이렇게 하면 라우트 될 때마다 각 컴포넌트 데이터를 불러오는데 시간이 걸리게 되고, 그동안 Loading...이 보이겠군요.
import { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));
const App = () => (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
</Router>
);
이처럼 다양한 방식을 통해 리액트에서 코드의 최적화를 고려해볼 수 있습니다. 기능 최적화를 위한 메모이제이션이나, 직접 컴포넌트 훅을 만들어 본 코드 리펙토링~최적화 부분도 후에 포스팅 할 수 있다면 좋겠네요!
'CODE STATES 44' 카테고리의 다른 글
styled-component 설치 시 갑자기 뜨는 에러 (0) | 2023.05.27 |
---|---|
왜 리액트는 Happy Hacking일까? (0) | 2023.05.27 |
Session 3 회고...너무 빨리 흐르는 시간. (1) | 2023.05.09 |
비동기적인 Ajax 요청 (0) | 2023.05.01 |
크로스 브라우징과 SEO (0) | 2023.04.28 |