Skip to content

[9팀 임규원] Chapter 1-3. React, Beyond the Basics#39

Open
q1Lim wants to merge 12 commits intohanghae-plus:mainfrom
q1Lim:main
Open

[9팀 임규원] Chapter 1-3. React, Beyond the Basics#39
q1Lim wants to merge 12 commits intohanghae-plus:mainfrom
q1Lim:main

Conversation

@q1Lim
Copy link

@q1Lim q1Lim commented Jul 21, 2025

과제 체크포인트

배포 링크

https://q1lim.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 개선

과제 셀프회고

  1. 재밌었다!
    무의식적으로 사용하는 hook에 대해 깊게 생각하지 않았는데 React 내부의 hook을 구현해보면서 작동 원리를 이해할 수 있는 기회가 되었습니다. 개인적으로 세번째 과제는 이 쇼핑몰 시리즈(?) 중에서 제일 흥미로웠습니다!

  2. 설계 친구 AI
    이번 과제의 목표는 AI에 대한 의존도를 낮추는 것이었습니다. (하지만 안 쓸 수 없는 😔 ) AI는 함수 내부에 구현해야 하는 요구사항을 정리하고, 설계에 도움을 주는 가이드 역할로만 설정했습니다. 처음부터 구현한 코드를 보여주지 말라는 요청과 함께 과제를 진행하다 보니 중간중간 답답한 부분도 있었고, 특정 함수를 직접 구현해 달라고 한 적도 있었지만 이러한 방식이 결국 스스로 사고하고 구현하는 힘을 기르는 데 더 효과적이었다고 느꼈습니다.

  3. 친해지고 싶은 TS
    실무에서 React만 썼기 때문에 아직 TS 타입 사용이 능숙하지 않아서 시간을 많이 소요했습니다. 아마 AI에게 추가 질문을 제일 많이 한 부분이 타입 관련 질문이었던 것 같습니다.

기술적 성장

진행하면서 고민했던 부분을 노션으로 나열(?)했습니다.
의식의 흐름을 나열해보자

자랑하고 싶은 코드

자랑하고 싶은 코드가 있었으면 좋았겠지만 이번 과제에서는 없습니다.

개선이 필요하다고 생각하는 코드

  • 심화과제 ToastProvider의 useMemo()
export const ToastProvider = memo(({ children }: PropsWithChildren) => {
  const [state, dispatch] = useReducer(toastReducer, initialState);
  const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]); // useMemo 1#
  const visible = state.message !== "";

  const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]); // useMemo 2#

  const showWithHide: ShowToast = useAutoCallback((...args) => {
    show(...args);
    hideAfter();
  });

  const toastActionValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]); // useMemo 3#
  const toastStateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]); // useMemo 4#

  return (
    <ToastActionContext.Provider value={toastActionValue}>
      <ToastStateContext.Provider value={toastStateValue}>
        {children}
        {visible && createPortal(<Toast />, document.body)}
      </ToastStateContext.Provider>
    </ToastActionContext.Provider>
  );
});

useMemo와 useAutoCallback을 활용해 리렌더링을 방지했고, 이 과정이 심화 과제의 목적이라는 것도 이해하고 있습니다. 다만, 생각보다 많은 곳에 useMemo를 사용하다 보니 정말 이 정도까지 필요한지, 혹은 과한 최적화는 아닌지에 대한 고민이 생겼습니다. 학습 목적으로 useMemo를 적극 활용하는 건 이해되지만, 실제 이렇게 많은 useMemo 사용이 일반적일까? 라는 생각도 들었습니다.

