[항해 플러스 프론트엔드 4기] 3주차 과제 회고

원정·2025년 1월 3일
1
post-thumbnail

3주차 과제도 <프레임워크 없이 SPA 만들기>로, 주제는 이벤트 관리와 렌더링 성능 최적화다.

목표는

  1. Hooks를 활용하여 상태 관리와 부수효과를 효율적으로 처리하기.
  2. 메모이제이션을 이용하여 불필요한 연산 최소화.
  3. 성능 프로파일링을 통해 애플리케이션 성능 최적화.

이다.

💰 얕은 비교와 깊은 비교


💵 shallowEquals

shallowEquals는 얕은 비교를 하는 함수로 객체의 첫 번째 깊이의 값만 비교한다.
이번 과제는 지난 과제들과 다르게 TypeScript로 작성됐다.

function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) {
    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;
  }

  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }

  return true;
}

위는 실제로 리액트(https://github.com/facebook/react/blob/main/packages/shared/shallowEqual.js)에 구현된 shallowEqual 함수다.

ishasOwnPropertyObject.isObject.hasOwnProperty와 동일한 로직이다.
Object.isObject.hasOwnProperty 메서드가 ES6에서 제공하는 기능이기 때문에 이를 구현한 폴리필을 사용했다.

아무튼 리액트와 같은 방식으로 과제에 작성하면 타입 오류가 발생한다.

'string' 형식의 식을 '{}' 인덱스 형식에 사용할 수 없으므로 요소에 암시적으로 'any' 형식이 있습니다.
'{}' 형식에서 'string' 형식의 매개 변수가 포함된 인덱스 시그니처를 찾을 수 없습니다.ts(7053)

원인은 TypeScript는 객체의 인덱스 접근에 대해 매우 엄격하여 제네릭 타입 T가 인덱스 시그니처를 가지고 있다는 보장이 없어서 나는 오류다.
즉 빈 객체일 수도 있는데 string을 키로 사용하는 것을 안전하지 않다고 판단한다.

해결 방법으로는 타입 단언을 할 수도 있다.
앞서 거친 조건문으로 타입 단언을 선언해도 괜찮다고 생각하지만, TypeScript를 잘 다룰 줄 모르는 입장에서 타입 단언은 왠지 거부감이 든다.

고민을 하던 중, 팀원 분께서 Reflect를 알려주셨다.

Reflect는 자바스크립트 내장 객체이다.
객체를 조작하는 여러 메서드들(get, set, has 등)을 제공한다.
기존 점 표기법이나 대괄호 표기법과 비슷하지만 함수 형태로 사용할 수 있다.

Reflect.get은 객체의 프로퍼티에 접근하는 방법으로 아래와 같은 특징이 있다.

  1. 존재하는 프로퍼티의 경우 해당 값 반환.
  2. 존재하지 않을 경우 undefined 반환.

Reflect를 검색해보면 Proxy와 함께 소개된다.
Proxy에 대해서 잘 모르기 때문에 모두 학습하고 넘어가는 건 시간이 지연될 것 같아서 위 내용만 간단하게 알고 넘어갔다.

export function shallowEquals<T>(objA: T, objB: T): boolean {
  if (Object.is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== "object" ||
    typeof objB !== "object" ||
    objA === null ||
    objB === null
  ) {
    return false;
  }

  const objAKeys = Object.keys(objA);
  const objBKeys = Object.keys(objB);

  if (objAKeys.length !== objBKeys.length) {
    return false;
  }

  for (const key of objAKeys) {
    const valueA = Reflect.get(objA, key);
    const valueB = Reflect.get(objB, key);
    if (!Object.is(valueA, valueB)) {
      return false;
    }
  }

  return true;
}

Reflect가 적용된 최종 코드다.
Object.is를 사용하기 전에 Reflect.has 메서드를 사용했었는데, 생각해보니 Reflect.get에서 해당 프로퍼티가 없을 경우 undefined를 반환해주기 때문에 제거했다.

💵 deepEquals

export function deepEquals<T>(objA: T, objB: T): boolean {
  if (Object.is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== "object" ||
    typeof objB !== "object" ||
    objA === null ||
    objB === null
  ) {
    return false;
  }

  const objAKeys = Object.keys(objA);
  const objBKeys = Object.keys(objB);

  if (objAKeys.length !== objBKeys.length) {
    return false;
  }

  for (const key of objAKeys) {
    const valueA = Reflect.get(objA, key);
    const valueB = Reflect.get(objB, key);
    if (!deepEquals(valueA, valueB)) {
      return false;
    }
  }

  return true;
}

deepEqualsshallowEquals와 유사하다.
차이점이라면 objAobjB의 타입이 "object"일 경우 해당 프로퍼티의 값들을 다시 deepEquals 함수의 인자로 넣어 가장 마지막 deps까지 비교한다.

💵 중복 코드 줄이기

shallowEqualsdeepEquals 함수는 동일한 로직이 동작한다.
다른 점은 마지막 조건문에서 Object.is를 썼냐, deepEquals를 썼냐는 점이다.

Object.isdeepEquals는 두 개의 값을 받아 boolean을 반환하는 함수다.
코드의 다른 점에서 유사한 구조를 띄고 있으니, 공통 함수로 묶을 수 있다.

export function baseEquals<T>(
  objA: T,
  objB: T,
  compareValue: (valueA: unknown, valueB: unknown) => boolean,
): boolean {
  if (Object.is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== "object" ||
    typeof objB !== "object" ||
    objA === null ||
    objB === null
  ) {
    return false;
  }

  const objAKeys = Object.keys(objA);
  const objBKeys = Object.keys(objB);

  if (objAKeys.length !== objBKeys.length) {
    return false;
  }

  for (const key of objAKeys) {
    const valueA = Reflect.get(objA, key);
    const valueB = Reflect.get(objB, key);
    if (!compareValue(valueA, valueB)) {
      return false;
    }
  }

  return true;
}

공통된 로직을 하나의 함수로 묶으면 위와 같다.
compareValue 함수를 인수로 받는다.
shallowEquals, deepEquals에서 각각 Object.isdeepEquals를 넘겨주면 된다.

💰 Hooks 구현하기


💵 들어가기 전에

과제에서 구현해야 할 훅들은 useRef, useMemo, useCallback이다.
이 훅들의 공통점은 리렌더링 최적화와 관련있는 훅들이다.
그렇다면 리액트에서는 언제 리렌더링이 발생하는지를 먼저 정리해보자.

  1. setState가 실행되는 경우.
  2. dispatch가 실행되는 경우.
  3. 컴포넌트의 key props가 변경되는 경우.
  4. props가 변경되는 경우.
  5. 부모 컴포넌트가 렌더링될 경우.

💵 useRef

useRefuseState는 상태를 저장하고 관리할 수 있다.
차이점은 useRef는 객체 형태로 current 프로퍼티에 값이 저장되고 값이 변해도 리렌더링을 발생시키지 않는다.

리렌더링이 발생해도 current에 저장된 상태를 유지하기 때문에 리렌더링과 관련없이 상태를 보관해야 할 때 유용하게 사용할 수 있다.
useState를 이용해서 useRef를 구현하라는 과제의 요구사항이 있다.
인자로 받은 값을 current 프로퍼티의 값으로 담아서 첫 번째 반환값인 state에 내보내주면 된다.

import { useState } from "react";

export function useRef<T>(initialValue: T): { current: T } {
  // React의 useState를 이용해서 만들어보세요.
  const [ref] = useState({ current: initialValue });
  return ref;
}

두 번째 반환값인 setState는 리렌더링을 발생시키므로, ref의 값을 바꾸고 싶을 때는 ref.current = newValue로 바꿔줄 수 있다.

내가 헷갈렸던 부분이 '상태를 변경시킬 때 무언가를 해줘야 하는 거 아닌가?'였다.
즉 상태를 직접 바꾸는 게 아니라 전용 함수를 통해서 바꿔줘야 하는 것이 아닐까라는 생각이 들었다.
useState에서 state를 직접 접근해서 바꾸지 않고 setState를 사용하는 것처럼.
하지만 useState를 돌이켜보면 state의 값을 직접 접근해서 바꿀 수 있다.
다만 setState를 사용하지 않기 때문에 리렌더링이 일어나지 않아 상태 변화를 감지하지 못할 뿐이다.
이 점을 놓치고 생각해서 setState와 같은 함수로 값을 변경해야 하는 거 아닌가라는 모호한 생각이 자꾸들었었다.

💵 useMemo

useMemo는 첫 번째 인자로 함수를 받고, 두 번째 인자로 의존성 배열을 받는다.
의존성 배열의 값이 변경될 때만 함수를 실행한다.

언제 의존성 배열의 값을 비교하게 될까?

useMemo뿐만 아니라 의존성 배열을 받는 훅들(useEffect, useCallback 등) 모두 동일하게 컴포넌트가 리렌더링되면서 실행된다.
컴포넌트도 하나의 함수고 리렌더링이란 함수를 다시 실행하는 것을 의미한다.
컴포넌트라는 함수가 실행되면서 내부의 함수(훅)들을 다시 실행하고 이 과정에서 의존성 배열로 받은 값들이 변했는지 확인한다.

useMemouseCallback의 차이점은 useMemo는 함수의 결과값을 저장하고, useCallback은 함수 자체를 저장한다.

과제에서는 useRef를 통해서 useMemo를 구현하도록 요구했다.
useMemo는 실행할 함수와 배교할 의존성 배열 두 개의 인자를 받아서 상태로 저장해야 한다.

export function useMemo<T>(
  factory: () => T,
  _deps: DependencyList,
  _equals = shallowEquals,
): T {
  const ref = useRef<{ deps: DependencyList; value: T } | null>(null);

  if (ref.current === null) {
    ref.current = { deps: _deps, value: factory() };
  }

  if (!_equals(ref.current.deps, _deps)) {
    ref.current = { deps: _deps, value: factory() };
  }

  return ref.current.value;
}

초기값으로 null을 넣어주고 null일 경우 ref.current{ deps: _deps, value: factory() }를 넣어준다.

왜 한 번에 useRef<{ deps: DependencyList; value: T }>({ deps: _deps, value: factory() });와 같이 넣지 않을까?

왜냐하면 useRef가 실행될 때마다 factory 함수가 실행되기 때문이다.
불필요하게 함수가 계속 실행되는 것을 막고자, 훅의 최초 호출 시에 null을 넣어주고 ref.currentnull일 경우 함수의 실행 결과를 저장한다.

useRef도 계속 실행되니까 null이 들어가고 그러면 어차피 첫 번째 조건문에서 다시 값을 넣어주면서 factory 함수가 실행되는 거 아닐까?

useRefuseState로 구현되어 있다.
useState에 이미 저장된 상태가 있다면 useState(initialValue)를 다시 실행해도 initialValue를 무시하고 이미 저장된 값을 반환한다.
따라서 useRef가 실행되도 current 프로퍼티 값이 null로 덮어씌워지지 않고 이전에 저장한 값을 불러옴으로써 첫 번재 조건문은 최초 훅 호출 시에만 통과하게 된다.

💵 useCallback

앞서 본 것처럼 useMemouseCallback의 차이점은 함수의 실행 결과를 저장하냐, 함수를 저장하냐의 차이다.

export function useCallback<T extends Function>(
  factory: T,
  _deps: DependencyList,
) {
  return useMemo(() => factory, _deps);
}

이 차이점만 알면 useCallback의 구현은 간단해진다.
함수의 실행 결과가 아닌 함수 자체를 저장해야 하기 때문에 함수를 래핑해서 넘겨주면 된다.

💵 useMemo vs useCallback

useMemouseCallback의 차이점은 알겠는데, 어느 상황에서 어떻게 쓰는 게 좋을까?

useMemo는 함수의 결과를 저장하므로, 비용이 큰 계산의 결과를 저장할 수 있다.
매번 비용이 큰 계산을 실행한다면 말 그대로 비용이 많이 들기 때문에, 결과에 영향을 줄 수 있는 걸 의존성 배열에 담아서 그 값이 바뀌지 않는 한 다시 계산하지 않고 계산된 결과를 반환하게 한다.

useCallback은 함수 자체를 저장한다.
컴포넌트가 리렌더링된다는 것은 컴포넌트 함수를 실행하는 것이고, 컴포넌트 내부의 함수를 재생성하게 된다.
useCallback은 리렌더링 시에 의존성 배열의 값이 변경되지 않는 한 함수를 재생성하지 않는다.
React.memo와 사용하면 부모 컴포넌트가 리렌더링 되더라도 자식 컴포넌트는 리렌더링 되지 않게 할 수도 있다.
React.memo와 같이 사용하지 않아도 여러 예시를 보긴 했는데, 사실 내가 써본 게 아니라서 잘 와닿지는 않았다.
과제를 진행하면서 상황에 맞춰서 사용해보자.

💰 React.memo


React.memo는 인자로 받은 컴포넌트를 렌더링하고 결과를 저장한 뒤, 리렌더링 시 컴포넌트의 props에 변화가 없다면 저장된 값을 재사용한다.
따라서 부모 컴포넌트로 부터 함수를 받고 있는데, 함수를 useCallback으로, 자식 컴포넌트를 React.memo한다면 부모 컴포넌트가 리렌더링 되어도 함수가 바뀌지 않으면 자식 컴포넌트의 불필요한 리렌더링을 방지할 수 있다.

import { shallowEquals } from "../equalities";
import React, { ComponentType } from "react";
import { useRef } from "../hooks";

export function memo<P extends object>(
  Component: ComponentType<P>,
  _equals = shallowEquals,
) {
  const MemoizedComponent = (props: P) => {
    const memoizedProps = useRef<P | null>(null);

    if (memoizedProps.current === null) {
      memoizedProps.current = props;
      return React.createElement(Component, props);
    }

    if (!_equals(memoizedProps.current, props)) {
      memoizedProps.current = props;
      return React.createElement(Component, props);
    }
  };

  return MemoizedComponent;
}

useRef를 사용하여 구현한 코드다.
useMemo와 유사하여 이를 사용하려 했으나,

  • useMemo의 의존성 배열에 props를 넣으려면 배열로 감싸야 함.
  • 렌더링마다 새로운 배열이 생성되어 의존성 비교가 무의미해짐.
  • prop의 변경 여부와 상관없이 항상 리렌더링 발생.

위와 같은 이유로 useRef를 사용하여 props를 저장하고 비교하는 방식으로 구현했다.

💰 ContextAPI


ContextAPI 사용법에 대해 상세하게 설명해주는 글(https://velog.io/@velopert/react-context-tutorial)

팀원 분께서 ContextAPI에 대해 자세히 알려주는 아티클을 소개해줬는데 주소는 위에 있다.

값과 업데이트 함수를 두 개의 Context로 분리하면 업데이트 함수 사용 시 값만 사용하고 있는 컴포넌트는 리렌더링되지 않는다.

기존에는 값과 업데이트 함수를 분리하지 않고 하나의 Context에서 사용했는데, 위 글을 보고 분리했다.
값과 없데이트 함수를 분리하면 값을 사용하지 않고, 업데이트 함수만 사용하는 컴포넌트는 값이 변경되도 리렌더링되지 않는다.

import React, { createContext, useContext, useState } from "react";
import { INotification } from "../type/type";
import { useCallback, useMemo } from "../@lib";

interface NotificationStateContextType {
  notifications: INotification[];
}

interface NotificationActionContextType {
  addNotification: (message: string, type: INotification["type"]) => void;
  removeNotification: (id: number) => void;
}

const NotificationStateContext = createContext<
  NotificationStateContextType | undefined
>(undefined);

const NotificationActionContext = createContext<
  NotificationActionContextType | undefined
>(undefined);

export const useNotificationStateContext = () => {
  const state = useContext(NotificationStateContext);
  if (state === undefined) {
    throw new Error(
      "useNotificationStateContext must be used within an NotificationContext",
    );
  }
  return state;
};

export const useNotificationActionContext = () => {
  const actions = useContext(NotificationActionContext);
  if (actions === undefined) {
    throw new Error(
      "useNotificationActionContext must be used within an NotificationContext",
    );
  }
  return actions;
};

export const NotificationContextProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [notifications, setNotifications] = useState<INotification[]>([]);

  const addNotification = useCallback(
    (message: string, type: INotification["type"]) => {
      const newNotification: INotification = {
        id: Date.now(),
        message,
        type,
      };
      setNotifications((prev) => [...prev, newNotification]);
    },
    [],
  );

  const removeNotification = useCallback((id: number) => {
    setNotifications((prev) =>
      prev.filter((notification) => notification.id !== id),
    );
  }, []);

  const notificationStateContextValue: NotificationStateContextType = useMemo(
    () => ({
      notifications,
    }),
    [notifications],
  );

  const notificationActionContextValue: NotificationActionContextType = useMemo(
    () => ({
      addNotification,
      removeNotification,
    }),
    [addNotification, removeNotification],
  );

  return (
    <NotificationActionContext.Provider value={notificationActionContextValue}>
      <NotificationStateContext.Provider value={notificationStateContextValue}>
        {children}
      </NotificationStateContext.Provider>
    </NotificationActionContext.Provider>
  );
};

내가 작성한 Context 가운데 하나다.
값과 업데이트 함수를 별도의 Context로 만들어 하나의 Provider로 내보냈다.

업데이트 함수를 useCallback으로 감싸고 최초 렌더링 시에만 함수가 생성되도록 의존성 배열은 비워줬다.

추가로 props에 넘길 때, 불필요한 리렌더링을 방지하기 위해서 useMemoprops에 넘겨줄 값들을 감싸줬다.

'notificationActionContextValue는 의존성 배열에 addNotificationremoveNotification를 넣어줬는데, 두 함수 모두 최초 렌더링 시에만 만들어지는데, 굳이 넣어줄 필요가 있을까?'라는 고민이 있었지만 lint 에러가 났고, 웬만하면 지켜주는게 좋다고 하여 추가해줬다.

💰 useDebounce


이번 과제에서 상품을 검색하는 Input이 있고, onChange 시에 setFilterfilter 값이 변경되어 컴포넌트가 리렌더링되는 부분이 있었다.
키보드를 누를 때마다 리렌더링이 발생하여, 이를 줄이고자 useDebounce 훅을 만들었다.
아쉽게 과제 테스트 코드 조건으로는 만족하지 못해, 사용하지 않았지만... 이런 점도 고려했다~~

import { useEffect, useState } from "react";
import { useCallback } from "../@lib";

export const useDebounce = ({
  setValue,
  ms,
}: {
  setValue: (value: string) => void;
  ms: number;
}) => {
  const [timeoutId, setTimeoutId] = useState<number | null>(null);

  const debounceChange = useCallback(
    (value: string) => {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }

      const newTimeout = setTimeout(() => {
        setValue(value);
      }, ms);

      setTimeoutId(newTimeout);
    },
    [timeoutId, setValue, ms],
  );

  useEffect(() => {
    return () => {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }
    };
  }, [timeoutId]);

  return debounceChange;
};

💰 마치며


개인적으로 3주동안 이번 과제가 제일 어려웠다.
다른 분들은 이번주 난이도가 제일 낮다고 했지만, 대부분 프론트엔드 개발자로 재직하고 계신 분들이어서 그런 것 같다.
나는 아직 프론트엔드 경험이 없어서 그런지 타입스크립트와 리액트에 익숙지 않다는 걸 체감했다.
그래도 이번 기회에 리액트에 대해서 자세히 짚고 넘어갈 수 있었다.
배운 내용을 정리하면 아래와 같다.

  • 리액트 컴포넌트 리렌더링 시기
    - props, state 업데이트 시
    • 부모 컴포넌트 리렌더링 시
  • useRef
    - DOM에 접근하는 것뿐만 아니라 상태를 저장할 수 있다.
    • 값이 변경되도 리렌더링되지 않기 때문에 리렌더링에 영향을 받지 않는 상태를 저장하기 좋다.
  • useMemo
    - 복잡한 연산의 결과나, 여러 값을 하나의 객체로 리턴하는 함수로 만들어서 메모이제이션 할 수 있다.
    • 의존성 배열에 있는 값을 얕은 비교를 통해 비교하고 다르다면 갱신한다.
  • useCallback
    - 함수 자체를 메모이제이션 한다.
    • 자식 컴포넌트에게 props로 넘겨주는 함수를 메모이제이션 하고 자식 컴포넌트는 React.memo로 메모이제이션 한다면 부모 컴포넌트가 리렌더링되더라도 리렌더링을 방지할 수 있다.
  • React.memo
    - 컴포넌트의 props를 메모이제이션하여 props가 변경되지 않는 다면 리렌더링하지 않는다.
  • ContextAPI
    - 전역 상태 관리 뿐만 아니라 컴포넌트 사이에 값을 전달하는 props가 아닌 다른 방법.
    • 값과 업데이트 함수를 분리하면 리렌더링 방지를 할 수 있다.
    • ProvidervalueuseMemo를 통해 메모이제이션 하지 않으면 불필요한 리렌더링이 발생할 수 있다.

