과제 체크포인트
배포 링크
https://yeongseoyoon-hanghae.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 정리를 위해 밤을 새는 대참사가
기술적 성장
React 내부 동작 원리 이해
useMemo, useCallback 등의 훅을 직접 구현하면서 React가 내부적으로 어떻게 메모이제이션과 의존성 비교를 수행하는지 깊이 이해하게 되었습니다. useRef를 활용한 이전 값 저장과 shallowEquals를 통한 의존성 비교 로직을 구현하면서 React의 최적화 메커니즘을 이해할 수 있었습니다.
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
const prevDepsRef = useRef<DependencyList>(_deps);
const resultRef = useRef<T>(factory());
const depsChanged = !_equals(prevDepsRef.current, _deps);
if (depsChanged) {
resultRef.current = factory();
prevDepsRef.current = _deps;
}
return resultRef.current as T;
}
옵저버 패턴과 useSyncExternalStore
createObserver를 구현하면서 외부 상태 관리와 React의 동기화 메커니즘을 학습했습니다. 특히 subscribe 함수가 unsubscribe 함수를 반환하는 패턴이 useSyncExternalStore과 일치한 다는 점을 깨달았습니다.
const subscribe = (listener: Listener): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
useSyncExternalStore에 대하여
React 18에서 Concurrent Rendering이 렌더링을 여러 번에 나누어 처리하기때문에. 첫 번째 컴포넌트를 렌더링하는 중간에 외부 상태가 변경되면, 두 번째 컴포넌트는 이미 변경된 값을 읽게 되는 현상이 발생하는데, 이를 "Tearing"이라고 하고 그를 해소하기 위한 훅으로 useSyncExternalStore를 도입하게 된것을 이해할 수 있었습니다.
원래도 해당 훅을 localStorage와 같은 외부 훅과 동기화 하기 위해서 사용했었는데 내가 잘 모르고 사용하고 있었구나 하는 생각을 했던 것 같습니다.
자랑하고 싶은 코드
createStorage의 동기화 문제 해결 f0d190f,eaba043,b8756fc,484b483
이번 과제에서 가장 도전적이었던 부분은 createStorage의 localStorage 동기화 문제를 해결하는 것이었습니다.
AS-IS: Observer 패턴만 사용
export const createStorage = <T>(key: string, storage = window.localStorage) => {
let data: T | null = JSON.parse(storage.getItem(key) ?? "null");
const { subscribe, notify } = createObserver();
const get = () => data; // 메모리 변수만 반환
const set = (value: T) => {
data = value; // 메모리 변수 업데이트
storage.setItem(key, JSON.stringify(data));
notify(); // 구독자들에게 알림
};
return { get, set, reset, subscribe };
};
처음에는 이렇게 간단하게 접근했습니다. Observer 패턴을 사용해서 상태 변화를 구독자들에게 알리고, 메모리 변수로 빠른 접근을 보장하는 구조였습니다. 하지만 이렇게 해보니 테스트 통과는 하지만 조금 찝찝한 부분이 있었습니다.
localStorage를 직접 조작하는 상황들을 생각해보니 다음 상황이 발생할것이라고 생각했습니다.
// 다른 탭에서 변경하는 시나리오
// 탭 A
const userStorage = createStorage<User>('user');
console.log(userStorage.get()); // { name: 'John' }
// 탭 B에서 직접 조작
localStorage.setItem('user', JSON.stringify({ name: 'Jane' }));
// 탭 A에서 다시 호출
console.log(userStorage.get()); // 여전히 { name: 'John' }
일단 첫번째로는 createStorage가 반환하는 인스턴스가 싱글턴이 아니기때문에 같은 key로 생성된 스토리지 인스턴스임에도 동기화 되지 않을 것이었습니다. 그럼에도 테스트가 통과한 이유는...
현재 테스트는
it("multiple 컴포넌트가 같은 storage를 구독할 때 동기화되어야 한다", () => {
const storage = createStorage("shared"); // 하나의 인스턴스만 생성
const { result: result1 } = renderHook(() => useStorage(storage));
const { result: result2 } = renderHook(() => useStorage(storage));
/** 후략 */
});
와 같이 구현되어 있어서 의도적으로 하나의 인스턴스를 공유하고 있어 테스트가 통과되었습니다.
실제로는 제 코드를 기반으로 한다면 각 컴포넌트가 서로 다른 인스턴스를 사용하게 되었을 것입니다.
// ComponentA.tsx
const ComponentA = () => {
const userStorage = createStorage<User>('user'); // 인스턴스 1
const user = useStorage(userStorage);
// ...
};
// ComponentB.tsx
const ComponentB = () => {
const userStorage = createStorage<User>('user'); // 인스턴스 2
const user = useStorage(userStorage);
// ...
};
그렇게 되면 인스턴스 1이 구독하는 observer와 인스턴스 2가 구독하는 observer가 완전히 별개가 되어버려서, ComponentA에서 userStorage.set()을 호출해도 ComponentB의 useStorage 훅은 변경 사실을 전혀 모르게 될 거라고 예상했습니다.
(그렇게 시작된 대화...두컷만화)
내 코드의 문제가 뭘까
export const createStorage = <T>(key: string, storage = window.localStorage) => {
let data: T | null = JSON.parse(storage.getItem(key) ?? "null"); // 각각 다른 data 변수, 게다가 초기화 시점에만 읽음
const { subscribe, notify } = createObserver(); // 각각 다른 observer
const set = (value: T) => {
data = value; // 인스턴스 1의 data만 업데이트
storage.setItem(key, JSON.stringify(data));
notify(); // 인스턴스 1의 구독자들에게만 알림
};
return { get, set, reset, subscribe };
};
처음의 코드에서는 같은 키 'user'로 createStorage를 여러 번 호출하면 각각 다른 data 변수, 각각 다른 observer 인스턴스, 각각 다른 구독자 목록을 가지게 되므로 결과적으로 ComponentA에서 상태를 변경해도 ComponentB는 전혀 알 수 없는 상황이 발생합니다.
또한 초기화 시점에 한 번만 localStorage를 읽고, 그 이후로는 메모리 변수만 반환하다 보니 외부 변경사항을 전혀 감지할 수 없었습니다.
게다가 객체 타입의 데이터를 다룰 때 매번 새로운 객체가 생성되어 useSyncExternalStore가 불필요한 리렌더링을 계속 발생시킬것으로 예상했습니다.
// AS-IS에서 객체 반환 시 문제
const get = () => data; // data가 객체면?
const userStorage = createStorage<User>('user');
userStorage.set({ name: 'John', age: 30 });
return useSyncExternalStore(storage.subscribe, storage.get);
useSyncExternalStore는 참조 비교로 변경을 감지하는데 JSON.parse는 매번 새로운 객체를 생성하므로
- useSyncExternalStore가 값이 변경되었다고 판단함
- 리렌더링 발생
- 다시 get() 호출
- 또 다른 새로운 객체 반환 .... 무한순환해버림 요 과정이 무한루프를 돌것으로 예상되었습니다.
TO-BE: 싱글톤 패턴 + 캐시 시스템
interface StorageInstance<T> {
get(): T | null;
set(value: T): void;
reset(): void;
subscribe(listener: () => void): () => void;
}
// 싱글톤 맵
const storageInstances = new Map<string, StorageInstance<unknown>>();
export const createStorage = <T>(key: string, storage = window.localStorage): StorageInstance<T> => {
// 이미 존재하면 기존 인스턴스 반환
if (storageInstances.has(key)) {
return storageInstances.get(key) as StorageInstance<T>;
}
const { subscribe, notify } = createObserver();
// 캐시로 참조 안정성 보장
let cachedValue: T | null = null;
let isInitialized = false;
const get = (): T | null => {
if (!isInitialized) {
const item = storage.getItem(key);
cachedValue = item ? JSON.parse(item) : null;
isInitialized = true;
}
return cachedValue; // 항상 같은 참조 반환
};
const set = (value: T) => {
storage.setItem(key, JSON.stringify(value));
cachedValue = value; // 캐시 즉시 업데이트
notify();
};
const instance = { get, set, reset, subscribe };
storageInstances.set(key, instance); // 싱글톤 저장
return instance;
};
위 코드에서는 싱글톤 맵을 생성하여 같은 키로 createStorage를 호출하게 되면 완전히 동일한 인스턴스를 받게 구현하였습니다.
const get = (): T | null => {
if (!isInitialized) {
// 실제 사용 시점에 localStorage 확인
const item = storage.getItem(key);
cachedValue = item ? JSON.parse(item) : null;
isInitialized = true;
}
return cachedValue;
};
또한 지연로딩을 통해 첫번째 호출인 경우에만 localStorage를 읽도록하여 외부 변경 감지를 돕도록 구현하였습니다.
이렇게 해두고 추가적인 테스트를 작성하였습니다.
이 두 테스트는 싱글톤 패턴이 올바르게 구현되었는지를 테스트하기 위해 작성하였는데요,
첫 번째 테스트 "같은 key로 생성된 다른 인스턴스들이 동기화되어야 한다"는 싱글톤의 핵심 기능을 검증합니다. 실제로는 각 컴포넌트가 독립적으로 createStorage("user")를 호출하는 경우가 대부분인데, 이때 같은 key를 사용하는 모든 호출이 실제로는 동일한 인스턴스를 반환해야 합니다. 만약 싱글톤 패턴이 제대로 구현되지 않았다면, storage1.set()으로 값을 변경해도 storage2를 구독하는 컴포넌트는 변경 사실을 전혀 모르게 되어 UI가 동기화되지 않는 문제가 발생하기 때문에 이런 문제를 테스트하기 위해 작성하였습니다.
또한 두 번째 테스트 "다른 key들은 서로 독립적으로 동작해야 한다"는 네임스페이스 격리를 검증합니다. 싱글톤 패턴을 구현할 때 자칫하면 모든 key가 하나의 인스턴스를 공유하거나, 한 key의 변경이 다른 key에 영향을 줄 수 있습니다. 때문에 사용자 정보("user")를 변경했을 때 테마 설정("settings")이 영향받지 않는지, 그리고 각 key별로 완전히 독립적인 상태 관리가 이루어지는지를 확인하도록 작성하였습니다.
그런데도 해결되지 않는 찝찝함...🥹
사실 어느정도 수준에서는 해결이 되었다고 생각했습니다. 그러나 외부에서 직접 localStorage를 조작하는 경우는 감지할 수 없었습니다.
(아 물론 조작안하면 되긴 하는데...찝찝하잖아)
이미 저는 로컬스토리지의 커스텀 이벤트를 알아버렸기 때문에...해당 방식을 가져와서 사용하기로 마음먹게 됩니다... 여기서 핵심은 이중 이벤트 감지라고 할 수 있는데요. storage 이벤트를 통해서는 다른 탭에서 변경을 감지하고, 커스텀 이벤트인 storage-inner-document 이벤트로는 같은 탭에서의 변경을 감지하는 방식입니다. 성호코치님 글 참고
// 같은 탭에서의 변경을 감지하기 위한 커스텀 이벤트
class StorageInnerDocumentEvent extends CustomEvent<StorageEventDetail> {
static readonly eventName = "storage-inner-document";
constructor(key: string, oldValue: string | null, newValue: string | null) {
super(StorageInnerDocumentEvent.eventName, {
detail: { key, oldValue, newValue },
});
}
}
// 글로벌 이벤트 핸들러
const handleStorageEvent = (event: Event) => {
let targetKey: string | null = null;
if (event instanceof StorageEvent) {
// 다른 탭에서의 변경 (브라우저 기본 storage 이벤트)
targetKey = event.key;
} else if (event.type === "storage-inner-document") {
// 같은 탭에서의 변경 (커스텀 이벤트)
targetKey = (event as CustomEvent<StorageEventDetail>).detail.key;
}
if (targetKey && globalObservers.has(targetKey)) {
// 캐시 무효화해서 다음 get() 호출 시 localStorage 재확인
if (cacheInvalidators.has(targetKey)) {
cacheInvalidators.get(targetKey)!();
}
globalObservers.get(targetKey)!.notify();
}
};
그리고 이벤트가 발생하면 해당 키의 캐시를 무효화시켜서, 다음 get() 호출 시에는 localStorage를 다시 읽도록 했습니다.
const invalidateCache = () => {
isInitialized = false; // 다음 get() 호출 시 localStorage 재확인
};
cacheInvalidators.set(key, invalidateCache);
실제 시나리오는 다음과 같을것이라고 생각합니다.
// 컴포넌트 A
const userStorage = createStorage<User>('user');
const user = useStorage(userStorage); // { name: 'John' }
// 컴포넌트 B (같은 탭)
const userStorage2 = createStorage<User>('user'); // 동일한 인스턴스!
userStorage2.set({ name: 'Jane' });
// 실시간 동기화 과정:
// 1. localStorage.setItem('user', '{"name":"Jane"}')
// 2. StorageInnerDocumentEvent 발생
// 3. handleStorageEvent 실행
// 4. 캐시 무효화 (isInitialized = false)
// 5. 모든 구독자에게 notify()
// 6. 컴포넌트 A 자동 리렌더링 → user = { name: 'Jane' }
계속 만약에 게임을 하면서 시나리오를 그려가는 기분이 들고 재밌었습니다.
블로커로 작용했던 질문이라 지송..
useEffect 구현 0636fa7, 517e7f6
type EffectCallback = () => void | (() => void);
export function useEffect(effect: EffectCallback, deps?: DependencyList): void {
const prevDepsRef = useRef<DependencyList | undefined>(undefined);
const cleanupRef = useRef<(() => void) | void>(undefined);
const isFirstRenderRef = useRef<boolean>(true);
const depsChanged =
deps === undefined || prevDepsRef.current === undefined || !shallowEquals(prevDepsRef.current, deps);
if (isFirstRenderRef.current || depsChanged) {
if (cleanupRef.current && typeof cleanupRef.current === "function") {
cleanupRef.current();
}
const cleanup = effect();
cleanupRef.current = cleanup;
prevDepsRef.current = deps;
isFirstRenderRef.current = false;
}
}
사실 useEffect같은 경우에 구현이 어려울 것 같지 않아서 추가적으로 구현해봤습니다.
prevDepsRef를 통해 이전 의존성을 저장하고, cleanupRef로 정리 함수를 관리하며 isFirstRenderRef로 첫 렌더링인지를 추적하도록 구현하였습니다.
또한 의존성 관련해서도 deps가 undefined면 매번 실행하고, 첫 실행이거나 shallowEquals로 의존성이 변경되었을 때만 effect를 실행하도록 구현하였습니다.
개선이 필요하다고 생각하는 코드
제가 추가적으로 작성한 createStorage 코드를 개선할 수 있으면 해보고 싶은데...뭔가 아이디어가 떠오르진 않는것 같습니다. 사실 createObserver를 제거해도 될 것 같은데, 현재의 구독형태를 그대로 가져가고 싶어서 createObserver를 제거하지 않고 하이브리드 형태로 구현하였습니다.
createStorage 코드
import { createObserver } from "./createObserver.ts";
interface StorageInstance<T> {
get(): T | null;
set(value: T): void;
reset(): void;
subscribe(listener: () => void): () => void;
}
interface StorageEventDetail {
key: string;
oldValue: string | null;
newValue: string | null;
}
class StorageInnerDocumentEvent extends CustomEvent<StorageEventDetail> {
static readonly eventName = "storage-inner-document";
constructor(key: string, oldValue: string | null, newValue: string | null) {
super(StorageInnerDocumentEvent.eventName, {
detail: { key, oldValue, newValue },
});
}
}
const globalObservers = new Map<string, ReturnType<typeof createObserver>>();
const cacheInvalidators = new Map<string, () => void>();
const handleStorageEvent = (event: Event) => {
let targetKey: string | null = null;
if (event instanceof StorageEvent) {
targetKey = event.key;
} else if (event.type === "storage-inner-document") {
targetKey = (event as CustomEvent<StorageEventDetail>).detail.key;
}
if (targetKey && globalObservers.has(targetKey)) {
if (cacheInvalidators.has(targetKey)) {
cacheInvalidators.get(targetKey)!();
}
globalObservers.get(targetKey)!.notify();
}
};
let globalListenersRegistered = false;
const getGlobalObserver = (key: string) => {
if (!globalObservers.has(key)) {
globalObservers.set(key, createObserver());
if (!globalListenersRegistered) {
window.addEventListener("storage", handleStorageEvent);
window.addEventListener(StorageInnerDocumentEvent.eventName, handleStorageEvent);
globalListenersRegistered = true;
}
}
return globalObservers.get(key)!;
};
const storageInstances = new Map<string, StorageInstance<unknown>>();
export const createStorage = <T>(key: string, storage = window.localStorage): StorageInstance<T> => {
if (storageInstances.has(key)) {
return storageInstances.get(key) as StorageInstance<T>;
}
const globalObserver = getGlobalObserver(key);
let cachedValue: T | null = null;
let isInitialized = false;
const invalidateCache = () => {
isInitialized = false;
};
cacheInvalidators.set(key, invalidateCache);
const get = (): T | null => {
if (!isInitialized) {
try {
const item = storage.getItem(key);
cachedValue = item ? JSON.parse(item) : null;
isInitialized = true;
} catch (error) {
console.error(`Error parsing storage item for key "${key}":`, error);
cachedValue = null;
isInitialized = true;
}
}
return cachedValue;
};
const set = (value: T) => {
try {
const oldValue = storage.getItem(key);
const newValue = JSON.stringify(value);
storage.setItem(key, newValue);
cachedValue = value;
window.dispatchEvent(new StorageInnerDocumentEvent(key, oldValue, newValue));
} catch (error) {
console.error(`Error setting storage item for key "${key}":`, error);
}
};
const reset = () => {
try {
const oldValue = storage.getItem(key);
storage.removeItem(key);
cachedValue = null;
window.dispatchEvent(new StorageInnerDocumentEvent(key, oldValue, null));
} catch (error) {
console.error(`Error removing storage item for key "${key}":`, error);
}
};
const subscribe = globalObserver.subscribe;
const instance = { get, set, reset, subscribe };
storageInstances.set(key, instance);
return instance;
};
학습 효과 분석
가장 큰 배움: React의 성능 최적화가 단순히 memo나 useMemo를 사용하는 것이 아니라, 데이터 흐름과 리렌더링 트리거를 정확히 이해하고 적절한 지점에 적용하는 것임을 깨달았습니다. 특히 useShallowSelector에서 상태 비교가 아닌 결과 비교로 수정한 경험이 인상 깊었습니다.
추가 학습 필요 영역: 현재 구현한 훅들은 기본적인 메모이제이션에 집중되어 있는데, Concurrent Features와 관련된 고급 최적화 기법들을 더 학습하고 싶습니다.
실무 적용 가능성: Context 분리 패턴과 selector 기반 상태 구독은 실제 프로젝트에서 바로 적용할 수 있을 것 같습니다.
과제 피드백
과제 자체가 어려운편이 아니라 스스로 학습을 하기 위한 동기 부여가 필요하지 않을까? 생각했습니다.
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
이전까지는 Fiber 아키텍쳐에 대해서 공부하기 싫어서 흐린눈하다가...이번 기회로 좀 해보자..! 싶어서 아주 얕게나마 공부해 보았습니다...
리액트 렌더링은 React 16에서 도입된 React Fiber를 통해 설명할 수 있을 것 같습니다. 동기식 스택 기반 렌더링에서 비동기적이고 중단 가능한 렌더링으로의 패러다임 전환을 가능하게 했습니다. React Fiber는 우선순위 기반 업데이트 처리를 통해 향상된 사용자 경험을 제공합니다.
React 15까지 사용된 Stack Reconciler는 문제가 있었습니다. 리액트 공식문서에서도 언급되는 내용인데,
Stack reconciler has inherent limitations such as being synchronous and unable to interrupt the work or split it in chunks Stack reconciler는 동기적이며 작업을 중단하거나 이를 여러 조각으로 나눌 수 없다는 한계를 가지고 있습니다.
// Stack Reconciler의 문제점을 보여주는 예시
function heavyComponent() {
// 1000개의 아이템을 렌더링한다고 가정
return (
<div>
{Array(1000).fill(0).map((_, i) => (
<ComplexItem key={i} data={complexCalculation(i)} />
))}
</div>
);
}
여기서 complexCalculation이 엄청나게 무겁다고 하겠습니다. 이때 Stack Reconciler는 1000개를 모두 처리할 때까지 메인 스레드를 점유했습니다. 그 사이 사용자가 버튼을 클릭하거나 입력을 해도 반응하지 않았습니다.
또한 호출 스택에 의존성을 두고 있어 컨텍스트를 잃지 않고는 작업을 일시정지 할 수 없고, 한 번의 패스로 전체 트리를 순회해야 했습니다. 게다가 모든 업데이트를 동일한 중요도로 처리하고 있어 긴급도에 따라 작업 처리를 할 수 없었습니다.
이런 문제점을 해소하기 위해 나온 것이 Fiber 아키텍쳐입니다. Fiber은 큰 작업을 작게 나누자! 라는 것을 아이디어로 두고 있습니다.
// 간소화된 fiber workloop
function workLoop(isAsync) {
while (nextUnitOfWork !== null) {
// 작은 단위의 작업 수행
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
// 비동기 모드에서는 시간을 체크
if (isAsync && shouldYield()) {
break; // 브라우저에게 제어권 양보
}
}
}
React는 계속 브라우저에게 우선순위가 높은 일에 대해 물어보게 됩니다. 사용자 입력이나 애니메이션 같은 급한 일이 있는 경우에는 작업을 양보하는 플로우라고 생각하면 될 것 같습니다.
게다가 리액트 기본 단위의 변화도 존재하는데, 기존 React에서는 컴포넌트가 기본 단위였고 Fiber에서는 Fiber Node가 기본 단위가 되었습니다. 이렇게 변경된 이유는 아마 기존의 트리 방식보다 연결 리스트를 통해 처리하는 것이 언제든 멈추고 이어서 동작이 가능하기 때문이 아닐까 생각합니다.
React 렌더링의 전체 과정
그렇다면 실제로 React 렌더링은 어떻게 이루어질까요? 크게 3단계로 나눠볼 수 있습니다. 일단 리액트 렌더링은 두 가지 단계로 설명할 수 있습니다.
1. Render Phase (Reconciliation) -> 중단 가능함
function renderPhase() {
// 중단 가능한 작업들을 주로함
// - 컴포넌트 함수 실행
// - Virtual DOM 생성
// - 이전 트리와 비교 (diffing)
// - 변경사항 목록 작성
// 절대 하지 않는 일
// - DOM 조작
// - 사이드 이펙트 실행
}
Render 단계는 여러 번 실행될 수 있고 중간에 급한 일이 생기면 처음부터 다시 시작할 수 있는 작업들을 수행합니다.
2. Commit Phase
function commitPhase() {
// 한 번에 모든 변경사항 적용
// - DOM 업데이트
// - useEffect 실행
// - ref 업데이트
// - 라이프사이클 메서드 호출
}
Commit 단계는 절대 중단되지 않습니다. 이 단계는 또 다시 3개의 하위 단계로 나뉩니다.
- Before mutation: DOM 변경 전 (getSnapshotBeforeUpdate)
- Mutation: 실제 DOM 변경 (요소 추가/제거/수정)
- Layout: DOM 변경 후 (useLayoutEffect, componentDidMount)
우선순위 시스템과 Lane
그렇다면 Fiber에서는 앞서 말한 '급한 일'을 어떻게 처리할까요?
React 18의 Lane 시스템은 우선순위를 비트로 관리합니다. 자세한 내용은 React 톺아보기블로그를 확인하면 좋을 듯한데, 아무튼 간략하게만 이해해보자면 Lane에는 다음과 같은 종류가 있습니다.
// 우선순위 레벨 (숫자가 작을수록 높은 우선순위)
const SyncLane = 0b0000001; // 동기 (가장 급함)
const InputContinuousLane = 0b0000100; // 사용자 입력
const DefaultLane = 0b0010000; // 일반 업데이트
const TransitionLane = 0b1000000; // 화면 전환
const IdleLane = 0b100000000000000; // 한가할 때
이때 위처럼 비트마스크를 통해 우선순위를 비교한다고 생각하면 될 것 같습니다.
리액트에서 업데이트가 발생하면 업데이트의 종류에 따라 Lane을 할당합니다. Reconciler는 이때 Lane들을 들고 있고, 해당 Lane 위의 업데이트들을 배치처리하게 됩니다.
이런 Lane들이 가지고 있는 각각의 우선순위들에 따라 업데이트를 실행하는데
// 사용자가 입력창에 타이핑 중
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
return (
<div>
<input
value={query}
onChange={(e) => {
// 고우선순위: 즉시 처리
setQuery(e.target.value);
// 저우선순위: 나중에 처리
startTransition(() => {
setResults(search(e.target.value));
});
}}
/>
<SearchResults results={results} />
</div>
);
}
React 18에서 등장한 startTransition같은 경우에는 우선순위를 낮게 처리하여 사용자가 빠르게 타이핑하면, 입력 반영은 즉시 되지만 검색 결과는 타이핑이 끝날 때까지 기다릴 수 있게 됩니다.
실제 렌더링 과정 예시
function ModernApp() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (e) => {
const value = e.target.value;
// 고우선순위: 즉시 스케줄링 (InputContinuousLane)
setQuery(value);
// 저우선순위: 지연 스케줄링 (TransitionLane)
startTransition(() => {
setResults(heavySearch(value));
});
// 실제 타임라인:
// T0: 사용자 타이핑
// T1: setQuery 업데이트 즉시 스케줄링
// T2: Render Phase - query 상태 업데이트, 입력창 즉시 반영
// T3: Commit Phase - DOM 업데이트 완료
// T4: startTransition 업데이트 낮은 우선순위로 스케줄링
// T5: 만약 사용자가 또 타이핑하면 T4의 작업은 중단되고 새로운 고우선순위 작업 시작
// T6: 타이핑이 멈추면 그때서야 heavySearch 결과 렌더링
};
return (
<div>
<input
value={query}
onChange={handleSearch}
style={{ opacity: isPending ? 0.7 : 1 }}
/>
<SearchResults results={results} />
</div>
);
}
메모이제이션에 대한 나의 생각을 적어주세요.
최적화 얘기가 나올때마다 그렇게 유명한 Kent C 아저씨도 '성능 최적화는 항상 그 비용이 따르고, 언제나 이점을 제공하는것은 아닙니다.'라고 이야기 하는 것을 본적이 있었습니다. 그런데 어떤 모회사에서는 Stefano의 글처럼 모든 부분을 메모하는 것을 컨벤션으로 둔다고 들었습니다. 저 글의 저자인 Stefano는 부분적인 최적화가 아니라 기본적으로 모든 컴포넌트를 메모하는 것이 더 효율적이라고 주장하는데, 모든 개발자들이 메모이제이션을 통한 최적화를 고려할 때 '어떤 부분을 메모할지'에 대해 고려할 지에 대한 비용을 줄이고 잠재적인 성능 저하를 방지하자 라고 이야기 하고 있습니다. 그리고 기본적으로 사용하는 것이 React 애플리케이션의 성능과 개발 효율성을 높이는 '합리적인 기본값'이다고 까지 했는데...
재밌는 점은 React 팀의 공식 블로그에서 다음과 같이 언급했다는 점입니다.
Before rolling out the compiler at Meta, we discovered that only about 8% of React pull requests used manual memoization and that these pull requests took 31-46% longer to author3. This confirmed our intuition that manual memoization introduces cognitive overhead, Meta에서 컴파일러를 출시하기 전에는 React 풀 리퀘스트의 약 8%만이 수동 메모화를 사용했으며, 이러한 풀 리퀘스트를 작성하는 데 31-46% 더 오래 걸리는 것을 발견했습니다. 이를 통해 수동 메모화가 인지적 오버헤드를 유발한다는 직관을 확인할 수 있었습니다.
메타 사람들도 React 만들어놓고... 8퍼만 수동 메모이제이션을 한다는데... 내가... 해야할까? 그리고 Stefano가 말한 '개발 효율성을 높임'에 대해 반하는 내용이 아닌가? 하는 생각도 들었습니다.
또 다른 리액트 블로그 글에서는 이렇게 얘기하고 있습니다.
In our current APIs, this means applying the useMemo, useCallback, and memo APIs to manually tune how much React re-renders on state changes. But manual memoization is a compromise. It clutters up our code, is easy to get wrong, and requires extra work to keep up to date. 현재 API에서는 state 변경 시 React가 리렌더링하는 양을 수동으로 조정하기 위해 useMemo, useCallback, memo API를 적용하는 것을 의미합니다. 하지만 수동 메모화는 타협점입니다. 코드가 복잡해지고 잘못되기 쉬우며 최신 상태를 유지하기 위해 추가 작업이 필요합니다.
솔직히 어느 정도는 무책임하다는 생각이 들었던 것 같습니다(ㅋㅋ). 아 그러니까 이제 컴파일러 나올거니까 이전 패러다임(API)는 다 불편했다 요런걸까요? 컴파일러를 앞광고하기 위한 수단으로 여태까지의 수동 메모이제이션 API들을 다...
당연히 리액트 컴파일러는 게임 체인저라 생각하지만 당장 프로덕트에 적용하기에는 리스크가 있다고 생각합니다. 그래서 저 개인적으로 어떤 방식으로 메모이제이션을 접근해야할지를 생각해봤습니다.
- 무거운 연산은 측정을 해보자.
- 명확한 케이스에만 메모이제이션을 하자. (복잡한 계산이라던가...등등)
- 팀 컨벤션을 정하자.
하지만 실무 경험상 1, 2번은 정말 체감상 느리다고 느껴지는 특수한 상황에서만 의미가 있습니다. 복잡한 계산이나 대용량 데이터 처리 같은 명확한 성능 병목점이 아닌 이상, 대부분의 경우 메모이제이션으로 인한 성능 개선을 체감하기 어렵습니다. 따라서 3번 팀 컨벤션이 가장 중요한 방향이라고 생각합니다. 체감되지 않는 메모이제이션 보다는 팀 전체의 개발 효율성과 코드 일관성이 더 큰 가치를 가진다고 생각되어서 입니다.
메모이제이션은 갑론을박도 많고, 리액트 컴파일러가 안정적으로 도입되기 전까지는 계속 팀 내에서도 논의될 만한 주제라고 생각합니다. 그러나 메모이제이션을 하는 것에 있어서도 개발자가 잘 파악하고 사용해야한다는 점, 어느정도 그를 파악하는 것에 병목이 있다는 점, 그리고 그 병목 리소스를 생각하는 것에 비해 그렇게 체감할만한 차이가 현대 브라우저에서는 없다는 점을 생각했을때 명확한 성능 이슈가 측정되었을때만 적용하고 그보단 팀에서 합의된 컨벤션이 있다면 그를 따르는 것이 좋지 않을까 생각합니다.
출처
https://kentcdodds.com/blog/usememo-and-usecallback https://attardi.org/why-we-memo-all-the-things/ https://react.dev/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024 https://react.dev/blog/2024/10/21/react-compiler-beta-release
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
취준일때부터 들었던 얘기였는데...Context는 전역 상태 관리 도구가 아니라 의존성 주입 도구로 바라보는 것이 중요하다고 생각합니다. Redux의 메인테이너인 Mark Erikson이 쓴 엄청 유명한 글이 하나 있는데, 해당 글에서는 Redux와 Context를 비교하면서 상태관리란 4가지 필수 요구사항이 있다고 기술합니다.
- 상태 저장 (Storing state values)
- 상태 읽기 (Reading state values)
- 상태 업데이트 (Updating state values)
- 변경 알림 (Notifying about changes) 위 네 개중에 상태를 업데이트하는 부분을 Context API는 충족하지 못합니다. 값을 읽고 저장하고 변경을 알릴 수 있지만 업데이트하는 내장 메커니즘이 없습니다. 그래서 Context + useState(혹은 useReducer)를 통해 상태 관리가 가능하다고 얘기하는 것입니다.
잠깐 혹자들이 말하는 Context는 전역 상태 관리 도구가 아니라 의존성 주입 도구다에 대해서 설명을 해보겠습니다.
일단 SOLID 법칙을 생각해보면 그중에 D(Dependency Injection)가 해당하는 부분이라 할 수 있습니다.
이 의존성 주입은 3가지 요소로 구성됩니다.
- 의존성: 어떤 객체가 동작하기 위해 필요한 다른 객체
- 주입: 의존성을 외부에서 제공하는 행위
- 역전: 의존성을 직접 생성하지 않고 외부에서 받는 제어의 역전
이것만 봐서는 당연히 어떤 말인지 모를텐데, 간단하게 이야기하자면 "필요한 도구를 직접 만들지 말고, 누군가 갖다 주는 걸 쓰자"라고 생각합니다. 개발자가 코드를 짜기 위해서 맥북을 가져오고 하는게 아니라 누군가가 최신식 맥북을 가져다 주는...암튼 토스의 두에싸 같은 느낌이랄까요.(우하하)
다시 컴포넌트로 돌아가보자면, 상위 컴포넌트에서 필요한 도구들을 준비하고 Context라는 통로를 통해서 하위 컴포넌트에 전달해주면 이 도구들을 받기만해도 된다! 요런 느낌이라 생각합니다.
Context와 useState를 조합하면 props drilling 문제는 해결되지만, 선택적 구독이 불가능합니다.
// Context + useState 조합
const AppStateContext = createContext();
function AppStateProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [cart, setCart] = useState([]);
const [notifications, setNotifications] = useState([]);
// 이 value 객체가 매번 새로 생성됨
const value = {
user, setUser,
theme, setTheme,
cart, setCart,
notifications, setNotifications
};
return (
<AppStateContext.Provider value={value}>
{children}
</AppStateContext.Provider>
);
}
// 이 컴포넌트는 theme만 필요함
function ThemeButton() {
const { theme, setTheme } = useContext(AppStateContext);
console.log('ThemeButton 렌더링'); // user, cart, notifications 변경 시에도 출력됨!
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
);
}
// 이 컴포넌트는 cart만 필요함
function CartCounter() {
const { cart } = useContext(AppStateContext);
console.log('CartCounter 렌더링'); // user, theme, notifications 변경 시에도 출력됨!
return <span>장바구니: {cart.length}개</span>;
}
user가 바뀌면 theme만 쓰는 컴포넌트도 리렌더링되고, cart가 바뀌면 notifications만 쓰는 컴포넌트도 리렌더링됩니다. Context는 전체 value가 바뀌었는지만 확인하기 때문입니다. 반면 Zustand 같은 전역 상태 라이브러리는 선택적 구독이 가능합니다. 코드를 작성하면 너무 길어질것 같아서...남기진 않는데, 비슷하게 작성한뒤 확인하면 user가 바뀌어도 ThemeButton과 CartCounter는 리렌더링되지 않습니다. 각 컴포넌트가 정말 필요한 상태만 구독하기 때문입니다. 따라서 만약 전역상태 관리를 필요로 정말 필요로한다면 전역상태 관리 라이브러리를 고려해보는 것이 어느정도의 관심사의 분리와 성능을 가져갈 수 있는 방법이라고 생각합니다.
Context와 전역상태 라이브러리의 차이점에 대해서만 위에서 언급을 했는데, 그 둘의 조합을 이야기 해보자면, Context는 스코프를 지정하는 역할로도 사용됩니다.
Zustand, Redux, Jotai 같은 전역 상태 라이브러리들의 가장 큰 특징이자 한계는 정말로 전역이라는 점입니다.
// Zustand store는 앱 전체에서 하나의 인스턴스만 존재
const useUserStore = create((set) => ({
currentUser: null,
setCurrentUser: (user) => set({ currentUser: user })
}));
function App() {
return (
<div>
<UserProfile /> {/* 동일한 store 사용 */}
<AdminPanel /> {/* 동일한 store 사용 */}
<GuestView /> {/* 동일한 store 사용 */}
</div>
);
}
요런 상황에서 Context와 함께 조합해서 사용하면 스코프 지정이 가능합니다.
function PersonalTodos() {
const todos = useTodoStore(state => state.todos);
const addTodo = useTodoStore(state => state.addTodo);
// 개인 할 일에 추가해도 팀 할 일에는 영향 없음
return (
<div>
<button onClick={() => addTodo('개인 미팅 준비')}>
개인 할 일 추가
</button>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
</div>
);
}
function TeamTodos() {
const todos = useTodoStore(state => state.todos); // 완전히 다른 todos 배열
const addTodo = useTodoStore(state => state.addTodo);
return (
<div>
<button onClick={() => addTodo('스프린트 계획')}>
팀 할 일 추가
</button>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
</div>
);
}
이렇게 '너는 이 스코프에서 동작하는 상태야 다른데서는 안쓸거야'라는 늬앙스로 스코프를 지정해 줄 수 있게되는 것입니다.
아무튼 정리하자면 Context는 상태관리가 아닌 의존성 주입의 도구이고, Context를 이용해서 스코프를 정의해 줄 수 있다고 생각합니다.
출처
https://blog.isquaredsoftware.com/2021/01/context-redux-differences/
리뷰 받고 싶은 내용
1. 추가한 테스트에 대하여, 해당 테스트가 정말 의미가 있을지 궁금합니다.
제가 검증하고 싶은 내용은 외부적으로 강제로 컴포넌트를 통해서가 아닌 수동으로 로컬스토리지를 조작하는 상황을 검증하고 싶은데, 해당 테스트를 통해 그부분을 검증할 수 있을까? 하는 생각이 들어서요! 이런 부분에 대해서는 검증을 하지 않는 것이 맞는 선택인지? 아니면 임의적으로라도 저렇게 검증할 수 있다면 좋은 선택일지..궁금합니다. 또한 만약에 테스트를 한다면 어떻게 검증 가능할까요? 추가한 코드는 다음과 같습니다.
it("외부에서 localStorage 직접 변경 시 감지해야 한다", () => {
const storage1 = createStorage<{ name: string }>("external-change-test");
const storage2 = createStorage<{ name: string }>("external-change-test");
const { result: result1 } = renderHook(() => useStorage(storage1));
const { result: result2 } = renderHook(() => useStorage(storage2));
// 초기값 설정
act(() => storage1.set({ name: "John" }));
expect(result1.current).toEqual({ name: "John" });
expect(result2.current).toEqual({ name: "John" });
act(() => {
const newData = { name: "Jane" };
localStorage.setItem("external-change-test", JSON.stringify(newData));
const customEvent = new CustomEvent("storage-inner-document", {
detail: {
key: "external-change-test",
oldValue: JSON.stringify({ name: "John" }),
newValue: JSON.stringify(newData),
},
});
window.dispatchEvent(customEvent);
});
expect(result1.current).toEqual({ name: "Jane" });
expect(result2.current).toEqual({ name: "Jane" });
});
it("다른 탭에서의 변경을 감지해야 한다 (storage 이벤트)", () => {
const storage = createStorage<{ theme: string }>("cross-tab-test");
const { result } = renderHook(() => useStorage(storage));
// 초기값 설정
act(() => storage.set({ theme: "light" }));
expect(result.current).toEqual({ theme: "light" });
act(() => {
const newData = { theme: "dark" };
localStorage.setItem("cross-tab-test", JSON.stringify(newData));
window.dispatchEvent(
new StorageEvent("storage", {
key: "cross-tab-test",
oldValue: JSON.stringify({ theme: "light" }),
newValue: JSON.stringify(newData),
storageArea: localStorage,
}),
);
});
expect(result.current).toEqual({ theme: "dark" });
});
2. useEffect 추가 구현부
type EffectCallback = () => void | (() => void);
export function useEffect(effect: EffectCallback, deps?: DependencyList): void {
const prevDepsRef = useRef<DependencyList | undefined>(undefined);
const cleanupRef = useRef<(() => void) | void>(undefined);
const isFirstRenderRef = useRef<boolean>(true);
const depsChanged =
deps === undefined || prevDepsRef.current === undefined || !shallowEquals(prevDepsRef.current, deps);
if (isFirstRenderRef.current || depsChanged) {
if (cleanupRef.current && typeof cleanupRef.current === "function") {
cleanupRef.current();
}
const cleanup = effect();
cleanupRef.current = cleanup;
prevDepsRef.current = deps;
isFirstRenderRef.current = false;
}
}
위와 같이 useEffect를 구현했는데 지금은 렌더링 시에 동기적으로 effect를 실행하는데, 사실 React의 실제 useEffect는 렌더링이 완료된 후 비동기적으로 실행되어 제 코드와 조금 다르게 느껴집니다. 어떻게하면 effect를 비동기적으로 실행시킬 수 있을까요? queueMicrotask를 쓰라는데...그렇게하니까 테스트가 깨져서...전반적으로 테스트를 바꿔야할지..? 그렇다면 waitfor을 통해서 호출을 비동기적으로 기다리게 해야할거같은데 useEffect는 사실 '비동기적'으로 돌아가는것이지 '비동기'함수인것은 아니라고 생각돼서 목적이 맞는것인지 의문입니다.
출처
https://www.velotio.com/engineering-blog/react-fiber-algorithm https://dev.to/thee_divide/reconciliation-react-rendering-phases-56g2 https://goidle.github.io/react/in-depth-react-reconciler_2/ https://legacy.reactjs.org/docs/implementation-notes.html#future-directions
과제 피드백
더 했으면 좋겠다고 하니 정말 더 하셨네요. 영서님 고생하셨습니다. 따로 과제나 회고에 대해서는 피드백을 드릴건 정말 잘 작성해주셔서 없을것 같고 다른 분들도 이걸 함께 나눠봤으면 좋겠네요. 작성된 하나하나의 주제들이 생각해보면 크고 알차기 때문에 발표나 글로..해서 하면 참 도움이 될 것 같고 좋겠네여
테스트 관련해서 답변해보면 노드 환경에서 할 수 있는 최대한의 시나리오 테스트 였던 것 같아요. 이 시스템 자체를 구현하는 관점에서 명확하게 하면 좋은 테스트이고 유의미했다고 생각해요. 다만 알겠지만, 해당 동작은 브라우저 구현에 달려있는 부분이 어느정도 있고 그게 잘 되었다는 가정하에 내용을 검증하는 것이기 때문에 지금 이벤트를 구현하는데 있어 복잡도가 높다보니 운영하는데 있어 그정도 효용이 있나~ 정도만 고민해보면 되지 않을까! (실제 모듈입장에서는 검증하는 범위가 같을 수도 있을 것 같아서요)
useEffect 구현 관련해서는 제가 알기로도 effect queue를 리액트에서 별도로 관리하기 때문에 큐를 별도로 쓰거나 하긴 해야할 것 같은데요. 아니면 setTimeout이나 Promise로 비슷...하게는 할 수 있을 것 같은데 직접 구현을 해봐야겠네요. 사실 알다시피 테스트는 이 과제를 위한 테스트이니 저도 추측이지만 waitFor를 사용해서 기다리는 게 어느정도 필요하지 않을까 싶어요. 저도 궁금한데 요거 한번 해보고 알려주실수 있나요? ㅎㅎㅎㅎ
고생하셨고 회고에서 남긴것처럼 코테전형이나 면접 일정이 남아있다고 하셨는데 준비 잘 하시고 우선순위 잘 나눠서 시간분배 잘 하고 과제 하셨으면 좋겠습니다.