장인은 도구탓을 하지 않는다. 주니어도 그렇다.

박성택 · 원프레딕트 프론트엔드 엔지니어
November 01, 2023

프론트 데스크에서 발표한 내용을 엔지니어링 블로그에 공유합니다. 주1

Intro

도구탓

흔히들 장인은 도구 탓을 하지 않는다고들 합니다.

하지만 아이러니하게도 주니어 개발자도 그런 경우가 많지 않을까 생각해 봅니다.

도구 탓이라고 표현했지만 결국에는 사용하는 도구를 잘 이해하지 못하고 사용한다는 뜻입니다.

왜냐하면 도구 탓을 하기 전에 본인 탓이 우선이기 때문입니다.

아마 주니어 개발자라면 한 번쯤은 분명 내가 작성한 코드가 잘못됐을 것이라는 생각을 해봤을 것입니다.

오늘 나누고자 하는 주제 또한 저를 포함한 많은 리액트 주니어 개발자들이 사용하는 것에만 신경 쓰고 그 이유에 대해서는 생각하지 않을 때에 관한 내용입니다.

부디 누군가의 옆구리를 찌르는 내용이길 바랍니다.

시발점 - 문제의 코드

  const dataList = channels.map((data) => {
    const { idList } = data;

    ...생략...

    // eslint-disable-next-line react-hooks/rules-of-hooks
    const result = useCustomQuery({
      ids: Object.values(idList),
    });

    return result;
  });

어딘가 불편합니다

// eslint-disable-next-line react-hooks/rules-of-hooks

참 거슬리는 문구가 아닐 수 없습니다

해당 코드로 인해서 당연하게도 코드 리뷰 단계에서 동료들의 피드백을 받을 수 있었습니다.

코드리뷰1
꼼꼼한 동료들의 코드리뷰 1

코드리뷰2
꼼꼼한 동료들의 코드리뷰 22

Why ? 왜 그랬어?

베어링 개수만큼 각 베어링의 channelId 목록이 담긴 배열로 map 함수를 돌면서 useCustomQuery 를 사용해 사용할 모든 result를 불러올 생각이었습니다.

당연히 규칙에 위반되기 때문에 ‘커스텀 훅을 생성해서 (나중에) 처리해야지!’라고 생각만 한 뒤 바로 수정하지 않고, 에러를 무시하고 ‘차트를 그리는 게 먼저다!’라는 핑계로 넘어가고는 잊어버렸다는 핑계였습니다.

정리하자면, 반복을 통해서 차트 상단에서 필요한 시그널 데이터를 모두 불러오자는 목적이 있었으나 ‘React Hook 규칙에 대해서 정확히 이해하지 못하고 사용하면서 나온 잘못이다.’입니다.

Why ! 외 않되!

hook규칙
Hook 규칙에 대한 공식문서

// ------------
// 첫 번째 렌더링
// ------------
useState('Mary')           // 1. 'Mary'라는 name state 변수를 선언합니다.
useEffect(persistForm)     // 2. 폼 데이터를 저장하기 위한 effect를 추가합니다.
useState('Poppins')        // 3. 'Poppins'라는 surname state 변수를 선언합니다.
useEffect(updateTitle)     // 4. 제목을 업데이트하기 위한 effect를 추가합니다.

// -------------
// 두 번째 렌더링
// -------------
useState('Mary')           // 1. name state 변수를 읽습니다.(인자는 무시됩니다)
useEffect(persistForm)     // 2. 폼 데이터를 저장하기 위한 effect가 대체됩니다.
useState('Poppins')        // 3. surname state 변수를 읽습니다.(인자는 무시됩니다)
useEffect(updateTitle)     // 4. 제목을 업데이트하기 위한 effect가 대체됩니다.

// ...

Hook의 호출 순서가 렌더링 간에 동일하다면 React는 지역적인 state를 각 Hook에 연동시킬 수 있습니다. 하지만 Hook을 조건문 안에서(예를 들어 persistForm effect) 호출한다면 어떤 일이 일어날까요?

  // 🔴 조건문에 Hook을 사용함으로써 첫 번째 규칙을 깼습니다
  if (name !== '') {
    useEffect(function persistForm() {
      localStorage.setItem('formData', name);
    });
  }

name !== '' 조건은 첫 번째 렌더링에서 true이기 때문에 Hook은 동작합니다. 하지만 사용자가 그다음 렌더링에서 폼을 초기화하면서 조건을 false로 만들 겁니다. 렌더링 간에 Hook을 건너뛰기 때문에 Hook 호출 순서는 달라지게 됩니다.

