리엑트 훅 왜 쓰고 어떻게 되어있나요?

dante Yoon·2022년 9월 11일
43

react

목록 보기
14/19
post-thumbnail

잠깐, 영상으로 보고 싶다면?

https://www.youtube.com/watch?v=tzjjZZL9p18

글을 들어가며

안녕하세요, 단테입니다.

리엑트18버전 릴리즈 이후 리엑트 훅은 더 이상 선택이 아닌 필수가 되었습니다. 앞으로 생기는 리엑트와 상태관리 라이브러리의 api가 훅을 기반으로 업데이트 되기 때문입니다.

단테의 Concurrent React 포스팅

왜 리엑트에서는 클래스를 사용하지 않는지 궁금한 적 없으셨나요? 그리고 왜 훅을 사용하게 되었는지에 대한 히스토리를 알고 앞으로의 리엑트에 추가되는 기능들을 공부하면 더 좋을 것 같습니다.

또한 eslint를 사용 시 다음과 같은 문구를 한번이라도 보신 적이 있으실 건데요, 공식문서에서도 나와있는 훅의 사용 조건이지만 왜 이렇게 사용해야 하는지, 왜 조건문 안에서는 사용하면 안되는지에 대한 궁금증은 매번 뒤로 넘기고 사용법을 익히는데만 주력했었습니다.

오늘은 위의 두가지 큰 주제를 중심으로 포스팅을 진행해보겠습니다.

왜 리엑트에서 클래스를 쓰지 않냐는 질문에 대하여

훅에 대한 이야기를 하기 전에 훅이 함수를 사용하여 로직이 작성되어있기 때문에
먼저 약간 딴 길로 새보려고 합니다.

리엑트는 UI 컴포넌트를 만드는데 사용되는 프레임워크입니다.

컴포넌트는 UI를 표현해야 하는 책임이 있습니다.

컴포넌트에 UI 표현과 관련 없는 로직을 만들경우 이러한 로직들을 재사용하는데 어려움이 생깁니다.
클래스 컴포넌트에서는 이러한 재사용성 문제를 해결하기 위해 render-props, high order component와 같은 복잡한 패턴을 차용하게 됩니다.

render props 패턴은 다음과 같습니다.


import React, { Component } from 'react';

class Example extends Component {
  state = {
    value: 'hello!'
  };
  render() {
    return this.props.render(this.state.value);
  }
}

class App extends Component {
  render() {
    return (
      <div>
        <Example render={value => <h1>{value}</h1>} />
      </div>
    );
  }
}

hoc 패턴은 다음과 같습니다.

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('Current props: ', this.props);
      console.log('Previous props: ', prevProps);
    }
    render() {
      // 들어온 component를 변경하지 않는 container입니다. 좋아요!
      return <WrappedComponent {...this.props} />;
    }
  }
}

함수 내부에서 생명주기 함수를 사용할 수 없기 때문에 위처럼 다른 클래스 컴포넌트를 반환하는 형태의 함수를 작성해 인자로 넘겨진 클래스 컴포넌트를 래핑하는 형태로 사용한 것입니다. 위 두 패턴은 로직을 재사용 가능하게 해주는 역할을 하지만 이 두 패턴의 단점이 있습니다.

앱이 복잡해지면 컴포넌트 트리는 wrapper hell 이라고 하는 모양이 됩니다.

이런 구조의 앱은 디버깅을 어렵게 하고 성능으로도 악영향을 미치는데요,
바람직한 형태의 재사용성은 아닙니다.

리엑트 코어팀 개발자인 Dan abramov는 코드 재사용에 사용할 수 있는 가장 좋은 도구로 함수를 꼽습니다.

훅은 함수에서 리엑트 클래스 컴포넌트에서 가지고 있었던 기능들을 가질 수 있게 해줍니다. 사용자는 호출하기만 하면 됩니다. 또한 바닐라 자바스크립트 함수와 동일한 모양이므로 여러 훅을 이용해 커스텀 훅으로 조립해 사용할 수 있습니다. 각각의 커스텀 훅은 독립적인 상태를 관리하며 다른 컴포넌트에 주입되어 캡슐화를 제공합니다.

즉 훅은 코드 재사용성을 위해 만들어졌습니다.
이것이 바로 리엑트에서 클래스를 굳이 사용헤야 할 이유가 없는 이유입니다.

