리액트의 함수형과 클래스형의 근본적인 차이

이종호·2021년 7월 30일
0

React

목록 보기
5/5
post-thumbnail

https://overreacted.io/ko/how-are-function-components-different-from-classes/
의 글을 보고 따라 작성한 글입니다.
본 블로글이 더 잘 정리되어 있으며, 다른 좋은 글도 많기 때문에 React에 관심이 있다면 꼭 보는 것을 추천합니다.

TL;DR

  • 클래스형과 함수형의 근본적인 차이는 정말 class와 function의 차이이다.
  • 클래스는 props나 state에 접근할 때, this를 통해서 접근한다.
  • 따라서 비동기 호출로 props를 호출할 때, 호출당시의 값이 변경될 수 있다.
  • 함수는 인자로서 props와 state를 간직하고 있고, 이는 js문법에 의해 closure로 그 값이 보존된다.
  • 따라서 이전에 호출된 값은 변경되지 않고 새로운 컴포넌트에 값이 새로 추가된다.
  • 따라서 비동기 호출로 props를 호출할 때, 호출당시의 값이 그대로 보존된다.

따라서 클래스에서 변경되지 않음을(함수형처럼) 구현하려면, closure를 이용해야하고,
함수형에서 클래스처럼 가장 최근에 변경된 값으로 변경하고 싶다면, ref를 통해 구현할 수 있다.

리액트의 함수형 컴포넌트와 클래스는 어떻게 다를까?

가장 먼저 '클래스는 함수형 컴포넌트에 비해 더 많은 기능(state, life cycle, ..)을 제공한다.'는 고전적인 답변이 있다. 그런데 리액트에서 Hooks을 쓸 수 있게 된 지금 이는 올바른 답변이라 보기 힘들어졌다.

많은 사람들이 '둘 중 하나가 성능면에서 조금 더 유리하다'는 말을 하곤 한다.
이에 대한 답을 얻기 위해 여러 벤치마킹 실험들이 이루어졌었다.
하지만 대부분의 결과들이 신뢰할 수 없는 것으로 밝혀졌다. 때문에 나는 이 글에서 벤치마킹과 관련된 것들은 언급하지 않을 생각이다. 사실 성능은 함수냐 클래스냐 보다는 무슨 동작을 하는 코드냐에 더 큰 영향을 받는다. 또한 우리 팀에서 살펴본 바로는 성능의 차이가 나는 경우에도 그 차이는 무시할 수 있을 정도로 작았다. 하지만 성능 최적화 전략에서 조금 다른 점들을 보여줬다.

어쨌거나 아주 특별한 이유가 없다면 현재 컴포넌트를 다른 형태의 컴포넌트로 다시 쓰는 것은 추천하지 않는다. Hooks는 아직 초창기이기 때문에 정석이라 할만한 것들이 존재하지 않는다.

그래서? 함수형 컴포넌트와 클래스 사이에는 근본적인 차이랄 것이 전혀 없는건가? 물론 아니다. 이 글에서 이 둘 사이의 큰 차이가 무엇인지 보여줄 것이다. 이 차이는 2015년에 함수형 컴포넌트가 처음 소개됐던 때 부터 존재했지만 간과되었던 것이다.

함수형 컴포넌트는 렌더린된 값들을 고정시킨다.

이게 무엇을 뜻하는지 알아보자.


아래 컴포넌트를 살펴보자.

function ProfilePage() {
  const showMessage = () => {
    alert('Followed ' + props.user);
  }
  
  const handleClick = () => {
    setTimeout(showMessage, 3000);
  }
  
  return (
    <button onClick={handleClick}>Follow</button>
  )
}

위 컴포넌트에 있는 버튼은 setTimeout을 이용해 네트워크 요청을 보내고 확인 창을 띄워주는 역할을 한다. 예를 들어 props.userDan이라면, 버튼을 누르고 3초 뒤에 Followed Dan이라는 창이 띄워진다. 매우 간단하다.

