YeongseoYoon-hanghae 님의 상세페이지[5팀 윤영서] Chapter 4-2 코드 관점의 성능 최적화

과제 체크포인트

마지막 배포링크!!!。₍ᐢ. .ᐢ₎

과제 요구사항

  • 배포 후 url 제출

  • API 호출 최적화(Promise.all 이해)

  • SearchDialog 불필요한 연산 최적화

  • SearchDialog 불필요한 리렌더링 최적화

  • 시간표 블록 드래그시 렌더링 최적화

  • 시간표 블록 드롭시 렌더링 최적화

과제 셀프회고

아!!! 최적화를 평소에 너무 안해봤다는걸 깨달은 주차...4기땐 진짜 10주차 과제 대충해서... 이번에 너무 힘들었슴다... 10주차까지 이렇게 계속해서 일이 있을줄은....우하하 그래도 뭔가 10주동안 열심히 해온것 같아서 뿌듯하기도 하고 그렇습니당..~ 오프코치님은 당연히 보실테고... 준일님도 보시겠죠?? 롤링페이퍼에도 썼지만... 코치님들 덕분에 10주 재밌고 알차게 보냈던 것 같습니다. 물론 빡빡한 일정에 포기하고 싶을때도 많았지만.. 준일님이 옆에서 계속 채찍질 해주시고 많이 도와주신 덕분에 정말 많이 배웠어요! 감사했습니다.

기술적 성장

memo를 슬기롭게 잘 쓰는 법

memo는 컴포넌트를 메모이제이션 할때 사용하는데, 프로퍼티의 얕은 비교를 통해 리렌더링이 필요한지 판단합니다.

memo를 사용할때의 실수는 부모 컴포넌트에서 객체나 배열, 함수를 직접 생성해서 props로 전달하는 것입니다. 예를 들어 <MyComponent data={{name: 'John'}} onClick={() => console.log('clicked')} />처럼 작성하면, memo로 감싸놨음에도 불구하고 부모가 리렌더링될 때마다 새로운 객체와 함수가 생성되어 자식 컴포넌트도 항상 리렌더링됩니다.

이 문제를 해결하는 첫 번째 방법은 useMemouseCallback을 적절히 활용하는 것입니다. 위의 예시를 올바르게 고치면 const data = useMemo(() => ({name: 'John'}), [])const handleClick = useCallback(() => console.log('clicked'), [])로 작성할 수 있습니다. 더 근본적인 해결책은 컴포넌트 설계 자체를 개선하는 것입니다. 복잡한 객체를 통째로 전달하는 대신 필요한 원시값들만 개별적으로 전달하면 memo가 훨씬 효과적으로 작동합니다. <UserCard user={userObject} />보다는 <UserCard name={user.name} age={user.age} email={user.email} />처럼 작성하는 게 좋습니다. 이렇게 하면 실제로 변경된 값이 있을 때만 리렌더링이 발생합니다.

함수 props의 경우에는 좀 더 신중하게 접근해야 합니다. 이벤트 핸들러를 부모에서 정의해서 전달하는 경우가 많은데, 이때 useCallback을 무분별하게 사용하기보다는 자식 컴포넌트 내부에서 처리할 수 있는 로직은 최대한 자식에게 맡기는 것이 좋습니다. 단순히 상태를 토글하는 함수라면 자식 컴포넌트에서 직접 구현하고, 부모는 상태값만 전달하는 방식으로 설계하면 불필요한 함수 전달을 피할 수 있게 됩니다.

memo의 두 번째 매개변수인 비교 함수(arePropsEqual)를 커스터마이징하는 것도 유용한 기법입니다. 기본적인 얕은 비교로는 충분하지 않은 경우, 예를 들어 배열의 내용이 같은지 확인하거나 특정 프로퍼티만 비교하고 싶을 때 사용할 수 있습니다.

솔직하게 말해서...시간이 별로 없기도 했고 이번에는 최적화가 주제였어서..^^ 잘 써볼일 없는 arePropsEqual 옵션 좀 써보자!하는 생각에 냅다 arePropsEqual를 갖다 박았습니다.