학습 효과 분석

  • 추가 학습이 필요한 영역까지는 아니지만, useRef가 useState를 이용해서 구현할 수 있다면 이번엔 useState를 구현해보고 싶다는 생각이 들었습니다. (가까운 미래의 나에게 전달 🤾 )
  • HOC에 대해 개념을 익혔고, 유용하게 활용하고 싶어서 HOC 구조를 연습하고자 합니다.
  • 심화과정에서 Context를 분리에서 구현하는 방법은 학습메이트님에게 힌트를 얻어서 진행했는데, 이 부분에 대해 실무에 적용하면 효과적이라고 생각했습니다. 특히 이번 심화과제에서의 type, message와 show, hide처럼 역할이 다른 데이터를 구분하여 분리하는 구조는 렌더링 최적화에 도움이 된다고 생각했습니다. 다만 Context 분리 시에 어떤 데이터가 어떤 컴포넌트에 영향을 미치는지에 대한 범위를 충분히 고려해서 설계해야할 것 같다는 생각 또한 들었습니다. (무분별한 Provider 감싸기는 코드의 가독성을 낮춰줄 것 같아요ㅠ)

과제 피드백

진행한 과제중에 재밌다고 느꼈던 부분 중 하나가 과제에 대한 소소한 힌트들도 한몫했습니다. 👍 그리고 참고할 수 있는 학습자료도 많고, 아카이브가 잘 되어있어서 공부하는데 많은 도움이 되었습니다.

학습 갈무리

리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.

<리액트의 렌더링 과정>

  • 렌더 트리거 : 상태 변경, props 변경, context 값 변경 등으로 컴포넌트가 다시 렌더링 됩니다.
  • 렌더 단계 : React는 변경된 컴포넌트를 함수처럼 다시 호출해서 jsx를 반환합니다. 이 과정에서 Virtual DOM이 새롭게 생성됩니다. 실제 브라우저에서는 반영되지 않지만 어떤 변화가 이루어졌는지 diff 계산을 진행합니다.
  • 커밋 단계 : Reconciliation (이전 Virtual DOM과 비교해 변경된 부분만 찾기)을 진행하고 실제로 변경된 DOM 조각만 실제 DOM에 반영합니다.

메모이제이션에 대한 나의 생각을 적어주세요.

메모이제이션의 핵심은 '비용'과 '빈도'라고 생각합니다. 연산량이 많고, 비용이 많이 드는 반복되는 계산이 많은 상황에서 불필요한 리렌더링을 방지하는 부분에서 활용됩니다.

  • 장점 : 불필요한 계산을 피하고 렌더링 성능 개선 가능
    하지만 무조건적인 메모이제이션의 사용은 메모이제이션 관리에 리소스를 더 소요할 수 있습니다.
  • 단점 : 코드 관리 복잡도 증가, 의존성 관리 부담
    메모이제이션은 성능 최적화할 수 있는 여러 방법 중 선택할 수 있는 옵션 중 하나라고 생각합니다.

컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.

  • 컨텍스트와 상태관리가 필요한 이유는 무엇일까?
    저는 준일코치님이 세션때 말씀주셨던 것처럼 관심사 분리와 참조 동일성이 먼저 떠오른 것 같습니다.
  • 관심사 분리
    UI와 비즈니스 로직 등 관심사 분리할 수 있어서 한 파일에 하나의 역할만 집중해서 협업 시 코드를 빠르게 이해하는데 도움이 될 것 같습니다.
  • 참조 동일성
    컨텍스트를 사용하는 것은 결국 동일한 데이터 혹은 상태를 공유해서 데이터의 일관성을 유지하기 위해 사용할 수 있는 방법 중 하나라고 생각합니다. 실무에서 컨텍스트를 사용했을 때는 항상 Provider를 감싸지 않은 영역에서 데이터를 참조하고 싶어서 어떻게 해야하지를 많이 고민했던 것 같습니다ㅎㅎ

리뷰 받고 싶은 내용

Copy link

@susmisc14 susmisc14 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

규원님, 3주차도 정말 고생 많으셨어요! 초반의 어려움을 멋지게 이겨내고, 매주 점점 더 발전하는 모습을 보니 정말 대단하다는 생각이 듭니다. 그 노력이 빛을 발하는 것 같아 저도 기분이 좋네요. 앞으로도 항상 응원하겠습니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shallowEqualsdeepEquals의 로직이 많이 유사해요! 코드 중복을 줄이 위해, deepEquals에 depth 매개변수를 추가하여 비교 깊이를 제어하는 것은 어떨까요? 이렇게 하면 shallowEquals는 단순히 deepEquals(a, b, 1)을 호출하는 방식으로 간결하게 구현할 수 있습니다.

