과제 체크포인트
https://minjaeleee.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 개선
과제 셀프회고
기술적 성장
평소 참조 동일성에 대해서 깊이 이해하지 않았습니다.(= 그동안 대충 아는 ‘척’만 하고 중요한 사실은 스윽 넘겼다…) 메모이제이션이나 hooks에서 사용하는 의존성 배열에 해당하는 값들은 항상 참조 동일성을 유지하게 되고 렌더링 부분에서 즉시 비용 감소로 여겼기 때문입니다. 사실 이 부분을 유심히 고민해보면 저는 리액트의 얕은 비교를 통한 렌더링 원리에 대해서 부족했던 것 같습니다. 따라서, 다시 이 부분을 따라가서 정리해보았습니다.
참조 동일성이란?
function MyComponent() {
const config = { darkMode: true };
useEffect(() => {
console.log("config changed!");
}, [config]);
return null;
}
- 이 컴포넌트가 리렌더링될 때마다
config객체는 새로 만들어집니다. - 값은 같아 보여도 참조가 다르기 때문에 useEffect는 매번 실행이 됩니다. ⇒ 이것이 바로 “모든 객체는 리렌더링 때 재생성되고, 참조가 다르다.” 라는 의미입니다.
참조 동일성을 유지하지 못하는 것이 어떤 문제를 유발하나?
과제를 진행하면서 memo, useMemo, useAutoCallback 훅을 작성한 코드를 보면 이해할 수 있습니다.
// memo.ts
import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";
// memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 렌더링을 방지합니다.
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals): FunctionComponent<P> {
// 메모이제이션된 컴포넌트 생성
const MemoizedComponent: FunctionComponent<P> = (props) => {
// 1. 이전 props를 저장할 ref 생성
const memoizedRef = useRef<{
prevProps: P | null;
rendered: ReturnType<FunctionComponent<P>> | null;
}>({
// 이전 props 저장
prevProps: null,
// 이전 JSX 저장
rendered: null,
});
// 3. eqauls 함수를 사용하여 props 비교 - 새롭게 컴포넌트 렌더링X
if (memoizedRef.current.prevProps !== null && equals(memoizedRef.current.prevProps, props)) {
return memoizedRef.current.rendered!;
}
// 4. props가 변경된 경우에만 새로운 렌더링 수행
memoizedRef.current.prevProps = props;
memoizedRef.current.rendered = Component(props);
return memoizedRef.current.rendered;
};
return MemoizedComponent;
}
memo는 props를 shallowEqual(얕은 비교)를 수행하고 다르면 리렌더링을 하고 같으면 리렌더링을 막습니다. 그런데 config 객체는 매번 새로 생성되므로 얕은 비교에서 false를 반환하고 리렌더링을 하게 됩니다.
결국엔 컴포넌트는 매번 리렌더링되고 memo의 효과는 없어집니다. 이러한 방식이 반복되고 구조가 비대해지다보면 불필요한 렌더링과 성능저하로 이어지겠죠.
참조 동일성을 안정화하는 방법
참조 동일성을 안정화하는 방법의 두 가지를 배웠습니다.
첫 번째, useRef를 사용하는 것입니다. 값이 변하지 않거나, 값이 바뀌어도 리렌더링을 유발하지 않아야 할 때 사용할 수 있습니다. 다만 객체, 함수, DOM 노드 등 “값은 유지하지만 UI와 무관”한 상태에만 사용해야 합니다. 이는 리액트 공식문서 useRef 사용 주의사항에도 볼 수 있습니다.
두 번째, 모든 의존성 값들을 useMemo나 useCallback으로 감싸 안정화하는 것입니다. 이번 과제의 ToastProvider를 이렇게 값을 안정화하여 사용했습니다. createActions 함수를 매번 재실행하지 않고 useMemo를 사용하여 참조의 안정성을 확보했고, hideAfter 함수 역시 hide에 의존한 메모이제이션을 해주어 일관된 참조를 보장하도록 구성했습니다.
// ToastProvider.tsx
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 = useAutoCallback((message: string, type: ToastType) => {
show(message, type);
hideAfter();
});
const commandValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]);
return (
<ToastCommandContext.Provider value={commandValue}>
<ToastStateContext.Provider value={stateValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext.Provider>
</ToastCommandContext.Provider>
);
});
이런 과정들을 통해서 얕은 비교에서 참조 동일성이 왜 중요한지를 이해하고, 리액트의 렌더링 원리에 대해서 더 깊게 이해할 수 있었습니다.
자랑하고 싶은 코드
얕은 비교 - 객체 순수 비교
처음에는 객체 비교를 하기 위해서 아래와 같이 type으로 구분을 했습니다. 물론, 테스트는 통과했지만 사실 자바스크립트에서 typeof value === “object” 라는 문자열로 반환되는 경우는 생각보다 많습니다. 배열, function, null, new Date(), new RegExp(), document.body …
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key) || (a as Record<string, unknown>)[key] !== (b as Record<string, unknown>)[key])
return false;
}
return true;
}
따라서 isObject 함수로 순수 객체일 경우를 한 번 판단하고, Object.keys()로 직접 가진 key만 추출을 하고, Object.prototype,hasOwnProperty.call()로 해당 객체가 직접 소유하고 있는지 다시 판단하는 로직으로 변경하여 객체 여부에 대한 엣지 케이스를 보완하고 정밀도를 높였습니다.
const isObject = (val: unknown): val is Record<string, unknown> => {
return typeof val === "object" && val !== null && !Array.isArray(val);
};
if (isObject(a) && isObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (a[key] !== b[key]) return false; // 얕은 비교
}
return true;
}
깊은비교 리팩토링
deepEquals 함수를 작성할 때에는, shallowEquals에서 depth가 추가된 배열이나 객체일 경우에만 재귀적으로 모든 depth에 대한 탐색 및 비교가 필요했습니다. 따라서, shallowEquals 함수를 가져와 이 부분을 추가해 주었습니다.
// 리팩토링 전
export const deepEquals = (a: unknown, b: unknown): boolean => {
// 1. 기본 값 비교
if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true;
// 2. 배열 비교
if (Array.isArray(a) && Array.isArray(b)) {
// 배열의 length로 비교
if (a.length !== b.length) return false;
// 재귀적으로 모든 depth의 요소를 비교
for (let i = 0; i < a.length; i++) {
if (!deepEquals(a[i], b[i])) return false;
}
return true;
}
// 배열이 아닌데 한쪽만 배열이면 false
if (Array.isArray(a) !== Array.isArray(b)) return false;
// 3. 순수 객체 비교
const isObject = (val: unknown): val is Record<string, unknown> => {
return typeof val === "object" && val !== null && !Array.isArray(val);
};
if (isObject(a) && isObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!deepEquals(a[key], b[key])) return false;
}
return true;
}
// 기본 타입인데 === 실패했으면 false
return false;
};
작성하고 보니, 기존 비교는 shallowEquals 함수를 실행시켜 비교시키고 재귀적으로 탐색 및 비교가 필요한 요소들에 대해서만 deepEquals 함수로 깊은 비교를 실행시키도록 리팩토링하여 불필요한 코드를 shallowEquals 함수로 재활용하면서 가독성을 높였습니다.
import { shallowEquals } from "./shallowEquals";
export const deepEquals = (a: unknown, b: unknown): boolean => {
if (shallowEquals(a, b)) return true;
// 배열인 경우 재귀 비교
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEquals(a[i], b[i])) return false;
}
return true;
}
const isObject = (val: unknown): val is Record<string, unknown> =>
typeof val === "object" && val !== null && !Array.isArray(val);
// 순수 객체 재귀 비교
if (isObject(a) && isObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!deepEquals(a[key], b[key])) return false;
}
return true;
}
return false;
};
개선이 필요하다고 생각하는 코드
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals): FunctionComponent<P> {
// 메모이제이션된 컴포넌트 생성
const MemoizedComponent: FunctionComponent<P> = (props) => {
// 1. 이전 props를 저장할 ref 생성
const memoizedRef = useRef<{
prevProps: P | null;
rendered: ReturnType<FunctionComponent<P>> | null;
}>({
// 이전 props 저장
prevProps: null,
// 이전 JSX 저장
rendered: null,
});
// 3. eqauls 함수를 사용하여 props 비교 - 새롭게 컴포넌트 렌더링X
if (memoizedRef.current.prevProps !== null && equals(memoizedRef.current.prevProps, props)) {
return memoizedRef.current.rendered!;
}
// 4. props가 변경된 경우에만 새로운 렌더링 수행
memoizedRef.current.prevProps = props;
memoizedRef.current.rendered = Component(props);
return memoizedRef.current.rendered;
};
return MemoizedComponent;
}
memo 컴포넌트에서 equals 함수를 사용하여 props를 비교할 때 "새롭게 컴포넌트 렌더링을 시키지 않는다." 라는 의도로 Component 함수를 호출하지 않고 return해버렸는데, 이렇게 Component(props)를 조건부로 호출하는 방식이 문제가 생길 수도 있겠다는 생각이 들었습니다. 예를 들어서, 아래와 같이 memo로 감싸진 컴포넌트를 사용할 때 내부에서 hooks를 사용하게 되면 equals를 통한 변경이 일어나지 않았음에도 Component(props) 함수를 생략해버리니 React의 hook 규칙 위반 가능성이 될 수 있을것 같습니다. 이 부분에 대한 개선이 필요해보입니다!
const MyComponent = memo(function MyComponent(props) {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("effect 실행됨");
}, []);
return <div>{count}</div>;
});
학습 효과 분석
리액트의 렌더링 과정과 원리에 대해서 심층 깊게 이해한 시간이었습니다. 다만, 꽤 많은 내용을 학습했기 때문에 제 지식으로 온전히 습득하기 위한 과정을 다시 한번 스스로 거쳐야겠습니다.
리액트의 렌더링 과정과 원리에 대해서는 리액트의 공식문서와 도서, 문서 등으로 학습했고 면접과 같은 중요한 날 전에는 이를 달달 외우곤 했습니다. 하지만, 직접 학습하고 구현해보니 자연스레 외워지게 되었습니다. 예를 들어서, 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 업데이트를 방지합니다.
이때 2주차 때 구현한 updateElement와 비교하면 주요 규칙은 다음과 같은 것들이 있습니다.
- 타입(type)이 다르면 업데이트
- 예) ```<div> → <span>```
- 이전 노드 제거 + 새로운 노드 추가
- 타입이 같으면 속성만 비교
- 예)) ```<div className=”a”> → <div className=”b”>```
- DOM은 재사용되고, className만 업데이트
- Key 기반 리스트 비교
- key를 기준으로 요소 재사용 여부를 판단
3. Commit Phase (실제 DOM 반영 단계) 앞서 수집된 변경사항을 기반으로 실제 DOM을 조작합니다.
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션에 대한 생각을 먼저 표현하기 전에, 배경이 되는 리액트의 렌더링에 대한 생각을 먼저 정리하겠습니다.
리액트의 렌더링에 대한 나의 생각
리액트의 렌더링은 단순하고 예측 가능하다는 점에서 탁월하지만, 렌더링마다 함수 전체가 재실행되고 객체, 함수, 배열 등 모든 참조가 새로 만들어진다는 구조적인 특성은 개발자가 깊이 고민하고 다루어야할 부분이라고 생각합니다. 그래서 저는 이 구조를 단점으로 보지 않는 대신 변경을 감지하는 기준이 참조이기 때문에 개발자는 참조를 유지할 책임이 있다고 생각합니다. 즉, 의미있는 리렌더링을 의도할 책임이 있다고 생각합니다. 그렇기 때문에 리액트의 렌더링 원리를 정확하게 이해하는 것은 값의 참조를 안정화해서, 의미있는 UI의 변화가 반영될 수 있는 것이라고 생각합니다.
메모이제이션
먼저, 메모이제이션이란 어떤 연산의 결과를 캐싱해 두었다가, 동일한 입력이 들어오면 재계산하지 않고 캐시된 결과를 재사용하는 최적화 기법입니다.리액트의 메모이제이션은 제가 앞서 설명한 리액트의 렌더링의 구조적 단점을 보완할 수 있는 중요한 기술입니다.
단, 앞서 제가 얘기한 “리엑트의 렌더링 원리를 정확하게 이해한 상태에서 구현할 때” 라는 전제가 붙습니다. React.memo, useMemo, useCallback 등은 참조의 동일성을 기반으로 동작하기 때문에 참조의 동일성을 지켜주지 않으면 오히려 불필요한 리렌더링, 불필요한 실행, 성능 저하로 이어지기 때문입니다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
상태 관리란?
상태(state)는 UI가 반응해야 하는 데이터의 현재 모습을 의미합니다. 이 상태를 언제, 어디서, 어떻게 관리할지를 결정하는 것이프론트엔드 앱 설계에서는 가장 중요한 결정 중 하나입니다. 현재 상태관리 라이브러리로는 Redux, Zustand, Recoil 등이 주류를 이루고 있으며 React의 상태관리는 컴포넌트 내부에서 useState를 다루는 것이 일상이 되었습니다. 하지만, 상태의 복잡도보다 더 중요한 것은 “이 상태를 누가 알아야 하는가?” 즉, 컨텍스트의 경계 설정입니다.
컨텍스트의 역할
React의 Context는 컴포넌트 트리 어디에서든 데이터를 공급하고 구독할 수 있게 해줍니다. 그러나, Context 내부 상태가 자주 바뀌면 그 상태를 구독 중인 모든 컴포넌트가 불필요하게 렌더링이 발생됩니다. 따라서, 컨텍스트는 공유 상태의 목적으로 쓰는 것이 가장 적절하다고 생각합니다.
공유 상태(Shared State)
공유 상태란 둘 이상의 컴포넌트가 함께 사용하는 상태를 의미합니다. 앱 전역에서 일관된 정보를 관리하는 전역 상태의 목적과는 다르게 특정 UI흐름이나 기능 내에서 여러 컴포넌트 간 데이터를 공유하고 싶을 때, 제한된 범위 내에서 데이터를 공유하고 싶을 때 사용하는데 목적이 있습니다.
결론
따라서 상태 관리의 목적을 이해하고 설계하는 것이 중요하다고 생각이 됩니다. 공유 상태가 필요할 때는 컨텍스트, 전역 상태가 필요할 때는 전역 상태를 고려하는 것이 장,단점과 목적에 따른 적절한 사용 방법이라고 생각합니다.
리뷰 받고 싶은 내용
- 메모이제이션이 필요한 부분을 어떻게 평가하며 사용하고 계신지가 궁금합니다!
- 과제를 진행하며 메모이제이션을 위한 참조 동일성을 보장하는 구조를 만들다 보니 depth가 깊어지거나 공유 상태 구조가 복잡해질수록 가독성도 떨어지고, 캐시 관련 유지비용이 더 생길 것 같습니다. 리액트에서도 최적화에 의한 확실한 이점이 있는 경우에만 사용할 것으로 명시가 되어 있는데.. 현업에서는 적용하기 애매한 포인트들이 여럿 있었는데, 주로 코치님께서는 어떤 상황에서 메모이제이션이 필요한 상황이라고 판단하고 의사결정했는지 간단하게라도 경험을 들려주실 수 있을까요?
과제 피드백
민재님 고생하셨습니다 ㅎㅎ 다른분들 PR에도 좋은 의견 많이 남겨주시고 리뷰 보니 명확한 기준을 가지고 잘 구현해주셨습니다 shallowEquals 접근도 좋은 접근이였습니다. 성능 최적화를 고려하고 작성해주신 것 같더라구요. 일정 자체가 빠듯하고 순식간에 흘러가지만 말씀해주신것처럼 회고를 작성하고 이런 배경, 고민한 흔적들을 함께 나눌 수 있는 시간이 있으면 확실히 좋을 것 같아요. 저희가 따로 뭔가 이런 시간을 추후에 마련할 수 있을지 이야기를 나눠봐야 하지만 어렵다면 팀 내에서라도 꼭 이런 부분 주도적으로 해보셔도 좋을 것 같습니다 ㅎㅎ
질문해주셨던 내용 답변 드려보면요!
이 부분은 아마 여러 고민을 하셨기 때문에 저와 비슷할 것 같은데, 과거에는 모든 코드에 메모이제이션을 하는 파와 절대 하지않는다 파(?)로 나뉘어져서 막 논쟁이 있었던 것 같은데, 최근에는 그런 사람들이 많이 사라진 것 같아요. 일반적으로 저희가 따르는 최적화 논리대로 섣부른 최적화는 하지 않되, 성능적으로 이슈가 발생하는 지점이 생긴다면 그 지점에 대해서 파악한 뒤 그 부분만 국지적으로 처리하는게 적절한 사용 방법인 것 같아요. 내부적으로 이미 처리가 어느정도 되어있고 최근 들어서는 모든걸 해결해주는 관점에서 개발을 하는 것은 아니지만 컴파일러의 발전도 지속되고 있잖아요. 필요한 부분이 발견되면 그 때 적용하는 형태로 하면 좋을 것 같습니다.
고생하셨고 다음 주도 지금처럼 팀원들과 함께 잘 진행해주시면 좋을것 같습니다. 고생하셨어요!