주의: 여기서 화살표 함수를 썼다는 것을 눈여겨 볼 필요는 없다. 일반 함수 선언 function handleClick()도 정확히 똑같은 방식으로 동작한다.

이 컴포넌트를 클래스로는 어떻게 만들까? 간단하게는 다음과 같이 할 수 있다.

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  }
  
  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  }
  
  render() {
    return (
      <button onClick={this.handleClick}>Follow</button>
    )
  }
}

대부분의 사람들은 위 두 컴포넌트가 동일하다고 생각할 것이다. 때문에 두가지 패턴 간의 리팩토링을 대수롭지 않게 하곤 한다.

하지만 두 코드는 미묘하게 다르다. 코드를 자세히 들여다보자. 혹시 어떤 차이가 보이는가? 나는 둘 사이의 차이를 알아차리는데 시간이 조금 걸렸다.

이데 대한 답을 직접 알아내고 싶어하는 사람들을 위해 live demo를 준비했다. 이 뒤에서 부터는 그 차이에 대한 설명과 왜 이것이 문제가 되는지에 대한 이야기들을 나누어보려고 한다.


이야기를 계속 이어나가기 전에, 내가 설명하고자 하는 차이는 Hooks와는 아무 관련이 없다는 것을 강조하고 싶다.

이는 단지 리액트에 존재하는 클래스와 함수형 컴포넌트 간의 차이에 대한 이야기일 뿐이다. 만약 함수형 컴포넌트를 자주 사용하고자 하는 독자가 있다면 이 글의 내용이 유용할 것이다.


왜 클래스는 이런식으로 동작할 까?

클래스의 showMessage를 살펴보자.

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  }
}

이 메서드는 this.props.user로 부터 값을 불러온다. Props는 리액트에서 불변값이다.

하지만, this는 변경 가능하며, 조작할 수 있다.

그것이 this가 클래스에서 존재하는 목적이다. 리액트가 시간이 지남에 따라 이를 변경하기 때문에 render나 라이프 사이클메서드를 호출할 때 업데이트 된 값들을 읽어올 수 있는 것이다.

따라서 요청이 진행되고 있는 상황에서 클래스 컴포넌트가 다시 렌더링 된다면, this.props또한 바뀐다.showMessage메서드가 "새로운"propsuser를 읽는 것이다.

위 사실은 UI의 성질에 대한 흥미로운 사실을 일깨워준다. 만약UI가 현재 애플리케이션 상태를 보여주는 함수라 한다면, 이벤트 핸들러 또는 시각적 컴포넌트와 같이 렌더링 결과의 한 부분인 것이다. 즉 이벤트 핸들러가 어떤 props와 state를 가진 render에 종속된다는 것이다.

하지만 this.props를 읽는 콜백을 가진 timeout이 사용되면서 그 종속 관계가 깨져버렸다. showMessage콜백은 더이상 어떤 render에도 종속되지 않게 되었고, 올바른 props또한 잃게 되었다. this로 부터 값을 읽어오는 동작이 만들어 낸 결과이다.


이 상황에서 함수형 컴포넌트라는 개념이 없다고 가정해보자. 이 문제를 어떡해 해결할 수 있을까?

이를 위해서는 render와 올바른 props, 그리고 이들을 읽는 showMessage사이의 관계를 다시 바로잡아주어야 한다. props가 길을 잃어버리게 되는 곳을 따라가다 보면 바로잡아 볼 수 있을 것이다.

한 가지 방법은 this.props를 조금 더 일찍 부르고 timeout함수에게는 미리저장해놓은 값을 전달하는 것이다.

하지만, 이러한 방법은 코드를 복잡하게 만드며 시간이 지날수록 에러에 노출될 가능성이 높아진다. 만약 여러 개의 prop에 접근해야 하거나 state까지 접근해야 하면 코드의 복잡도가 이에 비례하게 증가할 것이다. 무엇보다 showMessage가 다른 메서드를 부르고 그 메서드가this.props.something이나 this.state.something과 같은 코드를 포함해야 한다면 또 다시 문제에 부딪힌다. 우리는 this.props와 this.state를 showMessage가 부르는 모든 메서드에게 일일이 전달해줘야 한다.