Comment on lines +12 to +19
if (Array.isArray(objA) && Array.isArray(objB)) {
if (objA.length !== objB.length) return false;

for (let i = 0; i < objA.length; i++) {
if (!deepEquals(objA[i], objB[i])) return false;
}
return true;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배열과 객체를 비교하는 방식에 대해 궁금한 점이 있어요! 저는 처음에 배열도 '인덱스를 키로 갖는 객체'와 유사하다고 생각해서, 두 타입을 구분하지 않고 객체 비교 로직으로 한 번에 처리했었는데요. 규원님께서는 Array.isArray() 등으로 배열과 객체를 명확하게 구분해서 별도의 로직으로 처리해주셨더라고요. 이러한 방식이 비교의 정확성을 높이는 가장 확실한 방법이라고 생각하신 걸까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

키로 갖는 객체로 유사하지만 배열은 인덱스 기반이기 때문에 배열의 순서도 체크해야할 것 같아서 배열은 따로 분리해서 체크했습니다!

Comment on lines +15 to +17
for (let i = 0; i < objA.length; i++) {
if (!deepEquals(objA[i], objB[i])) return false;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

객체의 모든 키가 특정 조건을 만족하는지 검사하는 로직은 every() 배열 메서드를 사용하면 더 간결하게 작성할 수 있습니다. 예를 들어, for 루프 대신 아래와 같이 한 줄로 표현할 수 있어 가독성을 높이는 데 도움이 될 것 같습니다.

return keysA.every(key => 
  Object.hasOwn(objB, key) && deepEquals(objA[key], objB[key])
);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인덱스를 사용해야한다고 생각해서 일반적인 for문을 썼는데, every를 까먹고 있었네요! 리마인드 된 것 같아서 감사합니다 ㅎㅎ

Comment on lines +7 to +8
const prevRef = useRef(fn);
prevRef.current = fn;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 if 문을 추가해서 이전 callback과 현재 callback을 비교하는 과정을 추가했었는데 지금와서 생각해보면 과한 로직인 것 같아요.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

타입 안정성을 더 높일 수 있는 방법이 있어서 공유드려요! 실제 React의 useRefuseRef()처럼 초기값 없이 호출하는 경우와 useRef(0)처럼 초기값을 주는 경우의 타입이 다르게 추론돼요.

TypeScript의 **함수 오버로딩(Function Overloading)**을 사용하면 이런 케이스들을 더 정교하게 처리할 수 있습니다. 예를 들어, 아래처럼 여러 시그니처를 선언하는 거죠.

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> | MutableRefObject<T> {
  return useState(() => ({ current: initialValue }))[0];
}

이렇게 하면 useRef를 초기값 없이 호출하는 케이스도 지원할 수 있고, 타입스크립트가 ref.current의 타입을 더 정확하게 추론해주는 장점이 있습니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 이거 두현님 코드에서 유사한 걸 봤습니다! 지수님이 공유주신 것이 더 상세하네요~ 공유 감사합니다!


const setShallowState = useCallback((newValue: T) => {
// shallowEquals를 통해 상태 변경을 감지
setState((prev) => (shallowEquals(prev, newValue) ? prev : newValue));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 setState 바깥에서 새로운 값을 계산했는데, setState 콜백 안에서 prevValue를 사용해야 여러 번의 상태 업데이트가 연달아 일어날 때도 stale state 문제 없이 항상 최신 상태를 기반으로 값을 계산할 수 있겠네요. 제가 놓쳤던 부분인데 다른 분들은 다 꼼꼼히 챙기셔서 약간 슬퍼졌어요...

// 임의로 타입 조정 initialValue: T | (() => T)
const [state, setState] = useState<T>(initialValue);

const setShallowState = useCallback((newValue: T) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한 가지 개선할 점을 제안하자면, useState의 set 함수처럼 setShallowState도 함수를 인자로 받을 수 있도록 기능을 확장하는 것은 어떨까요?

예를 들어, setShallowState(prev => ({ ...prev, count: prev.count + 1 }))와 같은 패턴을 지원하면 사용자가 더 유연하게 상태를 관리할 수 있게 되어 훅의 완성도가 높아질 것 같습니다.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

과제의 요구사항에 맞게 정확히 State와 Command 컨텍스트를 분리하신 것 같아요! 추가로 제가 평소에 즐겨 사용하는 createSafeContext라는 패턴을 소개해 드리고 싶어요.
createContext를 사용할 때마다 초기값을 반드시 설정해야 하거나 Provider 외부에서 컨텍스트를 사용하는 실수를 방지하기 위해 null 체크를 하고 값을 useMemo로 감싸는 등 반복적인 작업을 하나로 묶어줄 수 있어요.

import { createContext, createElement, useContext, useMemo } from "react";

export function createSafeContext<ContextValue extends object | null>(
  rootComponentName: string,
  defaultValue?: ContextValue,
) {
  const Context = createContext<ContextValue | undefined>(defaultValue);

  const Provider = ({ children, ...rest }: ContextValue & { children?: React.ReactNode }) => {
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const value = useMemo(() => rest, Object.values(rest)) as ContextValue;

    return createElement(Context, { value }, children);
  };

  const useSafeContext = (consumerName: string) => {
    const ctx = useContext(Context);
    if (ctx) return ctx;
    if (defaultValue) return defaultValue;

    throw new Error(`\`${consumerName}\` must be used within \`${rootComponentName}\``);
  };

  Provider.displayName = rootComponentName + "Provider";

  return [Provider, useSafeContext] as const;
}

이 헬퍼를 적용하면, 현재 컨텍스트를 생성하는 부분을 아래처럼 바꿀 수 있습니다.

export const [ToastStateProvider, useToastState] =
  createSafeContext<ContextState>("ToastStateProvider");
export const [ToastCommandProvider, useToastCommand] =
  createSafeContext<ContextCommand>("ToastCommandProvider");

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오,,, 새롭게 알게 됩니다!!! 감사합니다 bbb

@jun17183
Copy link

코드가 담백(?)해서 좋아요,,,ㅎ

Copy link

@ldhldh07 ldhldh07 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

적절한 주석과 가독성 좋은 코드로 깔끔하다 느꼈습니다!

Copy link

@tomatopickles404 tomatopickles404 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

규원님~~~! 이번 주 해내셨군요 😎
규원님이랑 같은 팀이어서 너무 조아요 허허..
코드에 공부하면서 작성하신 흔적이 느껴지는 것 같습니당
남은 시간도 같이 재밌게 해봐요 ㅎㅎ

Comment on lines +9 to +10
const objA = a as Record<string, unknown>;
const objB = b as Record<string, unknown>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 제 취향인데요!
저는 이렇게 타입이 길어지는 경우에는 type을 따로 선언해두는게 더 가독성에 좋게 느껴지더라구요!
이렇게 해봐도 좋을 것 같아요!

type ObjectRecord = Record<string, unknown>;

const objA = a as ObjectRecord;
const objB = b as ObjectRecord;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 간결하게 쓰고자했던 것 같아요ㅠ 확실히 타입 지정을 먼저 선언하는게 좋은 것 같습니다! 감사합니다

const prevPropsRef = useRef<P | null>(null);
const renderOutputRef = useRef<ReactNode | null>(null);

if (!equals(prevPropsRef.current, props)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

초기 렌더링을 고려하는 로직이 추가되면 조금 더 안정적인 구조가 될 것 같아요!

if(!prevPropsRef.current || !equals(prevPropsRef.current, props));

const shallowSelector = useShallowSelector(selector);
return shallowSelector(store.getState());

return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

작성해주신 useSyncExternalStore 인터페이스만 봤을 때는 구체적으로 어떤 일을 하는지 직관적이지 않은 것 같아요!
함수에 이름을 붙여주는건 어떨까요?!

const subscribeToStore = store.subscribe;
const getCurrentState = () => shallowSelector(store.getState());

return useSyncExternalStore(
  subscribeToStore,
  getCurrentState,
);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확실히 다시 보니까 의도가 명확하게 보이지 않는 것 같아요,, 의견 주셔서 감사합니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants