| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 그리디
- greedy
- 알고리즘
- 항해플러스
- jQuery
- 백준
- 리팩토링
- 리액트
- 프론트엔드
- 2025년회고
- 항해
- SSE적용방법
- 백준 19939
- 항해99
- 박 터뜨리기
- 프로그래머스
- SSE 후기
- EC2
- 자바스크립트
- 백준 반례
- JavaScript
- SSE
- 탐욕알고리즘
- react
- next.js
- 코테
- 클린코드
- 코딩테스트
- SSE 적용 방법
- 테스트코드
- Today
- Total
공부 및 일상기록
[Javascript, React] 각종 리액트 훅 구현해보기 및 최적화 하기 본문
이번주엔 리액트의 각종 훅을 구현해보고 또 메모이제이션을 활용해보는 시간을 가졌다.
각종 리액트 훅 구현
얕은 비교 함수
import { isPlainObject } from "../utils/typeChecker";
export const shallowEquals = (a: unknown, b: unknown) => {
// 원시 데이터 타입 비교
if (Object.is(a, b)) return true;
// 배열 비교
if (Array.isArray(a) && Array.isArray(b)) {
// 길이가 다르면 false
if (a.length !== b.length) return false;
// 요소를 하나씩 비교
for (let i = 0; i < a.length; i++) {
// 요소가 다르면 false
if (!Object.is(a[i], b[i])) return false;
}
return true;
}
// 객체 비교
if (isPlainObject(a) && isPlainObject(b)) {
// 객체 키의 개수가 다르면 false
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
// 객체 키를 하나씩 비교
for (let i = 0; i < aKeys.length; i++) {
const currentKey = aKeys[i];
// 객체 키가 다르면 false
if (!Object.is(a[currentKey], b[currentKey])) return false;
}
return true;
}
// 그외 모두 false
return false;
};
일단 입력값에 대하여 얕은 비교를 수행하는 함수를 하나 만들었다.
이것으로 메모이제이션을 나중에 구현하기 때문에 중요한 요소이다.
객체비교에서 isPlainObject함수를 하나 만들어서 사용했는데, 그 이유는 null이나 배열도 "object"타입이기 때문이다.
그래서 아래처럼 순수한 객체를 판별하는 함수를 만들었다.
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
깊은비교함수
import { isPlainObject } from "../utils/typeChecker";
export const deepEquals = (a: unknown, b: unknown) => {
// 원시 데이터 타입 비교
if (Object.is(a, b)) return true;
// 배열 비교
if (Array.isArray(a) && Array.isArray(b)) {
// 길이가 다르면 false
if (a.length !== b.length) return false;
// 요소를 하나씩 비교
for (let i = 0; i < a.length; i++) {
// 요소가 다르면 false
if (!deepEquals(a[i], b[i])) return false;
}
return true;
}
// 객체 비교
if (isPlainObject(a) && isPlainObject(b)) {
// 객체 키의 개수가 다르면 false
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
// 객체 키를 하나씩 비교
for (let i = 0; i < aKeys.length; i++) {
const currentKey = aKeys[i];
// 객체 키가 다르면 false
if (!deepEquals(a[currentKey], b[currentKey])) return false;
}
return true;
}
// 객체 비교
return false;
};
얕은 비교함수와 큰 차이는 없지만 재귀적으로 비교할 수 있도록 만들면 깊은 비교가 가능하게 된다.
useRef 구현해보기 (Feat. useState)
import { useState } from "react";
export function useRef<T>(initialValue: T): { current: T } {
const [ref] = useState({ current: initialValue });
return ref;
}
useRef는 위 코드처럼 구현 가능하다.
이렇게 구현하면 setState를 사용하지 않고 ref객체를 직접 변경하기 때문에 리렌더링이 발생하지 않게 된다.
**주의사항
혹시 저 ref 객체를 useState가 아닌 일반 변수로 선언하게 되면 절대 안된다.
그 이유는 useState는 재 렌더링이 발생해도 해당 값을 메모리에 저장해두지만, 지역변수는 컴포넌트가 재 렌더링되면 새로운 객체가 되어버리기 때문에 ref처럼 사용할수가 없는 것이다.
useMemo 구현해보기 (Feat. useRef)
import type { DependencyList } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "./useRef";
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// 의존성 배열과 값을 저장하는 객체를 useRef로 관리
const ref = useRef<{ deps: DependencyList; value: T } | null>(null);
// 의존성 배열이 바뀌었거나, 값이 바뀌었으면 factory 실행해서 값 저장
if (ref.current === null || !_equals(ref.current.deps, _deps)) {
const value = factory();
ref.current = { deps: _deps, value };
}
return ref.current.value;
}
일단 ref를 통해 의존성 배열과 실제 value 관리할 수 있도록 한다.
그리고 의존성 배열이 바뀌거나 값 자체가 바뀌었다면 value를 바꿔준다.
이때, 의존성배열을 위에서 만든 얕은 비교함수를 사용하여 비교한다. 그래야 단순한 객체의 재 생성이 아닌 값이 바뀌었는지 비교할 수 있기 때문이다.
useCallback 구현해보기 (Feat. useMemo)
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import type { DependencyList } from "react";
import { useMemo } from "./useMemo";
export function useCallback<T extends Function>(factory: T, _deps: DependencyList) {
// 함수 자체를 값이라고 생각하고 useMemo를 사용해서 구현
// eslint-disable-next-line react-hooks/exhaustive-deps
const memoizedCallback = useMemo(() => factory, _deps);
return memoizedCallback as T;
}
useCallback은 함수 자체를 메모이제이션 하는 것이므로, useMemo를 통해서 함수 자체를 기억하고, 의존성 배열을 넘겨주는 방식을 택했다.
useAutoCallback
import type { AnyFunction } from "../types";
import { useCallback } from "./useCallback";
import { useRef } from "./useRef";
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
// 함수 자체를 값으로 관리
const ref = useRef<T>(fn);
// 함수가 바뀌었으면 최신화
if (ref.current !== fn) {
ref.current = fn;
}
// 항상 동일한 참조를 반환하면서 내부 로직만 최신화된 ref를 호출
const stableCallback = useCallback((...args: Parameters<T>): ReturnType<T> => {
return ref.current(...args);
}, []);
return stableCallback as T;
};
이 함수는 기존 useCallback은 의존성 배열의 값이 있어야만 최신의 함수를 넣어주는데, 이 커스텀 훅은 항상 동일한 참조를 내보내면서 내부적으로 최신 로직을 할당하여 실시간성을 부여받는다.
useShallowState
import { useState } from "react";
import { shallowEquals } from "../equals";
import { useCallback } from "./useCallback";
export const useShallowState = <T>(initialValue: T | (() => T)) => {
// useState를 사용하여 상태를 관리
const [state, setState] = useState<T>(initialValue);
// setState의 참조를 고정하기 위해 useCallback 사용
const setShallowState = useCallback((next: T) => {
// 이전 상태와 새로운 상태를 비교하여 변경이 필요한 경우에만 상태 업데이트
setState((prev) => (shallowEquals(prev, next) ? prev : next));
}, []);
// 상태와 상태 업데이트 함수를 반환
return [state, setShallowState] as const;
};
이 훅은 useState의 메모이제이션 버전이다.
일반적은 useState는 setState가 발생하면 무조건 렌더링이 다시 되지만, 이 훅을 사용하게 되면 만약 setShallowState가 발생해도 이전 값과 값 자체가 동일하다면 리렌더링이 발생하지 않도록 하는 함수이다.
useShallowSelector
import { useRef } from "react";
import { shallowEquals } from "../equals";
type Selector<T, S = T> = (state: T) => S;
export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
// 이전 상태를 저장하는 ref
const prev = useRef<{ state: T; selected: S }>(null);
// 선택된 상태를 반환하는 함수
const selected = (state: T): S => {
// 선택된 상태를 반환
const selected = selector(state);
// 이전 상태와 현재 상태가 같은지 확인
if (prev.current && shallowEquals(prev.current.selected, selected)) {
// 이전 상태를 반환
return prev.current.selected;
}
// 이전 상태를 업데이트 후 선택된 상태를 반환
prev.current = { state, selected };
return selected;
};
return selected;
};
이 함수는 선택된 값이 실제 바뀌지 않으면 리렌더링을 유발하지 않게 하는 것이다.
리덕스에서 부분 상태만 채택하여 쓸 때, 선택된 값이 객체, 배열등 참조형 데이터 일 때 사용하면 최적화가 이뤄진다.
useStore
import type { createStore } from "../createStore";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";
type Store<T> = ReturnType<typeof createStore<T>>;
const defaultSelector = <T, S = T>(state: T) => state as unknown as S;
export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
// 셀렉터 함수를 최적화
const shallowSelector = useShallowSelector(selector);
// 최적화 된 getSnapshot 함수
const getSnapshot = () => shallowSelector(store.getState());
// getSnapshot에 최적화된 selector 함수를 전달하여 불필요한 리렌더링 방지
const state = useSyncExternalStore((onStoreChange) => {
store.subscribe(onStoreChange);
return () => store.unsubscribe(onStoreChange);
}, getSnapshot);
return state;
};
이 훅은 리액트의 useSyncExternalStore를 사용하여 외부 스토어의 값을 구독하여 외부 저장소의 값이 변경될 때, 자동으로 내부적으로 최신 상태를 유지하기 위한 훅이다.
외부에 store를 따로 만들어 두고 구독하여 사용하게 만들어 뒀다면 이 훅이 매우 유용하다.
memo
import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
// 훅 사용을 위해 컴포넌트 생성
const MemoizedComponent = (props: P) => {
// 이전 프롭스와 리턴할 결과를 ref로 관리
const prevRef = useRef<{ props: P; result: React.ReactNode | null } | null>(null);
// 이전 프롭스와 현재 프롭스 비교
if (!prevRef.current || !equals(prevRef.current.props, props)) {
// 프롭스가 다르면 새로 렌더링
prevRef.current = { props, result: Component(props) as React.ReactNode };
}
// 프롭스가 같으면 이전 결과 재사용
return prevRef.current.result;
};
return MemoizedComponent;
}
이것은 React.memo와 같은 HOC이다.
즉 컴포넌트 자체를 최적화 하는 고차함수인것이다.
컴포넌트를 ref에 담고, 이전 프롭스와 현재 프롭스를 비교하여 프롭스가 다르면 리렌더링, 다르지 않다면 이전 결과를 사용하는 것이다.
물론 여기서도 비교는 얕은비교를 하여 객체가 바뀌었다고 리렌더링이 아닌 값 자체가 바뀌어야 리렌더링이 발생한다.
렌더링 최적화
이번 과제 중 Context API를 사용 할 때, 상태 컨텍스트와 액션 컨텍스트를 분리하여 재렌더링을 최적화 하는 부분이 있었다.
<ToastStateContext.Provider value={memoizedStateValue}>
<ToastActionContext.Provider value={memoizedActionValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastActionContext.Provider>
</ToastStateContext.Provider>
나는 현업에서 근무할때도 Context API를 자주 사용하곤했다. 그럼에도 이렇게 분리하여 최적화 할 수 있다는것을 왜 생각하지 못했을까..?
이번 과제를 하면서 어떤 코드든지 더 개선할수없을까? 하는 고민을 해봐야 한다고 생각했다.
물론 이를 사용하는 컴포넌트가 상태와 액션을 모두 사용한다면 굳이 컨텍스트를 나누지 않아도 성능은 똑같을 수 있다.
하지만 이번 과제를 통해 이런 방법도 있다는걸 배웠으니 꼭 적용해봐야겠다.
느낀점
전체적으로 이번 과제는 AI사용이 제일 적었고, 반대로 깊이 생각한 시간은 많았다.
과제 자체의 난이도는 낮은편이라고 들었는데, 나는 오히려 지난 과제들보다 고뇌하는 과정이 더 많았어서 난이도가 높게 느껴졌다.
그리고 이번 과제부터 타입스크립트를 사용하였는데, 원래 현업에서도 계속 사용했지만, 뭔가 그때보다 어렵게 다가왔다.
전반적으로 잘 마무리해서 기분은 뿌듯하다!
'개발 > Javascript' 카테고리의 다른 글
| [Javascript] 클린코드와 리팩토링 (6) | 2025.08.05 |
|---|---|
| Vitest + userEvent 테스트 중 innerHTML로 인한 DOM 재생성 문제 해결기 (5) | 2025.07.31 |
| [Javascript] 바닐라 자바스크립트로 가상돔 구현하기 (3) | 2025.07.21 |
| [Javascript] 바닐라 자바스크립트로 SPA를 간단히 구현해보고 느낀점 (4) | 2025.07.12 |
| [Javascript] this 간단히 정리해보기 (0) | 2024.04.30 |