const BasicFilter = memo(
  ({ query, credits, onChangeSearchOption }: Props) => {
    return (
      <HStack spacing={4}>
        <FormControl>
          <FormLabel>검색어</FormLabel>
          <Input
            placeholder="과목명 또는 과목코드"
            value={query}
            onChange={(e) => onChangeSearchOption("query", e.target.value)}
          />
        </FormControl>

        <FormControl>
          <FormLabel>학점</FormLabel>
          <Select
            value={credits}
            onChange={(e) => onChangeSearchOption("credits", e.target.value)}
          >
            <option value="">전체</option>
            <option value="1">1학점</option>
            <option value="2">2학점</option>
            <option value="3">3학점</option>
          </Select>
        </FormControl>
      </HStack>
    );
  },
  (prevProps, nextProps) => {
    return (
      prevProps.query === nextProps.query &&
      prevProps.credits === nextProps.credits &&
      prevProps.onChangeSearchOption === nextProps.onChangeSearchOption
    );
  }
);

export default BasicFilter;

이렇게 하니까 렌더링 최적화 관련해서는 그냥 별 생각안하고 메모이제이션 해버리자!는 생각해 편하더라고요..ㅎㅎ..

Chakra vs 직접 스타일링하기

처음엔 Chakra로 되어있는 코드의 일부를 최대한 원본과 동일하게 스타일링해보았습니다.

const BASE_CELL_STYLE = {
  paddingInlineStart: "var(--chakra-space-4, 1rem)",
  paddingInlineEnd: "var(--chakra-space-4, 1rem)",
  paddingTop: "var(--chakra-space-2, 0.5rem)",
  paddingBottom: "var(--chakra-space-2, 0.5rem)",
  fontSize: "var(--chakra-fontSizes-sm, 0.875rem)",
  verticalAlign: "middle" as const,
  textAlign: "left" as const,
};

const BUTTON_STYLE = {
  transition: "all 0.15s ease",
  fontSize: "var(--chakra-fontSizes-sm, 0.875rem)",
  background: "var(--chakra-colors-green-500, #38A169)",
  color: "var(--chakra-colors-white, #FFFFFF)",
  paddingInlineStart: "var(--chakra-space-4, 1rem)",
  paddingInlineEnd: "var(--chakra-space-4, 1rem)",
  paddingTop: "var(--chakra-space-2, 0.5rem)",
  paddingBottom: "var(--chakra-space-2, 0.5rem)",
  borderRadius: "var(--chakra-radii-md, 0.375rem)",
} as const;


// 중략
    const cellStyles = useMemo(
      () => ({
        id: { ...BASE_CELL_STYLE, width: "100px" },
        grade: { ...BASE_CELL_STYLE, width: "50px" },
        title: { ...BASE_CELL_STYLE, width: "200px" },
        credits: { ...BASE_CELL_STYLE, width: "50px" },
        major: { ...BASE_CELL_STYLE, width: "150px" },
        schedule: { ...BASE_CELL_STYLE, width: "150px" },
        button: { ...BASE_CELL_STYLE, width: "80px" },
      }),
      []
    );

    return (
      <tr>
        <td style={cellStyles.id}>{id}</td>
        <td style={cellStyles.grade}>{grade}</td>
        <td style={cellStyles.title}>{title}</td>
        <td style={cellStyles.credits}>{credits}</td>
        <td
          style={cellStyles.major}
          dangerouslySetInnerHTML={{ __html: major }}
        />
        <td
          style={cellStyles.schedule}
          dangerouslySetInnerHTML={{ __html: schedule }}
        />
        <td style={cellStyles.button}>
          <button
            className="chakra-button"
            style={BUTTON_STYLE}
            onClick={handleAddClick}
          >
            추가
          </button>
        </td>
      </tr>
    );
  },


useMemo로 스타일 객체를 한 번만 계산해서 캐싱하도록 구현했는데요, 상위 코드처럼 직접 스타일링 방식에서는 스타일 객체들이 컴포넌트 마운트 시점에 한 번만 계산되고, 이후 렌더링에서는 단순히 참조만 전달하면 됩니다. 하지만 Chakra UI의 Td,Tr 등의 컴포넌트는 매번 렌더링될 때마다 theme 값들을 참조하고, props를 처리하고, 스타일을 계산하는 과정을 거쳐야 하고, 이런 계산은 행의 수가 늘어나면 늘어날 수록 무거워집니다.

또 Chakra UI 컴포넌트들은 각각이 React 컴포넌트 인스턴스를 생성하고, 내부적으로 여러 hooks를 사용하면서 추가적인 메모리를 소비하는데 직접 스타일링은 순수한 DOM 요소만 생성하므로 메모리 효율성이 높습니다.

