과제 체크포인트
배포 링크
https://nemobim.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 개선
과제 셀프회고
기술적 성장
이번 과제를 통해 useRef부터 useCallback까지 다양한 리액트 훅의 동작 방식을 직접 체감할 수 있었습니다. 특히 평소 막연하게 사용하던 useMemo, useCallback, Context가 어떤 조건에서 리렌더링을 유발하는지 테스트하고 개선해보며 더 쉽게 이해할 수 있었습니다. ToastContext 리팩토링 과정에서는 단순히 Context로 감싸는 것만으로는 충분하지 않다는 점도 알게 되었습니다.(하위 컴포넌트들도 context 상태에 따라 렌더링 된다는 사실을 이제야 알았습니다...) 상태를 공유할때 어떻게 구조화하고 관리할지 고민해본 경험은 실무에서도 적용할 수 있을거같아요 ㅎㅎ
useSyncExternalStore를 활용한 커스텀 훅 구현은 이번이 처음이었는데 React가 요구하는 snapshot과 subscribe 방식의 개념을 실제로 적용해보며 어느정도 눈에 익혀진거 같습니다!
자랑하고 싶은 코드
다음에 다시 코드를 보게 될 때 흐름을 쉽게 이해할 수 있도록 주석을 꼼꼼하게 달았습니다. 팀 내부에서도 코드 리뷰를 진행했는데 전반적으로 비슷한 방식으로 구현한 것 같습니다. deepEquals.ts 쪽 코드만 서로 다른 방식으로 작성된 거 같아 이 부분을 가져왔습니다.
export const deepEquals = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
// null 처리
if (a == null || b == null) return false;
// 타입 다르면 다름
if (typeof a !== typeof b) return false;
// 배열
if (Array.isArray(a) && Array.isArray(b)) {
// 길이 다르면 다름
if (a.length !== b.length) return false;
// 각 요소 비교
return a.every((item, index) => deepEquals(item, b[index]));
}
// 객체
if (typeof a === "object" && typeof b === "object") {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
// 키 개수 다르면 다름
if (aKeys.length !== bKeys.length) return false;
// 각 키의 값 비교
return aKeys.every(
(key) => Object.hasOwn(b, key) && deepEquals(a[key as keyof typeof a], b[key as keyof typeof b]),
);
}
// 나머지는 === 로 비교
return false;
};
가능한 한 빠르게 비교할 수 있는 조건부터 확인해서 조기에 리턴하고 여러 케이스를 처리할 수 있도록 신경 써서 작성했습니다.
개선이 필요하다고 생각하는 코드
- deepEquals.ts 에서 일단 테스트 코드를 통과하는 케이스를 작성하였는데, AI를 통해 검증해보니 특수 객체 타입들(Date, RegExp, Map, Set 등)과 NaN을 제대로 처리하지 못하고 있다고 하더라구요.. 이런 부분들에 대해서 좀 더 다양한 케이스 처리가 필요할거같습니다.
//JavaScript 버전: 다양한 객체 타입(Date, RegExp, Map, Set 등)까지 지원
const deepEquals = (a, b) => {
//...기존과 동일
// Date 객체 비교: getTime 값이 같아야 동일
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// RegExp 객체 비교: source(패턴)과 flags가 동일해야 함
if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source && a.flags === b.flags;
}
// Map 객체 비교
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) return false; // 크기 다르면 false
for (let [key, value] of a) {
if (!b.has(key) || !deepEquals(value, b.get(key))) return false; // 키가 없거나 값이 다르면 false
}
return true;
}
// Set 객체 비교: 값만 비교 (순서 무관)
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) return false;
for (let value of a) {
if (!b.has(value)) return false; // b에 없는 값이 있으면 false
}
return true;
}
// 기본값 false
return false;
};
- 아래 코드에 대한 리뷰로
setState로 함수들어올 때를 대비해주는 코드가 추가되어도 좋을 것 같아요!라는 피드백을 받아서 그 부분도 추후에 개선해보려고 합니다..
/**얕은 비교를 통해 상태 변경을 감지하는 훅
* useState와 동일하나 상태 변경 시 얕은 비교를 통해 변경 여부를 확인하고, 변경된 경우에만 상태를 업데이트
* (useState는 모든 상태 변경을 감지하고 업데이트)
*/
export const useShallowState = <T>(initialValue: T) => {
// useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다.
// 초기값 설정
const [shallowState, setShallowState] = useState<T>(initialValue);
const stableSetState = useCallback((nextValue: T) => {
if (shallowEquals(shallowState, nextValue)) return; // 변경 안 됨
setShallowState(nextValue); // 변경됨
}, []);
return [shallowState, stableSetState];
};
// 지금은 이런 사용법이 동작하지 않음
setShallowState(prev => ({ ...prev, count: prev.count + 1 }));
함수면 실행, 아니면 그대로 사용
setShallowState(prevState => {
// 1. 함수인지 확인하고 값 해결
const resolvedValue = typeof nextValue === 'function'
? (nextValue as (prev: T) => T)(prevState) // 함수면 실행
: nextValue; // 값이면 그대로
// 2. 얕은 비교로 같은지 확인
if (shallowEquals(prevState, resolvedValue)) {
return prevState; // 같으면 이전 상태 반환 → 리렌더링 X
}
// 3. 다르면 새 값 반환 → 리렌더링 O
return resolvedValue;
});
학습 효과 분석
useRef 뜯어보기
useState는 내부적으로 초기값만 한 번 호출되고, 이후에는 저장된 상태를 계속 유지한다.
- 리렌더링 후에도 ref 객체는 유지 -> ref.current 값은 바뀌지만 setState로 갱신하지 않기 때문에 리렌더링은 일어나지 않음!
export function useRef<T>(initialValue: T): { current: T } {
// useState를 이용해서 만들어보세요.
const [ref] = useState<{ current: T }>(() => ({ current: initialValue }));
return ref;
}
- useState의 초기값을 함수로 전달하면 최초 1회만 실행된다.
function InitCounter() {
const [count] = useState(() => {
console.log("🧪 초기값 함수 실행됨");
return 0;
});
return <div>{count}</div>;
}
//리렌더링해도 다시 호출되지 않음
- lazy initializer?
**지연 초기화(Lazy Initialization)**는 값이나 객체를 실제로 필요한 시점에 생성하는 프로그래밍 패턴입니다. 미리 계산하거나 생성하지 않고 처음 사용될 때까지 기다린다. “값이 실제로 필요할 때까지 계산을 미루는 전략”
useMemo
deps가 변경되지 않으면 factory()를 재실행하지 않음
useMemo(factory, deps, equals) //함수, 의존성배열, 비교함수
- 변경 기준: 이전 deps와 새로운 deps를 equals 함수로 비교 (shallowEquals 사용)
- 이후 재호출: deps가 같으면 cached value 반환, 다르면 factory 재실행
useCallback
fn이라는 콜백을 의존성 deps가 바뀔 때만 새로 반환한다.
useCallback(fn, deps)는 사실상
useMemo(() => fn, deps)와 같음
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);
useSyncExternalStore
외부 저장소(스토어)의 변화를 감지해서 컴포넌트를 리렌더링시킴
예시를 통해 살펴보면 더 이해하기 수월하다
const set = (value: T) => {
try {
data = value;
storage.setItem(key, JSON.stringify(data));
notify();
} catch (error) {
console.error(`Error setting storage item for key "${key}":`, error);
}
};
//React 컴포넌트는 상태(state)나 props가 바뀌어야만 리렌더링된다.
//우리가 만든 set은 단순히 값만 변경한다.
data = value; // 내부 변수만 바꾸고
storage.setItem(key, JSON.stringify(data)); // localStorage에만 저장하고
notify(); // "값이 바뀌었어!" 라고는 말하지만...
React는 이 storage 내부 값이 바뀐 걸 알 수가 없다!
그래서 useSyncExternalStore를 사용한다.
useSyncExternalStore(subscribe, get)
subscribe: 값이 변경되었을 때 React에게 알려줄 함수get: 현재 값을 가져오는 함수
이 두 개를 등록해두면 React가 알아서 "오! 구독 중인 값이 바뀌었네? 이 컴포넌트 다시 그려야겠다!" 하고 리렌더링~
과제 피드백
과제의 방향성이 명확해서 좋았습니다. 단순히 "동작만 하는" 코드가 아니라 성능 최적화까지 고려해야 했기 때문에 React의 렌더링 흐름에 대해 실제로 고민해볼 수 있는 계기가 됐습니다!
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
리액트는 상태나 props가 변경되면 해당 컴포넌트를 다시 렌더링합니다. 이때 실제 DOM을 직접 변경하는 것이 아니라 먼저 **Virtual DOM(VDOM)**이라는 가상의 트리를 만들고 이전 VDOM과 비교하여 변경된 부분만 실제 DOM에 반영합니다. 이 과정을 **Reconciliation(조정)**이라고 하며, 이를 통해 DOM 조작을 최소화하고 성능을 높입니다.
리액트는 상태나 props 변경 →
Virtual DOM 생성 →
이전과 비교(Reconciliation) →
실제 DOM 업데이트 순서로 렌더링합니다.
컴포넌트는 상태 변경 시마다 함수 전체가 다시 실행되지만 리액트는 이전 렌더링과의 차이를 비교해 효율적으로 실제 DOM을 갱신합니다. 하지만 컴포넌트 함수가 실행되면 내부에 있는 함수, 객체, 배열 등이 모두 새로 생성되기 때문에 불필요한 렌더링이 발생할 수 있습니다.
이를 방지하기 위해 사용하는 것이 메모이제이션 Hook들입니다:
- useMemo: 연산된 값을 캐싱합니다 (예: const memoized = useMemo(() => 계산(), [deps]))
- useCallback: 함수 레퍼런스를 유지합니다 (예: const cb = useCallback(() => fn(), [deps]))
- React.memo: props가 바뀌지 않았다면 컴포넌트 리렌더링을 막습니다
메모이제이션에 대한 나의 생각을 적어주세요.
사실 저는 실무에서 메모이제이션을 자주 사용하지는 않습니다...그렇게 무거운 연산이 필요한 경우도 거의 없었고, 지금까지는 리액트가 알아서 잘 처리해줘서 별다른 문제 없이 사용해왔습니다. 메모이제이션을 사용했던 경우을 뽑아보면 자주 열리는 모달이나 바뀌지 않는 참조값을 넘겨야 하는 상황, useEffect 안에서 사용하는 함수에 useCallback을 써야 할 때 정도였던 것 같습니다.
개인적으로는 리액트 같은 도구를 쓰는 이유가 개발 편의성에 있다고 생각해서 뭔가 문제가 생기기 전까지는 리액트에게 최적화 기능을 전담하고 기능구현 하는 방식을 선택했던거 같습니다..ㅎㅎ 지금도 사실 어떤 상황에서 메모이제이션을 적용해야 하는지 판단하는 게 쉽지는 않네요. 나중에 병목이 생기면 하나하나 뜯어보면서 깨닳아보겠습니다.
그리고 메모이제이션이 최적화를 위한 도구이긴 하지만 멘토링 시간에 말씀해주신 것처럼 “어떤 상황에서 써야 할까?” 를 판단하는 그 자체도 비용이라고 생각합니다. 그래서 프로젝트 초기에 기준을 어느 정도 정해두는 게 좋겠다는 생각도 들었습니다. 이 상황에서는 메모이제이션을 쓰자! 또 메모이제이션은 단순히 값을 저장하는 기능이 아니라 렌더링 흐름을 제어할 수 있는 도구이기도 하니까 언제 써야 좋은지에 대한 감각을 계속 길러 놓으면 예측 가능한 컴포넌트를 만들기 수월하겠다고 느꼈습니다.
Q. 메모이제이션을 사용하지 않고도 해결할 수 있는 방법은 무엇일까? 메모이제이션을 사용하지 않고도 해결하기 위해서는 연산 자체를 가볍게 만들거나 렌더링 구조를 조정해서(상태관리 방식 개선, 컴포넌트 분리) 등으로 최적화
Q. 메모이제이션을 사용했을 때의 장점과 단점은 무엇일까? → 장점: 렌더링 최적화, 불필요한 연산 방지, 성능 개선 → 단점: 코드 복잡도 증가, 메모리 사용 증가, 과도한 적용 시 오히려 성능에 악영향
“지금이 메모이제이션을 적용할 적절한 시점인가?”를 판단하는 것 자체가 오히려 더 큰 비용이 될 수 있다.개발 중에 이런 판단이 병목이 되기도 한다. 차라리 처음부터 전반적으로 메모이제이션을 적용해두면 약간의 성능 손실은 있을 수 있지만, 의사결정이 빨라지고 전반적인 개발 생산성은 높아진다. — 준일 코치님의 멘토링
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
리액트는 단방향 데이터 흐름을 기반으로 하기 때문에 일반적으로 상위 컴포넌트에서 하위 컴포넌트로만 데이터를 전달할 수 있습니다. 상태관리 도구나 컨텍스트(Context)를 사용하면 이런 제한을 넘어서 구조적으로 직접 연결되지 않은 컴포넌트 간에도 데이터를 쉽게 전달할 수 있습니다.
예를 들어 다크모드(야간모드)처럼 앱 전체에서 공통적으로 사용하는 설정값은 여러 컴포넌트에서 동시에 접근해야 하기 때문에, 전역 상태관리나 컨텍스트를 사용하는 것이 효과적입니다. 이런 방식은 확실히 편리하지만 상태를 공유하는 컴포넌트가 많아질수록 변경 시 리렌더링 범위도 넓어지므로 성능을 고려하고 설계해야합니다.
개인적으로는 컨텍스트와 상태관리를 다음처럼 구분해서 사용합니다.
- 컨텍스트는 해당 페이지 또는 UI 범위 내에서만 사용하는 간단한 값을 공유할 때 사용합니다. (예: 테마, 언어 설정, 모달 상태 등)
// 테마, 사용자 정보 등 변경 빈도가 낮은 데이터
const ThemeContext = createContext();
const UserContext = createContext();
// 특정 페이지 범위의 상태
function ProductPage() {
const [filters, setFilters] = useState({});
return (
<FilterContext.Provider value={{filters, setFilters}}>
<ProductList />
<FilterSidebar />
</FilterContext.Provider>
);
}
- 상태관리 라이브러리는 구조적으로 직접 연결되지 않은 컴포넌트들 간에 데이터를 전달해야 하거나 앱 전역에서 일관된 상태가 필요한 경우에만 사용합니다.
// 장바구니, 알림, 복잡한 폼 상태 등
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id)
}))
}));
사실 상태관리로 처리하면 데이터를 주고받기는 편하지만 프로젝트가 커질수록 상태 흐름을 파악하거나 유지보수하기 어려워진다고 생각되어 저는 최대한 전역 상태관리를 지양하는 편입니다. 전역 상태는 최소화하고 구조 개선을 통해 해결할 수 있는 방향으로 가자. 상태는 컴포넌트 설계 단계에서부터 적절하게 나누어 관리하자 는 생각을 가지고 개발을 하고 있습니다. 초기에 많이 고려하고 만드는 편인거 같습니다.(이것도 비용같기도,,!)
리뷰 받고 싶은 내용
ToastProvider에서 useMemo vs useCallback 선택 기준
각 훅을 선택할 때 어떤 기준으로 판단해야 할지 조언받고 싶습니다. 아래는 제가 작성한 코드와 그에 대한 제 생각입니다. 이 흐름이 맞는지 검토 받고싶습니다. useMeMo는 값을, useCallback은 함수를 메모이제이션 한다고 이해했습니다.
1. createActions
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
함수가 담긴 객체를 반환하므로 결국 객체(값)를 메모이제이션 하니 useMemo 사용
2. hideAfter
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
debonce는 함수를 반환한다고 생각해서 useAutoCallback을 사용했었습니다. 실제로 useAutoCallback을 사용했을 때 E2E 테스트가 통과되어 커밋했지만 이후 불안정하게 실패했고 useMemo로 바꾸자 안정적으로 통과했습니다...(의문) 여기서 헷갈리는 점은 "함수도 결국 값"이니까 useMemo로 보는 걸까요..? 이런건 어떤식으로 판단하는게 좋을까요.
3. value
const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]);
const actionValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
이 두 경우는 명확하게 객체 메모이제이션이라고 생각해서 useMemo를 사용했습니다.
4. showWithHide
const showWithHide: ShowToast = useAutoCallback((...args) => {
show(...args);
hideAfter();
});
이부분도 useCallback을 사용해도 통과되긴 하던데 더 좋은 useAutoCallback이 있으니 해당 훅을 썼다는걸로 이해하면 될까요? "무조건 useAutoCallback이 더 낫다"고 볼 수 있는 건지 아니면 상황에 따라 구분해서 써야 하는 건지 궁금합니다!
타입 오류 처리에 대한 고민
실무에서 재사용 가능한 코드를 작성하다 보면 타입이 과도하게 복잡해지거나 정확한 타입 정의가 어려운 상황이 발생합니다. 타입 지정하느라 비용이 오래 들어가는거 같아 기능구현에 집중하자고 판단될때는 as, unknown, eslint-disable 등을 사용해 임시로 해결하곤 하는데 이게 옳은 방법인지 모르겠습니다.
이번 과제에서도 직접 만든 useCallback 훅에서 아래와 같이 타입 경고가 발생해 lint 규칙을 꺼버렸습니다: react-hooks/exhaustive-deps 추가
/* eslint-disable @typescript-eslint/no-unsafe-function-type,react-hooks/exhaustive-deps */
import type { DependencyList } from "react";
import { useMemo } from "./useMemo";
export function useCallback<T extends Function>(factory: T, _deps: DependencyList) {
// 직접 작성한 useMemo를 통해서 만들어보세요.
return useMemo(() => factory, _deps);
}
이런 우회가 정당한 예외처리라고 판단되는 기준이 있는지 궁금합니다.
- 타입 안정성 vs 개발 생산성 중 어느 것을 우선해야 하는지
- 팀에서 타입 우회를 허용할 명확한 기준이 있으신지
과제 피드백
안녕하세요 도은님! 과제 잘 진행해주셨네요 ㅎㅎ 고생하셨습니다!
개인적으로는 리액트 같은 도구를 쓰는 이유가 개발 편의성에 있다고 생각해서 뭔가 문제가 생기기 전까지는 리액트에게 최적화 기능을 전담하고 기능구현 하는 방식을 선택했던거 같습니다..ㅎㅎ 지금도 사실 어떤 상황에서 메모이제이션을 적용해야 하는지 판단하는 게 쉽지는 않네요. 나중에 병목이 생기면 하나하나 뜯어보면서 깨닳아보겠습니다.
저도 이런 전략이 좋다고 생각합니다 ㅋㅋ 사실 많이 써보질 않으면 필요성을 느끼는 것 자체가 어렵다고 생각해요.
컨텍스트는 해당 페이지 또는 UI 범위 내에서만 사용하는 간단한 값을 공유할 때 사용합니다. (예: 테마, 언어 설정, 모달 상태 등)
제 생각과 똑같네요!!
상태관리 라이브러리는 구조적으로 직접 연결되지 않은 컴포넌트들 간에 데이터를 전달해야 하거나 앱 전역에서 일관된 상태가 필요한 경우에만 사용합니다.
제가 항상 상태관리를 언제써야할까? 라고 설명할 때 용어 정리가 안되서 어려웠는데 이걸 한 문장으로 잘 정리해주셨어요!! 속이 뻥 뚫리는 기분입니다 ㅎㅎ
상태관리로 처리하면 데이터를 주고받기는 편하지만 프로젝트가 커질수록 상태 흐름을 파악하거나 유지보수하기 어려워진다고 생각되어
이건 저와 다른 생각인데요, 저는 오히려 프로젝트가 커질수록 상태관리 라이브러리를 통해 제어하는게 중요하다고 생각했어요 ㅎㅎ 상태 변이를 상태관리에서 제공하는 함수를 통해 추적만 하면 되니까요! 그런데 상태관리가 없다면... 전체적인 구조를 이해해야 이 상태가 어디에 어떻게 전파되는지 알 수 있기 때문에 훨씬 이해하는데 시간이 오래 걸린다고 생각해요.
ToastProvider에서 useMemo vs useCallback 선택 기준
어차피 useCallback도 useMemo로 만드는거라서요 ㅎㅎ
이 상황에서 useCallback을 사용한다고 가정해보면
useCallback(debounce(hide, DEFAULT_DELAY), [hide]) 처럼 정의할 수 있을 것 같아요!
showWithHide의 경우, useAutoCallback도 좋고 useCallback도 좋습니다. 다만 useAutoCallback은 값을 캐싱한다기보단, 참조를 캐싱하는거라서, debounce 처럼 실제 "함수의 과거 상태"를 유지해야 하는 경우에는 적합하지 않을 수 있답니다 ㅎㅎ
언제나 최신 값을 참조해야 한다면 useAutoCallback을 쓰는게 좋을 것 같아요!
타입 안정성 vs 개발 생산성 중 어느 것을 우선해야 하는지
지금 당장 일정이 급해서 미치겠따! 라는 상태가 아니라면 저는 어떻게든 타입 안정성을 더 채우려고 하는 편입니다 ㅎㅎ 그래야 장기적으로 봤을 때 생산성이 더 높아지니까요!
팀에서 타입 우회를 허용할 명확한 기준이 있으신지
PR 올린 다음에 말씀해주신 type ignore lint 가 있으면 팀원들이 먼저 물어보기도해요. 혹은 대신 해결해주기도 하거나!?
다만 메모이제이션에 대한 판단 사례처럼, 결국 타입을 제대로 학습하려면 타입과 관련된 문제들을 많이 겪어봐야 한다고 생각합니다. 타입 오류가 있으면 이를 기회삼아 공부하는거죠 ㅎㅎ