🚘 React 렌더링 최적화 기법
🖐🏻 들어가며,
간단한 투두 리스트 만들기를 하다보면 화면이 바로바로 변경됨이 확인되기 때문에 렌더링 최적화에 대해 크게 고민하거나 필요성에 대해 느낄 일이 많이 없다. 하지만 조금이라도 데이터가 많아지거나 프로젝트가 무거워지게 되면 초기 화면 렌더링 시간이 느려지는 것 뿐만 아니라 특정 페이지에서 화면 렌더링이 느림에 따른 불편한 장면들을 많이 볼 수 있게 된다.
이런 부분들이 내가 아닌 내가 만든 서비스를 사용하는 유저들에게 그대로 제공이 된다면 내가 아무리 super SEXY 한 기능이 있는 서비스를 만들었다고 한들 유저는 불편한 경험으로 인해 서비스 이탈을 하고 말 것이다.
그렇기 때문에 우리는 더 나은 서비스를 제공해서 유저로 하여금 더 좋은 경험을 선물하기 위해 렌더링 성능에 대해 공부하고, 고민하고 개선을 해나가야 한다. 그렇다면 렌더링 성능을 높이는 최적화 기법은 어떤 것들이 있을까?
프론트 엔드 웹 개발 공부를 시작하고부터 지금까지 렌더링에 대해 공부를 했다고는 했지만 실제로 이를 제대로 확인을 하며 공부를 해본 적은 없었던 것 같아서, 지금까지 그저 알잘딱 개발만 해왔다는 생각에 많이 부끄러웠다. 그래서 이번 기회에 렌더링에 대해 공부를 하면서 예제 코드들을 만들어 직접 눈으로 확인하고 그 결과를 기록해봤다.
1️⃣ state 변경을 고려한 컴포넌트 설계
렌더링을 최소화하는 방법 중 하나의 스크린을 구성하는 컴포넌트를 어떤 구조로 설계를 했고, 컴포넌트가 가지고 있는 상태를 어느 부분에서 선언해서 사용을 하는가가 가장 기본이 되는 것 같다. 아무리 메모이징을 하고 다른 것들을 적용한다고 하더라도 메모이징 많이 하게되는 것 또한 비용이, 또 설계적으로 잘못되었다면 불필요한 리렌더링을 막는 것은 쉽지가 않다.
위 그림과 같은 스크린을 그린다고 생각을 해보자. 화면에는 OTHER
, BUTTON
, CARD
컴포넌트가 있고, 버튼을 누를 때 마다 카드가 하나씩 늘어나는 구조이다. 위와 같은 화면을 어떤식으로 설계를 했을 때 렌더링 최적화를 잘할 수 있을까?
간단하게 생각해서, 스크린을 구성하는 각각의 컴포넌트들이 있으니 스크린이 모두를 담는 형식으로 설계를 할 수도 있겠다. SCREEN
컴포넌트를 구성하고, 이것의 하위로 BUTTON
, OTHER
, LIST
컴포넌트를 두며 LIST
는 각각의 카드들을 모두 하위로 가지고 있는 구조이다.
위와 같은 컴포넌트 구조의 스크린을 설계를 했다면, 이제는 하나의 카드를 붙여나갈 수 있도록 도와주는 카드 데이터를 담아주는 상태와, 상태를 변경하는 setState
함수를 위치시켜야한다.
const [ cardList, setCardList ] = useState([]);
각각의 카드를 그릴 수 있는 데이터를 담을 state
를 최상단의 SCREEN
컴포넌트에서 만들어주고 함수는 하위 컴포넌트인 Button 에 내려줘보면 어떨까? 그럼 내가 선언한 cardList
라는 state
는 버튼을 누를 때 마다 SCREEN
컴포넌트 내부에서 클릭이 될 때 마다 상태가 바뀔 것이고, 그에 따라 카드가 하나씩 추가가 되는 화면을 볼 수 있을 것이다.
BUTTON
컴포넌트가 상위 컴포넌트인 SCREEN
컴포넌트의 state
를 변경시키기 위해 setState
함수를 호출하고 SCREEN
컴포넌트가 가지고 있는 state
를 변경시키면, **SCREEN
컴포넌트의 state
가 변경되었기 때문에 리렌더링이 일어난다.**
그렇게 된다면, 해당 컴포넌트의 리렌더링으로 인해 리액트의 렌더링 규칙에 따라 하위 컴포넌트인 LIST
, BUTTON
, OTHER
컴포넌트의 렌더링이 일어나고 마지막으로 CARD
컴포넌트까지 리렌더링이 일어나며 새로운 카드가 하나 추가되는 것을 확인할 수 있다.
🤷🏻♀️ 기능적으로는 아무런 문제가 없다.
하지만 조금 더 관심을 가지고 리렌더링된 컴포넌트를 보면, 화면을 새로 그리기 위해 리렌더링이 필요한 컴포넌트는 LIST
컴포넌트인데, OTHER
컴포넌트까지 리렌더링이 될 필요가 있을까? 비록 지금은 OTHER 컴포넌트가 작은 Div 하나에 불과하겠지만, 만약 이 컴포넌트가 화면에 다시 렌더링 되기에는 무거운 컴포넌트였다면 카드가 하나가 붙을 때 마다 해당 페이지는 큰 부담이 될 것이다.
그렇다면, 이런 구조는 어떨까?
화면을 다시 그리는데에 필요한 state
는 실제로 리렌더링이 필요한 당사자인 LIST
컴포넌트가 들고있고, 그 하위 컴포넌트로 BUTTON
과 CARD
만 존재하게끔 구성을 해보자. 이렇게 설계를 한다면 LIST
컴포넌트와 OTHER
컴포넌트는 형제 컴포넌트가 될 것이다. 이제, 버튼을 눌러본다면
LIST 컴포넌트 내부에서 state
변경이 발생하고, 해당 컴포넌트에서 리렌더링이 일어난다. 그리고 이어서 하위 컴포넌트인 버튼과 카드가 리렌더링이 되며 화면이 새롭게 렌더링이 된다. OTHER 컴포넌트는 LIST 컴포넌트의 하위 컴포넌트가 아니기 때문에 리렌더링에 아무런 영향을 받지 않는다.
👀 눈으로 확인해보자
- 첫 번째 케이스
- 두 번째 케이스
뇌코딩으로는 아무것도 확인할 수 없다. 직접 만들어서 확인해보면 첫번째 케이스의 경우에는 버튼을 눌러 스크린의 state
를 바꿀 때 마다 state
와 아무런 관계가 없는 OTHER 컴포넌트가 불필요한 리렌더링이 일어남을 확인할 수 있다. 하지만 두번째 케이스의 경우에는 버튼을 눌러서 상태값이 바뀌더라도 OTHER 컴포넌트에는 아무런 영향을 끼치지 않아서 리렌더링이 일어나지 않는다.
2️⃣ 객체 타입의 state 변형하지 않기
컴포넌트에 props 를 내려주다보면, object 타입의 데이터를 내려주는 경우가 생긴다. 이렇게 object 타입의 데이터를 props로 내리게 되는 경우에는 object가 참조형 데이터이기 때문에 조심해야할 부분이 있다. 왜냐하면, “참조” 라는 녀석은 머리가 아픈 녀석이기 때문에, 개발자가 의도하지 않은 어떠한 문제를 일으킬 수 있기 때문이다.
그래서 객체를 props 로 내려줄 때는, 데이터의 변형을 최소화 해주는 것이 좋다. 예를 들면, 객체를 props 로 내려줄 때 새로운 객체를 생성해서 하위 컴포넌트로 내려줄 때는 “참조”에 의해 원하지 않는 렌더링이 발생할 수도 있다는 것이다.
👩🏻💻 예시를 들어볼까?
const memberList = ["해리포터", "드레이코 말포이", "세드릭 디고리", "루나 러브굿"];
const getMembers = (member) => {
swtich(member) {
case "해리포터":
return {
name: "해리포터",
house: "그리핀도르",
};
// ...
default :
return {
name: "해리포터",
house: "그리핀도르",
};
};
};
위와 같은 string 배열이 있다고 할 때 해당 배열의 값들을 이용해서 아래와 같은 객체의 배열을 return 하게끔 유틸 함수를 작성해봤다. 이제 나는 memberList
라는 배열과 getMembers()
라는 함수가 있기 때문에, 이 두 가지를 이용해사 호그와트 등장인물들의 기숙사를 구할 수 있다. 이 데이터를 가지고 카드 리스트를 만든다고 가정을 해보자.
위 그림과 같이 LIST
라는 상위 컴포넌트에서 getMembers
함수를 이용해 객체를 리턴하고 이를 CARD
컴포넌트의 props 로 넘겨주는 식으로 작성을 할 수 있다. 그렇게 된다면 getMembers
함수가 값을 계산해주고 이 값이 바로 CARD
에게 전해지기 때문에 코드는 아무런 오류 없이 정상적으로 실행이 된다.
만약 LIST
컴포넌트 내부의 다른 state
가 변경이 되어서 리렌더링이 발생한다고 하더라도 getMembers
함수를 사용하게 되는 배열이 변하지만 않는다면 여러번 호출이 되더라도 같은 값을 리턴하기 때문에 만약 CARD
컴포넌트가 props 의 변경 여부에 따라 렌더링을 최소화시켜주는 **React.memo()
에 의해 매핑이 되어있다는 가정 하에 리렌더링이 일어나지 않을 것이라고 기대할 수 있다.**
하지만, CARD
컴포넌트가 props
로 전달받고 있는 객체는 getMembers
가 호출이 되어 새로 생성된 객체로 그 값은 같지만 주소는 다른 주소를 참조하게 된다. 그렇기 때문에 props 는 주소값이 변경됨을 인지하고 props를 받고있는 하위 컴포넌트의 리렌더링을 야기하게 된다.
즉, props 객체의 값은 같더라도 그 참조 주소가 다른 새로운 객체주소로 인식이 된다면 자식 컴포넌트가 리렌더링이 유발될 수 있다는 것이다.
실제로 같은 객체 값을 내려줌에도 불구하고 컴포넌트의 리렌더링이 발생하는지 확인해보니 정말 발생했다. 사실 참조에 대해서 깊게 공부하지 않았을 때는 이런 부분에 있어서의 리렌더링이 일어날 것이라고는 생각도 하지 못했었는데, 덕분에 이런식으로 구조를 작성하는 것은 비효율적이라는 사실을 알게 되었다.
이번엔 위 그림과 같은 구조로 컴포넌트를 작성해보자. LIST
컴포넌트는 memberList
의 string
값만 props
로 넘겨주고, 그 값을 이용해 데이터가 필요한 당사자인 CARD
컴포넌트에서 직접 값을 가공해서 화면을 그리는 방식이다.
그리고 이 CARD
컴포넌트에 이전 케이스와 마찬가지로 React.memo()
를 적용한다면 LIST
내부에서 아무리 다른 상태변화가 일어난다고 한들, **CARD
에 전해지는 props
는 그 값도, 주소도 변화가 없기 때문에 리렌더링이 일어나지 않게된다.**
그리고 이론적인 부분을 실제 적용해서 실험을 해보면 아무리 상위 컴포넌트의 렌더링이 일어난다고 하더라도, 내려오는 props
가 변경되지 않았기 때문에 하위 카드들이 리렌더링은 발생하지 않았다.
이처럼 어떠한 데이터의 변형이 필요하다면 이것은 그 변형된 값을 필요로 하는 컴포넌트에서 직접 가공을 하는 것이 좋으며 props
로는 그 가공에 필요한 값만 내려주는 방식으로 설계를 하는 것이 더 효율적임을 알 수 있다.
3️⃣ 컴포넌트의 key 값에 index 를 넣는 것도 리렌더링이 일어날까?
라는 제목이 있다면 당연히 유발이 되니까 그런 제목을 넣은 것이다. 😛ㅋㅋㅋ
React 공식문서에서는, React 에서 컴포넌트를 mapping 할 때는 unique 한 key 값을 주도록 권장된다. 물론 각 배열의 index 값은 고유해보일 수도 있지만, 배열이 추가되거나 삭제되는 등의 변형이 일어난다면 이 변화에 따라 하위 컴포넌트의 리렌더링이 발생할 수 있다.
위 그림과 같이 index
를 이용해 key 값을 부여한 경우, 특정 값이 사라지면서 index가 다시 부여가 된다. 그렇기 때문에 컴포넌트들의 key 값이 바뀜을 인지한 컴포넌트는 이에 따라 리렌더링이 발생하게 된다. 해당 컴포넌트의 값 자체가 바뀐 것도 없는데 key 값 때문에 리렌더링이 일어나는 것은 비효율적이다.
뿐만 아니라 반복적인 index 변경이 발생하게 되면 순간적으로 화면에서의 렌더링 오류가 발생해서 화면을 바라보는 유저로 하여금 혼란스러운 경험을 야기시킬 수도 있다.
👀 눈으로 확인해보자
실제로 index
를 키값으로 설정해 배열을 변경했을 때 매 state
변경 시 마다 컴포넌트의 리렌더링이 됨을 확인할 수 있었다.
반면에 mapping 된 컴포넌트가 고유한 key 값을 가지고 있다면, 아무리 다른 요소가 삭제되거나 추가되었다고 하더라도 남아있는 요소들에게는 아무런 변화도 일어나지 않았기 때문에 불필요한 리렌더링이 발생하지 않는다.
👀 눈으로 확인해보자
4️⃣ React.memo() & useMemo() & useCallback()
지금까지는 구조와 설계적인 부분에 대한 내용이었다면 이제는 메모이제이션, 캐싱에 대해 알아보고 적용을 해보자. 컴포넌트의 렌더링 최적화에 대한 키워드를 검색하면 정말 많이 볼 수 있는 React.memo()
, useMemo()
, useCallback()
에 대한 내용이다.
✏️ React.Memo( )
React.memo()
는 컴포넌트를 랩핑하여 메모이제이션을 하고, 만약 props
의 주솟값을 포함한 값이 바뀌지 않았다면 리렌더링을 방지해주는 함수이다.
import React from 'react';
const Component = ( props ) => {
return <></>
};
export default React.memo(Component);
React.memo()
의 기본적인 문법은 위와 같이 컴포넌트를 함수의 인자로 넣어주면 컴포넌트가 그에 의해 React.memo()
로 래핑이 된다.
리액트에서의 렌더링을 돌아보면, 상위 컴포넌트의 state
변경이 있을 때 해당 컴포넌트에서 리렌더링이 일어나 그 하위 컴포넌트들은 모두 리렌더링이 된다고 했다. 하지만 위 그림과 같이 LIST
컴포넌트의 리렌더링으로 인해 모든 카드들이 리렌더링이 된다고 했을 때, 카드의 갯수가 별스타그램 타임라인의 카드들처럼 그 갯수가 많은 상태라면 꽤 곤란해질 수 있다.
따라서, 모든 카드들이 매번 상위 컴포넌트의 리렌더링이 발생할 때 마다의 리렌더링이 되지 않도록 React.memo()
로 래핑을 해준다면 이 함수는 상위 컴포넌트에서 리렌더링이 일어났을 때 컴포넌트로 내려받는 props
의 변경 여부를 확인하고 리렌더링의 여부를 확인해줄 것이다.
React.memo()
의 경우에는 이미 해당 포스트에서 많은 부분에서 적용이 되어있기 때문에 따로 더 예제코드를 작성해보지는 않았다. 바로 위 케이스에서도 이미 확인이 된 바 있다.
📝 useMemo( )
연산의 영역으로 넘어가보자. 우리는 웹 사이트 구현 간에 꽤 많은 연산을 하게 된다. 이 때, useMemo()
가 사용이 되며 useMemo()
를 사용하면 연산된 결과 값을 캐싱해주기 때문에, 매렌더링마다 고비용 연산이 일어나는 것을 방지할 수 있다. useMemo()
는 연산된 결과값이 바뀌지 않는다면 재연산이 일어나지 않기 때문이다.
그럼 리액트에 있어서 값을 기억해야하는 케이스는 무엇이 있을까? 회계 관련 사이트를 만들 때 여러가지 연산이나 평균값 같은 것들을 메모할 때? 특정 데이터들의 더하기, 빼기, 곱하기 등의 연산을 해야할 때? 그럼 이렇게 메모를 하는 것이 과연 화면 렌더링과 어떤 관계가 있을까?
분명 useMemo
는 연산된 결과 값을 캐싱해주기 때문에 위와 같은 예시로 사용이 될 수는 있다. 하지만 화면 렌더링 최적화 부분에 있어서 리액트가 유도한 케이스와는 아주 맞는 개념은 아닐지도 모른다. 그럼 만약 내가 ㅍ이스북 앱을 만들고 한 화면에 10000개가 넘는 카드를 만들어서 화면에 렌더링을 해줘야하는 경우를 접목해서 생각해보면 어떨까. 이 카드는 결국 매핑해서 화면에 그려지기 때문에 매핑된 값을 기억해주면 좋을 것 같다!
👀 눈으로 확인해보자
관련해서 블로그를 통해 봤던 기억이 있어서, 해당 블로그 글을 참고해서 나도 각각의 렌더링 시간을 체크해봤다.그럼, React Dev tools Profiler 탭을 이용해서 각각의 렌더링 시간을 측정해보자. 총 76개의 카드를 매핑했으며 각 카드들은 React.memo()
로 감싸지 않은 상태에서 오직 useMemo()
가 래핑 되어있는지 아닌지만 차이점을 두었다.
useMemo 감싸지 않은 경우
useMemo 감싼 경우
두 가지 케이스를 비교해보면, 확실히 useMemo
로 래핑한 경우의 스크린 렌더링 시간이 약 12ms 정도로 빠르게 렌더링이 됨을 확인할 수 있다.
👀 콘솔도 찍어볼까?
렌더링 시간과 함께 카드 리스트가 추가적으로 렌더링이 되는지를 콘솔을 찍어 확인해보면 useMemo
를 사용하지 않은 경우에는 스크린의 상태값이 변경될 때 마다 모든 카드들이 렌더링 되었지만 useMemo
를 사용한 경우에는 연산된 값이 바뀐 것은 아니었기 때문에 초기 렌더링 이후에는 추가적인 렌더링이 일어나지 않았다.
useMemo 감싸지 않은 경우
useMemo 감싼 경우
따라서, React의 useMemo
는 단순한 연산을 메모하는 용도 보다는 하위 렌더 트리의 렌더링에 있어서 고비용 연산을 방지하기 위해 특정 부분을 메모해서 사용하는 용도로 사용할 수 있다.
🎤 useCallback( )
이 함수는 useMemo()
와 그 메커니즘은 같은데, useMemo()
가 연산된 “값” 을 캐싱했다면, useCallback()
은 값이 아닌 “함수 그 자체”를 캐싱하게 된다. 그런데.. 컴포넌트의 렌더링 최적화를 공부하는 부분에 있어서 “함수 그 자체”를 캐싱을 하는 것이 렌더링에 있어서 어떤 도움을 주는걸까?
단순히 함수를 호출하고 사용하는 것으로 생각하고 useCallback()
훅을 바라보면, 컴포넌트의 렌더링 최적화가 아닌 자주 사용하는 함수를 캐싱해서 호출의 비용을 줄여주는 부분에서 이득을 보는 것일 것 같고.. 딱히 렌더링에는 크게 영향을 주지 않을 것 같다. 아니면, 함수의 호출이 빠르기 때문에 렌더링이 빠르게 되는걸까?
const deleteMember = (member: string) => {
setMembers((value) => value.filter((el) => el !== member));
};
const deleteMemberCallback = useCallback((member) => {
setMembers((value) => value.filter((el) => el !== member));
}, []);
실제로 useCallback
훅이 렌더링을 빠르게 도와주는지부터 생각을 해보면.. 단순히 함수만 작성을 해서 호출을 하는 것이 useCallback
으로 감싸는 것보다 빠를 것 같은 생각이 든다. 왜냐하면 결국은 useCallback
이라는 녀석을 불러와서 감싸주어서 무언가가 수행이 되는 개념이니 그 수행도가 더 느릴 것 같기 때문이다.
👀 눈으로 확인해보자
이번에도 React Dev tools Profiler 탭을 이용해서 각각의 렌더링 시간을 측정해보자. 총 여섯개의 버튼을 한번씩 누르면서 렌더링 시간을 측정해봤다.
useCallback 감싸지 않은 경우
useCallback 감싼 경우
위 측정 결과를 참고하면, 확실히 같은 함수임에도 불구하고 useCallback
으로 감싸줬을 때 렌더링 시간이 더 오래걸림을 확인할 수 있다. 왜냐하면, useCallback
은 함수를 정의하는 것 뿐만 아니라 그의 의존성 배열까지 함께 정의해야하기 때문에 더 고비용이기 때문이다. 결론적으로는 함수 컴포넌트를 실행할 때 useCallback
으로 감싸져 있다면 초기 렌더링이 더 느리거나, 렌더링 속도 개선 부분에 있어서는 유의미하지 않다.
🥲 처음 useCallback( ) 을 공부 했을 때..
const Component = () => {
const onClickHandler = useCallback(() => {
console.log("리윤트 천재");
}, []);
return <button></button>
}
내가 useCallback()
이라는 훅을 공부했을 때, 이런 식으로 코드를 작성하려고도 많이 했었다. ‘함수를 기억하고 있기 때문에 버튼을 누를 때 마다 해당 함수를 캐싱해서 다음 호출 때 속도가 빨라지는건가?’ 라는 생각을 했었고 React 에서 useCallback()
이라는 훅의 정확한 역할에 대해 제대로 인지하지 못했었기 때문이다. 실은 위와 같은 코드는 오히려 초기 렌더링을 느리게 만들 뿐만 아니라 의미가 없다.
그럼 도대체 useCallback()
은 어디에 쓰라고 만들어졌을까?.. useCallback()
을 사용해야하는 시점은 컴포넌트가 렌더링 되는 시점을 정의하는 것으로 시작한다.
🖐🏻 컴포넌트가 렌더링 되는 시점을 다시 한 번 되짚어보면,
state 가 바뀌었을 때
props 가 바뀌었을 때
부모 컴포넌트가 렌더링 었을 때 하위 컴포넌트의 경우
위 세가지가 리액트에서 정의하는 리렌더링이 되는 시점이었다. 여기서 우리가 주목할 부분은 세번째 항목인 부모 컴포넌트가 렌더링 되는 경우이다.
예를 들어서, 위의 사진과 같이 PAGE
컴포넌트에서 버튼 컴포넌트에 함수를 props
로 내려주는 경우를 보자. 이 경우에 유저가 **PAGE
컴포넌트에 있는 state
를 변경한다면 PAGE
컴포넌트가 렌더링이 일어나며 해당 컴포넌트에서 만들어진 함수를 다시 정의해서 버튼 컴포넌트로 내려주게 될 것**이다.
만약, 버튼 컴포넌트가 React.memo()
로 랩핑이 되어있다고 하더라도, 버튼 컴포넌트가 받는 함수의 주솟값이 달라지기 때문에 리렌더링을 피해갈 수는 없다.
하지만 여기서 버튼 컴포넌트에 내려주는 함수를 useCallback()
으로 감싸준다면? 페이지의 state
가 변경이 되어서 렌더링이 일어나더라도 useCallback()
으로 감싸진 함수는 dependency
가 변경되지 않는 한, 새롭게 만들어지지 않는다. 그렇기 때문에, 만약 버튼 컴포넌트가 React.memo()
로 랩핑이 된 상태라면, 상위 컴포넌트가 렌더링 된다고한들 props
가 변경되지 않았기 때문에 리렌더링은 일어나지 않는다.
💋 이것이 바로 React가 의도한 SUPER SEXY useCallback 이라고 한다.
👉🏻 렌더링 최적화 관련 연습 예제 코드
👩🏻💻 마무리
그 동안 렌더링에 대해 정확히 이해를 하고 성능 개선에는 자신이 있다고 생각을 했었지만 더 깊게 파고들고 직접 예제를 만들면서 눈으로 확인해보니 제대로 알지도 못하고 사용하고 있었던 것들이 많았다. 이번 기회에 잘못된 습관들에 대해 돌아볼 수 있었고 기본적인 부분들을 놓치지 말아야겠다는 생각도 할 수 있었다.
이번에 회사에서 새롭게 작업하고 있는 피쳐도 기존 성능이 떨어지는 부분이 있어서 서버를 새로 만들고 전반적인 설계를 다시 했었는데 이 과정에 있엇어 프론트 쪽에서도 최대한 렌더링 최소화하는 방법들을 적용해서 코코아톡 뺨치는 결과물을 만들어봐야겠다. 🤨 딱 대! 🌰🌰🌰🌰🌰