과제를 마치고 나서 궁금증이 더 생긴 건, 컴포넌트를 어떤 기준으로 분리하는지였다.
마침 다음 챕터인 클린 코드부터 다룰 주제라고 해서 기대가 된다.
이런 흐름으로 가도록 항해에서 커리큘럼을 짠 건가?!

💵 Keep: 현재 만족하고 계속 유지할 부분

이번주는 연말 약속이 있어, 과제를 바로 시작하지 못하고 월요일부터 시작했다.
늦게 시작하기도 했고, 리액트에 익숙지 않아, 하나씩 짚고 넘어가자는 생각에 천천히 과제를 진행했다.

기존에는 빠르게 과제 완수 후 리팩토링으로 했었는데, 이 방법도 나쁘지 않은 것 같다.

할 수 있다면, 퐁당퐁당해보고 나한테 더 맞는 방법을 찾아야겠다.

💵 Problem: 개선이 필요하다고 생각하는 문제점

토요일 약속, 일요일 숙취로 이틀을 보내고, 1월 1일에도 반나절은 잠만 잤다.
문득 무서워졌다.
24년도 뭔가 한 거 없이 지나간 것 같은데, 똑같이 반복될까봐.

슬랙에 연간 계획과 관련하여 코치님께서 올려주신 영상이 있어서 보고 연간 계획을 세웠다.

이직을 하고 나서도 나태해지면 안 되겠지만, 이직하기 전에는 더더욱 그러면 안 되는데.

💵 Try: 문제점을 해결하기 위해 시도해야 할 것

아침에 할 일 들을 적어놓는 습관을 들이고 있다.
파워 P라서 계획 세우는 걸 평생하지 않았지만, 요즘 부쩍 필요하다고 느낀다.

막연히 목표를 생각하고 행동을 하기보다, 목표를 생각하고 할 일을 쪼개서 하루마다 뭘 해야할지 어느정도 잡아놓고 실행하는 것이 필요하다.

저번주 코치님께서 해주신 말씀이 기억난다.
생각을 하는데 에너지를 쓰지말고 행동을 하는데 써야한다.

0개의 댓글

관련 채용 정보