(번역) React 훅은 실수일까요?

강엽이·2023년 3월 26일
28
post-thumbnail

원문 : https://jakelazaroff.com/words/were-react-hooks-a-mistake/

지난 몇 주 동안 웹 개발 커뮤니티는 매우 효율적인 UI 업데이트를 가능하게 하는 반응형 프로그래밍 패턴인 시그널에 대해 떠들썩 했습니다. Devon Govett은 시그널과 가변 상태에 대한 생각을 불러일으키는 트위터 스레드를 작성했습니다. Ryan Carniato는 시그널을 리액트와 비교하는 훌륭한 글로 응답했습니다. 양측 모두 좋은 주장이었고, 토론을 지켜보는 것은 정말 흥미로웠습니다.

위의 담론을 통해 분명하게 드러난 한 가지는 리액트 프로그래밍 모델을 사용하는 많은 사람들이 위 글들을 클릭하지 않았다는 것입니다. 왜 일까요?

제 생각에 문제는 사람들의 컴포넌트에 대한 멘탈 모델이 리액트의 훅과 함수형 컴포넌트와 일치하지 않는다는 것입니다. 저는 대담하게 주장합니다. 시그널 기반 컴포넌트가 훅이 있는 함수 컴포넌트 보다 클래스 컴포넌트와 훨씬 더 유사하기 때문에 사람들은 시그널을 좋아합니다.


조금만 뒤로 돌려봅시다. 리액트 컴포넌트는 다음과 같이 표시됩니다.

이전에도 React.createClass가 있었지만, 여기서는 그 시절에 대해 이야기하지 않습니다.

class Game extends React.Component {
  state = { count: 0, started: false };

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  start() {
    if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000);
    this.setState({ started: true });
  }

  render() {
    return (
      <button
        onClick={() => {
          this.increment();
          this.start();
        }}
      >
        {this.state.started ? "Current score: " + this.state.count : "Start"}
      </button>
    );
  }
}

각 컴포넌트는 React.Component 클래스의 인스턴스입니다. 상태는 state 속성으로 유지되었으며, 콜백은 인스턴스의 메서드에 불과했습니다. 리액트가 컴포넌트를 렌더링해야 할 때는 render 메서드를 호출했습니다.

여전히 컴포넌트를 위와 같이 작성할 수 있습니다. 아직 클래스 컴포넌트 구문은 제거되지 않았습니다. 그러나 2015년에 리액트는 상태 없는 함수형 컴포넌트라는 새로운 것을 소개했습니다.

function CounterButton({ started, count, onClick }) {
  return <button onClick={onClick}>{started ? "Current score: " + count : "Start"}</button>;
}

class Game extends React.Component {
  state = { count: 0, started: false };

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  start() {
    if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000);
    this.setState({ started: true });
  }

  render() {
    return (
      <CounterButton
        started={this.state.started}
        count={this.state.count}
        onClick={() => {
          this.increment();
          this.start();
        }}
      />
    );
  }
}

당시에는 이러한 컴포넌트에 상태를 추가할 방법이 없었기 때문에 클래스 컴포넌트에 보관하고 props로 전달해야 했습니다. 이 아이디어는 대부분의 컴포넌트가 상태 비저장이며, 트리의 맨 위에 있는 상태 저장 컴포넌트 몇 개를 통해 동작될 것이라는 것이었습니다.

그러나 클래스 컴포넌트를 작성하는 데 있어서 상황이 좀... 어색했습니다. 상태 저장 로직의 구성이 특히 까다로웠습니다. 윈도우 크기 조정 이벤트를 수신하기 위해 여러 클래스가 필요하다고 가정해 보겠습니다. 각 클래스에서 동일한 인스턴스 메서드를 다시 작성하지 않고 어떻게 할 수 있을까요? 컴포넌트 상태와 상호 작용하기 위해 필요한 경우에는 어떻게 해야 할까요? 리액트는 mixins으로 이 문제를 해결하려고 했지만, 팀이 단점을 깨닫고 mixins 사용을 빠르게 중지 하였습니다.

또한, 사람들은 함수 컴포넌트를 좋아했습니다! 심지어 그들에게 상태를 추가하기 위한 라이브러리도 있었습니다. 따라서 리액트가 내장 솔루션인 훅을 고안한 것은 놀라운 일이 아니었습니다.

function Game() {
  const [count, setCount] = useState(0);
  const [started, setStarted] = useState(false);

  function increment() {
    setCount(count + 1);
  }

  function start() {
    if (!started) setTimeout(() => alert(`Your score was ${count}!`), 5000);
    setStarted(true);
  }

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started ? "Current score: " + count : "Start"}
    </button>
  );
}

제가 처음 이것을 시도했을 때, 훅은 하나의 혁명이었습니다. 훅은 동작의 캡슐화와 상태 로직 재사용을 정말 쉽게 만들었습니다. 저는 무작정 뛰어들었습니다. 그 이후로 제가 작성한 클래스 컴포넌트는 에러 바운더리가 유일했습니다.

언뜻 보기에는 이 컴포넌트가 위의 클래스 컴포넌트와 동일하게 동작하지만 중요한 차이점이 있습니다. 여러분들이 이미 발견했을 수도 있습니다. UI에서 점수는 업데이트 되지만, 경고창이 보여질 때에는 항상 0이 보여집니다. setTimeoutstart 함수의 첫 번째 호출에서만 발생하기 때문에 초기 count 값에 대한 참조를 닫고 이후 초기 값만을 사용합니다.

useEffect로 이 문제를 해결할 수 있다고 생각할 수도 있습니다.

function Game() {
  const [count, setCount] = useState(0);
  const [started, setStarted] = useState(false);

  function increment() {
    setCount(count + 1);
  }

  function start() {
    setStarted(true);
  }

  useEffect(() => {
    if (started) {
      const timeout = setTimeout(() => alert(`Your score is ${count}!`), 5000);
      return () => clearTimeout(timeout);
    }
  }, [count, started]);

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started ? "Current score: " + count : "Start"}
    </button>
  );
}

이 코드에서 경고 창은 정확한 카운트를 표시합니다. 하지만 새로운 문제가 있습니다. 버튼을 계속 클릭하면 경고창이 끝나지 않습니다! 이펙트 함수 클로저가 "stale" 되는 것을 방지 하기 위해 종속 배열에 countstarted를 추가합니다. 값이 바뀔 때마다, 우리는 업데이트된 값을 보는 새로운 상태 얻습니다. 하지만 그 새로운 상태는 또한 새로운 타임아웃을 설정합니다. 버튼을 클릭 이후 5초가 되기 전에 새로 경고 창이 표시됩니다.

클래스 컴포넌트에서 메서드는 클래스 인스턴스에 대한 안정적인 참조를 가지고 있기 때문에 항상 최신 상태에 엑세스 할 수 있습니다. 함수 컴포넌트에서 모든 렌더는 자신의 상태에 대해 닫히는 새 콜백을 만듭니다. 함수가 호출될 때마다 해당 함수는 자체 클로저를 가져옵니다. 미래의 렌더링은 과거의 렌더링 상태를 변경할 수 없습니다.

클래스 컴포넌트에는 마운트된 컴포넌트당 하나의 인스턴스가 있지만 함수 컴포넌트에는 렌더당 여러 개의 "인스턴스"가 있습니다. 훅은 이러한 제약을 더욱 확고히 합니다. 그것이 여러분의 모든 문제의 근원입니다.

  • 각 렌더는 자체 콜백을 만듭니다. 이 콜백은 useEffect와 그와 비슷한 동작을 실행하기 전에 참조의 동일함을 확인하는 콜백입니다. 이것은 매우 자주 호출됩니다.
  • 콜백은 렌더링에서 state와 props에 대해서 닫히기 때문에, useCallback, 비동기 연산, 타임 아웃 등으로 인해 렌더링 동안 지속되는 콜백은 오래된 데이터에 액세스하게 됩니다.

