과제 체크포인트
배포 링크
PR만이 유일하게 이 잔혹한 세계에 저항할 방법이다!!
기본과제
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 개선
과제 셀프회고
React, Beyond the Basics....
기초를 넘어서기 위한 3주차 과제가 끝났다. 1, 2주차에서 JS 로 SPA 를 구현하면서 3주차가 되게 힘들거라고 예상했다.
리액트를 써보지 않은 입장에서 React hooks, React 메모이제이션, ... 험난했다. (+ Typescript 또한 안써봤다...)
그래도 1, 2 주차에 예방주사를 좀 맞아 놓은 덕에 내 예상보다는 조금은 수월하게 진행했다. 그렇다고 절대 쉽다는 말은 아니다.
1주였지만 리액트라는 것을 조금은 알아간거에 만족하고있다. 더 알아갈 일만 남았다.
그리고 양질의 과제 준비하시느라 고생 많으셨습니다. 준일 코치님!
"To infinity… and beyond!"
TIL 링크
- equalities 만들기
- React Hooks: useRef
- React Hooks: useMemo
- React Hooks: useCallback
- React Hooks: useShallowState
- React Hooks: useAutoCallback
- Higher-Order Component - HOC: memo
- 활용해보기
기술적 성장
새로 학습한 개념들은 너무나도 많았다. 어쩌면 전부 다 새로 학습한 개념이라해도 무방하다. 나는 최대한 덜 움직이고 효율적이게 효과를 내는 것을 좋아하기에 메모이제이션 이 제일 기억에 남는다.
memo (컴포넌트 메모이제이션), useMemo (값을 메모이제이션), useCallback (함수를 메모이제이션)
- 불필요한 렌더링을 방지
- 복잡한 계산과 큰 데이터 세트를 다룰 때 이전 결과를 재사용함으로써 비용을 줄일 수 있음 ⬇️ ⬇️ ⬇️ ⬇️ ⬇️
- 컴포넌트 예측 가능성을 높임
- 버그를 줄임
- 확장성과 유지보수성 증대
메모이제이션에 대한 생각은 아래 항목에 정리하겠다.
자랑하고 싶은 코드
커밋 링크: refactor: ToastProvider 리팩토링
/* eslint-disable react-refresh/only-export-components */
import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react";
import { createPortal } from "react-dom";
import { Toast } from "./Toast";
import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer";
import { debounce } from "../../utils";
import { useCallback, useMemo } from "@hanghae-plus/lib/src/hooks";
type ShowToast = (message: string, type: ToastType) => void;
type Hide = () => void;
// Context 분리
// 상태 Context
const ToastStateContext = createContext<{
message: string;
type: ToastType;
}>({ ...initialState });
// 이벤트 Context
const ToastCommandContext = createContext<{ show: ShowToast; hide: Hide }>({
show: () => null,
hide: () => null,
});
const DEFAULT_DELAY = 3000;
// Hook 수정
export const useToastCommand = () => useContext(ToastCommandContext);
export const useToastState = () => useContext(ToastStateContext);
export const ToastProvider = memo(({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(toastReducer, initialState);
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
const visible = state.message !== "";
// 메모이제이션
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
// 메모이제이션
const showWithHide: ShowToast = useCallback(
(...args) => {
show(...args);
hideAfter();
},
[show, hideAfter],
);
// props 로 넘길 값 (메모이제이션)
const commandValue = useMemo(
() => ({
show: showWithHide,
hide: hide,
}),
[showWithHide, hide],
);
return (
// Provider 중첩
<ToastCommandContext.Provider value={commandValue}>
<ToastStateContext.Provider value={state}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext.Provider>
</ToastCommandContext.Provider>
);
});
위 이미지는 Gemini CLI 를 통해서 리팩토링을 위해서는 어떻게 해야하는지에 대한 구현 힌트를 얻은 부분이다. (+ 3팀분들의 도움) 처음부터 끝 까지 스스로 하진 못했지만 내가 생각하기엔 배운 내용을 정리 및 활용하기에 좋았다. 좋은 테스트 항목을 넣어주신거 같다. 약간 엑기스 같은 느낌이 강하다.
개선이 필요하다고 생각하는 코드
import type { AnyFunction } from "../types";
import { useCallback } from "./useCallback";
import { useRef } from "./useRef";
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
// 매 렌더링마다 latestFnRef.current 값이 업데이트됩니다.
const latestFnRef = useRef(fn);
latestFnRef.current = fn;
// dispatcherRef: "latestFnRef을 여는 행위"를 하는 함수를 담을겁니다. 해당 함수는 자체는 변하지 않음.
const dispatcherRef = useRef<(...args: any[]) => any>();
// "latestFnRef을 여는 행위"를 정의합니다.
// 이 함수는 매 렌더링마다 새로 생성됩니다.
// 따라서 항상 최신 스코프의 `latestFnRef`를 참조합니다.
const dispatcher = (...args: any[]) => {
return latestFnRef.current(...args);
};
// 새로 생성된 dispatcher 함수를 dispatcherRef의 내용물로 업데이트.
dispatcherRef.current = dispatcher;
// autoCallback: 이 함수의 참조는 절대 변하면 안 됩니다.
// autoCallback은 오직 "dispatcherRef를 열어서 그 안의 dispatcher를 실행"하는 것입니다.
const autoCallback = useCallback((...args: any[]) => {
// 이 함수가 호출될 때, 그 시점의 dispatcherRef를 열고,
// 그 안에 있는 최신 dispatcher를 실행합니다.
return dispatcherRef.current?.(...args);
}, []); // 참조 고정
return autoCallback as T;
};
두 개의 ref 를 사용하여 코드의 복잡성을 증가 시킴.
학습 효과 분석
가장 큰 배움은 전반적인 리액트의 훅을 알게된 점이다. 과제지만 조금은 리액트를 뜯어본 느낌이였고 리액트가 어떤 방식으로 실행하는지 알아가는 과정 덕에 다음 과제 또는 다른 프로젝트에서 리액트를 써본다면 이번보다는 더 친해지지 않을까 싶다.
추가적으로는 Typescript 도 학습을 해야한다고 느꼈다. 어떻게 쓰겠다고 명시한다는게 좋지만 과제를 하면서 살짝 귀찮다고 느껴지는 경우도 많았다. 아직 손에 익지 않아서라고 생각이 든다. 익숙해지면 정말 개발에 많이 유용할 것 같다.
과제 피드백
e2e 테스트에서 맨 마지막 항목이 제일 인상 깊습니다. 아무래도 과제의 마침표를 찍는 테스트이기도 하지만 위에서도 얘기했듯이 3 주차 과제의 주제를 명확히 담고 있다고 개인적으로 생각합니다. (내가 만들고 내가 활용한다.) 하지만 심화 까지 가야지만 누릴 수 있는 테스트이기에 기본에서도 비슷한 포맷의 테스트가 있다면 좋을 것 같습니다. (기본에도 해당 뉘앙스의 테스트가 있는데 제가 못 알아챘을 수 있습니다. 그렇다면 죄송합니다.)
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
렌더링이 발생 되는 상황
- 최초 렌더링: 애플리케이션이 처음 로드 될 때
- 상태 변경: 컴포넌트 내에서
useState나useReducer의 setter 함수가 호출되어 상태가 변경 될 때 - 부모 컴포넌트의 리렌더링: 특별한 최적화 처리를 하지 않았다면 모든 자식 컴포넌트 리렌더링
렌더 와 커밋
- 렌더: 계획 세우기 (DOM 변경 없음)
- 리렌더링 트리거: state 변경등으로 리렌더링 시작
- Virtual DOM 생성
- 커밋: DOM 에 변경 사항 적용
- 재조정(Reconciliation): 새로 만들어진 Virtual DOM과 이전 렌더링 때 만들었던 Virtual DOM을 비교 (Diffing)
- 최소한의 변경 사항 계산: Diffing 알고리즘을 통해서 계산
- DOM 업데이트
📍정리
- State나 Props가 변경되면 컴포넌트의 리렌더링이 시작됩니다.
- React는 컴포넌트를 실행하여 Virtual DOM이라는 가상의 UI 구조를 만듭니다.
- 이전의 Virtual DOM과 비교하여 바뀐 부분만 찾아냅니다 (Reconciliation).
- 바뀐 부분만 실제 DOM에 적용하여 성능을 최적화합니다.
메모이제이션에 대한 나의 생각을 적어주세요.
성격, 성향적으로 효율성을 추구하는 나에게는 되게 신선하게 다가온 개념이었다. 먼저, 리액트가 해당 개념을 첫 도입한게 18년 10월이라고 한다. 그 뒤로 Hooks가 공식 도입되면서 코드 구조가 깔끔해지고 입문 장벽이 낮아졌다고 한다. 물론 남발하면 좋지 않지만
- 불필요한 캐싱 비용
- 의존성 관리 어려움
- 코드 가독성 저하
적재적소에 사용하면 성능 최적화는 보장이 되니까 굉장히 매력적이다. 특정 상황에 따라 사용하는 Hooks 가 달라지는 것도 좋다. 개발자에게 맥가이버 칼을 쥐어준 느낌이랄까... 과제로 인해 처음 접해본 개념이니까 아직은 사용 빈도가 높진 않지만 나중엔 꽤나 밥 먹듯이 사용할 수 있을 것 같은 예감이 든다.
내가 구현한 hooks
// useMemo.ts
import type { DependencyList } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "./useRef";
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// 단 한 번만 초기화. 컴포넌트가 사라지기 전까지 유지.
const memoizedRef = useRef<{ deps: DependencyList; result: T } | null>(null);
if (memoizedRef.current === null || !_equals(memoizedRef.current.deps, _deps)) {
// 만약 새로운 값으로 업데이트 해야한다면 랜더링을 유발하지 않는 방식으로..
memoizedRef.current = { deps: _deps, result: factory() };
}
// 마지막엔 저장된 result 반환
return memoizedRef.current.result;
}
// useCallback.ts
import type { DependencyList } from "react";
import { useMemo } from "./useMemo";
export function useCallback<T extends Function>(factory: T, _deps: DependencyList) {
const callbackFunc = useMemo(() => factory, _deps);
return callbackFunc as T;
}
// memo.ts
import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
// 새로운 컴포넌트 정의, 렌더링 할 때 props 넘겨줌.
const memoizedComponent: FunctionComponent<P> = (props) => {
const cache = useRef<{ preProps: P; result: React.ReactElement } | null>(null);
// 최초 렌더링 또는 props 변경 시 컴포넌트 호출하여 렌더링 값 설정
if (cache.current === null || !equals(cache.current.preProps, props)) {
const newResult = Component(props);
cache.current = { preProps: props, result: newResult };
}
return cache.current.result;
};
return memoizedComponent;
}
</div>
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
리뷰 받고 싶은 내용
- 개선이 필요한 코드에서도
useAutoCallback를 언급했습니다. latestFnRef와 dispatcherRef라는 두 개의 ref를 사용하여 구현했습니다. 주석으로 표기를 해놨으나 패턴을 쉽게 이해하기는 어려울 것으로 보입니다. 구조를 명확하지만 쉽게 파악할 수 있는 방법에 대해 리뷰 부탁드리겠습니다. 감사합니다.
useAutoCallback
import type { AnyFunction } from "../types";
import { useCallback } from "./useCallback";
import { useRef } from "./useRef";
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
// 매 렌더링마다 latestFnRef.current 값이 업데이트됩니다.
const latestFnRef = useRef(fn);
latestFnRef.current = fn;
// dispatcherRef: "latestFnRef을 여는 행위"를 하는 함수를 담을겁니다. 해당 함수는 자체는 변하지 않음.
const dispatcherRef = useRef<(...args: any[]) => any>();
// "latestFnRef을 여는 행위"를 정의합니다.
// 이 함수는 매 렌더링마다 새로 생성됩니다.
// 따라서 항상 최신 스코프의 `latestFnRef`를 참조합니다.
const dispatcher = (...args: any[]) => {
return latestFnRef.current(...args);
};
// 새로 생성된 dispatcher 함수를 dispatcherRef의 내용물로 업데이트.
dispatcherRef.current = dispatcher;
// autoCallback: 이 함수의 참조는 절대 변하면 안 됩니다.
// autoCallback은 오직 "dispatcherRef를 열어서 그 안의 dispatcher를 실행"하는 것입니다.
const autoCallback = useCallback((...args: any[]) => {
// 이 함수가 호출될 때, 그 시점의 dispatcherRef를 열고,
// 그 안에 있는 최신 dispatcher를 실행합니다.
return dispatcherRef.current?.(...args);
}, []); // 참조 고정
return autoCallback as T;
};
과제 피드백
정석님 배포 링크에 감동을 받았네요. 그나저나 gemini cli 결과를 보는 부분에서는 저 파랑색은 참 색이 화려하네요.
전에 말했던것 처럼 이전 항해를 하셨던 분들 중 정말 많은 분들 중에 리액트를 사용해보지 않았던 분들이 계셨지만 이렇게 문서까지 남기시면서 하셨던 분들은 정말 없었어요. 너무너무 잘해주고 계십니다 :+1 이런 부분들이 이제 추후에 리액트를 활용해 과정을 해나가시는데 있어서 적절하게 요소요소마다 API를 고르고 사용하는데 큰 도움이 될 것 같네요.
질문 주신거 바로 살펴보면요.
개선이 필요한 코드에서도 useAutoCallback 를 언급했습니다. latestFnRef와 dispatcherRef라는 두 개의 ref를 사용하여 구현했습니다. 주석으로 표기를 해놨으나 패턴을 쉽게 이해하기는 어려울 것으로 보입니다. 구조를 명확하지만 쉽게 파악할 수 있는 방법에 대해 리뷰 부탁드리겠습니다. 감사합니다.
우선 지금 구현을 보면 중간 과정에 어떤 의도가 있었는지 제가 파악은 못했지만 지금의 구조를 수정한다면 dispatcherRef가 불필요한 것 같아요! 바로 latestFn을 호출 하는 방식으로 하면 충분할 것 같아요. 혹시 궁금하셨던 부분이나 의도하셨던 부분이 제가 답변 드린 부분과 달랐다면 꼭 다시 질문주세요! (지훈님 코드리뷰 하시면서 본 것 같네요 ㅋㅋㅋ 동일한 의견입니다)
지치신거 아니죠? 정석님 지켜보겠습니다.. 다음 주도 화이팅입니다!