과제 체크포인트
배포 링크
https://jun17183.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 개선
과제 셀프회고
다른 분들은 이번 과제가 재밌을 거 같다고 말씀하셨지만 리액트가 낯선 저에겐 약간 겁이 나는 과제였습니다. useMemo나 useCallback을 제대로 사용해 본 적이 없었으며 커스텀 훅을 만들어 본 경험도 없었습니다. 그렇기 때문에 우선 개념 학습부터 제대로 하고 과제를 진행하기로 했습니다.
⭐️학습노트 피그잼 링크⭐️
https://www.figma.com/board/19cKvPZ3vmL6MeDC1E8Evl/Untitled?node-id=0-1&p=f&t=E24ExfHWF2QCEs4E-0
기술적 성장
React Fiber
개념 정리를 하면서 state나 deps는 어디에 어떤 형태로 저장되는지, 어느 시점에 비교를 하여 어떻게 렌더링이 되는지 궁금했습니다. 궁금증을 따라가다 보니 React Fiber라는 것을 알게 되었고 전반적인 구조와 흐름에 대해 알 수 있었습니다.
useRef
useRef 구현 후 기왕 이렇게 된 거 DOM 조작 기능까지 구현하고 싶었습니다. 하지만 찾아봤더니 useRef 함수만 구현해도 DOM 조작이 가능하더라구요. 어떤 원리로 가능한지 알아보았습니다.
1. ref는 특별한 props
className,onClick같은 일반 props와 달리 DOM에 전달되지 않음- React가 내부적으로 따로 처리하는 특수 속성
2. JSX 컴파일 과정에서 분리됨
// JSX: <input ref={myRef} type="text" />
// 컴파일 결과: { type: "input", props: { type: "text" }, ref: myRef }
3. DOM 생성 후 자동 연결
- React가 실제 DOM 엘리먼트를 생성
ref.current = domElement자동 할당
4. ref 타입 체크
if (ref && typeof ref === 'object' && 'current' in ref) {
ref.current = domElement; // 우리가 만든 { current: null } 객체
}
이전 회사에서의 프로젝트를 떠올리며 (React.memo, useMemo, useCallback)
이전 회사에서 항공 예약 사이트를 제작을 맡은 적이 있습니다. 모든 팀원이 React를 실무로 사용해 본 적이 없었기에 많은 이슈가 있었습니다. 그 중에서도 가장 크리티컬한 이슈는 공항 검색 모달이었습니다.
출발지와 도착지 input을 클릭하면 대륙-국가-도시-공항 목록을 나타내는 모달이 나타났습니다. 이 모달은 폴더처럼 접혀 있고, 클릭하면 슬라이딩으로 하위 항목이 노출되었습니다. 또한 input에 입력할 때마다 공항 데이터를 다시 불러와 목록을 그렸습니다.
전 세계 공항의 수가 워낙 많고 각 input 마다 모달이 노출되었던 점, 슬라이딩 등으로 인해 속도가 엄청나게 느렸고 사이트가 멈추기까지 했습니다. 그때 원인도 모른 채 이런 저런 코드도 고치고 고객사와 타협도 보며 문제를 넘겼던 기억이 있습니다.
지금은 퇴사를 하여 당시 코드를 수정할 수 없지만 이번 과제를 통해 배운 내용을 토대로 다음과 같이 코드를 수정하면 어땠을까 하는 아쉬움이 남습니다.
const useFilteredAirports = (searchTerm) => {
return useMemo(() => {
if (!searchTerm.trim()) return AIRPORT_DATA;
const filtered = {};
const term = searchTerm.toLowerCase();
Object.entries(AIRPORT_DATA).forEach(([continent, countries]) => {
const filteredCountries = {};
Object.entries(countries).forEach(([country, cities]) => {
const filteredCities = {};
Object.entries(cities).forEach(([city, airports]) => {
const filteredAirports = airports.filter(airport =>
airport.toLowerCase().includes(term) ||
city.toLowerCase().includes(term) ||
country.toLowerCase().includes(term) ||
continent.toLowerCase().includes(term)
);
if (filteredAirports.length > 0) {
filteredCities[city] = filteredAirports;
}
});
if (Object.keys(filteredCities).length > 0) {
filteredCountries[country] = filteredCities;
}
});
if (Object.keys(filteredCountries).length > 0) {
filtered[continent] = filteredCountries;
}
});
return filtered;
}, [searchTerm]);
};
// 메모화된 공항 선택 드롭다운 컴포넌트
const AirportDropdown = memo(({
isOpen,
searchTerm,
onSearchChange,
onAirportSelect,
onClose
}) => {
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const filteredAirports = useFilteredAirports(debouncedSearchTerm);
const [expandedItems, setExpandedItems] = useState(new Set());
// 토글 함수들을 useCallback으로 메모화
const toggleExpanded = useCallback((path) => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return newSet;
});
}, []);
const handleAirportClick = useCallback((airport) => {
onAirportSelect(airport);
onClose();
}, [onAirportSelect, onClose]);
if (!isOpen) return null;
return ( ... )
}
자랑하고 싶은 코드
2주차 과제에서 normalizeVNode와 같은 함수를 구현할 때, 모든 처리를 한 함수에서 구현했습니다. 하지만 코치님 솔루션을 보니 모두 분리하셨더라구요. 그 부분이 감명 깊어 equals 함수 구현에 이를 적용해 보았습니다.
import { Types } from "./types";
export const getType = (value: unknown) => {
if (value === null) return Types.NULL;
if (value === undefined) return Types.UNDEFINED;
if (Array.isArray(value)) return Types.ARRAY;
return typeof value;
};
import { Types } from "../types";
import { getType } from "../utils";
// 배열 얕은 비교
const isShallowArrayEqual = (a: Array<unknown>, b: Array<unknown>) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
};
// 객체 얕은 비교
const isShallowObjectEqual = (a: Record<string, unknown>, b: Record<string, unknown>) => {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key)) return false;
if (a[key] !== b[key]) return false;
}
return true;
};
export const shallowEquals = (a: unknown, b: unknown) => {
const typeA = getType(a);
const typeB = getType(b);
// 1. 타입이 다르면 다른 값
if (typeA !== typeB) return false;
// 2. 배열 비교
if (typeA === Types.ARRAY) {
return isShallowArrayEqual(a as Array<unknown>, b as Array<unknown>);
}
// 3. 객체 비교
if (typeA === Types.OBJECT) {
return isShallowObjectEqual(a as Record<string, unknown>, b as Record<string, unknown>);
}
// 4. 기본 타입 비교
return a === b;
};
(사실 피그잼으로 정리한 내용을 가장 자랑하고 싶다...ㅎ)
개선이 필요하다고 생각하는 코드
deepEquals에서 객체 키를 비교할 때 includes를 통해 존재 여부를 확인했습니다. 하지만 저희 학메 지수님께서 아래와 같이 리뷰해 주셨어요.
그래서 다음과 같이 수정하면 어떨까 합니다.
const isDeepObjectEqual = (a: Record<string, unknown>, b: Record<string, unknown>) => {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
// O(1) 시간복잡도로 개선
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!deepEquals(a[key], b[key])) return false;
}
return true;
};
마찬가지로 지수님이 남겨주신 리뷰인데요,
기존의 코드는 useRef()와 같이 초기값 없이 호출할 수가 없었으며 return 또한 undefined로 할 수 없었습니다. 아래와 같이 수정하면 이런 케이스를 더욱 잘 처리할 수 있지 않나 생각합니다.
import { useState } from "react";
interface MutableRefObject<T> {
current: T;
}
// 오버로드 시그니처들
export function useRef<T = undefined>(): MutableRefObject<T | undefined>;
export function useRef<T>(initialValue: T): MutableRefObject<T>;
// 실제 구현
export function useRef<T>(initialValue?: T): MutableRefObject<T | undefined> {
const [refObj] = useState<{ current: T | undefined }>(() => ({
current: initialValue
}));
return refObj;
}
학습 효과 분석 및 과제 피드백
가장 큰 배움은 각각의 훅 함수들입니다. 사용을 거의 안 해본 것은 물론이고 useMemo와 useCallback의 차이 등 개념도 잘 몰랐습니다. 이번 기회에 익힐 수 있어서 후련한 마음까지 듭니다.
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
렌더링 과정 및 최적화
- 상태 변경 시 업데이트 큐에 상대 변화 등록
- 우선순위에 따른 스케줄링 구성 (우선순위에 따른 최적화)
- 변화가 일어난 컴포넌트부터 루트까지 올라가며 lane 마킹
- 트리 중 lane이 없는 노드는 자식까지 제외 (렌더링이 필요하지 않는 곳은 제외)
- lane에 따라 각 컴포넌트 리렌더링
- 상태 업데이트 및 상태에 따른 hook 실행
- 이때 deps에 따라 실행 여부 결정
렌더링 관련 개념
- Virtual DOM: 실제 DOM의 가상 복사본, 메모리에서 빠르게 비교
- Reconciliation: 이전 Virtual DOM과 새 Virtual DOM을 비교해서 차이점 찾기
- diffing: 트리 구조를 효율적으로 비교하는 알고리즘
라이프사이클 메서드
useEffect(() => { ... }, []): mount 시 (componentDidMount)useEffect(() => { ... }, [state]): udpate 시 (componentDidMount + componentDidUpdate)- cleanup으로 componentWillUnmount 구현 가능
useLayoutEffect
function TimingExample() {
const [count, setCount] = useState(0);
console.log('1. 렌더링 중');
useLayoutEffect(() => {
console.log('3. useLayoutEffect - DOM 변경 후, 페인팅 전');
});
useEffect(() => {
console.log('4. useEffect - 페인팅 후, 비동기');
});
console.log('2. 렌더링 완료');
return <div>{count}</div>;
}
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션이 필요한 경우
- 무거운 계산이 반복될 때
- 자식 컴포넌트가 불필요하게 렌더링 될 때
- 참조값이 동일해야 할 때
메모이제이션을 사용하지 않으면 생길 수 있는 문제
: 메모이제이션을 사용하지 않으면 부모 컴포넌트의 state나 props와는 아무 상관이 없는 자식 컴포넌트도 불필요하게 리렌더링될 수도 있으며, 어떤 컴포넌트가 렌더링될 때마다 높은 비용의 계산이 계속 실행될 수 있다.
주의
메모이제이션은 메모리 사용량이 증가하고 복잡성 또한 증가하기에 과도한 사용은 성능 저하를 유발한다.
메모이제이션을 사용하지 않고 해결할 수 있는 방법
- 컴포넌트를 분리하여 리렌더링 최소화
- 부모 컴포넌트에서 너무 많은 상태를 관리하지 않는다.
- 하나의 객체로 상태를 관리하지 않고 분리한다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
컨텍스트와 상태관리의 필요성
- props drilling 해결
- 사용자 정보, 언어와 같은 전역 상태 관리
- 컴포넌트 간 소통 용이
단점
- 컨텍스트 변경 시 하위 컴포넌트 모두 리렌더링
- 너무 많은 컨텍스트 사용 시 콜백 지옥처럼 코드가 복잡해진다.
컨텍스트, 상태관리를 사용하지 않고 해결할 방법
- url 사용
- 커스텀 훅으로 분리
- children 패턴
주의할 점
- 매번 새로운 객체가 생성되지 않도록 메모이제이션
- 필요한 곳에서만 사용 (모두를 감싸지 않기)
리뷰 받고 싶은 내용
typeof로 null이나 배열의 타입 조회 시 object로 나오는 부분 때문에 일관성을 유지하고 싶어서 문자열로 구성된 Types 타입을 만들고 getType으로 null, array까지 모두 구분해 주었습니다. 하지만 이 과정에서 as 키워드를 사용하게 되었습니다. 타입스크립트를 공부할 때 as 키워드는 지양하는 것이 타입스크립트 철학에 맞다고 들어서 아래 코드에 의문이 있습니다. 코치님이라면 어떻게 작성할지 궁금합니다!
export const Types = {
NUMBER: "number",
STRING: "string",
BOOLEAN: "boolean",
SYMBOL: "symbol",
BIGINT: "bigint",
NULL: "null",
UNDEFINED: "undefined",
ARRAY: "array",
OBJECT: "object",
} as const;
import { Types } from "./types";
export const getType = (value: unknown) => {
if (value === null) return Types.NULL;
if (value === undefined) return Types.UNDEFINED;
if (Array.isArray(value)) return Types.ARRAY;
return typeof value;
};
import { Types } from "../types";
import { getType } from "../utils";
// 배열 얕은 비교
const isShallowArrayEqual = (a: Array<unknown>, b: Array<unknown>) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
};
// 객체 얕은 비교
const isShallowObjectEqual = (a: Record<string, unknown>, b: Record<string, unknown>) => {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key)) return false;
if (a[key] !== b[key]) return false;
}
return true;
};
export const shallowEquals = (a: unknown, b: unknown) => {
const typeA = getType(a);
const typeB = getType(b);
// 1. 타입이 다르면 다른 값
if (typeA !== typeB) return false;
// 2. 배열 비교
if (typeA === Types.ARRAY) {
return isShallowArrayEqual(a as Array<unknown>, b as Array<unknown>);
}
// 3. 객체 비교
if (typeA === Types.OBJECT) {
return isShallowObjectEqual(a as Record<string, unknown>, b as Record<string, unknown>);
}
// 4. 기본 타입 비교
return a === b;
};
과제 피드백
안녕하세요 홍준, 수고하셨습니다! 이번 과제는 React의 내장 훅들을 직접 구현해보면서 프레임워크가 어떻게 상태를 관리하고 최적화하는지 이론을 넘어 몸으로 깊이 이해하는 것이 목표였습니다.
홍준님의 회고를 보니 React가 낯선 상황에서도 개념 학습부터 차근차근 시작하셔서 정말 체계적으로 접근하신 것 같아요. 특히 피그잼으로 정리하신 학습노트! 아주 멋져요. 특히나 React Fiber의 전반적인 구조와 흐름까지 파악하시고, useRef의 DOM 조작 원리를 깊이 탐구하신 부분에서 뭐랄까 개발자스러운 탐구정신(?) 그런것들이 느껴졌어요! 이렇게 한번 구조와 그림을 정리해두고나면 원리들이 선명하게 느껴질거에요!
equals 함수 구현에서 getType, isShallowArrayEqual, isShallowObjectEqual 등으로 기능을 세분화해서 만들어본 접근도 좋았습니다. 코드를 작게 나누면 절대 틀릴 수 없는 코드들이 많이 만들어지죠.
이전 항공 예약 사이트 프로젝트에 대해서 설명해주는 부분도 인상적이었습니다. 실습과제와 실무를 엮어서 고민한다는 건 쉽지 않은데 잘했습니다.
Q) typeof로 인한 타입 구분 문제와 as 키워드 사용에 대한 의견
=> 지금 접근 방식은 이미 조건문으로 타입이 걸러졌기 때문에 as를 쓴다고 해도 사실 문제가 없고 as를 적절히 잘 사용한 예시라 생각합니다. 조금 더 나은 방식은 이렇게 조건에 따라 타입을 필터링을 할때 TypeScript에서는 타입 가드(Type Guard)를 사용하면 좋습니다. 그렇면 as 키워드 없이도 안전하게 타입을 좁힐 수 있습니다.
const isArray = (value: unknown): value is unknown[] => Array.isArray(value);
const isObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const shallowEquals = (a: unknown, b: unknown) => {
if (isArray(a) && isArray(b)) {
// 여기서 a, b는 자동으로 unknown[]로 추론됩니다
return isShallowArrayEqual(a, b);
}
if (isObject(a) && isObject(b)) {
// 여기서 a, b는 자동으로 Record<string, unknown>로 추론됩니다
return isShallowObjectEqual(a, b);
}
return a === b;
};
이렇게 하면 타입 안전성을 보장하면서도 런타임 타입 체크와 컴파일 타임 타입 추론을 모두 만족시킬 수 있습니다.
수고하셨습니다. 이제 시작하는 클린코드 챕터에서도 이런 탐구 정신으로 더욱 성장하시길 응원합니다! 화이팅입니다! :)