리액트는 이 문제를 해결하기 위해 렌더링 간에 안정적인 동일성을 유지하는 가변 객체인 useRef를 제공합니다. 저는 이것이 동일한 마운트된 컴포넌트의 다른 인스턴스 간에 값을 앞뒤로 전달하는 방법이라고 생각합니다. 이를 염두에 두고 훅을 사용하는 게임 코드의 버전은 다음과 같습니다.

function Game() {
  const [count, setCount] = useState(0);
  const [started, setStarted] = useState(false);
  const countRef = useRef(count);

  function increment() {
    setCount(count + 1);
    countRef.current = count + 1;
  }

  function start() {
    if (!started) setTimeout(() => alert(`Your score was ${countRef.current}!`), 5000);
    setStarted(true);
  }

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started ? "Current score: " + count : "Start"}
    </button>
  );
}

꽤 흐리멍텅합니다! 우리는 지금 두 곳에서 카운트를 추적하고 있으며, increment 함수는 두 곳 모두를 업데이트 해야 합니다. 이 코드가 동작하는 이유는 모든 start 클로저는 동일한 countRef를 접근할 수 있기 때문입니다. 한 곳에서 변경하면 모든 다른 클로저에서도 동일하게 변경된 값을 볼 수 있습니다. 그러나 ref를 변경한다고 해서 리액트가 다시 렌더링 되는 것은 아니기 때문에 useState를 없애고 useRef에만 의존할 수는 없습니다. 우리는 UI를 업데이트 하는데 사용하는 불변 상태와 현재 상태를 가진 가변 참조라는 두 세계 사이에 갇혀 있습니다.

클래스 컴포넌트에는 이러한 단점이 없습니다. 사실 마운트된 각 컴포넌트는 클래스의 인스턴스라는 일종의 내장된 참조를 제공합니다. 훅은 우리에게 상태 로직을 구성하기 위한 훨씬 더 나은 원시적인 것을 제공했지만, 대가가 따랐습니다.

새로운 것은 아니지만, 시그널은 최근 폭발적인 인기를 경험했으며 리액트 이외의 대부분의 주요 프레임워크에서 사용되는 것으로 보입니다.

일반적인 피치는 "잘게 나누어진 반응성(fine-grained reactivity)"을 가능하게 한다는 것입니다. 즉, 상태가 변경되면 그에 의존하는 DOM의 특정 부분만 업데이트합니다. 현재로서는 변경되지 않은 부분을 삭제하기 전에 전체 VDOM 트리를 다시 생성하는 리액트보다 속도가 더 빠릅니다. 하지만 저에게 이것들은 모든 구현 세부사항입니다. 사람들은 성능만을 위해 이러한 프레임워크로 전환하지 않습니다. 그들이 전환하는 이유는 이 프레임워크들이 근본적으로 다른 프로그래밍 모델을 제공하기 때문입니다.

예를 들어, Solid를 사용하여 우리의 작은 카운터 게임을 작성하면 다음과 같습니다.

function Game() {
  const [count, setCount] = createSignal(0);
  const [started, setStarted] = createSignal(false);

  function increment() {
    setCount(count() + 1);
  }

  function start() {
    if (!started()) setTimeout(() => alert(`Your score was ${count()}!`), 5000);
    setStarted(true);
  }

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started() ? "Current score: " + count() : "Start"}
    </button>
  );
}

이것은 첫 번째 훅 버전과 거의 같아 보입니다! 유일하게 눈에 보이는 차이점은 우리가 useState가 아닌 createSignal을 호출했고, countstarted가 값에 접근하고 싶을 때 호출하는 함수라는 것입니다. 그러나 클래스 및 함수 컴포넌트와 마찬가지로 유사한 형태는 중요한 차이를 가집니다.

