과제 체크포인트
배포 링크
https://soyalattee.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 개선
과제 셀프회고
기술적 성장
이번 과제를 통해 관성적으로 사용하던 React 훅들을 내부 동작과 원리를 이해하게되었습니다. 앞으로는 React 훅을 사용할때 더 의도를 명확하게 사용할 수 있을것 같습니다.
- useRef “DOM을 참조하거나, 렌더링과 무관하게 값을 유지하는 용도” 정도로만 이해하고, 암기식 패턴처럼 사용하고 있었습니다.
이번 과제를 통해 useRef가 렌더링 사이에서도 값을 유지하는 객체로 React 내부 상태로 인식되지 않아 리렌더링을 발생시키지 않는다는 점을 이해하게 되었고, useRef에 대한 활용범위를 넓힐 수 있었습니다.
- useState 내부 동작원리
useState 의 내부 구조를 탐색해보며 상태가 클로저와 배열을 기반으로 순서 기반으로 관리되는 구조라는걸 알 수 있었습니다.
특히 해당 아티클을 참고하며
setState호출 시, 컴포넌트 함수가 다시 실행되며 상태 값을 업데이트하는 흐름을 이해했습니다.
그리고 React의 Hook이 왜 그런 방식으로 설계 되었는지에 대한 이해를 얻을 수 있었습니다. 이를 통해 단순 동작하는 코드를 넘어 '이 방식이 성능에 어떤 영향을 줄까?'를 고민하고 설계할 수 있는 개발자가 될 수 있었습니다.
자랑하고 싶은 코드
팀원들과 코드리뷰를 진행하며 트러블슈팅한 과정이 만족스러워서 공유해보려고합니다.
memo.ts
<문제 발견>
진희님에게 memo.ts 코드에 관해 다음과 같은 피드백을 받았습니다.
다시 생각해보니 문제가 있는 코드였고, 해당 memo 를 사용해 테스트하기 위해 TestApp 파일을 만들었습니다.
<원인>
const TestComponent = memo((props: { name: string }) => {
console.log("TestComponent 렌더링:", props.name);
return <div>{props.name}</div>;
});
export const TestApp = () => {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>부모 리렌더링 (Count: {count})</button>
<TestComponent name="test" />
<div>부모 렌더링 횟수: {count}</div>
</>
);
};
예상대로 첫 렌더링후 재렌더링이 발생하며 같은 prop이 들어가자, null이 반환되어 자식 컴포넌트가 사라졌습니다.
<문제 해결>
component를 ref에 저장해서 관리해야 재렌더링이 발생하지 않겠구나 라고 생각되어 코드를 수정했습니다.
prevRef 에 props와 component를 객체로 만들어 동시에 저장하였고, 기존 props와 변경된 props를 비교하여, 변경이 일어났을때에만 prevRef.current.component(props) 를 반환했습니다.
const MemoizedComponent = (props: P) => {
const prevRef = useRef<{ props: P; component: FunctionComponent<P> } | null>(null);
if (prevRef.current === null || !equals(prevRef.current.props, props)) {
prevRef.current = { props, component: Component };
}
return prevRef.current.component(props);
};
return MemoizedComponent;
하지만 이 코드에도 문제가 있었습니다. 해당 코드는 함수를 반환하며 매번 컴포넌트를 호출하게되어, 반환할때마다 새롭게 렌더링이 되고있었습니다.
'컴포넌트의 렌더링 결과'를 저장하기 위해 다시 코드를 수정했습니다.
(TO-BE)
const MemoizedComponent = (props: P) => {
const prevRef = useRef<{ props: P; component: ReactNode | Promise<ReactNode> } | null>(null);
if (prevRef.current === null || !equals(prevRef.current.props, props)) {
prevRef.current = { props, component: Component(props) as ReactNode };
}
return prevRef.current.component;
};
return MemoizedComponent;
useRef에 저장할 값의 타입을 ReactNode로 하고, Copoment(props) 값을 저장하여 렌더링 결과를 캐싱할 수 있었습니다.
수정 커밋: d648629d37343f66e8414b4256c89a2e8d0a425d
shallowEquals&deepEquals
<문제 발견> 도은님에게 object와 배열 비교문 관련하여 피드백 받았습니다.
<원인> (AS-IS)
export const shallowEquals = (a: unknown, b: unknown) => {
if (typeof a !== typeof b) return false;
// 객체 비교
if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
const objA = a as Record<string, unknown>;
const objB = b as Record<string, unknown>;
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => {
if (key in objA && key in objB) {
return objA[key] === objB[key];
}
return false;
});
}
// 배열 비교
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, index) => v === b[index]);
}
return a === b;
};
Object 비교시에 배열도 처리됨으로 마지막에 작성한 배열비교 코드는 작동하지 않고 있었습니다.
하지만 해당 코드는 문제가 생길 수 있습니다.
예를들어,
shallowEquals({0: "a", 1: "b"}, ["a", "b"])
객체와 배열을 비교할 경우, 배열의 키 값도 0, 1 순으로 들어가게 됩니다. 이런 케이스에 의도치 않은 true 가 반환될 수 있습니다.
<문제 해결>
우선 배열비교를 객체 비교보다 먼저 할 수 있도록 했습니다.
그리고 객체를 비교할때 체크하던 null 체크도 위로 올려 가독성을 개선했습니다.
(TO-BE)
export const shallowEquals = (a: unknown, b: unknown) => {
// null 체크
if (a === null || b === null) return a === b;
// 타입이 다르면 다름
if (typeof a !== typeof b) return false;
// 배열 비교
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, index) => v === b[index]);
}
// 객체 비교
if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
const objA = a as Record<string, unknown>;
const objB = b as Record<string, unknown>;
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => {
if (key in objA && key in objB) {
return objA[key] === objB[key];
}
return false;
});
}
return a === b;
};
팀원들과의 코드리뷰를 통해 제가 놓치고 있던 에러 케이스를 빠르게 발견하고,
더 명확하고 읽기 쉬운 구조로 리팩토링할 수 있었습니다.
학습 효과 분석
앞서 언급했듯이, 단순 동작하는 코드를 넘어 '이 방식이 성능에 어떤 영향을 줄까?'를 고민하고 설계할 수 있는 개발자로 성장 할 계기가 되었습니다.
이번에 구현해본 memo와 useCallback을 활용한 렌더링 최적화 기법은 평소에 잘 적용하지 않고 개발했던 부분 이였습니다.
앞으로는 실제 업무에서 자주 사용하는 컴포넌트들에 직접 적용해보고, 렌더링 빈도 변화나 성능 차이를 DevTools로 분석해보며 효과를 검증해보려고 합니다.
과제 피드백
이번 과제는 React에서 Hook의 본질을 이해하고 직접 구조를 따라는 식으로 학습 경험을 쌓아갈 수 있어 좋았습니다. 그동안 "작동은 되지만 왜 이렇게 써야 하지?"라고 생각했던 부분들이 직접 구현하고 실험하면서 "이런 구조여서 이렇게 동작하는구나.."라고 체득되는 순간들이 많아 좋았습니다.
그런데, 제가 그냥 '테스트 코드 통과하네' 하고 넘어간 부분이 있었는데 (memo.js, deepMemo.js) 목요일 팀원들과 코드리뷰를 한 덕에 '어..이거 잘못짰네...?' 라고 알아챈 부분이 있었습니다.
제가 memo.js에서 null을 리턴하고 있었어요 ㅎㅎ.. 해당 커밋: 8aacb390aac0506f864fbb0e397b6103cd362b61
basic.test.tsx의 '직접 만든 memo' 테스트코드에서 호출 횟수는 체크하나, null 관련 체크는 없어서 통과되었나봐요 하핫
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
React의 렌더링은 크게 초기 렌더링과 리렌더링 두 단계로 나눌 수 있다.
1. 초기 렌더링
- JSX로 작성된 컴포넌트는 React.createElement() 로 변환되어 가상 DOM(vDOM) 을 구성
- 이 vDOM을 기반으로 React는 Fiber Tree를 생성함(=>16버전 이후)
- Fiber Tree를 바탕으로 실제 DOM을 생성하고, 브라우저에 그림 ** 15이하 버전에서는 vDOM 트리를 재귀적으로 순회하는 방식
2. 리렌더링
다음과 같은 경우 리렌더링이 발생한다.
useState,useReducer등으로 상태 업데이트- 부모 컴포넌트의 props 가 변경됨
useContext로 구독 중인 context 값이 변경됨forceUpdate()호출
리렌더링이 일어나면, React는 이전 vDOM과 새로운 vDOM을 비교(diffing)하고 변경된 부분만 real DOM 에 업데이트한다.
→ 이를 Reconciliation이라고 부른다.
컴포넌트 함수 자체는 리렌더링 시마다 다시 실행되지만, 실제 DOM 업데이트는 변경된 부분만 수행되는것 (부분 렌더링)
3. 자식 컴포넌트도 무조건 리렌더링 될까?
기본적으로 부모 컴포넌트가 리렌더링되면 자식도 재실행된다. 하지만 자식 컴포넌트가 props가 바뀌지 않았다면 굳이 리렌더링할 필요가 없다.
→ 이를 방지하기 위한 최적화 방법들이 있다.
| 방법 | 설명 |
|---|---|
React.memo(Component) | props가 바뀌지 않으면 컴포넌트 재실행 방지 |
useCallback(fn, deps) | 함수 참조가 바뀌는 것을 방지 (자식에게 함수 props 전달 시 유용) |
useMemo(valueFn, deps) | 값 계산을 캐싱하여 리렌더 시 재계산 방지 |
useRef | 렌더링과 무관하게 값을 유지할 수 있는 참조 저장소 |
정리
- 리액트는 상태나 props가 바뀌면 리렌더링을 수행
- 리렌더링은 컴포넌트 함수 재실행을 의미하며, 실제 DOM은 diff 알고리즘을 통해 필요한 부분만 갱신
- 부모가 리렌더링되면 자식도 재실행되지만, 메모이제이션 기법을 활용해 불필요한 렌더링을 막을 수 있음
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션이란, 컴퓨터가 이미 계산한 결과를 저장해뒀다가 같은 계산이 필요할 때 재사용하는 최적화 기법이다. React에서 메모이제이션이 필요한 이유는 다음과 같다.
1. 리액트 렌더링 시 매번 모든 코드를 재실행한다
React에서 렌더링은 컴포넌트 함수를 매번 다시 호출하는 과정이다. 상태가 바뀔 때마다 React는 컴포넌트 안에 있는 코드를 위에서부터 아래로 전부 실행한다.
즉, 매 렌더링마다 다음의 모든 과정이 다시 일어난다:
- 함수 호출
- 값 계산
- 객체 생성
만약 렌더링 과정에 아래와 같은 무거운 연산이 포함되어 있다면 어떨까?
예: (AS-IS)피보나치 계산
function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
function MyComponent({ num }: { num: number }) {
const result = fibonacci(num); // 매 렌더마다 계산되겠죠?
return <div>{result}</div>;
}
React에서 뜬금없이 피보나치를 돌일 일은 없을것 같지만, 이렇게 매 렌더링마다 동일한 계산이 반복되면 성능이 크게 떨어진다.
이때 사용하는 최적화 방법이 바로 메모이제이션이다.
(TO-BE)적용 예(useMemo)
function MyComponent({ num }: { num: number }) {
const result = useMemo(() => fibonacci(num), [num]); // num 바뀔때만 재계산 ✅
return <div>{result}</div>;
}
이제 num이 변경될 때만 피보나치가 다시 계산되므로 효율적으로 동작한다!
2. 불필요한 리렌더링을 방지하고 싶을때
컴포넌트가 받는 props가 바뀌지 않으면 이전 렌더링 결과를 재사용하여 불필요한 리렌더링을 방지하는 메모이제이션 기법이다.
부모 컴포넌트가 리렌더링될 때 자식 컴포넌트가 props의 변경이 없는데도 불필요하게 다시 렌더링되는 경우에 사용할 수있다.
props가 참조로 전달된 함수나 객체일 경우, 참조가 바뀌지 않도록 useCallback이나 useMemo로 미리 처리해줘야 효과적이다.
예: (AS-IS) 부모가 자식에게 props를 전달할 때
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<Child name="자식 컴포넌트" />
<button onClick={() => setCount(count + 1)}>Parent Count: {count}</button>
</>
);
}
function Child({ name }) {
console.log("자식 렌더링 발생!"); // 부모가 렌더링될 때마다 발생
return <div>{name}</div>;
}
부모가 리렌더링될 때마다 자식 컴포넌트는 변경된 props가 없음에도 불구하고 계속 리렌더링된다.
(TO-BE)적용 예 (memo)
const Child = React.memo(({ onClick }) => {
console.log("자식 렌더링 발생!");
return <button onClick={onClick}>자식 버튼</button>;
});
props가 변경되지 않는 한, 자식 컴포넌트는 리렌더링되지 않는다.
하지만 전달하는 props가 함수나 객체라면 참조 변경에 주의해야 한다.
3. 함수 참조를 유지해야 할 때
메모이제이션은 무거운 연산 외에도 함수나 객체의 참조(reference)를 유지하여 불필요한 리렌더링을 막을 때 유용하다.
특히 React에서 자주 발생하는 문제는 자식 컴포넌트에 콜백 함수를 넘길 때마다 참조가 변경되어 불필요하게 자식이 리렌더링되는 현상이다.
이런 경우에 useCallback으로 함수의 참조를 고정할 수 있다.
예: (AS-IS) 부모가 자식에게 함수를 전달
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => setCount((prev) => prev + 1); // 매번 새로 생성됨
return (
<>
<Child onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Parent Count: {count}</button>
</>
);
}
const Child = React.memo(({ onClick }) => {
console.log("자식 렌더링 발생!"); // 부모 업데이트시 자식도 렌더링 발생
return <button onClick={onClick}>자식 버튼</button>;
});
이 경우, 부모 버튼을 누를 때마다 자식은 변경되는 내용이 없지만 함께 매번 리렌더링 된다.
(TO-BE) 적용 예 (useCallback)
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => setCount((prev) => prev + 1), []); //참조 고정됨
return (
<>
<Child onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Parent Count: {count}</button>
</>
);
}
const Child = React.memo(({ onClick }) => {
console.log("자식 렌더링 발생!");
return <button onClick={onClick}>자식 버튼</button>;
});
이제 자식 컴포넌트는 첫 렌더링 이후 부모 컴포넌트가 리렌더링 되어도 더 이상 리렌더링되지 않는다.
정리
언제 메모이제이션을 사용할까?
- 무거운 연산의 반복 계산을 방지할 때 (
useMemo)- 피보나치, 큰 배열 계산 등 비싼 작업
- 함수 또는 객체 참조를 유지해 불필요한 리렌더링을 방지할 때 (
useCallback,memo)- 자식 컴포넌트로 콜백을 전달할 때
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
1. 컨텍스트(Context)
처음엔 React의 Context는 컴포넌트 간에 값을 전달할 때 props drilling (props를 여러 컴포넌트를 거쳐서 전달하는 비효율적인 상황) 을 피하기 위해 사용된다고 막연히 생각했다.
주로 앱 전역에서 공유해야 하는 상태(예: 언어 설정, 테마 등)를 관리할 때 적절하다.
예: (AS-IS) props drilling이 발생하는 상황
function App() {
const [theme, setTheme] = useState('light');
return <Toolbar theme={theme} />;
}
function Toolbar({ theme }) {
return <Button theme={theme} />;
}
function Button({ theme }) {
return <div>{theme} 테마 버튼</div>;
}
(TO-BE) context 적용
const ThemeContext = React.createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
return <Button />;
}
function Button() {
const theme = useContext(ThemeContext);
return <div>{theme} 테마 버튼</div>;
}
이렇게 하면 props를 통해 테마를 넘기지 않고도 어떤 컴포넌트에서도 테마에 접근할 수 있다.
2. 상태관리
앱의 규모가 커지고, 복잡해질수록 상태의 범위가 넓어지며 상태관리의 중요성이 높아진다.
상태관리가 없다면 전역 상태를 효율적으로 관리하기 어렵고 같은 상태를 여러 컴포넌트가 사용하거나 서로 멀리 떨어진 컴포넌트들이 상태를 공유하는 상황에서 관리가 어렵게 된다.
이런 문제를 해결하기 위해 Redux, Zustand, Recoil 같은 상태 관리 라이브러리가 등장했다!
정리
| 개념 | 용도 및 특징 | 언제 사용하는지 |
|---|---|---|
| Context | props drilling을 피하기 위한 React의 내장 기능 | 앱 전역적으로 공유할 상태 (테마, 언어 등)를 다룰 때 |
| 상태 관리 라이브러리 | 복잡한 전역 상태를 체계적으로 관리할 수 있게 도와주는 외부 도구 | 앱의 규모가 커지고, 상태가 여러 컴포넌트에서 복잡하게 얽혀 있을 때 |
리뷰 받고 싶은 내용
ToastProvider 를 구현할 때, useCallback과 useAutoCallback 중에 어떤걸 쓰는게 좋을까 라는 고민이 있었습니다.
useCallback만으로 충분히 최적화가 가능할것 같은데, 이때 useAutoCallback을 쓰면 좋다 라는 케이스가 어떤경우가 있을지 궁금합니다.
과제 피드백
안녕하세요 소연님! 과제 너무 잘 진행해주셨네요!!! 고생하셨습니다 ㅎㅎ 특히 문제해결과정을 잘 서술해주셔서 소은님의 사고를 따라가기가 수월했어요! 감사합니다!
basic.test.tsx의 '직접 만든 memo' 테스트코드에서 호출 횟수는 체크하나, null 관련 체크는 없어서 통과되었나봐요 하핫
좋은 피드백 감사합니다 ㅎㅎ 다음에는 null에 대해 체크하는 부분도 추가해놔야겠네요!!
ToastProvider 를 구현할 때, useCallback과 useAutoCallback 중에 어떤걸 쓰는게 좋을까 라는 고민이 있었습니다. useCallback만으로 충분히 최적화가 가능할것 같은데, 이때 useAutoCallback을 쓰면 좋다 라는 케이스가 어떤경우가 있을지 궁금합니다.
함수가 의존해야 하는 값이 없을 때는 useCallback이 좋고, 의존해야 하는 값이 있을 때는 useAutoCallback 을 사용하는게 좋다고 생각해요! 특히 useEffect 에서 사용해야 하는 함수이면 더더욱..!