과제 체크포인트
배포 링크
https://hanghae-plus.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 개선
과제 셀프회고
기술적 성장
ToastProvider로 인한 ProductCard 리렌더링 해결하기 (구현 과정에서의 기술적 도전과 해결)
리렌더링 문제를 해결하기 위해 크게 3가지에 대해 고민을 하고 접근을 해보았습니다.
- (1) Context API가 아닌 외부 상태 관리 도구 도입
ProductCard가 리렌더링 문제를 분명히 해결할 수는 있으나, Context API → 전역 상태 도구로의 전환이 필요하기 때문에 추가적인 리소스를 더해야하는 최후의 방법이라고 생각하여 다른 방안들을 고려하였습니다.
- (2) children에 대한 메모이제이션 적용해보기
Memoization을 적용하면 Context API에서 값을 가져오지 않는 컴포넌트에 대해서는 리렌더링을 방지할 수는 있으나, 실제로 children에 대한 Memoization은 효과가 없었습니다.
// ProductCard.tsx
export function ProductCard({ onClick, ...product }: Product & { onClick: (id: string) => void }) {
const addCart = useCartAddCommand();
// ...
}
// useCardAddCommand.ts
export const useCartAddCommand = () => {
const toast = useToastCommand();
return useAutoCallback((product: Product, quantity = 1) => {
addToCart(product, quantity);
toast.show("장바구니에 추가되었습니다", "success");
});
};
원인을 파악해보았을 때, ToastProvider의 state가 변경되면서 Toast를 구독하는 모든 컴포넌트에 리렌더링이 발생하면서 ProductCard도 리렌더링이 발생한다는 것을 인지하게 되었습니다. 그래서 이 문제를 해결하기 위해서는 “장바구니 담기” 버튼을 클릭했을 때 변경되는 Toast 메시지의 State와 Command를 분리해야한다고 생각하게 되었습니다. 이 과정에서 useToastCommand와 useToastState가 분리된 의도를 파악할 수 있었습니다.
- (3) Context를 분리하기
Command를 공유하는 Context와 State를 공유하는 Context를 다음과 같이 분리하였습니다.
<ToastCommandContext value={command}>
{children}
<ToastStateContext value={state}>{visible && createPortal(<Toast />, document.body)}</ToastStateContext>
</ToastCommandContext>
추가로 ToastProvider가 state 업데이트로 리렌더링될 때, command 객체 또한 함께 재생성되며 이를 구독하는 컴포넌트까지 리렌더링되는 문제가 있었습니다. 이를 방지하기 위해 command 객체 전체를 useMemo로 캐싱하고, 내부의 show, hide 메서드는 useCallback으로 참조를 고정하여 불필요한 리렌더링을 막았습니다.
자랑하고 싶은 코드
처음에는 (AS-IS)typeof와 여러 조건문을 사용해 각 타입을 일일이 비교하는 방식으로 구현했지만, 리팩토링을 통해 불필요한 분기를 제거하고, isObject 조건으로 핵심 비교 로직을 단순화하였습니다. 또한 자바스크립트에서 배열은 객체이므로, 배열에 대한 분기문과 로직을 추가하지 않았습니다.
AS-IS
// AS-IS
export const deepEquals = (a: unknown, b: unknown): boolean => {
if (a === b) {
return true;
}
if (typeof a === "object" && typeof b !== "object") {
return false;
}
if (typeof a !== "object" && typeof b === "object") {
return false;
}
if (isObject(a) && isObject(b)) {
// ...
}
return a === b;
};
TO-BE
export const deepEquals = (a: unknown, b: unknown): boolean => {
if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
const uniqueKeys = new Set([...aKeys, ...bKeys]);
for (const key of uniqueKeys) {
if (isObject(a[key]) && isObject(b[key])) {
return deepEquals(a[key], b[key]); // 재귀적으로 하위 속성들에 대한 비교
}
if (a[key] !== b[key]) return false;
}
return true;
}
return a === b;
};
재귀 함수는 알고리즘 문제 풀이할 때 밖에 사용해본 경험이 없는데, 실제로 깊은 비교에서 하위 속성들에 대한 비교를 재귀적으로 표현함으로써 깊은 비교를 더욱 명시적으로 나타낸 것 같아서 만족스럽습니다.
개선이 필요하다고 생각하는 코드
개선이 필요하다고 생각한 코드는 특별히 없습니다. 이전 과제와 비교하여 수월했던 것 같습니다!
학습 효과 분석
이번 과제를 진행하면서 스스로가 궁금증을 가지고 깊이있게 학습하는데 중요함을 배울 수 있었습니다.
이전까지는 과제를 수행하는데 있어 AI를 기반으로 요구사항을 구현하고 이를 이해하는데 급급했더라면, 이번 과제에서는 이전과 다른 방식으로 과제를 진행해보았습니다.
- 주석을 이용해 수도 코드를 작성
- 내가 알고 있는 지식을 기반으로 수도 코드에 맞춰 코드 작성
- 테스트 통과하는지 확인
- 테스트가 통과하지 않는다면 내가 가지고 있는 지식을 기반으로 왜 실패하였는지 확인
- 몇 가지 가설을 세운 후에 검증
- 테스트를 통과한다면 이후 가설에 대해서 AI와 논의 →AI 사용
- 추가적인 가설이 있는지, 혹은 현재 코드에서 리팩토링 방향에 대한 논의 → AI 사용
이 과정에서 React에서 다양한 훅들이 useRef를 기반으로 동작하며, 모든 훅의 기본은 useState라는 사실을 이해할 수 있게 되었습니다.
과제 피드백
ToastProvider 요구사항을 해결해나가면서 과거의 내가 디버깅을 할 때 무지성으로 console을 찍는 습관이 얼마나 비논리적이며 비효율적인 방식인지를 되돌아보면서 스스로를 반성하는 계기가 되었습니다.
앞으로는 문제가 발생했을 때, 문제가 발생하는 원인을 흐름이나 사용하고 있는 도구의 렌더링 / 원리를 바탕으로 고려하는 습관을 길러야겠습니다!
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
1. 리액트의 렌더링 과정
초기 렌더링: vNode -> NormalizeVNode -> Element -> Rendering
- vNode 생성: JSX →
createElement()→ Virtual DOM 생성 - NormalizeVNode → vNode를 내부적으로 정규화
- Element 생성: DOM 트리 생성
- Rendering: 실제 DOM에 그리기
리렌더링: Update -> vNode -> NormalizeVNode -> Diffing -> ReRendering(V-DOM) -> RePainting(DOM)
- Update: setState, dispatch 등으로 업데이트 트리거
- vNode 생성: 변경된 부분에 대해 새로운 vNode 트리 생성
- NormalizeVNode: 새로운 트리를 정규화
- Diffing: 기존 vNode와 새 vNode를 비교 (Reconciliation)
- ReRendering (V-DOM): 변경된 부분만 업데이트되도록 가상 DOM 갱신
- RePainting (DOM): 실제 DOM에 필요한 부분만 반영 (브라우저 페인팅)
2. 리액트의 렌더링 최적화 방법
- useCallback, useMemo를 활용하여 불필요한 함수 생성 혹은 계산을 최소화하기
- React.memo를 활용하여 불필요한 리렌더링 방지하기
- 불필요하게 외부 상태를 구독하고 있지 않은지 / 그리고 구독 상태로 인해 영향을 받고 있지 않은지 의존성을 확인해보기
메모이제이션에 대한 나의 생각을 적어주세요.
React에서는 useMemo, useCallback, React.memo 등을 통해 렌더링 시 함수 재생성, 연산 재수행, 불필요한 자식 컴포넌트의 리렌더링을 방지하는 데 활용됩니다.
하지만 메모이제이션이 항상 성능 최적화를 보장해주는 것은 아닙니다. 오히려 불필요하게 사용하게 된다면 메모리 사용만 증가하거나 의존성이 자주 변경되면 캐싱이 의미 없어져 오히려 성능을 악화시킬 수 있습니다. 관련 아티클을 찾아보며, 메모이제이션은 상황에 따라 신중하게 사용해야 하는 기법이라는 점을 다시금 느꼈습니다. - 내가 사용하는 useCallback, useMemo가 진짜 성능 최적화를 진행중인걸까?
메모이제이션을 사용하지 않고도 리렌더링을 줄이는 방법 중 하나는 상태의 격리입니다. React에서는 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 함께 리렌더링되는 특성이 있기 때문에, 상태를 불필요하게 끌어올리지 않고 컴포넌트 내부 또는 별도의 하위 컴포넌트로 격리하는 방식으로 리렌더링 범위를 최소화할 수 있습니다. 이런 구조적 접근을 통해 메모이제이션 없이도 성능 개선이 가능하다고 생각합니다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
Context API는 Props driiling을 피하기 위한 도구로서 사용하는데 아래의 장단점을 가진다고 생각합니다.
- 장점
- 선언적으로 컴포넌트를 명시함으로써 가독성을 높일 수 있음
- 컴포넌트 간 상태 공유가 용이함
- 단점
- Context API를 사용하는 경우 상태가 변경되면, 해당 Context를 구독하고 있는 모든 컴포넌트가 리렌더링되는 문제 발생
- Context가 많아지게 된다면 Provider의 늪에 빠져버릴 수 있음
그리고 Context API를 사용하지 않고 해결할 수 있는 방법 중 하나는 조합 패턴을 활용하는 방법이라고 생각합니다.
예를 들어, Modal -> ModalBody -> List로 구성된 컴포넌트가 있다고 가정해보겠습니다.
function Modal({ open, items, onClose }) {
if (!open) return null;
return (
<div>
<ModalBody items={items} onClose={onClose} />
</div>
);
}
function ModalBody({ items, onClose }) {
return (
<div>
<div>
<p>ModalBody</p>
<List items={items} />
</div>
<button onClick={onClose}>Close</button>
</div>
);
}
function List({ items }) {
return (
<div>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
현재 props drilling으로 인해, ModalBody에서는 사용하지 않는 items를 하위 컴포넌트로 전달해주는 역할을 하고 있습니다. 여기서 Context API를 활용하는 것도 하나의 방법이겠지만 다음과 같이 조합 패턴을 활용하여 이 문제를 해결할 수도 있을 것 같습니다.
function Modal({ open, items, onClose }) {
return (
<div>
<ModalBody onClose={onClose}>
<List items={items} />
</ModalBody>
</div>
);
}
function ModalBody({ onClose, children }) {
return (
<div>
<div>
<p>ModalBody</p>
{children}
</div>
<button onClick={onClose}>Close</button>
</div>
);
}
function List({ items }) {
return (
<div>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
리뷰 받고 싶은 내용
-
shallowEquals와 deepEquals의 요구사항에서 주어진 (1) 두 값이 정확히 동일한지, (2) 객체가 아닌 경우 처리, … 등의 케이스에 대해서는 굳이 다루지 않더라도 a, b 매개변수가 객체가 아니라면 결국 return 문에서 값과 타입을 함께 비교하지 않아도 된다고 생각하여 추가하지 않았는데 적절한 판단일까요?
export const shallowEquals = (a: unknown, b: unknown) => { if (isObject(a) && isObject(b)) { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; const uniqueKeys = new Set([...aKeys, ...bKeys]); for (const key of uniqueKeys) { if (a[key] !== b[key]) return false; } return true; } return a === b; }; -
AI를 잘 활용하는 방법은 AI에게 모든 것을 맡기는 것이 아닌, 내가 생각하지 못한 것을 함께 고민하고 지식을 확장하는 도구로서 생각하고 이번 과제를 진행하였습니다. 다만, 이번 과제에서 AI를 조금은 소극적으로 사용한 것이라는 생각이 드는 것 같습니다. 제가 제대로 과제를 수행한 게 맞을까요?
과제 피드백
Q. shallowEquals와 deepEquals의 요구사항에서 주어진 (1) 두 값이 정확히 동일한지, (2) 객체가 아닌 경우 처리, … 등의 케이스에 대해서는 굳이 다루지 않더라도 a, b 매개변수가 객체가 아니라면 결국 return 문에서 값과 타입을 함께 비교하지 않아도 된다고 생각하여 추가하지 않았는데 적절한 판단일까요?
A. 내용을 보면 커버가 가능할 수 있지만 몇몇 케이스를 놓치는 부분도 있는 것 같아요 물론 다른 코드에 의해서 해결될 수 있지만 두값이 NaN인 경우 false가 나올 수도 있을 것 같아요. 일부 코드가 간결할 수 있지만 정확히 모든 상황을 커버하는지는 좀 살펴봐야할 것 같아욥
Q. AI를 잘 활용하는 방법은 AI에게 모든 것을 맡기는 것이 아닌, 내가 생각하지 못한 것을 함께 고민하고 지식을 확장하는 도구로서 생각하고 이번 과제를 진행하였습니다. 다만, 이번 과제에서 AI를 조금은 소극적으로 사용한 것이라는 생각이 드는 것 같습니다. 제가 제대로 과제를 수행한 게 맞을까요?
A. 저는 병준님의 사용법이 맞다고 생각합니다. 움 코치분들 마다 생각은 다를 수 있겠지만 저는 이번 항해의 목표가 바이브 코딩 보다는 FE 딥다이브라고 생각합니다. 궁금한 것을 물어보되 되도록이면 수강생이 충분히 이해하면서 직접 코딩하는 게 많을수록 배워가는 것이 많을거라 생각합니다. FE 딥다이브를 이번항해로 배우고 바이브 코딩과 인공지능의 활용은 별도로 학습해야하지 않을까 싶어요..