Solid 및 다른 시그널 기반의 프레임워크들의 핵심은 컴포넌트가 한 번만 실행되고, 이 프레임워크는 시그널이 변경될 때 DOM을 자동으로 업데이트하는 데이터 구조를 설정한다는 것입니다. 컴포넌트를 한 번만 실행한다는 것은 하나의 클로저만을 가진다는 것을 의미합니다. 클로저는 클래스와 동일하기 때문에 클로저가 하나만 있으면 마운트된 컴포넌트당 안정적인 인스턴스를 다시 얻을 수 있습니다.

뭐라구요?!

사실입니다! 기본적으로 둘 다 데이터와 동작의 묶음일 뿐입니다. 클로저는 주로 연관된 데이터(닫힌 변수)가 있는 동작(함수 호출)인 반면, 클래스는 주로 관련된 동작(메서드)이 있는 데이터(인스턴스 속성)입니다. 만약 당신이 정말로 원한다면, 당신은 둘 중 하나를 다른 용어로 쓸 수 있습니다.

기술적으로, 이것들은 객체라고 불리는 클래스 인스턴스입니다. 어떤 사람들은 "클로저는 가난한 사람들의 객체입니다." 그리고 그 반대도 마찬가지라고 말합니다.

생각해 보세요. 클래스 컴포넌트를 사용하는 경우는 다음과 같을 수 있습니다.

  • 생성자는 컴포넌트가 렌더링해야 하는 모든 항목(초기 상태 설정, 인스턴스 메서드 바인딩 등)을 설정합니다.
  • 상태를 업데이트하면 리액트는 클래스 인스턴스를 변환하고, render 메서드를 호출하며 DOM에 필요한 내용을 변경합니다.
  • 모든 함수는 클래스 인스턴스에 저장된 최신 상태에 접근할 수 있습니다.

반면에 시그널 컴포넌트는 다음과 같습니다.

  • 함수 본문은 컴포넌트가 렌더링해야 하는 모든 항목(데이터 흐름 설정, DOM 노드 생성 등)을 설정합니다.
  • 시그널을 업데이트하면 프레임워크가 저장된 값을 변경하고 종속 시그널을 실행하고 DOM에 필요한 변경을 가합니다.
  • 모든 함수는 함수 클로저에 저장된 최신 상태에 접근할 수 있습니다.

이러한 관점에서 볼 때 트레이드오프를 보는 것이 조금 더 쉽습니다. 클래스와 마찬가지로 시그널도 가변적입니다. 좀 이상하게 보일 수도 있습니다. 결국, Solid의 컴포넌트는 아무것도 할당하지 않습니다. 리액트와 마찬가지로 setCount를 호출하기 때문입니다! 그러나 count는 값 자체가 아니라 시그널의 현재 상태를 반환하는 함수라는 것을 기억해야 합니다. setCount를 호출하면 시그널이 변경되고, count()를 호출하면 새 값이 반환됩니다.

Solid의 createSignal은 리액트의 useState처럼 보이지만 시그널은 가변 객체에 대한 안정적인 참조인 ref에 더 가깝습니다. 차이점은 불변성을 기반으로 구축된 리액트에서 ref는 렌더링에 영향을 미치지 않는 방법입니다. 그러나 Solid와 같은 프레임워크는 시그널을 전면과 중앙에 배치합니다. 프레임워크는 이들을 무시하지 않고 변경될 때 대응하여 값이 사용되는 DOM의 특정 부분을 업데이트 합니다.

큰 결과는 UI가 더 이상 상태의 순수한 함수가 아니라는 것입니다. 이것이 리액트가 불변성을 수용하는 이유입니다. 상태와 UI가 일관성을 유지하도록 보장합니다. 변경이 시작될 때 UI를 동기화 하는 방법도 필요합니다. 시그널은 그것을 위한 신뢰할 수 있는 방법이 될 것을 약속하며, 시그널의 성공 여부는 그 약속을 이행하는 능력에 달려있습니다.