물론 제가 만드는 프로덕트에서는 DX관점에서 생각해보았을때 이미 디자인 시스템이 존재하기때문에 굳이 라이브러리를 사용할 필요가 없기도하고, 이미 런타임 계산 비용등으로 인해 테일윈드로 전환해가고 있어서 Chakra를 사용하고 있진 않지만 간단하게 만들 수 있다는 점을 고려하면 Chakra를 고려해봄직 하다고는 생각이 듭니다.(노가다로 똑같은 스타일을 만들기 위해서 해보니...할만한게 못된다 생각...)

DnD × Popover(컨텍스트 UI)의 상호작용

Popover 컴포넌트는 내부적으로 Context를 사용하고 있는데요. https://github.com/chakra-ui/chakra-ui/blob/main/packages/react/src/components/popover/popover.tsx

export const PopoverAnchor = withContext<HTMLDivElement, PopoverAnchorProps>(
  ArkPopover.Anchor,
  undefined,
  { forwardAsChild: true },
)

////////////////////////////////////////////////////////////////////////////////////

export const PopoverContext = ArkPopover.Context

문제는 이렇게 context를 사용하면 상위 컴포넌트가 리렌더될 때마다 Provider의 모든 소비자들이 함께 리렌더된다는 점입니다.

https://github.com/user-attachments/assets/04fc46c7-e345-46ad-aa94-80114a183de9

영상에 보이는 것처럼, 좌측 상단에 popover가 계속해서 렌더링 되고있는 것을 확인할 수 있는데요. DnD 중 transform이 자주 바뀌어 상위가 리렌더되는 상황과 충돌하기 때문에 해당 popover가 보이는게 아닌데도 계속해서 저렇게 렌더링 되는..고런 상황이 되어부렀습니다...

그래서 이를 해결하기 위해서 다음과 같은 방법을 사용했습니다.

const { isOpen, onOpen, onClose } = useDisclosure();

<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose} isLazy lazyBehavior="unmount">
  <PopoverTrigger> ... </PopoverTrigger>
  {isOpen && <DeleteConfirmation onConfirm={handleConfirm} />}
</Popover>

Chakra가 제공하는 useDisclosure로 열림/닫힘을 제어하고, isLazy + lazyBehavior="unmount" + {isOpen && <Content/>} 조합으로 닫히면 완전 언마운트하도록 구현했습니다. 그래서 닫혀 있을 때는 DOM/컨텍스트 자체가 존재하지 않아 리렌더가 되지 않기를 기대하고 구현했는데요.

https://github.com/user-attachments/assets/5de951f9-3376-402d-8d58-533c2f3ad3df

보이는 것처럼 좌측 상단의 popover가 보이지 않는 것을 확인할 수 있었습니다!!!! 🥹 사실 단순하게 그냥 isOpen 상태만 둬도 될뻔했는데, 상위에서 전달되는 값때문에 계속 렌더링이 일어나는 줄 알고 엄청난 삽질을 한...

key의 불안정성을 없애자

react를 사용하는 개발자라면 "리스트 렌더링 시 key를 잘 부여해야 한다"는 사실은 잘 알고 있을 텐데요. 이번 과제 같은 경우에는 DnD가 있어 예상치 못한 성능 문제가 발생했습니다. 특히 index를 key로 사용한 리스트에서 순서 변경이 일어나니 엄청난 리렌더링이 발생하는것을 확인했습니다.

// 드래그 전 상태
const schedules = [
  { id: 'A', title: '수학' },     // key=0
  { id: 'B', title: '영어' },     // key=1  ← 이것을 드래그
  { id: 'C', title: '과학' },     // key=2
  { id: 'D', title: '역사' },     // key=3
];

// 드래그 후 상태  
const newSchedules = [
  { id: 'A', title: '수학' },     // key=0 (변화없음)
  { id: 'C', title: '과학' },     // key=1 ← 🚨 이전에 key=2였던 컴포넌트
  { id: 'D', title: '역사' },     // key=2 ← 🚨 이전에 key=3이었던 컴포넌트  
  { id: 'B', title: '영어' },     // key=3 ← 🚨 이전에 key=1이었던 컴포넌트
];

