과제 체크포인트
배포 링크
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 개선
과제 셀프회고
과제를 진행하며 기존에 알고있던 지식도 한번 더 다지고 새로 알게 된 메서드와 해당 훅을 제공하는 라이브러리에서는 어떻게, 왜 이렇게 구현했는지 한번 비교해보고 학습했습니다. 과제테스트를 통과한 후 다른 분들에게 도움을 드렸던 문제들을 정리해봤습니다. 예전에 눈으로만 보며 넘어가서 어렴풋이만 알고있던 지식들을 기록해보며 머릿속에 넣어보려 노력했습니다!
기술적 성장
자바스크립트의 원시&참조 타입
JavaScript의 원시타입과 참조타입을 다룹니다. 타입을 정확히 이해하면 변수 할당, 함수 파라미터 전달, 상태 관리에서 예상치 못한 버그를 방지할 수 있습니다. 특히 React나 상태 관리 라이브러리를 사용할 때 얕은 비교 동작을 이해하는 데 핵심적인 지식이 됩니다.
원시타입
원시타입은 JavaScript에서 값 자체가 변수에 직접 저장되는 가장 기본적인 데이터 타입입니다. 객체나 배열과 달리 메모리 주소가 아닌 실제 값이 저장되어, 변수 간 할당 시 값이 복사됩니다.
7가지 원시타입
JavaScript는 다음 7가지 원시타입을 제공합니다:
- number: 정수와 실수 (42, 3.14, Infinity)
- string: 문자열 ("hello", 'world',
template) - boolean: 논리값 (true, false)
- null: 의도적인 빈 값
- undefined: 값이 할당되지 않은 상태
특수 원시타입
- symbol: 유일한 식별자 생성 (ES6+)
- bigint: 큰 정수 처리 (ES2020+)
Symbol 타입 이해하기
Symbol은 변경 불가능(immutable)하고 유일함을 보장하는 원시타입입니다. 주로 객체 프로퍼티의 충돌 없는 키를 만들 때 사용합니다.
const user = {};
const id1 = Symbol('id'); // 인자의 문자열은 단지 description의 역할밖에 하지 않는다.
const id2 = Symbol('id');
user[id1] = 'user1';
user[id2] = 'user2';
console.log(user[id1]); // 'user1'
console.log(user[id2]); // 'user2'
console.log(id1 === id2); // false (같은 설명이어도 다른 값)
- 같은 문자열로 생성해도 항상 다른 값
- 열거되지 않는다.
- 문자열 인자는 디버깅 용도일 뿐, 값에 영향이 없다.
Symbol 함수의 정적 프로퍼티 중 Well-Known Symbol이라 불리는 특별한 심볼들이 있습니다. 이 Well-Known Symbol은 자바스크립트 엔진에 상수로 존재해 이 값을 참조해 일정한 처리를 진행합니다.
일정한 처리라 함은 어떠한 객체가 Symbol.iterator를 프로퍼티 key로 하는 메소드를 가지고 있다면 자바스크립트 엔진은 이 객체가 이터레이션 프로토콜을 따른다고 생각하고 이터레이터하게 동작하게 합니다.
저희가 사용하는 빌트인 객체 Array, String 등 순회 가능한 객체는 모두 내부에 Symbol.iterator를 가지고 있기에 이터레이션 프로토콜을 따라 반복문, 스프레드 연산자의 사용이 가능합니다.
// Symbol.iterator를 키로 사용한 메소드
const iterableObj = {
data: [1, 2, 3],
[Symbol.iterator]() {
let index = 0;
const data = this.data;
return {
next() { // 일반 함수 사용
if (index < data.length) {
return { value: data[index++], done: false };
}
return { done: true };
}
};
}
};
for (const value of iterableObj) {
console.log(value); // 1, 2, 3
}
bigint
bigint는 임의의 정밀도로 정수를 나타낼 수 있는 원시 타입입니다. Number 타입의 안전한 정수 범위(2^53 - 1)를 넘어서는 큰 정수를 다룰 때 사용됩니다.
// Number의 한계
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2); // true (정밀도 손실)
// BigInt 사용
const bigNum1 = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
const bigNum2 = BigInt(Number.MAX_SAFE_INTEGER) + 2n;
console.log(bigNum1 === bigNum2); // false (정확한 계산)
console.log(typeof bigNum1); // "bigint"
원시타입과 참조타입의 차이
참조 타입은 위에서 설명한 타입 외에 모든 객체, 배열 등이 참조 타입으로 분류됩니다. JavaScript의 데이터 타입을 이해하려면 원시타입과 참조타입의 근본적인 차이를 알아야 합니다. 이 차이는 변수 할당, 함수 인자 전달, 비교 연산에서 완전히 다른 동작을 보입니다.
값 저장 방식의 차이
원시타입: 변수에 실제 값이 직접 저장됩니다.
let a = 10;
let b = a; // 값 10이 복사되어 저장
a = 20; // a만 변경됨
console.log(a); // 20
console.log(b); // 10 (영향받지 않음)
참조타입: 변수에 **메모리 주소(참조)**가 저장됩니다.
let obj1 = { value: 10 };
let obj2 = obj1; // 메모리 주소가 복사되어 같은 객체를 참조
obj1.value = 20; // 같은 객체를 수정
console.log(obj1.value); // 20
console.log(obj2.value); // 20 (같은 객체이므로 함께 변경)
비교 연산의 차이
원시타입: 값 자체를 비교합니다.
let a = 5;
let b = 5;
console.log(a === b); // true (같은 값)
let str1 = "hello";
let str2 = "hello";
console.log(str1 === str2); // true (같은 값)
참조타입: 메모리 주소를 비교합니다.
let obj1 = { value: 5 };
let obj2 = { value: 5 };
console.log(obj1 === obj2); // false (다른 메모리 주소)
let obj3 = obj1;
console.log(obj1 === obj3); // true (같은 메모리 주소)
함수 인자 전달의 차이
원시타입: Call by Value (값에 의한 전달)
function changeValue(x) {
x = 100; // 복사된 값만 변경
}
let num = 50;
changeValue(num);
console.log(num); // 50 (원본 변수는 변경되지 않음)
참조타입: Call by Reference (참조에 의한 전달)
function changeObject(obj) {
obj.value = 100; // 같은 객체를 수정
}
let myObj = { value: 50 };
changeObject(myObj);
console.log(myObj.value); // 100 (원본 객체가 변경됨)
💡 이러한 차이점은 React 사용 시 알아야 하는 불변성 개념과 직결됩니다. 특히 참조타입을 다룰 때 의도치 않게 원본 객체나 배열을 변경하여 예상과 다른 동작을 일으킬 수 있으므로, 두 타입의 차이점을 명확히 이해하고 코드를 작성하는 것이 중요합니다.
얕은 비교, 깊은 비교
자바스크립트에서의 비교
자바스크립트에서 값을 비교하는 연산자는 두 가지입니다.
- 동등연산자(==): 값만 비교하고 타입은 무시합니다.
- 일치연산자(===): 값과 타입을 모두 비교합니다.
대부분의 경우 일치연산자로 충분하지만, 다음과 같은 특수한 경우에는 예상과 다른 결과가 나올 수 있습니다.
// 일치 연산자의 경우
NaN === NaN // false
+0 === -0 // true
정확한 비교가 필요하다면 Object.is() 메서드를 사용 해야합니다.
Object.is(NaN, NaN) // true
Object.is(+0, -0) // false
Object.is()는 일치연산자와 거의 동일하지만, NaN과 ±0을 올바르게 구분합니다.
얕은 비교 (shallowEqual)
얕은 비교(Shallow Comparison) 는 다음과 같이 동작한다고 알려져 있습니다.
원시 타입: 값 자체를 비교 참조 타입: 참조(메모리 주소)만 비교
하지만 React와 Zustand에서의 얕은 비교는 다르게 동작합니다.
Zustand의 얕은 비교 코드:
// src/react/shallow.ts
export function shallow<T>(valueA: T, valueB: T): boolean {
if (Object.is(valueA, valueB)) { // 값을 비교
return true
}
if (
typeof valueA !== 'object' ||
valueA === null ||
typeof valueB !== 'object' ||
valueB === null // null 체크 및 타입 검사
) {
return false
}
if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) { // prototype을 검사 Array.prototype, Object.prototype ....etc
return false
}
if (isIterable(valueA) && isIterable(valueB)) { // isIterable은 해당 value에 Symbol.iterator 가 존재하는지 확인한다.
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) { // hasIterableEntries은 entries 메서드가 존재하는지 확인한다. Map, Set의 경우
return compareEntries(valueA, valueB) // 이미 entries 메서드가 존재하면 실행
}
return compareIterables(valueA, valueB) // entries가 존재하지 않으면 실행
}
// assume plain objects 일반 객체 처리
return compareEntries(
{ entries: () => Object.entries(valueA) },
{ entries: () => Object.entries(valueB) },
)
}
Zustand의 얕은 비교는 객체와 배열의 최상위 레벨 값까지 비교합니다. compareEntries와 compareIterables 함수가 객체를 순회하며 값을 비교합니다.
// src/react/shallow.ts
const compareEntries = (
valueA: { entries(): Iterable<[unknown, unknown]> },
valueB: { entries(): Iterable<[unknown, unknown]> },
) => {
const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries())
const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries())
if (mapA.size !== mapB.size) {
return false
}
for (const [key, value] of mapA) {
if (!Object.is(value, mapB.get(key))) {
return false
}
}
return true
}
const compareIterables = (
valueA: Iterable<unknown>,
valueB: Iterable<unknown>,
) => {
const iteratorA = valueA[Symbol.iterator]()
const iteratorB = valueB[Symbol.iterator]()
let nextA = iteratorA.next()
let nextB = iteratorB.next()
while (!nextA.done && !nextB.done) {
if (!Object.is(nextA.value, nextB.value)) {
return false
}
nextA = iteratorA.next()
nextB = iteratorB.next()
}
return !!nextA.done && !!nextB.done
}
compareEntries 함수는 어댑터 패턴을 통해 Map, Set, 일반객체를 순회하며 값을 비교합니다. compareIterables 함수는 entries 메서드가 없는 Array, String 등의 데이터를 순회하며 비교합니다.
React의 shallowEqual도 유사한 방식으로 동작합니다:
// react/packages/shared/shallowEqual.js
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (is(objA, objB)) { // is 함수는 단지 Object.is() 함수를 분리해놓은 함수입니다.
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
// $FlowFixMe[incompatible-use] lost refinement of `objB`
!is(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
실제로 얕은 비교 코드를 구현할 때는 일반적으로 알려진 얕은 비교(메모리 주소만 참조)와 달리, 객체의 최상위 레벨 속성까지 비교하는 방식을 사용해야 합니다.
깊은 비교
깊은 비교는 객체나 배열의 중첩된 모든 속성을 재귀적으로 비교하는 방식입니다. React나 Zustand 같은 라이브러리에서는 성능상의 이유로 일반적으로 사용하지 않으며, 객체 크기에 따라 비용이 기하급수적으로 증가합니다.
export const deepEquals = (a: unknown, b: unknown) => {
// 1. 값이 같으면 바로 true 반환 참조 타입의 경우 주소 비교
if (Object.is(a, b)) {
return true;
}
// 2. 원시 값 처리
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
return Object.is(a, b);
}
// 3. 배열 깊은비교 재귀
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;
}
// 4. 객체 깊은비교 재귀
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (!deepEquals(a[key as keyof typeof a], b[key as keyof typeof b])) return false;
}
return true;
};
얕은 비교와 달리 배열이나 객체의 중첩된 구조를 만날 때마다 즉시 재귀 호출을 통해 더 깊은 레벨(DFS)로 들어가 끝까지 파고들어 비교를 완료합니다.
JSON.stringify() 방식
간혹 JSON.stringify()를 통한 비교를 사용하는 경우가 있지만, 여러 문제점이 존재합니다:
- 속성 순서 의존성
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 2, a: 1 };
JSON.stringify(obj1); // '{"a":1,"b":2}'
JSON.stringify(obj2); // '{"b":2,"a":1}'
// 같은 내용이지만 false!
deepEqualsJSON(obj1, obj2); // false
- 성능 문제
const largeObj = { /* 매우 큰 객체 */ };
// JSON.stringify는 전체 객체를 문자열로 변환
// 메모리 사용량이 2배가 됨 (원본 + 문자열)
// 불필요한 문자열 생성 비용 및 문자열 비교의 비용은 작지 않다.
이외에도 NaN, Infinity 같은 특수 값 처리 불가, 데이터 손실 가능성, 함수나 undefined 값 무시 같은 문제가 있습니다. 다음과 같은 제한적인 상황에서는 고려해볼 수 있습니다:
// 1. 간단한 plain object만 비교
const config1 = { timeout: 5000, retries: 3 };
const config2 = { timeout: 5000, retries: 3 };
// 2. 속성 순서가 보장되는 경우 (같은 소스에서 생성)
const response1 = JSON.parse(apiResponse);
const response2 = JSON.parse(apiResponse);
// 3. 프로토타이핑이나 간단한 테스트
React에서의 실용적 활용
React와 연계해서 사용하기에는 memo의 두 번째 인자에 직접 비교 함수를 제공하는 방식이 더 적합합니다:
const MemoizedComponent = memo(SomeComponent, arePropsEqual);
// arePropsEqual은 콜백 함수로 prevProps와 currentProps를 받습니다
추가 팁
💡 React의 불변성을 지키기 위해 깊은 복사가 필요하다면 lodash-es의 cloneDeep 함수를 이용하면 간편하게 깊은 복사를 통해 새로운 객체를 얻을 수 있습니다.
트러블슈팅 지원 사례
Q1. useRef에 왜 함수를 넣어줘야 하나요?
이 useRef 구현에서 useState에 함수를 전달하는 이유는 불필요한 객체 생성을 방지하기 위해서입니다.
const [ref] = useState({ current: initialValue }); // 매번 새 객체 생성
위처럼 작성하면 컴포넌트가 리렌더링될 때마다 { current: initialValue } 객체가 새로 생성됩니다. useState는 초기값을 한 번만 사용하지만, 객체 생성 자체는 매번 발생합니다.
이를 해결하는 방법이 지연 초기화(lazy initialization)입니다.
const [ref] = useState(() => ({ current: initialValue })); // 최초 한 번만 실행
함수를 전달하면 useState가 최초 렌더링 시에만 함수를 실행하여 객체를 생성합니다. 이후 리렌더링에서는 함수가 실행되지 않아 불필요한 객체 생성을 방지합니다.
React의 실제 구현에서 이를 확인할 수 있습니다:
// react/packages/react-reconciler/src/ReactFiberHooks.js
// 최초 렌더링 시 실행
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
const initialStateInitializer = initialState;
initialState = initialStateInitializer(); // 함수 실행
}
// ... 생략
}
// 업데이트 시 실행
function updateReducerImpl<S, A>(
hook: Hook,
current: Hook,
reducer: (S, A) => S,
): [S, Dispatch<A>] {
const queue = hook.queue;
// 초기값 재실행 없이 큐에서 상태 처리
queue.lastRenderedReducer = reducer;
}
// mountWorkInProgressHook, updateWorkInProgressHook부터 살펴봐야 하지만 너무 길어져 넘기겠습니다.😅
- 마운트 시: mountStateImpl에서 typeof initialState === 'function' 검사 후 함수 실행
- 업데이트 시: updateReducerImpl에서 배치 업데이트를 위해 fiber의 큐에 변경사항만 저장, 초기값 함수는 재실행하지 않음
이는 React에서 인라인 스타일을 권장하지 않는 이유와 동일합니다. 렌더링 과정에서 객체 리터럴은 항상 새로운 참조를 생성하기 때문입니다. 이번과제 ToastContext 구축 시 value 부분에도 적용되는 개념입니다.👀
참고문서
Q2. useMemo에 return에 왜 에러가 뜨는지 모르겠어요
문제 코드
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
const valueRef = useRef<T | undefined>(undefined);
const depsRef = useRef<DependencyList>([]);
if (!_equals(depsRef.current, _deps)) {
valueRef.current = factory();
depsRef.current = _deps;
}
return valueRef.current; // error : 'T | undefined' 형식은 'T' 형식에 할당할 수 없습니다.
TypeScript 에러입니다. useMemo 훅의 반환 타입은 T로 정의했지만, valueRef.current는 undefined일 가능성이 있어 타입 불일치가 발생합니다.
해결 방법: 초기 상태 처리 추가
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
const valueRef = useRef<T | undefined>(undefined);
const depsRef = useRef<DependencyList>([]);
// 초기 렌더링이거나 의존성이 변경된 경우
if (valueRef.current === undefined || !_equals(depsRef.current, _deps)) {
valueRef.current = factory();
depsRef.current = _deps;
}
return valueRef.current; // 이제 T 타입으로 추론됨
valueRef.current === undefined 조건을 추가하여 초기 렌더링 시 undefined 상태를 처리합니다. 이제 반환 시점에는 항상 T 타입이 보장됩니다.
학습 효과 분석
이번 과제를 통해 일반적인 얕은 비교와 React의 얕은 비교 방식이 다르다는 점을 파악했습니다. 기초적인 타입별 특징과 메모리 할당 방식을 다시 학습했고, 참조 타입 사용 시 React의 불변성 원칙을 지키는 방법을 정리했습니다.
Zustand와 React의 얕은 비교 구현 코드를 분석한 결과, Zustand는 Symbol.iterator 활용, Map과 Set 객체 처리, Object.is 메서드 사용 등 더 정교한 비교 로직을 구현하고 있음을 확인했습니다.
과제를 빠르게 진행하여 다른분들의 문제를 파악해보며 설명하는 과정에서 왜? 를 납득시키기 위한 좀 더 논리적인 원리를 파고드는 경험도 했습니다.
현재 과제는 Preact의 방식과 유사한 패턴이지만 좀 더 대규모 환경인 React의 코드를 헤집어보며 hook 실행 시점과 지연초기화가 어떻게 가능한지 학습했습니다. (몇천줄 속에서 코드 찾는 요령이 늘은 것 같아요 👍)
과제 피드백
useAutoCallback 구현 시 인자를 전달하지 않아도 테스트가 통과하는 부분이 있었습니다. (E2E 제외) UnitTest가 추가되어도 괜찮겠다! 생각이 들었습니다.
현 React의 구조는 너무 복잡한데 Preact로 접근해서 풀어보는 방식이 새롭고 좋았습니다!! 쉽게 배우고 -> 깊게 들어가는 느낌
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
저는 React 내부적으로 렌더링이 일어나는 순서에 대해 딥다이브하며 한번 작성해봤습니다.
Fiber 아키텍처
Fiber는 React 16부터 도입된 재조정(Reconciliation) 엔진입니다. 각 컴포넌트나 요소를 객체로 표현합니다. 이하 설명은 Fiber 아키텍처의 렌더링 과정입니다.
Fiber 노드의 구조:
// Fiber 노드
{
type: 'div' | Component,
props: {...},
child: null,
sibling: null,
return: null,
alternate: null,
effectTag: NoEffect,
stateNode: DOMElement
// ... 생략
}
두 단계의 렌더링 과정
React의 렌더링은 두 단계로 나누어 집니다.
Render Phase (렌더 단계)
- Virtual DOM을 조작하여 변경점을 계산하는 단계입니다
- Fiber 아키텍처를 통해 작업을 중단, 재시작할 수 있습니다
- 동시성 모드에서 비동기적으로 실행됩니다
- 컴포넌트 함수를 호출하고 Virtual DOM에만 반영하며, 실제 DOM은 변경하지 않습니다
Commit Phase (커밋 단계)
- Render Phase에서 계산된 변경사항을 실제 DOM에 적용합니다
- 라이프사이클 메서드와 훅을 실행합니다
- 일관된 화면 업데이트를 위해 동기적으로 실행됩니다
- 모든 작업 완료 후 브라우저가 화면을 다시 그립니다 (Paint)
이중 트리 구조
React는 두 개의 트리를 동시에 관리합니다.
- Current Tree: 현재 화면에 표시된 상태 트리
- Work-in-Progress Tree: 업데이트가 적용되는 새로운 상태 트리
이 구조는 더블 버퍼링 방식으로 작동합니다. 백그라운드에서 새로운 트리를 구성하고, 완료되면 포인터를 교체하여 새 트리를 활성화합니다. 이를 통해 작업 중단과 재시작, 우선순위 기반 업데이트가 가능합니다.
한줄로 표현해보자면 React의 렌더링은 **Render Phase(Virtual DOM 조작 및 비교) → Commit Phase(실제 DOM 반영)**의 두 단계를 반복하며(loop) Fiber 아키텍처기반으로 현재 보여지는 currentTree, 변경사항에 대한 업데이트를 진행중인 Work-in-Progress Tree 통해 효율적이게 렌더링을 진행합니다.
현재의 동작이 마운트냐, 업데이트냐에 따라 달라지는 WorkLoop 부분도 확인했지만 스케쥴러부터 관련 함수들까지의 양이 너무 방대해 아직 완벽하게 흐름을 이해하지 못했습니다.😅
관련 아티클 React 톺아보기 -2
메모이제이션에 대한 나의 생각을 적어주세요.
개인적인 생각으로는 메모이제이션 훅은 예기치 못한 동작들을 원하는 방향으로 돌아갈 수 있게 도와주는 훅에 더 가깝다고 느꼈습니다.
물론 대규모 프로젝트나 빅 데이터를 다루지 못했기에 이런 결론에 도달했을 수 있지만, 조금 더 근본적인 생각으로 돌아가보자면 이 메모이제이션 훅들은 렌더링을 개선해주고 큰 계산의 결과값을 캐싱하는 용도로 나온 훅으로 알고있습니다.
그런데 이 훅들을 사용하지 않았을 때 버벅임이나 성능적인 문제가 아니라 기술적인 오류가 발생한다면 가장 먼저 내가 컴포넌트의 구조를 잘못 잡았나? 부터 생각해보고 이 훅들을 사용하지 않아도 "정상적"으로 돌아가는 방향으로 개선하는걸 우선해볼 것 같습니다.
분명 이 메모이제이션 훅들은 필요한 훅이지만 비용이 적지 않은 만큼 내가 설계한 방향이 맞았나? 더 나은 방법은 없을까? 라고 리팩토링의 고민을 하게 해줄 수 있는 훅이라고 생각해봤습니다 🙇♂️
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
왜 컨텍스트와 상태관리가 필요한가?
Props Drilling의 한계
컴포넌트 트리가 깊어질수록 props 전달 체인이 길어집니다. 특히 중간 컴포넌트들이 자신은 사용하지 않는 데이터를 단순히 하위 컴포넌트로 전달하기 위해서만 존재하게 되는 상황이 발생합니다. 이는 변경점 발생 시 모든 중간 단계 컴포넌트를 수정해야 하는 문제로 이어집니다.
형제 컴포넌트 간 데이터 공유의 복잡성
React의 단방향 데이터 흐름에서 형제 컴포넌트끼리 직접 소통할 방법이 없습니다. 데이터를 공유하려면 가장 가까운 공통 부모 컴포넌트까지 상태를 끌어올려야 하는데, 이는 상위 컴포넌트의 책임을 무겁게 만들고 컴포넌트 간 결합도를 높입니다.
전역 상태 관리의 필요성
애플리케이션 전반에서 사용되는 데이터(인증 상태, 설정 값 등)는 컴포넌트 계층과 상관없이 접근할 수 있어야 합니다. props로 이런 데이터를 전달하면 모든 컴포넌트가 이 데이터에 의존하게 되어 결합도가 높아집니다. 결국 컨텍스트와 상태관리는 "데이터가 필요한 곳에서 바로 접근할 수 있게 해주는 도구" 라는 생각이 들었습니다.
컨텍스트와 상태관리를 사용하지 않으면 어떤 문제가 발생할까?
인터페이스 변경 시 수정 범위 확산
props로 전달되는 데이터의 타입이나 구조가 변경되면, 해당 데이터를 전달하는 모든 중간 컴포넌트의 props 인터페이스를 수정해야 합니다. 이는 변경의 영향 범위를 예측하기 어렵게 만들고, 실수로 누락되는 부분이 생길 가능성을 높입니다.
컴포넌트의 독립성 저하
특정 props에 의존하는 컴포넌트는 해당 props를 제공하는 상위 컴포넌트 없이는 동작할 수 없습니다. 이는 컴포넌트의 재사용성을 제한합니다.
상태 일관성 관리의 어려움
동일한 데이터를 여러 컴포넌트에서 각각 관리하면 데이터 동기화 문제가 발생합니다. 한 곳에서 데이터가 업데이트되어도 다른 곳의 데이터는 이전 상태를 유지하는 불일치가 생길 수 있습니다.
중복 연산과 메모리 비효율성
같은 데이터 가공 로직이 여러 컴포넌트에 분산되어 있으면 동일한 계산이 중복으로 수행됩니다. 또한 비슷한 상태를 여러 곳에서 관리하면 메모리 사용량이 불필요하게 증가할 수 있습니다.
컨텍스트의 다양한 활용법
컴파운드 컴포넌트 패턴으로 제한된 범위의 Context 활용
전역 Context의 리렌더링 문제를 피하면서도 관련 컴포넌트들끼리는 상태를 공유할 수 있는 절충안입니다. 특정 기능 단위로 Context 범위를 제한하여 해당 영역 내에서만 상태를 공유합니다. 이 패턴은 Modal, Accordion, Tab 등 UI 컴포넌트에서 내부 상태를 공유해야 할 때 특히 유용합니다. 컴포넌트의 내부 구현은 Context를 사용하지만 외부에서는 일반적인 props 인터페이스로 사용할 수 있어 캡슐화가 잘 됩니다.
Custom Hooks를 통한 상태 로직 재사용
상태 관리 로직을 훅으로 분리하면 여러 컴포넌트에서 동일한 상태 패턴을 재사용할 수 있습니다. 전역 상태 없이도 비슷한 상태 로직이 필요한 컴포넌트들에서 일관된 동작을 구현할 수 있습니다. localStorage나 sessionStorage와의 동기화, API 호출 상태 관리, 폼 validation 등의 로직을 훅으로 만들어 재사용하는 방식입니다.
서버 상태 관리 라이브러리 활용
React Query, SWR 같은 라이브러리를 사용하면 서버에서 가져오는 데이터는 별도로 관리할 수 있습니다. 캐싱, 동기화, 백그라운드 업데이트를 자동으로 처리해주므로 클라이언트 상태와 서버 상태를 분리하여 복잡성을 줄일 수 있습니다.
리뷰 받고 싶은 내용
ToastProvider
- hideAfter에 useMemo를 사용하지 않을 방법은 없었을까요? useAutoCallback을 사용하면 내부 디바운싱에서 타이머가 초기화되지 않는 문제가 있었습니다. 함수를 메모이제이션 하는거니까 useCallback쪽이 더 어울린다고 생각이 들어서요!
const hideAfter = useAutoCallback(debounce(hide, DEFAULT_DELAY));
// ⚠️타이머가 비정상으로 동작해서 Toast가 꺼지지않음
export const debounce = <T extends AnyFunction>(callback: T, delay: number) => {
let timeoutId: ReturnType<typeof window.setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
callback(...args);
timeoutId = null;
}, delay);
};
};
- 이런 Provider 구현 시 useReducer가 더 좋을까요? 저는 useState로 상태값을 두고 handle함수를 Provider 내부에 둔 상태가 한눈에 보여 파악하기 쉽다고 생각했습니다! 코치님은 useReducer를 선호하시나요?? 이유도 궁금합니다!
/* eslint-disable react-refresh/only-export-components */
import { useAutoCallback } from "@hanghae-plus/lib";
import { createContext, memo, type PropsWithChildren, useContext, useMemo, useReducer } from "react";
import { createPortal } from "react-dom";
import { debounce } from "../../utils";
import { Toast } from "./Toast";
import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer";
type ShowToast = (message: string, type: ToastType) => void;
type Hide = () => void;
const ToastContext = createContext<{
message: string;
type: ToastType;
}>({
...initialState,
});
const ToastCommandContext = createContext<{
show: ShowToast;
hide: Hide;
}>({
show: () => null,
hide: () => null,
});
const DEFAULT_DELAY = 3000;
const useToastContext = () => useContext(ToastContext);
const useToastCommandContext = () => useContext(ToastCommandContext);
export const useToastCommand = () => {
const { show, hide } = useToastCommandContext();
return { show, hide };
};
export const useToastState = () => {
const { message, type } = useToastContext();
return { message, type };
};
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 = useAutoCallback(debounce(hide, DEFAULT_DELAY));
const showWithHide: ShowToast = useAutoCallback((...args) => {
show(...args);
hideAfter();
});
const command = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
return (
<ToastCommandContext.Provider value={command}>
<ToastContext.Provider value={state}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext.Provider>
</ToastCommandContext.Provider>
);
});
- 얕은 비교 함수를 구현하고나서 Zustand 코드를 살펴보았는데 Map과 Set, Symbol.iterator, 일반 객체를 분리해서 순회하는 부분을 보았습니다. 저는 따로 분리하여 처리하지 않았는데 처리하지 않았을 때 문제가 될 부분이 있을까요?
번외: 준일 코치님 멘토링 세션을 진행하며 제가 왜 빅테크를 가고 싶어하는지, 업무를 해당 회사들에 어울리는 인재처럼 진행하고 있었는지 다시 한번 돌아보게 되었습니다 감사합니다🙇♂️
과제 피드백
안녕하세요 윤우님! 정리를 너무 잘 해주셨네요 ㅎㅎ 고생하셨습니다!!
hideAfter에 useMemo를 사용하지 않을 방법은 없었을까요? useAutoCallback을 사용하면 내부 디바운싱에서 타이머가 초기화되지 않는 문제가 있었습니다. 함수를 메모이제이션 하는거니까 useCallback쪽이 더 어울린다고 생각이 들어서요!
이 때는 useCallback이 아니라 useMemo를 사용해야 한다고 생각해요! debounce로 만든 함수 자체를 메모해야 하기 때문이죠! 그래서 솔루션도 살펴보시면 debounce를 useMemo로 만들어서 사용하고 있답니다 ㅎㅎ
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
요로코롬...
근데 생각해보면 useCallback으로 써도 괜찮을 것 같네요!
const hideAfter = useCallback(debounce(hide, DEFAULT_DELAY), [hide]);
이런 Provider 구현 시 useReducer가 더 좋을까요? 저는 useState로 상태값을 두고 handle함수를 Provider 내부에 둔 상태가 한눈에 보여 파악하기 쉽다고 생각했습니다! 코치님은 useReducer를 선호하시나요?? 이유도 궁금합니다!
복잡한 상태를 다룰수록 reducer가 좋다고 생각합니다 ㅎㅎ 물론 state로 구현해도 충분하지만요! reducer로 만들면 순수함수가 되고, 순수함수는 테스트하기가 용이해지는 장점도 있답니다!
다만 이건 팀 컨벤션이 제일 중요하다고 생각해요. 제가 reducer를 쓴 이유는 학습에 대한 이유도 있답니다! 아마 useReducer 자체가 익숙하지 않은 사람들도 많을 것 같아서, 이렇게도 사용할 수 있다는걸 보여주고 싶었어요.
얕은 비교 함수를 구현하고나서 Zustand 코드를 살펴보았는데 Map과 Set, Symbol.iterator, 일반 객체를 분리해서 순회하는 부분을 보았습니다. 저는 따로 분리하여 처리하지 않았는데 처리하지 않았을 때 문제가 될 부분이 있을까요?
Map, Set 등을 처리할 때 문제되겠죠!? 사실 실무에서는 대부분 만들어진 것들을 사용하기 때문에, 내부가 어떻게 동작하는구나 정도만 알고 있어도 충분하다고 생각해요 ㅎㅎ
deppEqual도 fast-deep-equal 같은 라이브러리를 사용한답니다 ㅋㅋ