Ayden's journal

Breaking the Rules of Hooks

리액트를 배우다 보면 자연스럽게 훅(Hook)에 대해 접하게 된다. useState, useEffect 같은 기본적인 훅을 사용하며 컴포넌트의 상태를 관리하고, 생명주기를 다루는 방법을 익히게 된다. 동시에 리액트에서는 훅을 사용할 때 반드시 지켜야 할 몇 가지 규칙이 있으며, 이를 어기면 예기치 않은 오류가 발생할 수 있다는 이야기를 듣게 된다. 리액트 공식 문서에서도 이를 강조하고 있다. 훅은 최상위에서만 호출해야 하며 조건문이나 반복문 안에서는 사용할 수 없다. 반드시 리액트 함수 내에서만 호출해야 하며 클래스 컴포넌트에서는 호출할 수 없다.

도전적인 개발자라면 주어진 규칙에 무조건적으로 순응하지 않는다. 대신, 그 규칙이 반드시 지켜야 할 절대적인 원칙인지, 아니면 단순한 관례인지 직접 검증해본다. 훅의 규칙도 마찬가지다. 반복문이나 조건문 내부에서 훅을 호출하면 정말로 문제가 발생할까? 최상위에서만 호출해야 한다는 원칙이 언제나 필수적일까? 이런 질문을 품고 실험해 보면, 대부분의 경우 규칙이 옳다는 사실을 확인하게 된다. 리액트의 설계 원리를 고려했을 때, 이러한 규칙들은 개발자가 예상치 못한 버그를 피할 수 있도록 돕기 때문이다. 그렇기 때문에 대부분의 개발자는 결국 규칙을 받아들이게 된다.

 

나는 그러지 않았을 뿐.

 

절반은 훅의 규칙을 받아들이면서도, 나머지 절반은 여전히 ─ 그리고 영원히 ─ 훅의 규칙에 어떤 허점이 있을 거라 믿으며 그 한계를 밀어붙인다. 그리고 오늘 아침, 나는 마침내 훅의 규칙을 위반하면서도 정상적으로 동작하는 사례를 발견했다.

아래의 클래스는 일반적인 리액트 개발자들의 상식에 정면으로 반하고 있으며, 훅의 규칙을 무비판적으로 받아들인 개발자라면 절대 떠올릴 수 없는 방식으로 작성되어있다. 클래스의 멤버 변수는 훅을 호출하여 초기화하고 있고, a라는 메서드에서는 useEffect를 호출하여 사용하고 있다.

class State {
  #state = useState(0)
  router = useRouter()

  get number(): number {
    return this.#state[0]
  }

  constructor() {
    console.log(this)
  }

  setNumber = (value: SetStateAction<number>) => {
    console.log(this)
    this.#state[1](value)
  }

  push(url: string) {
    this.router.push(url)
  }

  a ()  {
    useEffect(() => {
      console.log("a")
    }, [])
  }
}

 

이 State 클래스는 훅의 규칙을 위반하고 있다. 그 사실 자체는 분명하다. 하지만 정상적으로 동작하는 지에 대해서는 조금 논란의 여지가 있겠다. 여기에는 몇 가지 전제 조건이 필요하기 때문이다. 우선 static 멤버 변수에는 훅을 호출하여 사용할 수 없다. 따라서 프라이빗, 퍼블릭 멤버 변수에만 훅을 호출하여 사용할 수 있다. 또한 클래스 자체도 아무 곳에서나 호출할 수 없다. 반드시 함수형 컴포넌트와 커스텀 훅 내부에서만 호출할 수 있다. 이러한 조건만 만족한다면 State 클래스는 훅의 규칙을 위반하면서도 정상적으로 동작하게 된다.

 

State 클래스가 특정 조건 하에서 멀쩡히 동작하는 이유를 정확히 알 수는 없다. 하지만 자바스크립트에서 클래스가 생성자 함수 등으로 변환된다는 사실을 생각해보면, 어쩌면 리액트가 이 클래스의 호출 결과를 일종의 커스텀 훅으로 취급하는 게 아닐까 하는 생각이 든다. 

 

아래는 또 다른 예시이다. 위에서 확인했던 바와 같이 ReactState 클래스를 호출해도 useState같이 정상적으로 동작한다. 그런데 상태를 담은 ReactState를 컴포넌트나 훅이 아니라 또 다른 클래스에서 호출한다면 어떻게 될까?

class ReactState {
  private RS = useState(0);

  value = () => {
    return this.RS[0];
  }

  setValue = (value: number) => {
    this.RS[1](value);
  }
}