위처럼 두번째의 값을 네번째로 옮기게되면, key값이 달라지게 되는데요. React는

  • key=1 컴포넌트가 "영어"에서 "과학"으로 바뀌었다고 생각
  • key=2 컴포넌트가 "과학"에서 "역사"로 바뀌었다고 생각
  • key=3 컴포넌트가 "역사"에서 "영어"로 바뀌었다고 생각 위처럼 생각하게 됩니다. 실제로는 컴포넌트들이 위치만 바뀌었을 뿐인데, 내용이 변경된 것으로 잘못 인식합니다. 이번 과제에서도 index를 통해서 key값을 지정하는 부분이 있어 이를 고유한 키값을 가질 수 있도록 변경하였습니다.

코드 품질

코드의 전체적인 품질은 사실...최적화를 정말 무거운 계산에만 useMemo를 하는게 아니면 잘 해보지 않아서...마음에 들진 않습니다. 그리고 항상 논란이 되는 주제인 '메모를 언제 해야하는가'에 대해서도 의문이 있는 편이라, 이번에는 최적화 과제이니 만큼 열심히 메모이제이션 하긴했지만 리렌더링이 그렇게까지 나쁜가? 얼마나 최적화를 해야하는가? 에 대해서는 계속 궁금합니다.

암튼 코드 품질 보다는 계속해서 해결하려고 했던 리렌더를 잘 해결한 것 같아서 뿌듯합니다.

모달이 닫힐때 리렌더가 일어나는 부분 해소

https://github.com/user-attachments/assets/4edeed59-037a-4e38-836c-17cd17fc2676

시간표 추가시 해당 테이블에만 리렌더 발생하도록 해소

https://github.com/user-attachments/assets/2f1302e2-7c70-48f8-87f0-ea4407ade2f6

시간표 복제시 불필요한 리렌더 해소

https://github.com/user-attachments/assets/011c2265-8f6d-4c7b-b0c0-802daa966d52

시간표 삭제시 불필요한 리렌더 해소

https://github.com/user-attachments/assets/464e378f-2888-4bdc-a99e-897add85fb2d

dnd 시 불필요한 리렌더 해소

https://github.com/user-attachments/assets/5c076571-acf8-43bb-9d43-b4e8fe86abda

시간표 모달내에서 상태 변경시 불필요한 리렌더 해소

https://github.com/user-attachments/assets/a414c10e-0dbe-462f-adad-c7cce95c7f52

학습 효과 분석

중복 제거와 Promise.all 올바른 활용법

처음 api의 호출부분은 다음과 같이 되어있었는데요.

const fetchAllLectures = async () => await Promise.all([
  (console.log('API Call 1', performance.now()), await fetchMajors()),
  (console.log('API Call 2', performance.now()), await fetchLiberalArts()),
  (console.log('API Call 3', performance.now()), await fetchMajors()),
  (console.log('API Call 4', performance.now()), await fetchLiberalArts()),
  (console.log('API Call 5', performance.now()), await fetchMajors()),
  (console.log('API Call 6', performance.now()), await fetchLiberalArts()),
]);

겉보기에는 Promise.all을 사용해 병렬 처리를 하는 것처럼 보이지만, 실제로는 두 가지 문제를 가지고 있습니다.

// await를 Promise.all 내부에서 사용
await Promise.all([
  await fetchMajors(),      
  await fetchLiberalArts(), 
  await fetchMajors(),    
  // ...
]);

// Promise 객체들을 배열로 전달
await Promise.all([
  fetchMajors(),      
  fetchLiberalArts(), 
  fetchMajors(),      
  // ...
]);

지금은 내부적으로 await 키워드를 사용하고 있는데, 내부적으로 await을 사용하는게 아니라 외부에만 선언하고, 내부적으로는 Promise 객체들을 배열로 전달하는것이 옳은 사용방식입니다. 잘못 사용한 방식은 각 API가 순차 실행되고, 올바르게 사용하면 모든 API가 병렬적으로 실행됩니다.

또한 지금과 같은 경우에는 의도적으로 동일한 데이터를 여러 번 요청하고 있는데요.

export const createCachedFetch = () => {
  const promiseCache = new Map<string, Promise<unknown>>();

  return async <T>(
    key: string,
    fetchFn: () => Promise<T>,
    callNumber: number
  ): Promise<T> => {
    if (promiseCache.has(key)) {
      console.log(`API Call ${callNumber}`, performance.now());
      return promiseCache.get(key) as Promise<T>;
    }

    console.log(`API Call ${callNumber}`, performance.now());
    const promise = fetchFn()
      .then((result) => {
        return result;
      })
      .catch((error) => {
        promiseCache.delete(key);
        throw error;
      });

    promiseCache.set(key, promise as Promise<unknown>);
    return promise;
  };
};

