과제 체크포인트
배포 링크
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 개선
과제 셀프회고
취업준비와 과제를 병행하는 것에 대한 회고
사실은 첫 주차에서도 기업 과제 준비를 병행 했었습니다. 당시 pr에도 언급했었지만 그 때는 얼레벌레 ai로 구현해서 스노우볼을 만든 채로 넘어갔습니다. 이런 상황이 앞으로도 계속 될 수 있기 때문에 과제와 취업준비의 밸런스에 대해 고민을 하게 되었습니다. 금주 멘토링에서의 테오의 조언이 방향을 잡는 것에 대해 도움이 되었습니다. 테오가 언급해주신 키워드는 두 가지였습니다.
- 해야만 한다는 강박에서 벗어나도 괜찮다는 안심(?)
- 컨텍스트 스위칭은 오히려 지치지 않게 해준다.
저는 스스로 집중력이 좋지 않다고 생각해 컨텍스트 스위칭을 두려워 했으나, 적절한 전환이 몰입을 도와준다는 것을 알게 되었습니다. 적절한 스위칭으로 저에게 맞는 컨디션을 오래 유지하는 방법을 찾아서 과제든, 취업이든 "해야만 하는 것"이 아니라 호기심으로 파고드는 마음 가짐을 가지고 싶습니다. 결론은 어디에 얼마나 시간을 할애하던, 그 자체에 강박을 가지지 않고 순간순간에 집중 하는 것을 목표로 두기로 했습니다.
기술적 성장
1. useAutoCallback
[고민 포인트]
- “렌더링 시점에 최신화"의 정확한 의미
처음에 React 의 라이프 사이클을 고려하여 useEffect를 활용해 ref에 함수를 할당하고자 했습니다.
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
const ref = useRef<T | null>(null);
useEffect(() => {
ref.current = fn; // 렌더링 완료 후 업데이트
}, [fn]);
return useCallback((...args: Parameters<T>) => {
return ref.current!(...args);
}, []) as T;
};
- 테스트코드 실패 후 다시 생각해보니 useEffect는 커밋 단계 이후 시점에 등록된 콜백이 실행된다는 것이 생각났습니다.
- 렌더링이 완료된 이후에 할당했기 때문에 “렌더링 시점”에는 ref.current가 null이었다는 것을 이해했습니다.
[최종 코드]
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
const prevFn = useRef<T | null>(null);
prevFn.current = fn;
return useCallback((...args: Parameters<T>) => {
return prevFn.current?.(...args);
}, []) as T;
};
- 렌더링 중에 prevFn을 업데이트 하기 위해 useAutoCallback이 호출되는 시점에 함수를 할당하도록 개선했습니다.
- 실행 시점에 prevFn.current를 호출하여 최신 함수를 사용하도록 했습니다.
- 빈 의존성 배열로 함수의 참조를 유지하고자 했습니다.
[의문점]
부수효과는 useEffect에서 처리해야하는 것 아닌가?
저는 훅 내부에서 발생하는 부수효과는 useEffect에서 처리해야한다고 생각했기 때문에 이 부분을 개선하는 과정에서 한동안 병목이 있었습니다. (당연히 useEffect에서 할당해야한다고 생각해 다른 곳에서 이유를 찾으려고 했습니다.)
useEffect에서 수행되어야 한다고 생각한 이유는 다음과 같습니다.
- React의 함수 컴포넌트는 순수(pure)해야 한다. (동일 입력 → 동일 출력) 그리고 ref의 값을 변경 하는 행위는 사이드 이펙트에 해당한다고 생각했습니다.
- 이러한 사이드 이펙트를 다루는 것이 useEffect이기 때문에 useEffect에서 수행되어야 한다고 생각했습니다.
- 또한 ref.current를 렌더링 중에 읽거나 쓰지 말아야 한다고 공식문서에서 보고 정리한 기억이 있어서 헷갈렸던 것 같습니다.
[알게된 사실]
- 이 코드에서는 예외(?)일 수 있다.
- gemini가 답변해준 렌더링 중에 변경하는 것이 오히려 안전한 이유 입니다.
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
const prevFn = useRef<T | null>(null);
// 1. 렌더링 최상단에서 "무조건" 값을 덮어쓴다.
prevFn.current = fn;
// 2. 이 할당 행위가 현재 렌더링의 결과물에 영향을 주지 않는다.
return useCallback((...args: Parameters<T>) => {
return prevFn.current?.(...args);
}, []) as T;
};
왜 안전한가?
-
멱등성: Strict Mode에서 이 코드가 두 번 실행된다고 해도, prevFn.current = fn 은 두 번 다 똑같은 fn을 할당할 뿐입니다. 여러 번 실행해도 결과는 한 번 실행한 것과 같습니다.
-
렌더링 결과에 영향을 주지 않음 prevFn.current = fn 이라는 할당 행위 자체가 useCallback이 반환하는 함수의 참조값에 영향을 주지 않습니다. useCallback은 의존성 배열이 []이므로 항상 최초에 만들어진 함수를 그대로 반환합니다. 즉, 렌더링의 순수성이 지켜집니다. ref에 값을 쓰는 행위는 "나중에" 실행될 콜백 함수를 위한 준비 작업일 뿐, 현재 렌더링의 출력을 결정하지 않습니다.
-
공식문서에서 “렌더링 중 ref를 변경하지 말라”고 하는 진짜 의도는 다음과 같습니다.
"렌더링 중에 ref를 변경해서, 그 변경으로 인해 현재 렌더링의 결과물(JSX)이 달라지게 만들지 마세요. 렌더링은 순수해야 합니다."
2. useMemo
[고민 포인트]
"의존성 배열이 변경될 때만 factory를 실행하고, 그렇지 않으면 이전 값을 반환"하는 로직을 구현할 때 내부적으로 useRef를 활용해 이전 의존성과 값을 저장했습니다. "객체의 속성만 변경"하는 방식과 "객체 자체를 재할당"하는 방식 중 어떤 것이 더 적절한지, React의 실제 구현과 어떤 차이가 있는지 궁금하여 분석했습니다.
1. 속성 변경 방식
ref.current.deps = deps;
ref.current.value = factory();
- 기존 객체의 속성만 변경
- 참조는 그대로 유지
2. 객체 재할당 방식
ref.current = { deps, value };
- 새로운 객체를 할당
- 참조가 변경됨
3. 구조적/동작적 차이점
| 구분 | 속성 변경 방식 | 객체 재할당 방식 |
|---|---|---|
| 참조 | 동일 | 변경됨 |
| 메모리 | 기존 객체 재사용 | 새 객체 할당 |
| GC 부담 | 적음 | 약간 증가 |
| 외부 참조 | 항상 동일 | 변경될 수 있음 |
- 속성 변경: 외부에서 ref.current를 저장해두면, 항상 같은 객체를 참조
- 객체 재할당: 외부에서 ref.current를 저장해두면, 변경 시 참조가 달라짐
4. React 내부 구현과의 비교
React의 useMemo/useRef 내부 구현(의사코드)
// useMemo
if (hook.memoizedState === null || !areHookInputsEqual(deps, hook.memoizedState.deps)) {
hook.memoizedState = { deps, value };
}
// useRef
if (hook.memoizedState === null) {
hook.memoizedState = { current: initialValue };
}
- React는 객체 재할당 패턴을 선호
- 불변성(immutability) 원칙을 지키고, 상태 변경을 명확히 표현
5. 객체 재할당의 장점
- 불변성 유지: 참조가 바뀌므로 변경 여부를 쉽게 감지
- 상태 변경의 명확성: 새로운 상태임을 명확히 표현
- 최적화: 참조 비교(
===)만으로 변경 여부 판단 가능 - React 패턴과 일치: 공식 구현과 동일한 구조로 미래 호환성↑
[결론]
- React의 구현이 객체 재할당 패턴으로 구성되어 있어 유사한 구조로 설계하기로 결정했습니다.
- 객체 재할당 패턴은 불변성/최적화/유지보수 측면에서 더 유리한 면이 있다고 이해했습니다.
- 성능 차이는 미미할지라도 구조적 일관성을 유지하기로 했습니다.
[최종 코드]
export function useMemo<T>(factory: () => T, deps: DependencyList, equals = shallowEquals): T {
const ref = useRef<{ deps: DependencyList; value: T } | null>(null);
if (ref.current === null || !equals(ref.current.deps, deps)) {
const value = factory();
ref.current = { deps, value };
return value;
}
return ref.current.value;
}
학습 효과 분석
React의 memo HOC(Higher-Order Component)를 직접 구현하면서 메모이제이션의 핵심 개념과 동작 원리를 학습했습니다.
1. 메모이제이션의 목적
메모이제이션은 불필요한 리렌더링을 방지하여 성능을 최적화하는 기법입니다.
// 메모이제이션 없이: props가 같아도 매번 새로운 컴포넌트 생성
const Component = (props) => <div>{props.name}</div>;
// 메모이제이션 적용: props가 같으면 이전 결과 재사용
const MemoizedComponent = memo(Component);
2. 두 가지 ref의 역할
메모이제이션을 위해서는 두 가지 정보를 유지해야 합니다.
const prevPropsRef = useRef<P | null>(null); // 이전 props
const prevResultRef = useRef<React.ReactElement | null>(null); // 이전 렌더링 결과
prevPropsRef
- 얕은 비교를 위한 이전 props 저장
// 이전 props와 현재 props를 얕은 비교
const propsChanged = !prevPropsRef.current || !equals(prevPropsRef.current, props);
- 필요 이유: props가 실제로 변경되었는지 확인하기 위해
prevResultRef
- 렌더링된 컴포넌트 재사용
if (propsChanged) {
// props가 변경되었을 때만 새로운 React 엘리먼트 생성
prevResultRef.current = React.createElement(Component, props);
}
// 이전 렌더링 결과 반환 (메모이제이션)
return prevResultRef.current;
- 필요 이유: props가 같을 때 이전에 생성된 React 엘리먼트를 재사용하기 위해
3. 구현 과정에서의 학습
- HOC는 새로운 컴포넌트를 반환해야 한다.
// 컴포넌트를 반환하는 HOC
export function memo<P extends object>(Component: FunctionComponent<P>) {
return function MemoizedComponent(props: P) {
const prevPropsRef = useRef<P | null>(null);
const prevResultRef = useRef<React.ReactElement | null>(null);
// 메모이제이션 로직...
return prevResultRef.current;
};
}
- 메모이제이션 로직의 핵심
// 1. Props 비교 (얕은 비교)
const propsChanged = !prevPropsRef.current || !equals(prevPropsRef.current, props);
if (propsChanged) {
// 2. 메모이제이션된 컴포넌트 생성
prevPropsRef.current = props;
prevResultRef.current = React.createElement(Component, props);
}
// 3. 메모이제이션된 결과 반환
return prevResultRef.current;
얕은 비교(Shallow Comparison)란?
- 객체의 첫 번째 레벨 속성들만 비교하는 방식
// 얕은 비교 예시
const prevProps = { name: "John", age: 30 };
const currentProps = { name: "John", age: 30 };
// 얕은 비교: true (모든 속성이 동일)
// 깊은 비교: true (내용이 동일)
리엑트는 왜 얕은 비교를 사용하는가?
- 성능상의 이점
- 리액트의 VirtualDOM은 객체 덩어리 -> 참조 비교만 해도 리랜더링 여부를 충분히 알 수 있다.
- 깊은 비교는 모든 중첩 객체를 재귀적으로 비교해야 하므로 비싼 비용이 든다.
- React의 철학 -> "변경이 있을 때만 리랜더링"
- 불변성 원칙
// ❌ 잘못된 방식 (변경)
const user = { name: "John", age: 30 };
user.age = 31; // 같은 참조, 다른 내용
// ✅ 올바른 방식 (불변)
const user = { name: "John", age: 30 };
const updatedUser = { ...user, age: 31 }; // 새로운 참조, 새로운 내용
React는 데이터의 불변성을 권장하기 때문에 객체나 배열을 직접 수정하는 대신 새로운 객체를 생성하여 상태를 업데이트 하는 방식을 사용한다.
[최종 코드]
import React, { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks/useRef";
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return function MemoizedComponent(props: P) {
const prevPropsRef = useRef<P | null>(null);
const prevResultRef = useRef<React.ReactElement | null>(null);
const propsChanged = !prevPropsRef.current || !equals(prevPropsRef.current, props);
if (propsChanged) {
prevPropsRef.current = props;
prevResultRef.current = React.createElement(Component, props);
}
return prevResultRef.current;
};
}
학습한 핵심 포인트
- 메모이제이션의 핵심은 두 가지 ref를 활용 상태 관리이다.
- React는 불변성을 권장하므로 참조가 바뀌면 내용도 바뀌었다고 확신할 수 있어 얕은 비교만으로도 충분히 변경 감지가 가능하다.
- 메모이제이션은 메모리 사용량을 증가시키지만, 불필요한 렌더링을 방지하여 전반적인 성능을 향상시킬 수 있다.
과제 피드백
- 리액트가 재밌게 느껴지는 과제였습니다.
- 역시 직접 만들어보는게 책 다독 하는 것 보다 훨씬 구조적인 이해가 쉽다는 것을 알게 되었습니다.
- 자연스럽게 리액트의 렌더링 사이클에 대해 깊게 생각해볼 수 있는 계기가 되었습니다!
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
[리액트의 렌더링 과정]
리엑트의 렌더링 단계는 3가지로 구별할 수 있습니다. (준일 코치님의 발제자료를 참고하여 작성합니다.)
1. Trigger
“너 렌더링을 시작해!” 라고 명령 받는 단계
렌더링을 의도하는 호출을 했을 때 가장 먼저 시작되는 단계입니다.
-
첫번째 렌더링이 수행 될 때(DOM에 마운트)
ReactDOM.createRoot(....).render() -
컴포넌트의 state가 변경될 때(상태 변경 훅의 setter 함수 호출)
-
부모컴포넌트로부터 받는 props가 변경될 때 (자식 컴포넌트의 렌더링이 트리거)
2. Render
“어떤 부분”을 “어떻게” 업데이트 해야하는지 확인하는 단계
- Virual DOM을 기준으로 변경할 내용을 계산하고(Reconciliation), 이 변경 내역을 내부에 저장합니다.
- 렌더 단계에서는 DOM에 직접적인 수정이 일어나지 않고, 변경할 요소를 React의 메모리에 저장하는 단계입니다.
- 이 단계에서 컴포넌트 함수(함수형 컴포넌트일 경우)를 호출합니다. 이 호출을 통해 어떤 JSX가 반환 되는지, 즉 UI가 어떻게 보여야 하는지 확인합니다.
3. Commit
렌더링 과정이 끝나고 “변화한 부분을 실제 DOM”에 반영하는 단계
- 실제 DOM에 변경 사항을 적용하며 이 과정에서 컴포넌트의 생명주기 메소드를 실행합니다.
- 이 부분에서 컴포넌트가 화면에 표시되기 적전과 직후에 실행되는 메소드들이 중요한 역할을 합니다. (useEffect, useLayoutEffect etc..)
[리액트의 렌더링 최적화 방법]
리액트 렌더링의 핵심은 바뀌지 않은 부분을 다시 그리지 않게 하는 것 입니다. 목적에 따라 3가지로 나눌 수 있습니다.
- 컴포넌트 리렌더링 방지(memo)
- 부모 컴포넌트가 리렌더링될 때, 자식 컴포넌트 props의 변경사항은 없을 때
- 렌더링 비용이 비싼 컴포넌트(UI가 복잡하거나 자식 컴포넌트가 많은 경우)
- 함수 재생성 방지(useCallback)
- React.memo와 함께 사용 - props로 전달된 함수의 참조를 유지하여 자식 컴포넌트의 리렌더링을 방지
- 렌더링 최적화는 아니지만 useEffect의 의존성으로 관리되는 함수의 참조를 유지하여 렌더링사이드 이펙트를 방지
- 고비용 연산 결과 재사용(useMemo)
- 연산 결과가 props로 전달되어야 할 때
- 연산 결과 자체가 비용이 많이 드는 경우
[리액트의 렌더링과 관련된 개념들]
제가 생각하는 렌더링과 관련된 주요 키워드는 세가지 입니다.
Fiber
- 재조정 과정을 효율적으로 하기 위한 아키택처
- 과거에는 재조정이 시작되면 멈출 수 없어서 화면이 멈춰보이는 현상이 있었는데, Fiber라는 작은 단위로 재조정 과정을 쪼깨어 우선순위를 매길 수 있게 만듦 → 사용자를 기다리지 않게 한다.
Virtual DOM
- 자바스크립트 메모리상에 존재하는 가벼운 복사본
- DOM을 직접 조작하는 것은 매우 느리고 비싼 비용 → 가벼운 가상돔에 먼저 수정하고, 최종적인 변경만 DOM에 반영하여 최소한의 DOM 비용을 소모한다.
Reconciliation
- 무엇이 바뀌었는지 알아내는 과정
- 컴포넌트의 상태가 변경되면 React는 새로운 가상돔 트리를 만들어 이전 가상돔 트리와 비교하는데, 이 차이점(diff)을 계산하는 과정을 말한다.
[리액트의 렌더링과 관련된 라이프사이클 메서드]
useEffect
- 렌더링 단계(Commit 단계) 후, 브라우저의 화면 그리기(Painting) 과정이 완료된 이후에 비동기적으로 실행됩니다.
- Fetching, Subscriptions, DOM 수동 조작(React에서는 ref를 권장합니다.)
- 대부분의 사이드 이펙트를 처리합니다.
useLayoutEffect
- 렌더링(Commit 단계)은 완료되었지만, 브라우저가 화면을 그리기(Painting) 직전에 동기적으로 실행됩니다.
- DOM의 레이아웃(스크롤 위치, 크기 등)을 읽고, 그 값에 따라 동기적으로 DOM을 변경하여 사용자 경험이 좋아야 할 때. 예) 스크롤 위치를 특정 값으로 설정
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션이 언제 필요할까?
- 비용이 많이 드는 계산
- 동일한 입력에 대해 반복적으로 계산되는 값을 캐싱할 때
- 자주 변경되지 않는 값이 있을 때
메모이제이션이 유용하지 않은 경우
- 처리량이 적은 경우 → 오히려 오버헤드를 유발한다.
- 초기화 하는 동안 메모이제이션은 어플리케이션의 속도를 저하 시킴 → 리렌더링 단계에서만 이점이 있다.
- 의존성 배열이 너무 자주 변경되는 경우 → 이 경우에는 항상 재계산 되기 때문에 성능적인 이점이 떨어진다.
- 메모하고있는 값을 자식 컴포넌트로 전달하지 않는 경우 → 컴포넌트 트리에 깊이 전달되지 않는 경우, 다른 컴포넌트의 렌더링에 영향을 주지 않으므로 참조를 기억하기위해 메모이제이션을 하는게 비효율적일 수 있음.
모든 값에 메모이제이션을 사용한다면?
- 불필요한 리렌더링을 줄인다
- 비용이 많이 드는 계산의 반복을 방지한다.
- 메모이제이션 오버헤드
전체를 다 useMemo 쓴다고 생각하는 지침을 만들어보자 (특정한 예외케이스가 아닌 모든 경우에 적용하는 지침)
의존성 배열이 빈배열이거나 자식 컴포넌트에서 해당 값을 사용하지 않는 경우를 제외하고 모든 값에 useMemo를 사용한다.. ?
→ 잘못 된 지침!
- "의존성 배열이 빈배열이거나"
→ 의존성 배열이 빈 배열이라면 굳이
useMemo를 사용할 필요가 없다. - "자식 컴포넌트에서 해당 값을 사용하지 않는 경우를 제외하고" → 추후에 변경사항이 생길 여지가 있는 조건이다. 확장성을 고려하지 않은 지침이다.
어떤 것에 대한 지침은 생각하지 않게 하는, 간단해야 하는 것
수고로우면 지키기 힘들다.
[지침에 대한 반증]
function Foo() {
const [xs, setXs] = useState([]);
const [ys, setYs] = useState([]);
const z = useMemo(() => sum([...xs, ...ys]), [xs, ys]);
const z2 = f(xs) + g(ys);
const zs = useMemo(() => [...xs, ...ys], [xs, ys]);
const zs2 = [...xs, ...ys];
return (
<div>
<Bar x={z} />
</div>
);
}
zs2를Bar에props로 넘길 경우,Foo가 렌더링이 될 때 마다 메모리 주소를 새롭게 인식해서Bar도 같이 렌더링 된다. →useMemo를 쓰기 적절하다.z2를Bar에props로 넘길 경우,z2는 원시 값이기 때문에Foo가 업데이트 된다고 해서Bar가 업데이트 되지 않는다. → 굳이useMemo를 사용하지 않아도 된다.
[결론]
‘데이터가 원시 값일 경우 useMemo를 사용하지 않아도 된다’ 라는 지침이 가장 적절하다. -> 이 경우에 데이터의 결과값이 원시값일지라도 비용이 드는 계산일 경우에는 useMemo를 쓰면 좋다.
모든 값에 useMemo를 사용하지 않는다면?
- 메모이제이션 오버헤드 없음
- 비용이 많이 드는 계산을 매번 반복해야함
- 불필요한 리렌더링 발생
모든 값에 useMemo를 사용 vs 모든 값에 useMemo 미사용
모든 값을 메모이제이션을 했을 때의 오버헤드와 메모이제이션 비교 하는 비용보다 메모이제이션을 하지 않았을 때의 리렌더링 비용이 더 크기 때문에 모든 값에 useMemo를 사용하는 것이 더 이점이 많다고 생각합니다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
컨텍스트와 상태관리가 필요한 이유는?
1. 컴포넌트 간 데이터 공유의 복잡성
React는 단방향 데이터 흐름을 따르기 때문에, 깊은 컴포넌트 트리에서 데이터를 전달하는 것이 복잡해집니다.
// 깊은 컴포넌트 트리에서 props 전달의 문제
function App() {
const [user, setUser] = useState({ name: "John", role: "admin" });
return (
<Layout>
<Header user={user} /> {/* 1단계 */}
<Main>
<Sidebar user={user} /> {/* 2단계 */}
<Content>
<Article user={user} /> {/* 3단계 */}
<Comments>
<CommentForm user={user} /> {/* 4단계 */}
<CommentList>
<Comment user={user} /> {/* 5단계 */}
</CommentList>
</Comments>
</Content>
</Main>
</Layout>
);
}
2. Props Drilling 문제
중간 컴포넌트들이 실제로 사용하지 않는 props를 전달해야 하는 문제가 발생합니다.
// Props Drilling 예시
function Sidebar({ user }) {
// user를 실제로 사용하지 않지만 전달해야 함
return (
<div>
<Navigation />
<UserMenu user={user} /> {/* 실제로 user를 사용하는 컴포넌트 */}
</div>
);
}
function Navigation() {
// user가 필요 없지만 상위에서 전달받음
return <nav>{/* 네비게이션 내용 */}</nav>;
}
3. 전역 상태 관리의 필요성
애플리케이션 전체에서 공유되어야 하는 상태들이 존재합니다.
// 전역 상태의 예시들
const globalStates = {
user: { name: "John", isAuthenticated: true },
theme: { mode: "dark", primaryColor: "#007bff" },
language: "ko",
notifications: [],
cart: { items: [], total: 0 },
preferences: { autoSave: true, notifications: true }
};
4. 성능 최적화의 어려움
불필요한 리렌더링을 방지하기 어려운 구조가 됩니다.
// 상태 변경 시 모든 하위 컴포넌트가 리렌더링
function App() {
const [user, setUser] = useState({ name: "John" });
const [theme, setTheme] = useState("light");
return (
<div>
<Header user={user} theme={theme} />
<Main user={user} theme={theme} />
<Footer user={user} theme={theme} />
</div>
);
// user나 theme 중 하나라도 변경되면 모든 컴포넌트가 리렌더링
}
컨텍스트와 상태관리가 해결해주는 것
1. Props Drilling 해결
// ✅ Context를 사용한 깔끔한 구조
const UserContext = createContext();
function App() {
const [user, setUser] = useState({ name: "John" });
return (
<UserContext.Provider value={{ user, setUser }}>
<Layout>
<Header />
<Main>
<Sidebar />
<Content>
<Article />
</Content>아 ,
</Main>
</Layout>
</UserContext.Provider>
);
}
// 필요한 곳에서만 사용
function Article() {
const { user } = useContext(UserContext);
return <div>Hello, {user.name}!</div>;
}
2. 코드 분리와 모듈화
const UserContext = createContext();
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (!context) throw new Error("useUser must be used within UserProvider");
return context;
}
- 사용자 상태와 관련된 로직을 UserProvider와 useUser 훅으로 캡슐화하여 독립적인 모듈을 만들 수 있습니다.
- Context를 활용해 사용자 상태를 관리하는 "관심사"를 분리하고, useUser 커스텀 훅으로 내부 구현을 추상화하여 상태를 관리할 수 있습니다.
각각 적절한 사용은 언제일까?
Context API
- 컨텍스트가 필요한 컴포넌트들 사이에서 지역적인 상태 공유가 필요할때
- 데이터의 변경이 적을 때, 리렌더링 발생이 적은 상태를 공유할 때
- Compound Component Pattern에서의 쓰임
상태관리 라이브러리
- 여러 페이지에서 복잡한 상태관리가 필요할 때
- 상태가 자주 공유되거나 변경되어야 할 때
결론
리액트는 단방향 데이터 바인딩을 기반으로 한 라이브러리이기 때문에 앞에서 언급한 문제점들은 불가피 하다고 생각합니다. 하지만 단방향 데이터 바인딩이 주는 이점이 리액트 어플리케이션을 확장 가능하고 유지보수하기 쉽게 만들어 주는 기반이 되었고, 이를 통해 데이터 흐름을 명확하고 예측 가능하게 만들어 주기 때문에 리액트가 웹 표준으로 자리 잡을 수 있었다고 생각합니다.
상태관리와 Context API는 단방향 흐름을 지키면서도 리액트의 구조적인 문제를 해결하고 컴포넌트 간 상태를 효율적으로 전달하기 위해 발전한 도구들입니다. 각 상황에 맞게 적절하게 사용하는 것이 리액트를 잘 활용하는 것이라고 생각합니다.
리뷰 받고 싶은 내용
ToastProvider
export const ToastProvider = memo(({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(toastReducer, initialState);
const actions = useToastActions({ dispatch });
const visible = state.message !== "";
return (
<ToastActionsContext value={actions}>
<ToastStateContext value={state}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext>
</ToastActionsContext>
);
});
export function useToastActions({ dispatch }: { dispatch: Dispatch }) {
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
const showWithHide = useAutoCallback((...args: Parameters<typeof show>) => {
show(...args);
hideAfter();
});
const actions = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
return actions;
}
[구현 의도]
- Toast 와 관련된 액션 로직을 훅으로 추상화하고자 했습니다.
- 컴포넌트 리렌더링 마다 함수의 참조가 변경되는 것을 방지하고자 했습니다.
- 리렌더링으로 인한 사이드이펙트에서 액션함수들의 참조를 유지할 수 있는 방법이 2가지가 있다고 생각했습니다.
- useState 초기화 함수를 사용하는 방법
- useMemo 로 메모이제이션하는 방법
useState의 초기화 함수에 할당하는 경우는 첫 렌더링 이후에 바뀌지 않을 것이 보장되는 값들이어야 한다고 생각하는데, 이 액션함수들이 그런 경우에 해당하는 근거가 코드 상에 없다고 생각했기 때문에 useMemo로 메모이제이션을 했습니다. (그런데 질문을 준비하면서 공식 문서에 useReducer가 반환하는 dispatch 함수의 참조는 컴포넌트의 생애주기 동안 변경되지 않음을 보장한다는 것이 명시되어 있음을 알게 되었습니다. 결국 두 가지 모두 효용이 있는 방법이라고 생각됩니다!)
[질문]
- useMemo를 사용하다보니 관련된 다른 함수들의 참조도 함께 메모이제이션하게 되어 결국 모든 함수들을 메모이제이션하게 되었는데, 이게 맞나? 라는 생각이 들었습니다. useState의 초기화 함수를 사용하는 방식이 그런 면에서는 조금 더 직관적이고 간단한(?) 방법이 될수도 있을 것 같습니다. 이 부분에 대해서 코치님의 의견은 어떠신가요?!
- 저는 리액트에서 적절한 추상화의 경계를 나누는 것(?)이 어렵게 느껴지는데, "추상화 레벨"이라는 것을 잘 나누는 기준이 있을까요? 위 코드의 useToastActions 훅의 추상화 레벨은 적절하게 나누어져 있다고 볼 수 있을까요?
- 실제로 비즈니스로직과 함께 코드를 구현하다 보면 단일책임을 갖도록 하는것이 어렵다고 생각하는데, 책임을 나누는 눈을 기르고 싶다면 리팩토링이나 코드를 작성할 때 어떤 것을 중심적으로 생각하는게 좋을지 궁금합니다!
과제 피드백
안녕하세요 지호, 수고하셨습니다! 이번 과제는 React의 내장 훅들을 직접 구현해보면서 프레임워크가 어떻게 상태를 관리하고 최적화하는지 이론을 넘어 몸으로 깊이 이해하는 것이 목표였습니다.
취업 준비와 과제를 병행하는거 쉽지 않을텐데 적절한 몰입을 위한 스위칭을 고민하다니 대단해요. 선택과 집중은 중요하지만 그렇다고 하나에만 몰빵(?)하는건 올바른 집중이 아니니까요. 몰입에는 정해진 시간이 있으니 적절히 스위칭을 하는건 도움이 되죠.
또한 "AI 없이 직접 고민하고 실험해보는" 접근 방식을 택하신 점이 정말 인상적입니다. 회고에서 적어둔 useAutoCallback과 useEffect등을 직접 고민해본 부분은 다른 항해친구들에게도 많은 인사이트가 될거에요!
compareObjectProperties를 따로 만들어서 equals 함수들의 중복을 제거하는 부분이나 memo 구조를 ref로 깔끔하게 구현한 부분도 좋았습니다.
"모든 값에 useMemo를 쓸까?"라는 질문은 React를 하면서 내내 고민을 하게 될 부분인테 이번 기회에 충분히 고민을 해보게 된 것 같아 기쁘네요. 수고 많았습니다!
Q) useMemo를 사용하다보니 관련된 다른 함수들의 참조도 함께 메모이제이션하게 되어 결국 모든 함수들을 메모이제이션하게 되었는데, 이게 맞나? 라는 생각이 들었습니다. useState의 초기화 함수를 사용하는 방식이 그런 면에서는 조금 더 직관적이고 간단한(?) 방법이 될수도 있을 것 같습니다. 이 부분에 대해서 코치님의 의견은 어떠신가요?! => useMemo를 하나 쓰게 되면 다 써야 되는게 맞죠. 발제시간에도 말씀드렸지만 그래서 다쓰자파와 격리하고 컴포넌트 수준에서 메모하자로 나눠지곤 합니다. 아니면 상태관리 도구에서는 자체 memo기능을 제공하기고 하구요. 지금 과제의 경우에는 지금 지호가 한 걱처럼 useMemo을 다쓰는 방식을 해야겠네요.
Q) 저는 리액트에서 적절한 추상화의 경계를 나누는 것(?)이 어렵게 느껴지는데, "추상화 레벨"이라는 것을 잘 나누는 기준이 있을까요? => 추상화는 어렵지만 사람이 말할때에는 자연스럽게 추상화를 하고 있습니다. 기능이나 기획을 논할때 "토스트 팝업을 띄워봐", "이럴때 토스트을 3초간 보여주세요." 라는 식으로 말하지. "토스트 팝업의 값을 true로 바꿔주세요." 라고 말하진 않죠. <토스트 팝업 기능> 이라고 말할 수 있기에 추상화가 단위가 되고 <일정 편집>, <일정 삭제> 단위의 추상화와 <일정 관리>의 추상화처럼 이미 반복적으로 표현을 하는 이름단위에서 추상화를 확인할 수 있습니다.
위 코드의 useToastActions 훅의 추상화 레벨은 적절하게 나누어져 있다고 볼 수 있을까요?
=> 그렇죠. 이렇게 생각해주세요. 기획에서 사용하는 용어대로 노출이 되어 있는가? 메시지를, 보이고 하고, 사라지게 하는 것은 반복적으로 표현하면서 그밖에는 별도로 표현하는게 없으니 추상화레벨은 적절합니다.
=> 가령 조금 더 오랫동안 보여주세요. 라던가. 강조해서 빨간색으로 보여주세요라는 요구사항이 왔다면 이부분도 추상화의 영역에 포함을 시켜야겠죠?
Q) 실제로 비즈니스로직과 함께 코드를 구현하다 보면 단일책임을 갖도록 하는것이 어렵다고 생각하는데, 책임을 나누는 눈을 기르고 싶다면 리팩토링이나 코드를 작성할 때 어떤 것을 중심적으로 생각하는게 좋을지 궁금합니다!
=> 결국 정리하면 요구사항, 즉 기획의 언어 중심이라고 생각해주세요. 그게 어렵다면 내가 다른 사람에게 내가 구현한 것들을 비개발자들에게 어떻게 말하고 설명하고 있는지 한번 생각해보세요. 추상화는 인간의 기본적인 습성에서 비록된 개념이기에 (인간은 원래 복잡하고 구체적인 걸 싫어합니다.) 비개발자들이 말하는 곳에서 힌트를 찾을 수 있답니다.
수고하셨습니다. 클린코드 챕터도 화이팅입니다!