React 렌더링 최적화 과제 PR
과제 체크포인트
배포 링크
https://realstone2.github.io/front_6th_chapter1-3/
기본과제
equalities
- shallowEquals 구현 완료
- deepEquals 구현 완료
hooks
- useRef 구현 완료
- useMemo 구현 완료
- useCallback 구현 완료
- useDeepMemo 구현 완료
- useShallowState 구현 완료
- useAutoCallback 구현 완료
High Order Components
- memo 구현 완료
- deepMemo 구현 완료
심화 과제
hooks
- createObserver를 useSyncExternalStore에 사용하기 적합한 코드로 개선
- useShallowSelector 구현
- useStore 구현
- useRouter 구현
- useStorage 구현
context
- ToastContext, ModalContext 개선
과제 셀프회고
이번 과제는 스스로 어느정도 이해하고 있는 파트여서 어렵지 않았다고 느꼈다. 그래서 그 안에서 세부 키워드들의 지식을 다듬고 정리해서 글을 잘 작성해보자라는 목표를 갖고 과제를 시작했다.
useSyncExternalStore과 context 등의 과제가 나왔을 때 환호했다.
늘 정리하고 싶었던 머리속 내용이 있었고, 해당 개념을 글도 작성해보고 스스로 정리할 수 있는 기회라고 느꼈다.
하지만 머리속에 있는 내용을 정리해서 글을 작성하는건 쉬운일이 아니었고, 내가 알고있는 개념에다가 살이 붙기 시작하면서 간단하게 작성하려고 했던 내용이 거대한 내용이 되기도 했다.
그러다보니 내가 원하는 만큼의 회고를 전부 작성하지 못해서 아쉽다.
과제를 끝낼때마다 항상 아쉽다. 더 잘할 수 있을거같은데...
기술적 성장
useSyncExternalStore 기반의 상태관리 라이브러리와 useEffect 기반의 상태관리 라이브러리들의 트레이드오프 부분을 이해하고 사용할 수 있게 되었습니다.
자랑하고 싶은 코드
PR을 자랑하고 싶습니다..
개선이 필요하다고 생각하는 코드
이번엔 어느정도 정답이 정해져있던 코드인 것 같아서, 주제에 대한 생각을 pr에 담아놨습니다! 제가 알고 있는 내용들이 올바르게 알고있는게 맞는지 점검 받고 싶습니다.
학습 효과 분석
리액트가 외부 스토어와 연결될 때 발생하는 문제들과 여러 외부 스토어들이 선택한 각각의 트레이드 오프에 대해서 명확히 이해할 수 있게 되었습니다.
리액트 Fiber에 대해서는 추가학습이 더 필요할 것 같다고 생각합니다.
과제 피드백
이번 과제는 과제 자체는 비교적 간단한 내용이었다고 생각합니다. 그치만 PR에서 정리하는 내용 자체가 스스로의 깊게 생각해볼 수 있는 시간이었고, 팀원들과 피드백도 받으며 얘기해볼 수 있는 과정이라 좋았던 것 같습니다.
심화 학습 고찰
useSyncExternalStore 고찰
이번에 외부 스토어를 직접 구현해보면서 useSyncExternalStore를 사용해보았다. useSyncExternalStore 자체는 아주 간단하고 가벼운 내용이지만 왜 등장했는지부터, 어떤 문제를 해결하고자 했는지를 어떤 부작용이 있는지 깊게 학습해볼 수 있었다.
왜 등장했는지
react 18의 동시성 기능(Suspense, startTransition, streaming SSR)이 도입되면서, 렌더링 중에 일시 중지 하고 나중에 이어서 렌더링하는 기능이 생겨났다.
하지만 이 때 외부 상태관리와 함께 사용하면 tearing라고 말하는 렌더링 상태 불일치가 발생한다.
// 컴포넌트 A와 B가 같은 외부 store를 읽음
// React가 A만 먼저 렌더하다가 중간에 멈춤(yield)
// 그 사이 외부 store 값이 변경됨
// B는 최신값으로 렌더되지만 A는 이전값 → 불일치 발생
이 문제를 해결하기 위해 useSyncExternalStore 가 도입되었다.
문제
tearing문제는 해결되었지만 useSyncExternalStore는 동시성 렌더링과 올바르게 동작하지 않는다.
zustand는 useSyncExternalStore기반으로 구성되어있다.
zustand를 예시로 들어서 코드를 살펴보았다. https://codesandbox.io/p/sandbox/zustand-suspense-demo-forked-psqczj?file=%2Fsrc%2FApp.js%3A41%2C38
해당 코드를 보면 suspense 대기까지 isPending으로 동작했어야했지만 isPending이 노출되지 않는다.
useSyncExternalStore로부터 업데이트된 상태변경이 즉각반응하고 startTransition을 통해 업데이트 되었는지 알 수 없기 때문이다.
대안? 트레이드 오프?
반면에 jotai는 store를 useEffect로 구독하는 형태로 구성되어있다. useSyncExternalStore를 사용하지 않고 React 상태에 의존성을 두었다.
zustand때와 거의 동일한 흐름의 코드이지만 isPending이 아주 잘 동작한다. https://codesandbox.io/p/sandbox/zustand-suspense-demo-forked-t2pqlr
jotai는 react 상태 기반으로 state가 관리되기 때문에 startTransition을 통해서 의도된 렌더링 흐름이 흘러간다.
그러나 당연히 그러면 그냥 외부스토어를 구독한 상태가 되니 처음에 발생했던 tearing 문제가 다시 발생한다. https://codesandbox.io/p/sandbox/react-tearing-jotai-wqwwqt?file=%2Fsrc%2FCounter.js%3A10%2C1
https://blog.axlight.com/posts/why-use-sync-external-store-is-not-used-in-jotai/ jotai 개발자 블로그글에 자세히 설명이 되어있다. jotai에서는 tearing 현상이 발생하더라도 동시성 기능과 의도대로 동작하는 것을 선택한 것이다.
즉 useSyncExternalStore hook이 외부스토어와 리액트간의 모든 문제를 해결해주는 만능 hook이 아니고 트레이드오프의 선택지인 것이다.
모든 라이브러리마다 해결하고자 하는 방향이 있는거고 zustand와 jotai 모두 각각의 의도가 있는 것이다.
shallow compare 고찰
어쩌다보니 깃허브 이슈에 올리게 돼서 링크로 대체합니다! https://github.com/hanghae-plus/front_6th_chapter1-3/issues/47
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
리액트 렌더링
리액트에서의 렌더링 과정을 간단하게 설명해보자면 4가지 단계로 설명할 수 있다.
Trigger => Render => Commit => Paint
위에 흐름을 좀 더 살을 붙여서 정리하면
-
Trigger 리액트에서 props, state 가 변경될 때 Trigger가 발생
-
Render 트리거가 발생하게 되면 Render 과정이 발생하게 된다. Render 과정에서는 재귀적으로 prev VDom에 있는 내용과 새로 렌더링된 VDom을 비교하는 과정을 거친다.
-
Commit 변경된 사항이 있다면 Dom에 커밋
-
Paint commit된 내용을 브라우저가 렌더링
렌더링은 문제인가?
https://kentcdodds.com/blog/fix-the-slow-render-before-you-fix-the-re-render
렌더링은 문제일 수도 있고 문제가 아닐 수도 있다. 렌더링이 발생하더라도 Reconciliation 과정에서 변동된 사항이 없다면 commit은 발생하지 않는다. commit이 발생하고 브라우저에서 repaint가 발생할 때가 잦아지는 경우가 문제이다.
물론 렌더링중 느린 렌더링이 발생할 경우는 문제이다.
이런 경우에는 렌더링을 최적화하는 방법들의 도입이 필요하다
렌더링 최적화
리액트에서 제공하는 렌더링 최적화 도구들은 React.useCallback React.useMemo React.memo 들이 있다. 각각 props, deps 들이 변경될 때 얕은비교 과정을 거쳐서 변경되었을 경우 재호출을 하는 방식이다.
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션?
리액트에서는 state, props, context가 변경되면 해당 컴포넌트에서 리렌더링이 발생하고 컴포넌트 안에 있는 모든 값들은 다시 재계산 된다.
이 때 실제로 재계산이 필요한 경우에만 재계산하도록 하는 의도로 메모이제이션을 한다.
사용하지 않았을 때 발생할 수 있는 문제
만약에 props로 넘기는 변수가 object라면 리렌더링이 될 때 마다 재할당이 되고 당연히 메모리주소가 바뀌며 props가 변경되었다고 인식한다.
만약 성능이 좋지 않은 list 아이템 컴포넌트에 props를 넘겼다면, 렌더링이 발생할 때 마다 list 아이템 컴포넌트를 재계산하는데 아주 큰 성능이슈가 발생하게 될 것이다.
이 때 React.memo와 React.useMemo를 사용해서 불필요한 렌더링을 막을 수 있을 것이다.
메모이제이션에 대한 장점, 단점, 나의 생각
리액트에서 메모이제이션에 대한 논쟁은 핫하다.
회사에서만해도 우리는 의견이 갈리고 있다. 리드 개발자분의 의견은 어떤곳에는 쓰고 안쓰고 생각하는 고민 비용도 비싸고, 굳이 그럴 이유도 없다는 의견이다. 그 반대의 의견은 비싼 계산을 하는 곳도 없고 문제도 없는 코드들이라는게 뻔히 보이는데 왜 넣어야하는지 모르겠다는 의견이다.
양쪽 다 어떤 맥락인지는 이해가 된다. 아무리 고민해도 어려운 것 같다.
- 극단적으로 예시를 든 코드
//메모를 해도 안해도 렌더링이 발생하는 곳에서 왜 항상 얕은 비교 코드를 불필요하게 실행시키는가!
const [state, setState] = React.useState(0);
const memoizedValue = useMemo(() => {
return state * 2; // 예시로 상태의 두 배를 계산
}, [state]);
return (
<div>
{memoizedValue}
<button onClick={() => setState(state + 1)}>Increment</button>
</div>
);
그래도 내가 생각하는 이상적인 메모이제이션은 느린 리렌더링이 발생하는 경우의 문제를 해결할 때 사용하면 된다고 생각한다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
전역상태관리 왜 필요할까
최근에는 전역상태관리의 필요성 자체가 많이 줄어들고 있다.
tanstack-query 같은 서버 상태 관리 라이브러리가 없을 때는 서버 데이터 요청을 과도하게 하지 않기 위해서 전역적으로 데이터 저장소를 두어 관리하기도 하였다. 최근에는 서버 상태 관리 라이브러리가 이런 부분을 채워주고 있고, 전역상태라는게 필요한 경우가 없다라고 말할 정도로 필요성이 줄어들었다.
나의 생각도 비슷하다. 전역상태관리는 특수한 경우를 제외하고 앱 기능을 만들 때는 거의 불필요하다. ex) global snackbar? 게다가 웹에서는 searchParams라는 UI 정보를 보여주는 아주 좋은 요소도 있다.(심지어 지라에서는 모달을 띄워주는 요소로도 사용하고 있다)
하지만 전역상태관리는 필요없다해도 scope 단위의 상태관리는 매우 유용하다. 개발자의 의도를 담고 클린하게 코드를 관리하기 아주 좋은 방법이라 생각한다.
정리해보자면, 전역상태라이브러리라고 칭하고 있긴 하지만, 필요성이 줄었고 지금은 스코프 단위의 상태관리로써 접근하는게 좋다고 생각한다. (앱 전역에서 사용해야된다면 앱 전체의 스코프 상태관리라고 접근한다)
Context Hook
Context Hook은 Context를 Provider 내부에 있는 자식 컴포넌트들에게 제공해주는 Hook이다. (Provider 내부가 스코프가 된다.)
동작 방식은 Provider에 제공된 context 값을 공유하게 되고 Object.is를 통해 변경되었는지 감지하여 리렌더링한다. 이 때 useContext를 통해 사용하는 자식 컴포넌트들은 모두 함께 리렌더링이 되는 흐름이다.
외부 라이브러리를 사용할필요 없이 React만 사용한다면 바로 사용할 수 있다.
그러나 단순히 그냥 상태관리로써 사용하기에는 렌더링 최적화하는 일련의 과정이 간편하지 않다.
그런데 여기서 하나 더 짚고가야될 점이 있다. 렌더링이 정말 문제인가도 생각해보자.
렌더링
렌더링은 문제일 수도 있는거지, 무조건 문제라고 말하기는 어렵다.
렌더링 과정을 간략하게 보면 아래와 비슷하다.
render → reconciliation → commit
↖ ↙
state change
기본적으로 렌더링이 발생해도 reconciliation과정에서 VDom과 Dom과 비교했을 때 변화가 없다면, 커밋이 일어나지 않는다. Context Hook으로 상태관리할 때를 예로 들자면, 불필요하게 리렌더링이 발생하는 컴포넌트라고해도 실제로는 어차피 commit이 일어나지 않는다. 커밋이 없는 경우에는 dom이 바뀌지 않고, 사용자에게 영향을 주지 않을 것이다.
물론 렌더링 과정만으로 영향을 주는 경우도 있다. 렌더링중에 느린 렌더링이 발생하면 연산과정이 많아지면서 의도치 않게 버벅이는 현상을 마주하게 된다.
전달하고자 하는 말은 느린 렌더링만 발생하지 않는다면, 즉 앱을 내가 효율적으로 잘 관리하고만 있다면, useContext는 간편하고 좋은 상태관리 수단이 될 수 있다는 말을 하고 싶다.
- 내가 생각하는 효율적인 관리 useMemo, useCallback, memo 등의 memoization hook을 사용해서 느린 렌더링을 관리해서 효율적으로 관리하는 것을 얘기함
context hook에 대한 나의 생각
위에서 말한거처럼 효율적으로 앱을 관리하고 있다면 context hook은 리액트 내부코드이기때문에 tearing 현상을 걱정할 필요도 없고, 쉽고 예측가능한 코드를 작성할 수 있다. 상태관리 수단으로도 괜찮은 방법일 수 있다.
하지만 이런 과정을 고민하고 적용하는 것 자체가 상당히 귀찮다고 생각한다.(애초에 상태관리하도록 제공해주는 메서드가 없으니..) 그리고 리액트 개발자는 리렌더링을 극도로 거부감 느끼도록 가스라이팅을 당했기 때문에 나도 거부감이 강하기도 하다.
그래서 context hook은 기본적으로 상태를 주입하는 용도로 사용할 때 좋다고 생각한다.(애초에 그러라고 만들어진 API) 데이터를 주입하는 용도로만 사용할 때 불필요한 렌더링 발생 걱정도 없고, 작성자의 의도를 명확히 파악할 수 있기도 하다.
리뷰 받고 싶은 내용
1. Context, 외부 상태관리 라이브러리 선택 기준
제가 정리한 Context Hook에 대한 관점이 실무적으로 적절한지 피드백을 받고 싶습니다
- "효율적으로 앱을 관리하고 있다면 context hook도 좋은 상태관리 수단"이라는 관점에 대해서 어떻게 생각하시나요?
- 저번 Q&A 시간에는 전부다 memo를 사용한다고 하셨는데, tool쪽이라 렌더링에 민감한 부분이 영향이 크실까요? 그냥 단순한 웹, 앱 등에서도 전부다 memo하는게 효율적일까요?
- 코치님이 선호하시는 상태관리는 어떤 것인가요?
- 저는 서버상태관리 라이브러리들이 생겨나면서 flux 패턴으로 관리하는 패턴자체의 필요성이 많이 줄었다고 생각하여습니다. 그러다보니 jotai를 선호하게 되었는데, 이런 관점에서 코치님이 바라보시는 생각이 궁금합니다.
2. useSyncExternalStore vs useEffect 기반 구독의 트레이드오프
제가 분석한 zustand(useSyncExternalStore) vs jotai(useEffect) 방식의 트레이드오프가 정확한지 확인하고 싶습니다
- tearing 방지 vs 동시성 기능 호환성
- 실제 프로덕션 환경에서 이런 트레이드오프가 의미있는 차이를 만드는지
- 라이브러리 선택 시 이런 요소들을 어느 정도 가중치로 고려해야 하는지
과제 피드백
안녕하세요 진석님! 굉장히 깊이있는 고찰을 해주셨네요 ㅎㅎ 재밌게 잘 읽었어요! 고생하셨습니다.
"효율적으로 앱을 관리하고 있다면 context hook도 좋은 상태관리 수단"이라는 관점에 대해서 어떻게 생각하시나요?
개인적으로 관리해야 하는 상태가 많아질수록 context를 통해 상태를 관리하는게 굉장히 어렵다고 생각해요. 무엇보다... 성능 최적화를 하는게 너무 어렵답니다 ㅋㅋ (해본사람의 이야기..)
저번 Q&A 시간에는 전부다 memo를 사용한다고 하셨는데, tool쪽이라 렌더링에 민감한 부분이 영향이 크실까요? 그냥 단순한 웹, 앱 등에서도 전부다 memo하는게 효율적일까요?
렌더링에 민감해서 했다기보단... 언제 쓰는게 좋을지에 대한 판단을 매번 하는게 귀찮고 한 번 memo를 시작하면 그 여파가 계속 커져서 (메모로 인한 메모로인한 메모로인한 메모로인한, .... 메모) 이럴꺼면 그냥 다써~~ 라고 팀 내에서 합의를 했답니다 ㅎㅎ 커뮤니케이션 비용 때문인거죠
코치님이 선호하시는 상태관리는 어떤 것인가요?
저는 간단한 상태관리는 아마 zustand 사용할 것 같고 (사실 실무에서 제대로 사용해본적이 없어요), 복잡한 상태를 다룰 때에는 redux를 사용할 것 같습니다. redux의 경우 action을 thunk로 쪼개서 조합할 수 있다보니 다루는 상태가 복잡할 때 편의성을 많이 제공해줘요 ㅎㅎ zustand도 그런게 잇는지 모르겠네..
저는 서버상태관리 라이브러리들이 생겨나면서 flux 패턴으로 관리하는 패턴자체의 필요성이 많이 줄었다고 생각하여습니다. 그러다보니 jotai를 선호하게 되었는데, 이런 관점에서 코치님이 바라보시는 생각이 궁금합니다.
앞선 내용과 이어지는 부분인데요, flux가 중요한건 아니라고 생각해요. 대부분의 경우에는 아마 jotai만 사용해도 충분할 것 같네요 ㅎㅎ 제가 zustand나 redux를 선호하는 이유는 react에 종속적이지 않은 방식으로 만들 수 있기 때문인데요, jotai는 react와 강결합(?) 되는 부분이 없지않아 있어서... 그게 조금 거슬렸어요. 이건 선호도 차이도 있고 팀의 인식 차이도 있기 때문에 중요한 부분은 아니라고 생각합니다!
제가 분석한 zustand(useSyncExternalStore) vs jotai(useEffect) 방식의 트레이드오프가 정확한지 확인하고 싶습니다.
흠.. 일단 동시성에 대한 부분이 사용성에 얼마나 큰 영향을 주는가를 따져봤을 때... 아마 미미하지 않나? 라고 생각해요. 아마 저는 "사용성"보단 "편의성"을 더 중요하게 생각할 것 같아요. 개발자가 사용하기에 얼마나 더 편리한가! 라고 해야하나 ㅋㅋ
가령, 어플리케이션을 만들 때 사실 네이티브로 만드는게 제일 성능도 좋고 매끄러워요. 그런데 웹뷰를 많이 사용하는 이유는 생산성과 편의성 때문이 크다고 생각합니다.
빠르게 서비스를 만들어가야 하는 부분도 고려 대상이라는거죠. 그래서 저는 "우리 팀이 익숙한 것"도 고려해야 한다고 생각해요. 팀원 대부분이 zustand가 익숙하다면 저는 zustand가 좋은 대안이라고 생각하고, jotai가 익숙하다면 jotai가 좋은 대안이겠죠!?
혹은 개발자의 편의성을 확보해주는 여러가지 도구가 있는지 살펴보는 과정도 필요할 것 같아요.