때문에 이미 호출했는지를 Promise 객체 자체로 캐싱해두고 다음번에 캐싱된 데이터를 제공하기 위한 유틸을 구현했습니다.

before Pasted Graphic 2

after Pasted Graphic 3

과제 피드백

최적화를 통해 리렌더를 적게 발생시켜라라는 과제의 목표가 너무 명확해서..딱히 없는 것 같습니다!

리뷰 받고 싶은 내용

  1. 코치님은 실무에서 메모이제이션을 자주 하시는 편이신가요? 그에 대해서도 갑론을박이 있는데, 코치님은 dx 비용을 위해 전체적으로 다 메모이션하는 것을 선택하시는 편인지, 아니면 최소한의 부분에 대해서만 메모이제이션 하시는 편이신지도 궁금합니다.
  2. 이전에 코치님께서 번역하신 글중에 리액트 컴파일러에 대해서 번역하신 글을 본적 있는데요. 실무에서 컴파일러 사용하고 계신가요??? 이번에 메모이제이션하면서 너무 애를 먹었는데...컴파일러가 이런 부분에 대해서 해소할 수 있을 것이라고 기대하시는지도..궁금합니다.(리액트 팀은 그러하다고 말하긴 하지만요)

과제 피드백

고생하셨습니다~ 벌써 마지막 과제네요. 모든 일에 있어서 처음부터 끝까지 한결같이 열심히 하기 어려운데, 정말 잘 마무리 해주신 것 같습니다. 멋져요. 과제 전반에 있어서는 당연히 잘 해주셨고, 성능 최적화 관점에서 잘 주셨습니다. 말씀해주신것처럼 성능 최적화라는건 늘 예방을 위해 하는 작업이 아니라 문제가 발생했을 때 해야하는 세부적인 작업이라고 생각해요. 그리고 늘 이런 작업들이 하나하나 개별로 봤을때는 엄청난 작업은 아니지만 티끌모아태산이 되는 작업이 성능 최적화라고 생각합니다. 단순히 1~2ms 가 줄어들더라도 그런 관점에서 FE개발자는 계속 고민을 해야 하는 직군인것 같아요.

코치님은 실무에서 메모이제이션을 자주 하시는 편이신가요? 그에 대해서도 갑론을박이 있는데, 코치님은 dx 비용을 위해 전체적으로 다 메모이션하는 것을 선택하시는 편인지, 아니면 최소한의 부분에 대해서만 메모이제이션 하시는 편이신지도 궁금합니다.

늘 이야기하지만 성능 최적화 관점에서는 명확하게 성능적인 이슈가 발생하는 것이 자명하지 않으면 문제가 발생하기 전에는 최적화를 진행하지 않는 편인것 같아요. 메모이제이션 관점에 있어서도 이미 리액트 측에서 최적화를 제공해주는 부분이 있고 이미 알고 있겠지만 디버깅이 어려워지는 명확한 부분이 있기 때문에 최소화 하는 편입니다.

이전에 코치님께서 번역하신 글중에 리액트 컴파일러에 대해서 번역하신 글을 본적 있는데요. 실무에서 컴파일러 사용하고 계신가요??? 이번에 메모이제이션하면서 너무 애를 먹었는데...컴파일러가 이런 부분에 대해서 해소할 수 있을 것이라고 기대하시는지도..궁금합니다.(리액트 팀은 그러하다고 말하긴 하지만요)

프로덕션 레벨에 이걸 적용할 수 있는 용기있는 회사가 있을까 싶네요 ㅎㅎ 당연히 리액트 팀에서는 그걸 목표로 달리고 있지만, 초기 발표에서는 저는 대체할 수 없는 별도의 프로젝트라고 생각했습니다. 근데 그 이후 지속적인 발표에 있어서는 어느정도 커버가 가능할 수 있겠다라는 생각은 드는것 같아요. 그럼에도 각각 어떤 목적에서 써야하는지 이해하는건 계속되지 않을까..라고 예측만 하고 있습니다. 관련해서 아직 정식 배포가 아니기 때문에 추적하면서 공부를 해보는건 좋은것 같아요.

고생하셨고 앞으로의 개발 인생도 화이팅하세요!