다음 사진을 보면 클래스 컴포넌트만을 사용했을 때와 훅을 사용했을 때의 차이점이 명확하게 드러납니다. 관심사의 분리를 할 수 있고 로직을 재사용할 수 있게 됩니다.

객체지향 프로그래밍에서 각 객체를 클래스로 표현하는 것이 가장 편하고 보편적이어서 수 많은 레퍼런스에서 객체지향 기법을 클래스 기반으로 설명하듯이,

리엑트는 16.8버전 이후로부터 UI 컴포넌트를 만드는데 있어함수를 사용해 재사용성을 최대화 하게끔 디자인되었으니깐요. 그리고 리엑트는 UI 컴포넌트 라이브러리입니다.

쉽게 만들려고 함수 기반으로 모든 로직을 작성하는 프레임워크에 클래스를 억지로 끼워 맞추려고 하니까 잘 안되는 거죠.

클래스를 쓰고 싶으면 앵귤러를 써야 할까?

많은 답변 중 하나가 될 수는 있지만 최선의 답은 아닌 것 같아요. 이런 답보다는 저는
왜 굳이 클래스에 집중하려고 하시는지 질문을 스스로에게 던져보았으면 좋겠습니다.

저는 객체지향 프로그래밍이 객체간의 역할과 책임을 분명하게 하고 확장에 유연한 객체를 만드는데 도움을 주기위해 사용한다고 알고 있는데요,

리엑트 프레임워크의 확장에 유연하고 재사용성을 올리는데 함수 기반의 컴포넌트와 훅 api가 제공된다면 굳이 바퀴를 재생산해서 클래스를 사용해야 하는 이유가 있나 싶습니다.

자바스크립트에서 클래스 쓰는게 익숙하세요?

클래스 문법은 es5 부터 지원되는 문법입니다.
그리고 자바스크립트의 클래스는 다른 언어의 클래스와는 다릅니다.

bind, this와 같은 개념은 숙련된 자바스크립트 개발자라도 복잡한 코드 가운데 쉽게 길을 잃게 만드는 주범들입니다.

클래스에 대한 지식을 필히 알고 있어야 한다.

쓴 약이 몸에 좋다.

그렇다고 손님이 먹는 밥상에 굳이 쓴 약을 올려둬야 할까요.
약을 먹는 건 독립적으로 진행해도 되지 않나 싶습니다.

훅은 16.8버전 이전까지 클래스 기반의 리엑트 로직을 함수 기반으로 대체하기 위해 만들어졌습니다. 두 가지의 방법을 모두 사용해야 할 필요가 없습니다. 훅이 만들어진 목적과 의의를 생각하면, 클래스를 사용할 이유가 없습니다. a+b 보다 a만 사용하는게 간편하기 때문입니다.

리엑트 훅은 어떤 원리로 작동할까

훅 사용 규칙

create-react-app이나 create-next-app을 사용하면 기본적으로 eslint에 훅 사용에 대한 규칙을 어겼을 때 경고 문구를 보여줍니다.

이 중 최상위(at the Top Level)에서만 Hook을 호출해야 합니다를 보면 반복문, 조건문 혹은 중첩된 함수 내에서 훅을 사용하지 말라는 항목과 함께 동일한 순서로 훅이 호출되는 것을 보장해야 한다고 나와 있습니다. 이 조건은 훅의 동작 원리와 연관있기 때문에 눈 여겨 보아야 합니다.

훅의 동작 원리

리엑트는 훅이 호출되는 순서에 의존하여 어떤 state가 어떤 useState에 호출되는지 알 수 있습니다.

순서라니 무슨 말일까요?

훅은 proxy와 같은 기법을 사용하지 않고

자바스크립트의 클로저를 사용했습니다.

단테가 설명한 클로저: 클로저는 자신이 생성될 때의 스코프에서 알 수 있었던 변수 중 언젠가 자신이 실행될 때 사용할 변수들만 기억하여 유지시키는 함수다.

어떻게 구현되어있는지 예제 코드로 알아보겠습니다.

코드는 아래 두 곳에서 가져왔습니다.
1. JSConf.Asia 2019 - Getting Closuer on React Hooks by Shawn Wang
2. React hooks: not magic, just arrays

let state = [];
let setters = [];
let firstRun = true;
let cursor = 0;

useState로 반환되는 state, setState를 각기 다른 배열에 넣을 것입니다. (state, setters),

컴포넌트가 첫 렌더링 되었는지에 대한 상태를 나타내는 firstRun,
그리고 cursor라는 변수가 있네요?

