과제 체크포인트
배포 링크
https://hwirin-kim.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 개선
과제 셀프회고
이번 과제에서는 여러가지 리액트 훅을 만들어보고, 해당 훅으로 렌더링 최적화를 진행해보았다. 여러가지 의문점도 많이 생겼고 스스로 혼란에 빠지기도 했지만 분명히 이 경험은 내가 리액트를 이해하는데 큰 도움이 되었다.
기술적 성장
이번 과제에서 useRef를 useState를 사용하여 구현하는 과제가 있었다.
export function useRef<T>(initialValue: T): { current: T } {
const [ref] = useState({ current: initialValue });
return ref;
}
이렇게 간단하게 ref상태를 만드는데, 이 ref는 setState를 사용하지 않고 ref.current에 직접 값을 할당/변경 하여 useRef처럼 사용하는 방식이다. setState로 새로운 객체를 생성하지도 않으니 값이 변해도 리렌더링 되지 않았다. 그런데 나는 여기서 의문이 들었다. "일반 지역변수를 사용해도 렌더링이 발생하지 않는다면, 그냥 내부에 ref객체 변수를 만들어서 리턴하면 되지 않나?"
export function useRef<T>(initialValue: T): { current: T } {
const ref = { current: initialValue };
return ref;
}
이렇게 말이다.
하지만 이것은 당연히 동작하지 않았다. 먼저 렌더링이 다시 발생할때마다 ref객체는 새 객체를 다시 만들어 반환하게 되기 때문이다. 즉, 값을 변경해서 저장하더라도 만약 렌더링이 다시 발생해버리면 이 값들은 다시 초기화 되버리는 것이다.
하지만 useState를 사용한다면 처음 렌더링시 객체를 생성하고, 해당 객체를 내부 hook 메모리에 저장해둔다. 이 후 렌더링시 저장해둔 값을 꺼내어 사용하는 방식이기 때문에 값을 기억할 수 있게 된다.
어떻게보면 너무나도 당연해서 위처럼 사용하지 않았지만, 진짜 안되는걸까? 하는 의문으로 실험해보니 더욱 이해가 잘되고 기억에 남았다.
앞으로도 간단해도 왜 안되는지 명확하지 않으면 직접 만들어 보는 수고(?)를 들여서라도 잘 공부해놔야겠다.
자랑하고 싶은 코드
지난번 과제까지도 그랬지만,, 테스트 통과를 위해 고민하고 통과해서 무언가 자랑하고 싶은 코드라고 할만한게 없는것 같다.. ㅠㅠ 그래도 하나를 뽑아본다면 얕은비교, 깊은 비교 함수를 만들 때 입력받은 파라미터 타입별로 다른 방법의 비교 방식을 취해줘야하는데 원시 데이터는 Object.is()메서드를 이용하면 간편히 가능했고, 배열의 경우 Array.isArray()메서드가 있어서 간단히 처리 되었지만, 객체의 경우 typeof value==="object" 만으로는 배열이나 Null을 걸러낼 수 없기 때문에 isPlainObject라는 유틸함수를 만들어 코드를 좀 더 깔끔하고 읽기 좋게 바꿨다는 점이다.
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
개선이 필요하다고 생각하는 코드
개선이 필요하다고 생각한 코드를 개선 후 아래 과제 피드백에 적었습니다!! 궁금했던 부분이 있어서요!
학습 효과 분석
이번 과제 중 Context API를 사용 할 때, 상태 컨텍스트와 액션 컨텍스트를 분리하여 재렌더링을 최적화 하는 부분이 있었다.
<ToastStateContext.Provider value={memoizedStateValue}>
<ToastActionContext.Provider value={memoizedActionValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastActionContext.Provider>
</ToastStateContext.Provider>
나는 현업에서 근무할때도 Context API를 자주 사용하곤했다. 그럼에도 이렇게 분리하여 최적화 할 수 있다는것을 왜 생각하지 못했을까..? 이번 과제를 하면서 어떤 코드든지 더 개선할수없을까? 하는 고민을 해봐야 한다고 생각했다. 물론 이를 사용하는 컴포넌트가 상태와 액션을 모두 사용한다면 굳이 컨텍스트를 나누지 않아도 성능은 똑같을 수 있다. 하지만 이번 과제를 통해 이런 방법도 있다는걸 배웠으니 꼭 적용해봐야겠다.
과제 피드백
전체적으로 이번 과제는 AI사용이 제일 적었고, 반대로 깊이 생각한 시간은 많았다. 과제 자체의 난이도는 낮은편이라고 들었는데, 나는 오히려 지난 과제들보다 고뇌하는 과정이 더 많았어서 난이도가 높게 느껴졌다. 그리고 이번 과제부터 타입스크립트를 사용하였는데, 원래 현업에서도 계속 사용했지만, 뭔가 그때보다 어렵게 다가왔다. 전반적으로 잘 마무리해서 기분은 뿌듯하다!
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
이번 1,2,3주차 과제를 진행하며 내가 이해한 개념들
- 리액트의 렌더링 과정, 가상돔 개념, Reconciliation)
- 컴포넌트가 처음 마운트 되거나, 상태의 변경, props의 변경등으로 렌더가 실행된다.
- 컴포넌트 함수의 실행으로 JSX를 반환받는다.
- 반환받은 JSX로 가상돔 객체를 만든다. (Virtual DOM)
- 이전 가상돔 객체와 새롭게 생긴 가상돔을 비교한다.
- 무엇이 바뀌었는지 비교 분석한다.
- 위 두 과정이 Diffing (Reconciliation)
- 이제 분석 결과를 실제 돔에 반영한다.
- 리액트의 렌더링 최적화 방법
- 렌더링은 주로 상태의 변경으로 발생하게 된다.
- 따라서 다시 계산될 필요가 없는 상태들을 메모이제이션하여 새 객체가 되지 않도록 한다.
- memo : props가 바뀌지 않으면 해당 컴포넌트 리렌더링을 방지
- useMemo : 계산비용이 큰 값을 디펜던시가 바뀔때만 재 계산
- useCallback : 콜백함수를 디펜던시가 바뀔때만 새로 생성
- 리액트의 렌더링과 관련된 라이프사이클 메서드
- useState, useReducer등 초기 상태 설정
- 함수형 컴포넌트의 실행으로 JSX반환
- useEffect로 마운트 직후 실행 할 동작 설정 (componentDidMount)
- useEffect의 의존성 배열로 상태 또는 props 변경 후 실행 할 동작 설정(componentDidUpdate)
- useEffect의 return에 언마운트 직전 정리 작업 할 동작 설정 (componentWillUnmount)
- memo, useMemo, useCallback등으로 렌더링 여부 결정 및 최적화 (shouldComponentUpdate)
메모이제이션에 대한 나의 생각을 적어주세요.
리액트 메모이제이션은 불필요한 렌더링이 발생할 때 필요하다. 그럼 어떤 환경이 불필요한 렌더링일까? 사실 성능 저하가 거의 없는 경우 불필요한 렌더링일지라도 굳이 성능개선을 할 필요는 없다고 생각한다.
그래서 내가 생각했을 때 다음과 같은 상황에서 사용해야한다고 생각한다.
- 연산 비용이 매우 크다. (정렬, 필터링 반복렌더링 등등..)
- 자식에 props로 내려가는 함수나 객체가 바뀌는 경우
그럼 언제 불필요할까?
- 아주 가벼운 컴포넌트인 경우
- 리렌더링 빈도가 높지 않은 경우
- 상태가 자주 변경되는 경우
메모이제이션을 사용하지 않고 해결할 수 있는 방법은 무엇일까?
- 자주 바뀌는 컴포넌트와 그렇지 않은 컴포넌트의 분리
- 컨텍스트를 사용하는 경우 액션과 상태의 분리 (이번 과제의 ToastContext에도 적용함!)
- 비동기 처리 결과를 최상단에서 관리하지 않기
- 물론 최상단에서 필요한 경우엔 그렇게 사용해야한다.
- 하지만 필요한 요청을 컴포넌트 내부에서 처리하여 전체가 재렌더링되지 않도록 관리한다!
메모이제이션을 사용했을 때의 장점과 단점은 무엇일까?
-
장점
- 불필요한 재 렌더링을 막는다.
- 렌더링이 최소화 되므로 성능이 향상된다.
- 의도된 변경만 감지 가능하다.
-
단점
- 코드가 복잡해진다.
- 무분별하게 사용하는경우 메모리 사용량만 증가할 가능성이 있다.
- 잘못 사용하는 경우 의도하지 않은 캐싱이 될 수 있다.
- 열심히 최적화 했지만 시간대비 효과가 미미할 수 있다..?
- 위 경우는 메모이제이션의 문제가 아니라 구조적인 문제일 수 있다고 생각한다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
컨텍스트와 상태관리가 필요한 이유는 무엇일까?
- 컨텍스트는 전역적으로 필요한 데이터를 props로 매번 넘기지 않고 간단히 전달 가능하다.
- 상태관리는 컴포넌트간 상태 업데이트의 흐름을 명확하고 깔끔하게 관리하기 위해 필요하다.
컨텍스트와 상태관리를 사용하지 않으면 어떤 문제가 발생할까?
- 프롭스 드릴링이 발생할 가능성이 있다.
- 여러 컴포넌트에서 동일한 상태를 중복 정의하면 동기화 되지 않거나 일관성을 갖기 힘들다.
- 규모가 커질수록 데이터 흐름을 파악하기 어렵기 때문에 위 두 문제가 두드러진다.
컨텍스트와 상태관리를 사용했을 때의 장점과 단점은 무엇일까?
-
장점
- 전역 상태에 접근하기 쉽다.
- 디버깅이 용이하다.
- 일관된 상태 관리가 편리하다.
-
단점
- 굳이 복잡하지 않을 코드에 적용하는 경우 오히려 작업시간이 증가한다. (오버엔지니어링..)
- 학습 곡선이 존재한다. (코드는 나만 작업하는것이 아니기때문에.. 우리 팀원들이 사용 가능한지도 봐야한다..)
- 오히려 잘못 사용하는 경우 리렌더링이 확산될 수 있다.
컨텍스트와 상태관리를 사용하지 않고도 해결할 수 있는 방법은 무엇일까?
- 프롭스드릴링을 최소화 시킨다.
- 소규모 앱이라면 상태 끌어올리기 등을 사용한다.
- 보안에 이슈가 없는 데이터라면 URL쿼리를 사용하는것도 좋은 방법이다.
컨텍스트와 상태관리를 사용할 때 주의해야 할 점은 무엇일까?
- 하나의 컨텍스트에 너무 많은 데이터를 다루면 오히려 리렌더링 범위가 넓어지게 된다.
- 상태와 액션을 분리하여 불필요한 리렌더링을 줄인다! (이번 과제 내용!)
리뷰 받고 싶은 내용
import type { RouterInstance } from "../Router";
import type { AnyFunction } from "../types";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";
const defaultSelector = <T, S = T>(state: T) => state as unknown as S;
export const useRouter = <T extends RouterInstance<AnyFunction>, S>(router: T, selector = defaultSelector<T, S>) => {
// useSyncExternalStore를 사용하여 router의 상태를 구독하고 가져오는 훅을 구현합니다.
// 셀렉터 함수를 최적화
const shallowSelector = useShallowSelector(selector);
// 최적화 된 getSnapshot 함수
const getSnapshot = () => shallowSelector(router);
// getSnapshot에 최적화된 selector 함수를 전달하여 불필요한 리렌더링 방지
const state = useSyncExternalStore((onStoreChange) => {
router.subscribe(onStoreChange);
return () => {};
}, getSnapshot);
return state;
};
위는 이번 과제에 진행했던 useRouter를 구현하는 함수입니다. 처음엔 useSyncExternalStore에 구독 해지 함수를 딱히 넣어주지 않았습니다. router.unsubscribe라는 메서드가 딱히 존재하지 않았고, 이렇게 둬도 동작을 하기에.. 이해도가 부족한 상태에서 뭔가 더 건드리고 싶지 않아서 일단은 넘어갔습니다.
그런데 이렇게 두면 컴포넌트가 언마운트 되어도 useRouter를 호출했던 컴포넌트 수 만큼 메모리 낭비가 이뤄지는게 아닌가 하는 생각이 들었습니다. (그저 단순한 제 추측이긴합니다..)
그래서 Router에 빠져있던 unsubscribe를 추가하고 아래와 같이 수정했습니다.
const state = useSyncExternalStore((onStoreChange) => {
router.subscribe(onStoreChange);
return () => router.unsubscribe(onStoreChange);
}, getSnapshot);
위처럼 수정했지만 사실 딱히 체감되는건 없었습니다.. 그래서 어떻게 확인해볼 방법이 없을까? 고민했지만 딱히 방법을 찾지 못했습니다. 구독해지가 안되어있을때 정말 메모리 낭비가 이뤄지는지, 그걸 어떻게 확인할 방법이 있는지 궁금합니다.
과제 피드백
안녕하세요 휘린! 수고했습니다. 이번 과제는 React의 내장 훅들을 직접 구현해보면서 프레임워크가 어떻게 상태를 관리하고 최적화하는지 이론이 넘어 몸으로 깊이 이해하는 것이 목표였습니다.
이번 과제는 분명 정답이 있는 과제였지만 그 과정에서 체득하는 것이 목적인만큼, AI를 써보지 않고 고민 고민을 하면서 "그냥 안되는 걸까?"하는 의문으로 직접 실험해보신 과정이 정말 인상적이네요. 개발자라면 일단 해보고 탐구하는 정신이 필요하요. ㅎ
그리고 또한 Context를 상태와 액션으로 분리하여 불필요한 리렌더링을 방지해봐야 하는 걸 느껴본 것도 좋은 인사이트라 생각합니다. 실무적인 측면에서는 Context보다는 상태관리 도구를 직접 쓰는 편인데 그러한 상태관리들이 이렇게 상태과 액션을 분리해서 다루고 있고 개념적으로도 상태과 액션을 분리해서 생각하면 조금 더 아키텍쳐를 선명히 바라 볼 수 있죠.
더 좋은 코드가 없을지 생각해봐야겠다 생각하는 태도 좋네요. 이번 클린코드 챕터에서 그 인사이트들을 마음껏 실현해보기를 바래요.
이번 과제를 통해 React가 제공하는 편리한 API들이 내부적으로 어떤 문제를 해결하고 있는지 몸소 체험하셨을 거예요. 특히 "메모이제이션은 만능이 아니다"라는 깨달음과 함께 구조적 개선의 중요성을 인식하신 점이 훌륭합니다.
Q) useRouter의 구독 해지와 메모리 누수 확인 방법
네, 구독 해지를 하지 않으면 메모리 누수가 발생합니다. 한줄의 코드로 그걸 알기는 어렵고 for문을 많이 돌려본다음 Chrome DevTools 활용해서 컴포넌트를 여러 번 마운트/언마운트한 후 다시 Snapshot을 촬영하여 메모리가 어떻게 바뀌는지 확인해보세요.
그게 아니라면 단순하게 subscribe에 로그를 찍어본다음 컴포넌트를 unmount를 해도 로그가 남아 있다면 누수 동작이 발생하는 걸 확인할 수 있을거에요.
수고하셨습니다. 깊이를 탐구하는 지난 3주간의 Deep dive체험이 즐거운 시간과 깊이를 탐구하는 방법에 대한 시야와 계기가 되었기를 바래요. 앞으로 하게 될 클린코드 챕터도 화이팅입니다!