요약하면 다음과 같습니다.

  1. 먼저 클래스 컴포넌트를 사용하여 렌더링 간에 단일 인스턴스의 상태를 공유했습니다.
  2. 그런 다음 훅이 있는 함수 컴포넌트를 사용하여 각 렌더가 고립된 인스턴스를 가집니다.
  3. 이제 우리는 다시 단일 인스턴스에 상태를 유지하는 시그널로 전환하고 있습니다.

리액트 훅은 실수였을까요? 훅을 통해 컴포넌트를 더 쉽게 분해하고 상태 저장 로직을 재사용할 수 있었습니다. 이것을 작성하는 저에게 만약 훅을 버리고 클래스 컴포넌트로 돌아갈 것인지 묻는다면, 저는 아니라고 말할 것입니다.

리액트가 클래스 패러다임에 더 깊이 빠져서 게임 개발에서와 같은 컴포넌트와 같은 것을 구현했다면 어떻게 보였을지 궁금하긴 합니다.

동시에 시그널의 매력이 클래스 컴포넌트에서 이미 가지고 있던 것을 되찾고 있다는 사실도 잊을 수 없습니다. 리액트는 불변성에 큰 베팅을 했지만, 사람들은 동시에 두 가지를 모두 가질 수 있는 방법을 찾고 있습니다. 이것이 immerMobx와 같은 라이브러리가 존재하는 이유입니다. 가변 데이터로 하는 인체공학적 작업이 정말 편리할 수 있다는 것이 밝혀졌습니다.

하지만 사람들은 함수 컴포넌트와 훅의 미학을 좋아하는 것 같고, 여러분은 새로운 프레임워크에서 그들의 영향력을 볼 수 있습니다. Solid의 createSignal은 리액트의 useState와 거의 유사합니다. Preact의 useSignal도 마찬가지 입니다. 리액트가 주도하지 않는다면 이 API들이 이렇게 제공될 것이라고 상상하기 어렵습니다.

시그널이 훅보다 좋습니까? 저는 그것이 옳은 질문이라고 생각하지 않습니다. 모든 것에는 트레이드오프가 있으며, 시그널이 만들어내는 트레이드오프는 매우 확실합니다. 시그널은 불변성과 UI의 순수한 함수 상태를 포기하여 더 나은 업데이트 성능과 마운트된 컴포넌트당 안정적이고 가변적인 인스턴스를 제공합니다.

시간이 지나면 시그널이 리액트가 수정하기 위해 만든 문제를 가져올 수 있는지 알 수 있습니다. 하지만 현재, 프레임워크는 훅의 구성 가능성과 클래스의 안정성 사이에서 적절한 지점을 찾으려고 노력하고 있는 것 같습니다. 적어도, 이것은 탐구할 가치가 있는 옵션입니다.

profile
FE Engineer

9개의 댓글

comment-user-thumbnail
2023년 3월 28일

안녕하세요! 글 잘 읽었습니다.
초반에 "Ryan Carniato는 시그널을 리액트와 비교하는 훌륭한 글로 응답했습니다." 구문의 링크가 잘못 연결되어 있어 알려드립니다!

답글 달기
comment-user-thumbnail
2023년 3월 28일

이거 완전 vue3 ref... 라는 생각이 드네요 ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 3월 28일

svelte도 signal기반에 있나요?

1개의 답글
comment-user-thumbnail
2023년 4월 3일

solid.js의 signal이 인기가 좋은것은, computed state를 풀어내는 명확함 때문이겠죠. 게다가 svelte처럼 VDOM을 이용하지 않는 방식이고. 여러모로 가장 진화된 문법으로 보입니다.

const count = 1
// react
const double = count * 2 // 심플함. 하지만 멍청하게도 count의 변경과 동시에 계산되지 않음
// vue
const double = computed(() => count.value * 2) // computed라는 별도 스코프가 필요함
// svelte
$: double = count * 2 // $: 에 경기 일으키는 사람이 많음
// solid
const double = count() * 2 // this is it!!
2개의 답글
comment-user-thumbnail
2023년 4월 9일

좋은 글 감사합니다!!

답글 달기