"함수형 컴포넌트가 최근에 나왔고 급부상하고 있어요. 함수형 컴포넌트가 작성하기도 쉽고, 읽기도 쉽고, 클래스형 컴포넌트가 할 수 있는거 대부분 다 할 수 있어요. 앞으로는 함수형 컴포넌트로 작성하시면 됩니다."
내가 처음 리액트를 접하고, Hello World 예제를 만들어볼때부터 계속해서 봤던 말이다. 이때까지 당연하듯이 함수형 컴포넌트를 써왔고 대략적으로는 왜 함수형 컴포넌트를 쓰는지, 클래스형 컴포넌트와의 차이는 뭔지, 어쩌다 대세가 되었는지에 대해서는 알고 있었지만, 이 주제에 대해 딥하게 다뤄본적이 없는 것 같아 글로 정리해보려고 한다.
함수형 컴포넌트 자체는 리액트의 초창기부터 존재했다. 단, 지금은 모두들 친숙하고 마구마구 사용하는 Hook이 없어서 제한적인 역할만 소화할 수 있었다. 그건바로 상위 컴포넌트로부터 props를 받아서 단순히 뷰를 렌더링하는 역할. 함수형 컴포넌트는 자체적인 상태를 가질수도 없었고, 전지전능하신 클래스형 컴포넌트께서 가지고 계시던 생명주기 메서드들을 대체할 수단도 없었다. 그저 단순하게 시킨대로 렌더링만 하던, 클래스형 컴포넌트로 치자면 PureComponent 역할을 하는 순수한 친구였을 뿐이다.

반면 많은 기능을 내장하고 있는 Component를 상속받는 클래스형 컴포넌트는 자신의 상태값을 가지며 이를 변경해 리렌더링을 발생시킬 수도 있고, 생명주기 메서드들을 통해서 컴포넌트의 생명주기 중 원하는 시기에 원하는 코드가 실행되도록 할수도 있었다. 그래서 옛날 레거시 리액트 코드들은 모두 클래스형 컴포넌트로 작성되어 있다. 컴포넌트에 기능을 붙이려면 딱히 선택권이 없었기 때문이다.
그리고 React 16.8 버전을 통해 함수형 컴포넌트에서는 기존에 클래스형 컴포넌트만 할 수 있던, 자체적인 state 관리는 useState 훅으로 더욱 간결하고 독립적으로 할 수 있게 되었고, 생명주기 메서드 또한 useEffect와 같은 훅으로 대부분 동작을 흉내낼 수 있게 되었다.

이렇게 훅이라는 든든한 지원군을 얻은 함수형 컴포넌트는, 기존에 클래스형 컴포넌트의 여러가지 단점들에 불만을 가지고 있던 개발자들의 마음에 쏙 들어서 압도적인 대세가 되었고 계속해서 발전해왔다. 그렇다면 클래스형 컴포넌트는 어떤 단점들이 있었고, 함수형 컴포넌트는 어떤 부분이 그렇게 좋아서 각광받은걸까?
가독성 면에서, 함수형 컴포넌트가 클래스형 컴포넌트보다 우수하다. 클래스형 컴포넌트의 state는 변화 흐름을 파악하기가 어렵다. 다양한 생명주기 메서드와 커스텀 메서드에서 state가 변경될 수 있으며, 이 메서드들의 실행순서와 선언순서는 별개다. 또한, state가 하나의 큰 덩어리 객체로 되어있기 때문에 this.setState를 사용하는 코드가 있다고 해서 추적하고 싶은 특정 상태가 변경된다고 확신할 수도 없다.
반대로 함수형 컴포넌트의 경우에는 컴포넌트가 하나의 큰 state 객체를 가지는 것이 아닌, 독립적인 형태로 각 state를 선언하고 사용할 수 있다. 따라서 비교적 state의 변화 흐름을 파악하기 용이하며, 따라서 가독성도 높다.
또한 클래스형 컴포넌트에서는 this 바인딩 또한 신경써줘야 한다는 부분이 골치아프다. 클래스형 컴포넌트를 보면 constructor에서 아래와 같이 이벤트 핸들러 바인딩을 수행해주는 모습을 많이 볼 수 있으며, 이러한 구문이 없다면 에러가 나는 경우도 다반사다.
class Toggle extends React.Component {
constructor(props) {
super(props);
this.state = {isToggleOn: true};
// This binding is necessary to make `this` work in the callback
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState(prevState => ({
isToggleOn: !prevState.isToggleOn
}));
}
render() {
return (
<button onClick={this.handleClick}>
{this.state.isToggleOn ? 'ON' : 'OFF'}
</button>
);
}
}
이는 클래스 메서드가 콜백 방식으로 실행될 때, Strict Mode로 실행되어 undefined가 되어버리기 때문에 발생하는 문제다. 물론 화살표 함수로 바꾸는 등의 변경으로 해결이 가능한 문제긴 하지만, 언제든지 실수가 발생할 여지가 있기에 골치아픈 문제인건 변함이 없다.
반면 함수형 컴포넌트에서는 애초에 this를 사용할 일이 없다. 따라서 this 바인딩 문제도 신경쓰지 않아도 된다.
여러 클래스형 컴포넌트에서 특정 로직이 반복되는 경우가 있다면, HOC(Higher Order Component) 등을 정의해 중복을 제거할 수 있다. 하지만 이렇게 HOC를 여러개 선언하고 사용하다보면 Wrapper 지옥에 빠져들 위험이 있다. 또한 HOC는 원한다면 자식 컴포넌트를 mutate 할 수 있는 권한이 있기에 사용하는 입장에선 주의해야하는 단점도 있다.