이렇게 하는 것은 클래스의 장점을 무색하게 만든다. 또한 이러한 방법을 기억하거나 컴벤션을 만들어 유지하는것도 어렵다. 결국 버그가 나기 쉬운 구조가 되는 것이다.

alert코드를 handleClick안에 넣는 것 또한 좋은 해결책이 아니다.(이유는 위와 유사) 우리는 코드를 쉽게 쪼갤 수 있으면서, 호출했을 때의 props와 state를 유지할 수 있는 구조를 찾아야 한다. 이 문제는 리액트에만 국한된 것이 아니다. this와 같이 변경 가능한 object에 데이터를 저장하는 ui라이브러리들 또한 적용 가능한 문제다.

혹시 생성자에서 메서드를 bind하면 되지 않을까?

class ProfilePage extends React.Component {
  constructor(props) {
    super(props);
    this.showMessage = this.showMessage.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }
  
  showMessage() {
    alert('Followed ', this.props.user);
  }
  
  handleClick() {
    setTimeout(this.showMessage, 3000)
  }
  
  render() {
    return <button onClick={this.handleClick}>Follow</button>
  }
}

답은 No다. 이 방법은 아무것도 해결하지 못한다. this.props를 너무 늦게 읽는다는 것이 문제지 문법이 문제는 아니다. 이 문제는 자바스크립트의 클로저로 이를 해결할 수 있다.

클로저는 시간이 지남에 따라 변할 수 있는 값이라고 생각하기는 쉽지 않기 때문에 이 문제법의 해결법으로는 간과되곤 한다. 하지만 리액트에서 props와 state는 불변값이다. 이 녀석들이 클로저의 약점을 보완해준다.

말인 즉슨, 특정 render에서 props와 state를 클로저로 감싸준다면, 우리가 원하는 방식으로 동작하게 할 수 있다는 것이다.

class ProfilePage extends React.Component {
/*  constructor(props) {
    super(props);
    this.showMessage = this.showMessage.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }
  
  showMessage() {
    alert('Followed ', this.props.user);
  }
  
  handleClick() {
    setTimeout(this.showMessage, 3000)
  }
*/
  
  render() {
    // props의 값을 고정!
    const props = this.props;
    
    // Note: 여기 *render 안에 *존재하는 곳
    // 클래스의 메서드가 아닌 render의 메서드
    const showMessage() {
      alert('Followed ', props.user);
    }

    const handleClick() {
      setTimeout(showMessage, 3000)
    }

    return <button onClick={handleClick}>Follow</button>
  }
}

props 값은 render될 때의 값으로 고정해둔 것이다.

(showMessage를 포함한) 클로저 안에 있는 코드들은 render될 당시의 props를 그대로 사용할 수 있다. 리엑트가 우리가 쓸 변수들을 더이상 뺏어가지 못한다.

이제 원하는 함수들을 얼마든지 추가할 수 있다. 또한 이 함수들은 모두 동일한 props와 state를 사용할 것이다. 클로저가 우리를 구원해주었다.


이 방법은 잘 동작하지만, 조금 꺼림칙하다. 메서드를 클래스에 선언하지 않고 render내부에 선언할 건데 굳이 클래스를 이용할 필요가 있나 라는 생각이 든다.

클래스라는 "껍데기"를 벗기고 함수형 컴포넌트로 다시 선언해보자.

값이 인자로 전달되었기 때문에 아까와 마찬가지로 props는 보존된다.

클래스의this와는 다르게 함수가 받는 인자는 리액트가 변경할 수 없다.

