[6팀 이민재] Chapter 1-3. React, Beyond the Basics #25
[6팀 이민재] Chapter 1-3. React, Beyond the Basics #25minjaeleee wants to merge 24 commits intohanghae-plus:mainfrom
Conversation
1. 얕은 비교를 수행하는 shallowEquals 함수 구현 2. 순수 객체 비교 - 엣지 케이스를 고려 3. commit시 발생하는 lint 에러 주석 처리
1. shallowEqauls로 1dpeth 비교를 수행 2. deepEquals로 배열, 객체 모든 depth 탐색 및 비교를 수행
1. shallowEquals 재활용해서 실행시키고, deepEquals 모든 요소 탐색 및 비교가 필요한 재귀 함수 로직만 실행시키며 가독성을 향상시킴
1. 이전 의존성과 결과를 저장할 ref 생성 2. 현재 의존성과 이전 의존성 비교 3. 의존성이 변경된 경우 factory 함수 실행 및 결과 저장 4. 메모이제이션된 값 반환
1. 직접 작성한 useMemo를 통한 useCallback 기능 구현
1. 직접 작성한 useMemo를 통한 useDeepMemo 기능 구현
1. shallowEquals로 비교 2. useCallback으로 setState를 고정하여 항상 같은 참조를 유지하게 함 3. resolveNextState 함수형 업데이트 처리
1. 콜백함수가 참조하는 값을 렌더링 시점에 최신화 시키는 기능 2. useCallback으로 항상 동일한 참조를 유지하게 시키는 기능
1. 이전 props를 저장할 ref 생성 2. equals 함수 비교를 통해 동일하면 이전 값 반환 3. equals 함수 비교를 통해 동일하지 않을 때만 렌더링 수행 4. 결국 메모이제이션된 컴포넌트 생성 및 반환 - hoc 구현
1. deepEquals 함수를 사용하여 props 비교
1. 순수 객체 비교시, a 객체의 key만 비교하는 로직이므로 깊은 비교를 수행할 때, 정상적인 기능을 하지 못하는 이슈 수정
1. 이전 상태를 저장하고, shallowEquals를 사용해 상태가 변경되었는지 확인하는 로직
1. 외부 상태 구독 방식 구현 2. shallow memoization을 결합한 방식으로 기능 구현
1. 외부 상태 구독 방식 구현 2. shallow memoization을 결합한 방식으로 기능 구현
1. command와 state context가 결합되어 있어서 불필요한 렌더링 유발됨. 이로 인한 context 분기 처리
1. 직접 만든 useAutoCallback과 useMemo 기능을 통해서 필요한 값과 함수들에 대해서 메모이제이션
|
안녕하세요 민재님!
|
| import { shallowEquals } from "./shallowEquals"; | ||
|
|
||
| export const deepEquals = (a: unknown, b: unknown): boolean => { | ||
| if (shallowEquals(a, b)) return true; |
There was a problem hiding this comment.
이 함수에서 얕은 비교해서 true 로 처리하면 깊은 비교를 안 하게 되는 거 아닐까요?
객체 안에 객체나 배열이 있을 때 처음 객체만 판단해서 같으면 그냥 true 하면 중첩 된 부분들의 값을 비교하지 않을 것 같은데 이렇게 얕은 비교로만 먼저 true로 반환해도 괜찮을까 궁금합니다
There was a problem hiding this comment.
태영님 리뷰 감사합니다!
만약에 배열 내부에서 2depth 이상의 중첩된 배열이라면 얕은 비교로 1depth로 순회할 때, (예를 들어 [1, 2]와 [1, 2])는 참조 값이 달라(!==) false를 반환하게 되고 깊은 비교를 하게됩니다!
제가 이렇게 구조화한 이유는 1depth만 있는 구조에서는 성능을 위해 재귀적으로 깊은 비교 함수를 실행하지 않고 비용을 줄여 얕은 비교만으로 실행시키고 싶어서 성능을 고려하여 이렇게 구조화하였습니다!
제가 고려하지 못한 엣지 케이스가 있다면 피드백 주시면 너무 감사하겠습니다 👍
| return a === b; | ||
| export const shallowEquals = (a: unknown, b: unknown): boolean => { | ||
| // 1. 기본 타입 값들을 정확히 비교해야 한다. | ||
| if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true; |
There was a problem hiding this comment.
Object.is를 활용해보시면 좋을 것 같아요. NaN 비교도 해주는 친구입니다.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/is
There was a problem hiding this comment.
용준님 리뷰 감사합니다!
맞습니다! Object.is 메소드로 더욱 안정적인 비교를 하면 좋을 것 같습니다
추후 리팩토링에 적용해보겠습니다 ㅎㅎ
| if (keysA.length !== keysB.length) return false; | ||
|
|
||
| for (const key of keysA) { | ||
| if (!Object.prototype.hasOwnProperty.call(b, key)) return false; |
There was a problem hiding this comment.
사실 Object를 구별하는 모든 엣지 케이스를 고려하고 싶었습니다. 아시다시피, 자바스크립트 에러로 type이 "object"로 반환되는 것들이 많다보니, 그런 측면에서 의도를 봐주시면 될 것 같습니다!
| rendered: null, | ||
| }); | ||
|
|
||
| // 3. eqauls 함수를 사용하여 props 비교 - 새롭게 컴포넌트 렌더링X | ||
| if (memoizedRef.current.prevProps !== null && equals(memoizedRef.current.prevProps, props)) { | ||
| return memoizedRef.current.rendered!; |
There was a problem hiding this comment.
rendered의 초기값으로 Component를 넣어주게되면 if 문 내의 return값에 타입단언을 하지않아도 될 것 같은데 어떻게 생각하세요?
There was a problem hiding this comment.
지현님 리뷰 감사합니다 ㅎㅎ
오 그렇겠네요!
추후 리팩토링 하게 되면 고려해보겠습니다!
|
LGTM!!!!!!!!!!!! |
|
희진님 리뷰 남겨주셔서 감사합니다! |
| if (Array.isArray(a) !== Array.isArray(b)) return false; | ||
|
|
||
| // 3. 순수 객체 비교 | ||
| const isObject = (val: unknown): val is Record<string, unknown> => { |
There was a problem hiding this comment.
여러 케이스에 대해 생각하시고 구현하신 느낌이 확 드네요 ! 나중에 저도 적용해봐야겠어요.
과제 체크포인트
https://minjaeleee.github.io/front_6th_chapter1-3
배포 링크
기본과제
equalities
hooks
High Order Components
심화 과제
hooks
context
과제 셀프회고
기술적 성장
평소 참조 동일성에 대해서 깊이 이해하지 않았습니다.(= 그동안 대충 아는 ‘척’만 하고 중요한 사실은 스윽 넘겼다…) 메모이제이션이나 hooks에서 사용하는 의존성 배열에 해당하는 값들은 항상 참조 동일성을 유지하게 되고 렌더링 부분에서 즉시 비용 감소로 여겼기 때문입니다.
사실 이 부분을 유심히 고민해보면 저는 리액트의 얕은 비교를 통한 렌더링 원리에 대해서 부족했던 것 같습니다. 따라서, 다시 이 부분을 따라가서 정리해보았습니다.
참조 동일성이란?
config객체는 새로 만들어집니다.⇒ 이것이 바로 “모든 객체는 리렌더링 때 재생성되고, 참조가 다르다.” 라는 의미입니다.
참조 동일성을 유지하지 못하는 것이 어떤 문제를 유발하나?
과제를 진행하면서 memo, useMemo, useAutoCallback 훅을 작성한 코드를 보면 이해할 수 있습니다.
memo는 props를 shallowEqual(얕은 비교)를 수행하고 다르면 리렌더링을 하고 같으면 리렌더링을 막습니다.
그런데 config 객체는 매번 새로 생성되므로 얕은 비교에서 false를 반환하고 리렌더링을 하게 됩니다.
결국엔 컴포넌트는 매번 리렌더링되고 memo의 효과는 없어집니다.
이러한 방식이 반복되고 구조가 비대해지다보면 불필요한 렌더링과 성능저하로 이어지겠죠.
참조 동일성을 안정화하는 방법
참조 동일성을 안정화하는 방법의 두 가지를 배웠습니다.
첫 번째, useRef를 사용하는 것입니다.
값이 변하지 않거나, 값이 바뀌어도 리렌더링을 유발하지 않아야 할 때 사용할 수 있습니다.
다만 객체, 함수, DOM 노드 등 “값은 유지하지만 UI와 무관”한 상태에만 사용해야 합니다. 이는 리액트 공식문서 useRef 사용 주의사항에도 볼 수 있습니다.
두 번째, 모든 의존성 값들을 useMemo나 useCallback으로 감싸 안정화하는 것입니다.
이번 과제의 ToastProvider를 이렇게 값을 안정화하여 사용했습니다.
createActions 함수를 매번 재실행하지 않고 useMemo를 사용하여 참조의 안정성을 확보했고, hideAfter 함수 역시 hide에 의존한 메모이제이션을 해주어 일관된 참조를 보장하도록 구성했습니다.
이런 과정들을 통해서 얕은 비교에서 참조 동일성이 왜 중요한지를 이해하고, 리액트의 렌더링 원리에 대해서 더 깊게 이해할 수 있었습니다.
자랑하고 싶은 코드
얕은 비교 - 객체 순수 비교
처음에는 객체 비교를 하기 위해서 아래와 같이 type으로 구분을 했습니다. 물론, 테스트는 통과했지만 사실 자바스크립트에서 typeof value === “object” 라는 문자열로 반환되는 경우는 생각보다 많습니다. 배열, function, null, new Date(), new RegExp(), document.body …
따라서 isObject 함수로 순수 객체일 경우를 한 번 판단하고, Object.keys()로 직접 가진 key만 추출을 하고, Object.prototype,hasOwnProperty.call()로 해당 객체가 직접 소유하고 있는지 다시 판단하는 로직으로 변경하여 객체 여부에 대한 엣지 케이스를 보완하고 정밀도를 높였습니다.
깊은비교 리팩토링
deepEquals 함수를 작성할 때에는, shallowEquals에서 depth가 추가된 배열이나 객체일 경우에만 재귀적으로 모든 depth에 대한 탐색 및 비교가 필요했습니다. 따라서, shallowEquals 함수를 가져와 이 부분을 추가해 주었습니다.
작성하고 보니, 기존 비교는 shallowEquals 함수를 실행시켜 비교시키고 재귀적으로 탐색 및 비교가 필요한 요소들에 대해서만 deepEquals 함수로 깊은 비교를 실행시키도록 리팩토링하여 불필요한 코드를 shallowEquals 함수로 재활용하면서 가독성을 높였습니다.
개선이 필요하다고 생각하는 코드
memo 컴포넌트에서 equals 함수를 사용하여 props를 비교할 때 "새롭게 컴포넌트 렌더링을 시키지 않는다." 라는 의도로 Component 함수를 호출하지 않고 return해버렸는데, 이렇게 Component(props)를 조건부로 호출하는 방식이 문제가 생길 수도 있겠다는 생각이 들었습니다.
예를 들어서, 아래와 같이 memo로 감싸진 컴포넌트를 사용할 때 내부에서 hooks를 사용하게 되면 equals를 통한 변경이 일어나지 않았음에도 Component(props) 함수를 생략해버리니 React의 hook 규칙 위반 가능성이 될 수 있을것 같습니다.
이 부분에 대한 개선이 필요해보입니다!
학습 효과 분석
리액트의 렌더링 과정과 원리에 대해서 심층 깊게 이해한 시간이었습니다.
다만, 꽤 많은 내용을 학습했기 때문에 제 지식으로 온전히 습득하기 위한 과정을 다시 한번 스스로 거쳐야겠습니다.
리액트의 렌더링 과정과 원리에 대해서는 리액트의 공식문서와 도서, 문서 등으로 학습했고 면접과 같은 중요한 날 전에는 이를 달달 외우곤 했습니다.
하지만, 직접 학습하고 구현해보니 자연스레 외워지게 되었습니다.
예를 들어서, 2주차 때 학습한 과제에서 React element의 object 속성을 이해할 때 type, props, children 값들의 정의를 글로 외우다 보니 항상 몇 일 지나면 까먹었는데
이번에 직접 object로 트랜스파일링 하는 과정을 겪고, DOM에 반영하는 과정을 겪다보니 자연스레 type, props, children 값이 필요한 이유와 의미에 대해서 이해할 수 있었습니다.
또 리액트의 렌더링은 “얕은 비교를 수행하며 가상 DOM은 변경된 사항만 DOM에 직접 반영한다” 라는 의미를 워딩으로 외우다보니, 막상 얕은 비교는 어떻게 이루어지는지 참조 동일성은 어떻게 유지해야하는지 내부 원리에 대해서 정확히 이해하지 못하고 있었습니다.
자연스레 과제를 하면서 부족했던 부분을 확인하고 지식을 습득할 수 있었습니다.
결론은 직접 구현하면서 이해하는 과정이 있다보니 재미있게 학습할 수 있었고, 1~3주차의 과제를 통해서 리액트에 대한 깊은 이해를 더욱 할 수 있는 기반이 만들어진 것 같습니다.
과제 피드백
과제 학습을 수행하며 리액트와 자바스크립트에 대한 조금 더 많이 이해할 수 있게 되었고, 더 깊은 수준을 이해할 수 있는 중요한 디딤돌이 되었습니다.
또한, AI와 함께 과제를 수행하면서 빠르게 나의 코드를 구조화하고, 분석하고, 리팩토링을 할 수 있었습니다.
다만 요구하는 특정 기능을 “정답”에 가까운 코드로 만드는 것은 과제를 수행함에 있어서 사람마다 큰 습득이나 변별력이 없겠다는 생각이 들었습니다.
AI로 빠르고 일원화된 정답을 제출하는데, 코드를 작성하기 위해 고민한 시간과 노력들 그리고 의도와 목적들을 모두 표현하는게 더 중요하겠다는 생각이 들었습니다.
그래서 과제로서 이런 문제를 해결하는데는 어렵겠지만 경험하고 해결한 사례, 배경, 고민한 흔적들을 나눌 수 있을만한 과제나 시간이 있으면 더 좋을 것 같습니다.
아직.. 구체적인 예시까지는 생각은 못 해봤습니다.. ㅎ
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
리액트의 렌더링 과정은 React Element 생성 → 가상 DOM 비교 (diffing) → 실제 DOM 반영 (commit) 세 가지 단계로 구성이됩니다.
1. JSX → React Element 생성
JSX는 React.createElement 호출로 변환되어 React Element라는 일반 Javascript 객체가 됩니다.
2. Reconciliation (조정 단계)
이전 렌더링 결과와 새로운 React Element를 비교하여 업데이트해야 할 변경점만 계산하게 됩니다. 이때, 이 과정을 통해 성능을 높이고 불필요한 DOM 업데이트를 방지합니다.
3. Commit Phase (실제 DOM 반영 단계)
앞서 수집된 변경사항을 기반으로 실제 DOM을 조작합니다.
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션에 대한 생각을 먼저 표현하기 전에, 배경이 되는 리액트의 렌더링에 대한 생각을 먼저 정리하겠습니다.
리액트의 렌더링에 대한 나의 생각
리액트의 렌더링은 단순하고 예측 가능하다는 점에서 탁월하지만, 렌더링마다 함수 전체가 재실행되고 객체, 함수, 배열 등 모든 참조가 새로 만들어진다는 구조적인 특성은 개발자가 깊이 고민하고 다루어야할 부분이라고 생각합니다.
그래서 저는 이 구조를 단점으로 보지 않는 대신 변경을 감지하는 기준이 참조이기 때문에 개발자는 참조를 유지할 책임이 있다고 생각합니다. 즉, 의미있는 리렌더링을 의도할 책임이 있다고 생각합니다.
그렇기 때문에 리액트의 렌더링 원리를 정확하게 이해하는 것은 값의 참조를 안정화해서, 의미있는 UI의 변화가 반영될 수 있는 것이라고 생각합니다.
메모이제이션
먼저, 메모이제이션이란 어떤 연산의 결과를 캐싱해 두었다가, 동일한 입력이 들어오면 재계산하지 않고 캐시된 결과를 재사용하는 최적화 기법입니다.리액트의 메모이제이션은 제가 앞서 설명한 리액트의 렌더링의 구조적 단점을 보완할 수 있는 중요한 기술입니다.
단, 앞서 제가 얘기한 “리엑트의 렌더링 원리를 정확하게 이해한 상태에서 구현할 때” 라는 전제가 붙습니다.
React.memo, useMemo, useCallback 등은 참조의 동일성을 기반으로 동작하기 때문에 참조의 동일성을 지켜주지 않으면 오히려 불필요한 리렌더링, 불필요한 실행, 성능 저하로 이어지기 때문입니다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
상태 관리란?
상태(state)는 UI가 반응해야 하는 데이터의 현재 모습을 의미합니다. 이 상태를 언제, 어디서, 어떻게 관리할지를 결정하는 것이프론트엔드 앱 설계에서는 가장 중요한 결정 중 하나입니다.
현재 상태관리 라이브러리로는 Redux, Zustand, Recoil 등이 주류를 이루고 있으며 React의 상태관리는 컴포넌트 내부에서 useState를 다루는 것이 일상이 되었습니다.
하지만, 상태의 복잡도보다 더 중요한 것은 “이 상태를 누가 알아야 하는가?” 즉, 컨텍스트의 경계 설정입니다.
컨텍스트의 역할
React의 Context는 컴포넌트 트리 어디에서든 데이터를 공급하고 구독할 수 있게 해줍니다.
그러나, Context 내부 상태가 자주 바뀌면 그 상태를 구독 중인 모든 컴포넌트가 불필요하게 렌더링이 발생됩니다. 따라서, 컨텍스트는 공유 상태의 목적으로 쓰는 것이 가장 적절하다고 생각합니다.
공유 상태(Shared State)
공유 상태란 둘 이상의 컴포넌트가 함께 사용하는 상태를 의미합니다.
앱 전역에서 일관된 정보를 관리하는 전역 상태의 목적과는 다르게 특정 UI흐름이나 기능 내에서 여러 컴포넌트 간 데이터를 공유하고 싶을 때, 제한된 범위 내에서 데이터를 공유하고 싶을 때 사용하는데 목적이 있습니다.
결론
따라서 상태 관리의 목적을 이해하고 설계하는 것이 중요하다고 생각이 됩니다. 공유 상태가 필요할 때는 컨텍스트, 전역 상태가 필요할 때는 전역 상태를 고려하는 것이 장,단점과 목적에 따른 적절한 사용 방법이라고 생각합니다.
리뷰 받고 싶은 내용