useState('Mary')           // 1. name state 변수를 읽습니다. (인자는 무시됩니다)
// useEffect(persistForm)  // 🔴 Hook을 건너뛰었습니다!
useState('Poppins')        // 🔴 2 (3이었던). surname state 변수를 읽는 데 실패했습니다.
useEffect(updateTitle)     // 🔴 3 (4였던). 제목을 업데이트하기 위한 effect가 대체되는 데 실패했습니다.

React는 두 번째 useState Hook 호출에 대해 무엇을 반환할지 몰랐습니다. React는 이전 렌더링 때처럼 컴포넌트 내에서 두 번째 Hook 호출이 persistForm effect와 일치할 것이라 예상했지만 그렇지 않았습니다. 그 시점부터 건너뛴 Hook 다음에 호출되는 Hook이 순서가 하나씩 밀리면서 버그를 발생시키게 됩니다.

이것이 컴포넌트 최상위(the top of level)에서 Hook이 호출되어야만 하는 이유입니다. 조건부로 effect를 실행하기를 원한다면, 조건문을 Hook 내부에 넣을 수 있습니다.

How? 그래서 어떻게 했어?

저장소에 문제의 Merge Request를 올린 후 휴가를 떠난 저였습니다.

휴가 복귀하니 문제의 코드들이 main branch에 merge가 된 상태였습니다.

빠른 템포로 돌아가는 스프린트 와중에 코드 리뷰 중인 MR에 approve 버튼을 눌렀을 동료들의 마음이 차마 가늠이 가지 않았습니다.

하지만 이미 엎질러진 물!

다시 태스크를 가져와서 개선하기로 했습니다.

사실 가장 간단한 방법은... 문제가 되는 useCustomQuery를 반복문 밖으로 빼는 겁니다.

  const result = useCustomQuery({
    ids: Object.values(idList),
  });

  const dataList = channels.map((data) => {
    const { idList } = data;

    // ...중략...

    return result;
  });

문제가 되던 반복하면서 useCustomQuery를 쏘던 result를 밖으로 빼고, 대신 반복문을 통해 얻은 모든 channelId가 담긴 배열을 인자로 넘겨줍니다.

하지만 가만히 들여다봤을 때, 여전히 불편함이 가시질 않았습니다.

그래서

커스텀 훅 생성

const useShaftCenterSignalData = (
  props: useOverviewSignalDataProps
) => {
  const { channelData } = props;

  const channelIds = channelData?.map((data) => Object.values(data.channelIdList)).flat();

  const { data: signalData } = useCustomQuery({
    channelIds
  });

  // signal data 없음
  if (signalData === undefined || Object.keys(signalData.signals).length === 0) {
    return defaultSignalData;
  }

  // signalData가 있는 경우
  const { signals } = signalData;

  const shaftCenterSignalDataList = [...channelData]?.map((data) => {
    // ...중략...

    return createShaft(getSignalsByChannel(signals, channelIdList), target);
  });

  return shaftCenterSignalDataList;
};

좋은 코드의 기준은 개개인이 다를 수 있겠지만 불편함이 조금은 줄었을까요!

정리

지금까지 주니어가(제가) 흔히 하는 실수인 React Hook 규칙 위반 사례와 나아가서 커스텀 훅을 통한 개선 방법에 대해서 얘기해 봤습니다.

이 주제를 논하고자 마음먹었을 때, 사실은 부끄러운 마음이 더 컸습니다.

사실 흔히 하는 실수일 수 있으나 해서는 안 되는 실수이기도 하고, 남들은 하지 않는 실수일 것이 분명했기 때문입니다.

하지만 이 주제가 누군가에게는 공감을 일으킬 수 있으리라 생각했고, 이처럼 사소하다고 느낄 수 있을 만한 부분도 팀 단위에서 코드 리뷰가 이루어지고 있다는 부분을 강조하고 싶었습니다.

돌이켜보면 사실은 저의 게으름이 자아낸 실수 아닌 실수라고 생각합니다.

협업이 자연스레 이뤄지는 환경 속에서, 내 로컬 검은 바탕에 써 내려간다고 해서 나만 보는 코드가 아님을 항상 명심해야겠습니다.


  1. 원프레딕트의 모든 프론트엔드 엔지니어는 매주 목요일에 기술 주제를 나누는데, 이 모임의 이름이 ‘프론트 데스크’입니다.
원프레딕트는 더 나은 제품을 고민하며 기술적인 문제를 함께 풀어낼 동료를 찾고 있습니다.
자세한 내용은 채용 사이트를 참고해 주세요.