Skip to content

리액트를 배우셨군요 유감입니다 3편 (useEffect와 Props Drilling이라는 지옥도)

Published: at 오후 03:45

1편에서는 리액트의 탄생 비화와 ‘자바스크립트 피로감’에 대해, 2편에서는 ‘JSX’와 ‘컴포넌트’라는 리액트의 기묘한 철학에 대해 알아봤습니다.

오늘은 드디어 이 고통의 핵심으로 더 깊이 들어가 보려고 합니다.

바로 리액트 개발자라면 누구나 한 번쯤 머리를 쥐어뜯게 만들었던 ‘useEffect’, 그리고 데이터 흐름을 고통스럽게 만드는 ‘Props Drilling’에 대해 이 책이 얼마나 통렬하게 꼬집는지 함께 보시죠.

useEffect 스스로를 쏘기 위해 만들어진 총

useEffect는 “함수형 컴포넌트에서 사이드 이펙트는 어떻게 처리하죠?”라는 질문에 대한 리액트의 대답인데요.

하지만 저자의 말에 따르면, 그 대답은 “버그와 함께, 혼란스러운 방식으로, 개발자의 정신을 피폐하게 만든다”입니다.

만약 리액트 훅들을 하나의 가족에 비유한다면, useEffect는 착한 마음을 가졌지만 계속해서 집에 불을 지르는 말썽쟁이 십대와 같다고 하더라고요.

componentDidMount의 사악한 쌍둥이

과거 클래스 컴포넌트 시절에는 우리에게 아주 명확한 생명주기 메소드들이 있었죠.

componentDidMount는 컴포넌트가 마운트될 때 딱 한 번, componentDidUpdate는 props가 바뀔 때, componentWillUnmount는 컴포넌트가 사라지기 직전에 실행되었습니다.

아주 명확하고 예측 가능했죠.

리액트는 이 명확함을 보고 “이 모든 걸 하나의 혼란스러운 함수로 합쳐보면 어떨까?”라고 생각한 게 틀림없습니다.

const NewComponent = ({ id }) => {
  useEffect(() => {
    console.log("마운트된 건가? 업데이트된 건가? 둘 다인가? 누가 알겠어!");

    return () => {
      console.log("클린업! 언마운트인가? 둘 다인가? 미스터리!");
    };
  }, [id]); // id가 바뀔 때... 아니면 마운트될 때... 아니면...
};

이 모든 혼란의 중심에는 바로 ‘의존성 배열’이라는 녀석이 있습니다.

파멸의 의존성 배열

useEffect의 두 번째 인자인 의존성 배열은 이펙트가 언제 실행될지를 결정하는데요.

간단해 보이지만, 온갖 함정으로 가득 차 있죠.

_ 배열이 없으면 모든 렌더링 후에 실행됩니다.

보통은 버그로 이어지죠.

_ 빈 배열([])을 넣으면 마운트 시 한 번만 실행됩니다.

(하지만 StrictMode에서는 두 번 실행되는 거짓말쟁이죠!)

* 배열 안에 값을 넣으면, 그 값이 바뀔 때마다 실행됩니다.

(하지만 객체나 함수를 넣으면 참조가 매번 바뀌어서 항상 실행될 수도 있죠!)



그리고 이 의존성 배열은 우리에게 ‘무한 루프’라는 선물을 주기도 하는데요.

const InfiniteLoop = () => {
  const [count, setCount] = useState(0);

  // count가 바뀔 때마다 실행되는데, 실행될 때마다 count를 바꿈
  useEffect(() => {
    setCount(count + 1);
  }, [count]);

  return <div>{count}</div>;
};

이 코드를 실행하면 당신의 브라우저는 비명을 지르며 멈출 겁니다.

”CPU 사용량이 왜 100%죠?” 라고 묻는다면, “useEffect 때문입니다” 라고 답하면 됩니다.

데이터 페칭이라는 대참사

모든 리액트 입문자가 처음으로 useEffect를 사용해 데이터를 가져오려고 시도하는 코드는 보통 이렇습니다.

useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));
}, [userId]);

깔끔해 보이지만, 이 코드에는 로딩 상태도, 에러 처리도, 경쟁 상태(Race Condition) 방지도, 심지어 데이터를 가져오는 중에 컴포넌트가 언마운트되는 경우에 대한 처리도 전혀 없습니다.

이 모든 것을 제대로 처리하려면 코드는 30줄이 넘는 괴물이 되어버리죠.

그러면 사람들은 말합니다.

”그냥 React Query 쓰세요.”

결국 리액트가 만든 문제를 해결하기 위해 또 다른 라이브러리를 도입하게 되는 겁니다.

Props Drilling 끝없이 내려가는 데이터

Props Drilling은 당신의 증조할머니가 당신에게 가보를 물려주고 싶은데, 직접 주는 대신 할머니에게 주고, 할머니는 어머니에게, 어머니는 당신에게 주는 것과 같은데요.

문제는 할머니와 어머니는 그 가보에 전혀 관심이 없다는 점이죠.

그들은 그저 이 세대 간의 ‘폭탄 돌리기’ 게임에서 중간 전달자일 뿐입니다.

이것이 바로 리액트의 데이터 흐름이죠.

‘단방향’이라는 말은 ‘불편하다’는 말을 있어 보이게 표현한 것뿐입니다.