function createSetter(cursor) {
  return function setterWithCursor(newVal) {
    state[cursor] = newVal;
  };
}

createSetter 함수는 cursor를 인자로 받아 state 배열의 cursor 인덱스 영역에 새로운 값을 입력하는 setterWithCursor 함수를 반환 합니다.

// This is the pseudocode for the useState helper
export function useState(initVal) {
  if (firstRun) {
    state.push(initVal);
    setters.push(createSetter(cursor));
    firstRun = false;
  }

  const setter = setters[cursor];
  const value = state[cursor];

  cursor++;
  return [value, setter];
}

useState는 첫 렌더링 시(firstRun) setters 배열에 createSetter(cursor)를 넣어주고 있어요. 그리고 첫 렌더링이 마쳤다는 firstRun = false를 해주고 있네요.
cursor또한 값을 1 올렸습니다.
그리고 state[0], setters[0]을 배열에 넣어 반환하고 있어요.

잘 따라오고 계시죠?

마지막으로 renderFunctionComponent를 보겠습니다.

// Our component code that uses hooks
function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi"); // cursor: 0
  const [lastName, setLastName] = useState("Yardley"); // cursor: 1

  return (
    <div>
      <Button onClick={() => setFirstName("Richard")}>Richard</Button>
      <Button onClick={() => setFirstName("Fred")}>Fred</Button>
    </div>
  );
}

useState가 두 번 실행되었어요. 주석에 적힌 counter에 따라 state, setters 배열이 어떻게 구성되었는지 살펴볼게요. cursor 0 에는 state,setters의 0 번째 인덱스에, cursor 1에는 state,setters의 1 번째 인덱스에 값이 들어갔습니다.

RenderFunctionComponent를 렌더링한 MyComponent를 호출했습니다.
컴포넌트를 렌더링 하기 전에 cursor를 0으로 초기화 해주고 있음을 알 수 있어요.
Fred 버튼을 눌렀더니 setters[0]이 호출되었고 그 이후 다시 상태 값을 확인해보니 firstName이 의도한대로 Fred로 변경되었네요.

// This is sort of simulating Reacts rendering cycle
function MyComponent() {
  cursor = 0; // resetting the cursor
  return <RenderFunctionComponent />; // render
}

console.log(state); // Pre-render: []
MyComponent();
console.log(state); // First-render: ['Rudi', 'Yardley']
MyComponent();
console.log(state); // Subsequent-render: ['Rudi', 'Yardley']

// click the 'Fred' button

console.log(state); // After-click: ['Fred', 'Yardley']

훅 호출 순서가 중요한 이유

예제 코드를 기반으로 생각해보았을 때
First-renderSubsequent-render에서 조건에 따라 훅이 호출되거나 생략될 때 어떻게 될까요?

let firstRender = true;

function RenderFunctionComponent() {
  let initName;
  
  if(firstRender){
    [initName] = useState("Rudi");
    firstRender = false;
  }
  const [firstName, setFirstName] = useState(initName);
  const [lastName, setLastName] = useState("Yardley");

  return (
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}

First-render에서 (firstRender === true) useState("Rudi")가 호출된 반면에


Subsequent-render에서는 호출되지 않았습니다.
첫 렌더링 시 state, setters에는 세번째 인덱스까지 값이 들어가 있는데
리렌더링 시 firstName, lastName이 cursor 1, 2를 가르키는게 아니라 0, 1을 가르키게 되어버린 것이죠.

렌더링에 따라 useState로 반환되는 상태 값과 setter 함수가 다른 배열의 인덱스를 참조할 경우 렌더링 시점에 따른 상태 참조 불일치가 일어나기 때문에 조건문 안에서는 사용하면 안됩니다.

위 예제코드를 통해 왜 conditional하게 훅을 사용하면 안되는지,
훅의 선언 순서가 왜 중요한지에 대해 이해가 되었으면 합니다.
앞으로 eslint에서 다음과 같은 에러 발생 시, 본 포스팅 내용을 상기하며 머뭇거리며 넘어가는 일이 없으면 좋겠습니다.

감사합니다 :)

profile
성장을 향한 작은 몸부림의 흔적들

3개의 댓글

comment-user-thumbnail
2022년 9월 19일

좋은 글입니다. 잘 읽었습니다. 감사합니다.

1개의 답글
comment-user-thumbnail
2022년 9월 20일

I appreciate the information and advice you have shared. I will try to figure it out for more.
Quick Pay Portal

답글 달기