export default function Page() {
  const { value, setValue } = new ReactState();

  return (
    <div>
      <span>{value()}</span>
      <button onClick={() => setValue(value() + 1)}>Dec</button>
      <button onClick={() => setValue(value() - 1)}>Inc</button>
    </div>
  );
}

 

커스텀 훅 안에서 커스텀 훅을 호출할 때 문제가 없는 것처럼, 이 경우에도 문제없이 작동한다. 다만 여기서는 한 가지 주의해야 할 점이 있다. ReactState 클래스의 value 메서드를 일반 함수로 작성하게 되면 new ReactStateFactory().create()의 결과물을 구조분해할당할 때 this가 ReactState가 아니라 전역 공간이 되어버린다. 귀찮아지기 싫으면 화살표 함수 사용을 추천한다.

class ReactState {
  private RS = useState(0);

  value = () => {
    return this.RS[0];
  }

  setValue = (value: number) => {
    this.RS[1](value);
  }
}

class ReactStateFactory {
  private RS = new ReactState();

  create() {
    return {
      value: this.RS.value,
      setValue: (value: number) => this.RS.setValue(value),
    };
  }
}

export default function Page() {
  const { value, setValue } = new ReactStateFactory().create();

  return (
    <div>
      <span>{value()}</span>
      <button onClick={() => setValue(value() + 1)}>Dec</button>
      <button onClick={() => setValue(value() - 1)}>Inc</button>
    </div>
  );
}

 

아래의 경우는 모두 정상 동작한다. 다만 몇 가지 조건이 있는데, 최종적인 ReactState 클래스 호출은 컴포넌트나 커스텀 훅 내부에서 이루어져야 한다는 것이다.

class ReactStateFactory {
  private RS = new ReactState();

  create() {
    return {
      value: this.RS.value,
      setValue: (value: number) => this.RS.setValue(value),
    };
  }
}

class ReactStateFactory {
  create() {
    const RS = new ReactState();

    return {
      value: RS.value,
      setValue: (value: number) => RS.setValue(value),
    };
  }
}

class ReactStateFactory {
  private RS = ReactState;
  
  create() {
    const rs = new this.RS();

    return {
      value: rs.value,
      setValue: (value: number) => rs.setValue(value),
    };
  }
}

 

따라서 첫 번째 경우에는 ReactStateFactory의 평가가 무조건 컴포넌트나 커스텀 훅 내부에서 이루어져야 한다. 하지만 두 번째와 세 번째의 경우 ReactStateFactory는 외부에서 평가하되, create 메서드만 컴포넌트나 커스텀 훅 내부에서 호출해도 문제 없이 동작한다.

// 첫 번째 경우
export default function Page() {
  const { value, setValue } = new ReactStateFactory().create();

  return (
    <div>
      <span>{value()}</span>
      <button onClick={() => setValue(value() + 1)}>Dec</button>
      <button onClick={() => setValue(value() - 1)}>Inc</button>
    </div>
  );
}

// 두 번째와 세 번째 경우
const reactStateFactory = new ReactStateFactory();

export default function Page() {
  const { value, setValue } =  reactStateFactory.create();

  return (
    <div>
      <span>{value()}</span>
      <button onClick={() => setValue(value() + 1)}>Dec</button>
      <button onClick={() => setValue(value() - 1)}>Inc</button>
    </div>
  );
}

 

추가적으로 알아두어야 할 사실은 static 프로퍼티의 경우 클래스 호출과 무관하게 초기화되기 때문에, static 프로퍼티에 state를 보관하려는 시도는 항상 실패한다(정확히는 "TypeError: Cannot read properties of null (reading 'useState')" 에러가 발생한다)는 것이다. 일반적으로 null 에러는 useState가 컴포넌트 밖에서 평가되었다는 뜻이고, undefined 에러가 떴다면 십중팔구 호출 과정에서 this 참조가 의도와 다른 객체를 바라보고 있다는 뜻이다. 나는 이런 기준으로 디버깅하고 있다.

 

 

 

조건부이기는 하지만 클래스에 훅을 가져다 사용할 수 있다는 사실을 알게 된 것은 리액트를 가능한 한 객체지향적으로 다뤄보고자 하는 나의 목표 달성에 큰 도움이 되었다. 하지만 객체지향은 수많은 객체들의 유기적인 결합이며, 그 안에서 훅을 포함하는 클래스 혹은 메서드가 어디에서 호출될 지, 어떤 식으로 평가될 지를 추적하는 것은 쉽지 않다.

따라서 리액트가 제시하는 훅의 규칙에서는 벗어났지만, 결국은 다른 규칙을 통해 이 부분을 보완할 수 밖에 없는 것 아닐까 생각하고 있다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기