소품들의 크리스마스 트리

리액트에서 데이터는 이런 식으로 흐르는데요.

const App = () => {
  const [user, setUser] = useState({ name: "John" });
  return <Dashboard user={user} setUser={setUser} />;
};

const Dashboard = ({ user, setUser }) => {
  return <Profile user={user} setUser={setUser} />;
};
// ...중간 컴포넌트 생략...
const NameEditor = ({ user, setUser }) => {
  // 드디어 이 props를 사용!
  return (
    <input
      value={user.name}
      onChange={e => setUser({ ...user, name: e.target.value })}
    />
  );
};

6개의 컴포넌트 중 5개는 usersetUser에 전혀 관심이 없지만, 리액트가 시켰기 때문에 모두 이 props를 받아서 아래로 전달해야만 합니다.

이것의 진짜 문제는 코드가 길어지는 것보다도 ‘결합도(coupling)‘가 높아진다는 점인데요.

중간에 있는 모든 컴포넌트가 자기가 쓰지도 않는 props에 의존하게 되면서, 이제는 그저 우체부 역할을 하는 컴포넌트가 되어버립니다.

Context API라는 구원투수 (인 척하는 것)

리액트도 이 문제를 모르는 건 아니어서 ‘Context API’라는 해결책을 내놓았는데요.

const UserContext = createContext();

const App = () => {
  const [user, setUser] = useState({ name: "John" });

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Dashboard />
    </UserContext.Provider>
  );
};

const NameEditor = () => {
  const { user, setUser } = useContext(UserContext);
  // ...
};

이제 중간 컴포넌트들을 건너뛰고 데이터를 바로 주입할 수 있게 되었습니다.

문제가 해결된 것 같죠?

대신 우리는 ‘보이지 않는 의존성’, ‘Context가 바뀔 때마다 발생하는 무수한 리렌더링’, 그리고 앱 최상단을 뒤덮는 ‘프로바이더 지옥’이라는 새로운 문제를 얻게 되었습니다.

Props Drilling을 해결하려다 ‘Context 혼란’이라는 더 큰 문제를 만들어낸 셈이죠.

패턴과 안티패턴 그 좋고, 나쁘고, 이상한 것

리액트의 패턴들은 마치 패션 트렌드와 같은데요.

오늘 최고라고 칭송받던 것이 내일은 코드 스멜이 되고, 작년의 안티패턴이 올해의 베스트 프랙티스가 되기도 하죠.

‘컨테이너/프레젠테이셔널’ 패턴을 기억하시나요?

데이터 로직을 다루는 ‘스마트’ 컨테이너 컴포넌트와, 오직 화면만 그리는 ‘멍청한’ 프레젠테이셔널 컴포넌트로 나누는 것이 한때는 황금률이었습니다.

하지만 훅이 등장하면서, 이제는 그냥 하나의 컴포넌트 안에서 데이터도 가져오고 화면도 그리는 것이 일반적이 되었죠.

몇 년간 우리가 맹세했던 그 패턴은 하루아침에 “왜 굳이 컴포넌트를 두 개로 나누죠?”라는 비아냥을 듣는 안티패턴이 되어버렸습니다.

HOC(Higher-Order Components), Render Props 같은 패턴들도 한 시대를 풍미했지만, 이제는 커스텀 훅에 자리를 내주고 복잡성의 상징처럼 여겨지게 되었죠.

성능 우리가 스스로에게 하는 거짓말들

”리액트는 빠르다!” 와 “리액트는 느리다!”는 주장이 항상 팽팽하게 맞서는데요.

진실은, 리액트는 당신이 허락하는 만큼만 빠르다는 겁니다.

그리고 보통 우리는 리액트가 빨라지는 것을 잘 허락하지 않죠.

리액트 성능에 대한 신화들



이런 불필요한 리렌더링을 막기 위해 우리는 React.memo를 사용하는데요.

이건 리액트가 스스로 “불필요한 리렌더링이 문제라는 것을 인정합니다”라고 말하는 것과 같죠.

하지만 React.memo조차도 props로 객체나 함수를 내려주면 속수무책으로 깨져버립니다.

그래서 우리는 useMemouseCallback을 쓰게 되죠.

성능 문제가 있지도 않은데 미리 최적화하겠다며 코드를 더 복잡하게 만드는 ‘섣부른 최적화의 중심지’에 오신 것을 환영합니다.

마치며

useEffect는 선언적인 패러다임에 명령형 작업을 억지로 끼워 넣으려고 할 때 어떤 일이 벌어지는지를 보여주는 대표적인 사례입니다.

Props Drilling은 단방향 데이터 흐름이라는 원칙을 너무 고집할 때 얼마나 고통스러운지를 보여주죠.

그리고 수많은 패턴과 성능 최적화 기법들은, 리액트가 스스로 만들어낸 복잡성을 해결하기 위해 또 다른 복잡성을 더하는 과정에 불과할 때가 많습니다.

결국 이 모든 것을 겪고 나면, 우리는 리액트의 모든 함정을 외우고, 그것들을 자동적으로 피해 가며, 한때는 더 간단한 방법이 있었다는 사실조차 잊어버리게 될 겁니다.

고통을 통해 우리는 성장하니까요.

아마도요.