함수 선언부에서 props를 분해해준다면 더 명확하게 표현할 수 잇다.
만약 부모 컴포넌트가 다른 props를 이용해 ProfilePage를 또다시 render하게 되면 리액트는 ProfilePage를 새로 호출한다. 그래도 이전에 클릭했던 버튼의 이벤트 핸들러는 이전 render에 종속되있기 때문에 이전 user값들을 사용하게 된다. 그 값들은 변경되지 않기 때문이다.

때문에 Sophie페이지에서 함수형 컴포넌트 follow버튼을 누르고 Sunil페이지로 이동해도 알람창의 내용은 Sophie를 팔로우했다고 알려준다.

이로써 리액트 클래스와 함수형 컴포넌트 사이의 큰 차이를 이해하게 되었다.

함수형 컴포넌트는 render 될 때의 값들을 유지한다.

Hooks의 state에서도 가은 원리가 적용된다. 아래의 예제를 살펴보자

function MessageThread() {
  const [message, setMessage] = useState('');

  const showMessage = () => {
    alert('You said: ' + message);
  };

  const handleSendClick = () => {
    setTimeout(showMessage, 3000);
  };

  const handleMessageChange = (e) => {
    setMessage(e.target.value);
  };

  return (
    <>
      <input value={message} onChange={handleMessageChange} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

(여기서 실행해 볼 수 있다.)

위 컴포넌트는 메세지 앱UI로 쓰기에는 좋은 구조가 아니지만 우리가 이야기하고 있는 개념을 잘 담고있다. 메세지 전송이 이루어졌을 때 컴포넌트는 어떤 메세지가 전송됐는지를 헷갈려서는 안된다.
이 함수의 메세지는 클릭핸들러가 호출됐을 때의 state를 고정시켜둔다.
때문에 내가 "Send"를 눌렀을 당시의 input메세지값을 간직할 수 있게 된다.


지금까지 리액트에서 함수가 props와 state 값을 유지한다는 것에 대해 알아보았다. 그런데 만약 특정 render에 종속된 것 말고 가장 최근의 state나 props를 읽고 싶다면 어떻게 해야할까? 나중에 render될 값을 "미리 가져와서" 쓰고싶다면?

클래스에서는 this가 변할 수 있는(mutable)값이기 때문에 그냥 this.props, this.state를 읽어오면 된다.
이랙트가 알아서 이를 처리해준다.
그런데 함수형 컴포넌트에서도 this처럼 변할 수 잇고 서로 다른 render들끼리 공유할 수 잇는 녁석이 하나 있다.

그 친구를 우리는 "ref"라고 부른다.

function MyComponent() {
  const ref = useRef(null);
  // `ref.current`로 읽고 쓸 수 있다.
  // ...
}

하지만, ref는 this와 다르게 직접 관리해줘야한다.

ref는 클래스의 인스턴스 영역과 같은 역할을 수행한다.
이는 함수가 가변적인 성질이 필요할 때 비상구 역할을 해준다.
"DOM refs"라는 녁석도 들어봤을 것이다.
하지만, 리액트의 ref는 조금 더 포괄적인 기능을 제공한다. 무언가를 넣을 수 잇는 박스라고 봐도 무방하다.

얼피 보기에도 this.something은 something.current와 비슷한 기능을 할 것처럼 보인다. 이들은 같은 개념의 값이다.

리액트의 ref가 자동으로 state나 props를 최신값으로 유지하는 것은 아니다. 일반적으로 이러한 기능을 쓰게 되는 경우는 드물기 때문에 이를 기본동작으로(default)으로 두는 것은 비효율적이다. 만약 ref를 이용해 최신값을 유지하고 싶다면 다음과 같은 방식을 사용할 수 있다.

function MessageThread() {
  const [message, setMessage] = useState('');
  const latestMessage = useRef('');

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };

  const handleSendClick = () => {
    setTimeout(showMessage, 3000);
  };

  const handleMessageChange = (e) => {
    setMessage(e.target.value);
    latestMessage.current = e.target.value;
  };

우리가 showMessage로 부터 message를 읽는다면 우리가 send버튼을 눌렀을 떄의 message를 볼 수 있다.
하지만, latestMessage.current를 읽는다면, (Send버튼을 누를 이후 타이핑을 계속하는 경우에도)우리는 가장 최근에 보내진 메시지 값을 읽어올 수 있다.

두가지 데모(1, 2)를 비교해 보면서 차이를 보자.
"ref"는 렌더링의 일관성을 "조절"할 수 있게 해주는 유용한 데이터 박스다.

ref는 고정된 값이 아니기 때문에, rendering 도중에 읽거나 쓰는 것은 피하는 것이 좋다.
렌더링 내에서는 예측 가능한 일들만 일어나는것이 권장되기 때문이다.
하지만 특정 props와 state의 최신 값을 불러오고 싶을 때마다 ref를 수동으로 처리하는 것은 내키지 않는다.

다행히 Hooks의 effect를 이용해 이를 자동화 할 수 있다.

function MessageThread() {
  const [message, setMessage] = useState('');

  // 최신값을 쫓아간다
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };

데모

effect 함수 내부에 DOM이 업데이트 될 때마다 ref값이 변하도록 설정해줬다.
이렇게 하면 인터럽트 가능한 렌더링에 의존적인 Time Slicing and Suspense와 같은 기능들이 값 변경에 의해 피해를 받지 않도록 할 수 있다.

ref를 꼭 사용해야 하는 경우는 많지 않다. 될 수 있다면 props나 state를 고정시키는 것이 좋다.

하지만, interval이나 subscription같은 명령형 API를 다룰 때는 ref가 유용하게 쓰일 수 있다.
prop, state, 심지러 함수까지 어떤 값이던 고정시켜둘 수 있다는 것을 기억하자.

또한 ref를 이용한 패턴은 최적화에도 적합하다.(useCallback이 자주 바뀐다던지 할 때).
하지만 이럴 때는 reducer를 쓰는 것이 조금 더 나은 해결책일 수도 있다.


이번 글에서는 클래스 사용하며 놓칠 수 있는 부분과 클로저로 그것을 해결하는 방법도 다뤘다.
하지만, dependency array로 Hooks를 최적화 하려 했을 때 이전에 쓰던 클로저에 의해 버그가 발생될 수 잇다는 것도 눈치챘을 것이다. 이게 클로저의 문제일까?
그렇지 않다고 생각한다.

클로저는 눈치채기 쉽지 않은 문제들을 해결하는것에 도움을 준다.
또한 비슷한 방식으로, 동시성 모드에서 정확한 동작을 하도록 해줄 수도 있다.
이것들이 가능한 이유는 컴포넌트안의 로직이 render되었을 당시의 props와 state를 고정시키기 때문이다.

내가 보았던 경우들 중에서 오래된 클로저가 일으키는 문제들은 대부분 "함수는 변하지 않는다." 혹은 "props는 항상 같다"라는 잘못된 가정에서 비롯됐다.
이 포스트가 이를 명확하게 이해하는데 도움을 줬으면 좋겠다.

함수는 props와 state를 감싸고 있다. 그렇기 때문에 함수에서 identity가 중요하다.
(원문: Fnction close over their props, state - and so their identity is just as important)
이건 함수형 컴포넌트의 특징이지 버그가 아니다. 함수는 useEffect혹은 useCallback와 같은 dependency array에 있어서는 떨어질 수 없는 개념이다.

만약 리액트에서 대부분의 코드를 함수로 쓴다면 코드 최적화나 시간에 따라 어떤 값이 변할 수 있는가에 대해서 다시한번 생각해 볼 필요가 있을 것이다.

Fedrik이 언급했던 것처럼

Hooks를 써오며 느낀 것 중 하나는 어떤 값이 언제 변할지 모른다라는 생각을 가지고 코딩해야 한다는 것이다.

profile
코딩은 해봐야 아는 것

0개의 댓글