함수형 컴포넌트에서는 반복되는 로직을 보통 커스텀 훅으로 묶어서 처리한다. 커스텀 훅은 함수형 컴포넌트에서 호출하는 형태로 사용되기 때문에 일반적으로 렌더링에 영향을 미치지는 못한다. 즉, 사용하는 입장에서 렌더링에 대한 부수효과를 걱정하지 않고 부담없이 재사용할 수 있어, 재사용성이 높다고 할 수 있다.
간단히 말하자면, 함수형 컴포넌트는 렌더링 된 값을 고정해두고, 클래스형 컴포넌트는 렌더링 된 값이 고정되지 않는다.
위 예제에서 함수형 컴포넌트/클래스형 컴포넌트 이름 가져오기 버튼을 누른 뒤 3초 이내에 이름 한영전환 버튼을 누르면, 서로 다른 결과를 렌더링한다.
함수형 컴포넌트는 클릭했을 당시의 props 값을, 클래스형 컴포넌트는 콜백 함수가 실행될 때의 props 값을 사용하게된다. 일반적으로 개발자는 클릭했을 당시의 값이 찍히는 것을 기대할 것이다.
이렇게 동작의 차이를 보이는 이유는 간단하다. 클래스형 컴포넌트 예시 는 리렌더링이 발생할 때 this.props 값도 변경된다. 그리고 이벤트 핸들러는 여전히 이 this.props 값을 참조하고 있기에, 최신값이 보이게 된다.
반면, 함수형 컴포넌트는 렌더링을 할때 props를 인수로 받아 렌더링한다. 인수로 받은 값은 immutable하며, 이 고정된 값을 이용해 이벤트 핸들러를 정의하고 해당 이벤트 핸들러가 콜백함수로 호출된다. 따라서 이벤트가 발생한 시점의 props값으로 콜백함수가 호출되는 것이다. props가 변경되고 리렌더링이 발생하면, 바뀐 props로 함수형 컴포넌트가 호출되고, 새로운 이벤트 핸들러가 정의된다.
클래스형 컴포넌트의 빌드 결과는 함수형 컴포넌트의 빌드결과보다 무겁다. 클래스형 컴포넌트의 메서드는 빌드 과정에서 최소화될 수 없으며, 사용하지 않는 메서드에 대한 트리쉐이킹도 수행할 수 없어 아쉬운 면이 있다.
실제로 그런지 간단한 Vite 프로젝트를 통해 확인해봤다. 같은 Counter 기능을 수행하는 클래스 컴포넌트와 함수형 컴포넌트를 각각 만든 뒤, 각각의 상황에서 빌드 결과물의 차이를 살펴봤다.
먼저 함수형 컴포넌트의 코드와 빌드 결과다.
import { useState } from "react"
export default function FunctionalComponent() {
const [count, setCount] = useState(0);
const handleClickUp = () => {
setCount(prev => prev + 1)
}
const handleClickDown = () => {
setCount(prev => prev - 1)
}
const unusedMethod = () => {
console.log('HELLO WORLD')
}
return (
<div>
COUNT : {count}
<button onClick={handleClickUp}>+</button>
<button onClick={handleClickDown}>-</button>
</div>
)
}

js 파일의 크기가 142.88kb, gzip 기준 45.9kb다. 그리고 실제 소스코드를 살펴보면, 컴포넌트의 이름, 내부 함수들, 변수명 등 모든 것이 최소화 되었음을 확인할 수 있다. 그리고 선언만 해놓고 사용하지 않은 unusedMethod는 트리쉐이킹까지 되었다.

아래는 클래스형 컴포넌트의 코드와 빌드 결과다.
import { Component } from 'react';
export default class ClassComponent extends Component {
state = { count: 0 }
handleClickUp = () => {
this.setState(prev => ({ ...prev, count: prev.count + 1 }))
}
handleClickDown = () => {
this.setState(prev => ({ ...prev, count: prev.count - 1 }))
}
unusedMethod = () => {
console.log('HELLO WORLD')
}
render() {
return (
<div>
COUNT : {this.state.count}
<button onClick={this.handleClickUp}>+</button>
<button onClick={this.handleClickDown}>-</button>
</div>
)
}
}

js 파일의 크기는 143.33kb(+0.45kb), gzip 기준 46.09kb(+0.19kb) 다. 그리고 실제 소스코드를 살펴보면, 메서드 이름은 최소화되지 않았으며, 사용하지 않는 메서드도 트리 쉐이킹되지 않아 소스코드에 포함되어있다.

만약 하나의 컴포넌트가 아닌 여러 컴포넌트가 포함된 상황이라면? 아마도 번들 사이즈가 더 차이날 것이다.
이렇게 함수형 컴포넌트가 클래스형 컴포넌트의 많은 단점들을 커버해주지만, 함수형 컴포넌트만이 답이고 만능이라고 할 수는 없다.
여전히 함수형 컴포넌트는 못하지만 클래스형 컴포넌트만 할 수 있는 일들이 있고(자식 컴포넌트의 에러를 잡는 것), 클래스형 컴포넌트의 생명주기 메서드와 함수형 컴포넌트에서 사용하는 비슷한 용도로 사용하는 훅의 동작은 완전히 똑같다고 볼 수 없다.
또한, 클래스형 컴포넌트는 리액트 팀에 의해 Deprecate 되지도 않았으며, 몇몇 프로젝트 및 레거시 코드에서는 클래스형 컴포넌트로 작성된 코드가 아주 잘 돌아가고 있다.
단, 리액트 팀에서는 새로운 버젼에서 새로운 훅들을 발표하고 있으며, 대부분의 개발자들이 새로운 프로젝트는 함수형 컴포넌트를 중심으로 개발하고 있다. 따라서 꼭 클래스형 컴포넌트를 사용해야하는 상황이 아니라면, 함수형 컴포넌트 방식으로 개발하는 것이 좋다고 생각한다.