React의 hooks API는 v16.8부터 등장해 대-함수 컴포넌트 시대를 열었다. 그 이전에는 클래스 컴포넌트와 함수 컴포넌트의 구분이 철저했고, 함수 컴포넌트는 주류가 아니었다. 클래스 컴포넌트가 할 수 있는 생명주기 관리같은 일들을 함수 컴포넌트는 하지 못했기 때문이다. (그리고 공식문서 보면 아직도 클래스 컴포넌트의 잔재가 많이 남아있다.)
그치만 클래스 컴포넌트는 장점만큼 단점도 명확했다. 크게 3가지 정도로 추려볼 수 있고, 내가 리액트를 공부하기 시작할 때는 이미 클래스 컴포넌트 사용을 지양하는 추세였기 때문에 '그냥 그렇구나' 정도로만 이해한다.
1. 로직 분리가 어렵다
컴포넌트는 하나의 책임만 지는 것이 좋다. 로버트 C. 마틴의 저서 'Clean Code'에는 이런 말이 있다.
"함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다."
객체나 컴포넌트도 마찬가지다. SOLID의 SRP 원칙도 같은 말을 하고 있고, 이게 컴포넌트가 된다고 달라질 것은 없다.
그럼에도 불구하고 여러 로직이 섞인 컴포넌트가 필요할 때가 있는데 이럴 경우 로직을 분리하는게 좋다. 근데 클래스 컴포넌트는 그게 어려웠다고 한다.
HOC라는 대안이 있긴 하지만 최고의 답은 아니다. 수많은 래핑으로 인해 핵심 로직 파악이 어렵기 때문이다.
로직이 분리되지 않는다는 것은 로직이 재사용 되기 어렵다는 뜻이기도 하다. 같은 내용의 로직이 분리되지 않는다는 이유로 여러번 복붙해서 사용해야 한다면 좋은 코드라고 보기 어렵다. 혹시 문제라도 발견된다면 그 코드가 사용된 모든 컴포넌트를 찾아 하나하나 수정해줘야 한다.
2. 진입장벽이 높았다.
클래스 컴포넌트도 여러 종류가 존재한다. Pure Component, Presentational Component 등 개념적인 내용에 대해 공부해야 할 것이 많고, 메소드도 굉장히 다양했다. 심지어 deprecated 된 메소드도 있기 때문에 잘 확인해서 써야 했다.
그리고 리액트를 가볍게 쓰려는 사람들에게 this
의 개념은 높은 허들일 수 밖에 없다. 자바스크립트가 클래스 개념을 어떻게 도입했는지 알아야 하고 this
를 알기 위해서는 실행 컨텍스트에 대해서도 알아야 한다. 쉽지 않다.
이런 이유들로 인해 리액트 팀은 클래스 컴포넌트 말고 '간단한' 기능밖에 없었던 함수 컴포넌트를 밀어주기로 했다. 그러기 위해 클래스 컴포넌트에서만 되던 기능들을 함수 컴포넌트에서도 되게끔 붙여줘야 했는데 그게 hooks API다.
아 그리고 함수형 컴포넌트라고 잘못 알고 있는 분들이 많은데, "함수 컴포넌트"가 공식 명칭이다. Functional Component가 아니다.
설명에 앞서, 함수 컴포넌트는 함수처럼 생겼지만, 함수 그 자체로 돌아가지 않는다고 이해하는 편이 더 쉽다. 훅을 선언하는 부분, 로직 부분, 리턴 부분이 각각 따로 떨어져나가 다른 절차를 거쳐 컴포넌트를 구현한다고 보는게 더 좋다.
Hook을 사용하는데 있어서 제약조건이 2개 있다.
- hook은 함수 컴포넌트 혹은 custom hook 내부에서만 호출할 수 있다.
- hook이 호출되는 순서는 항상 동일해야 한다.
이 제약조건들은 괜히 생긴게 아니다. 1번 조건은 그래도 왜 저런 제약조건이 있어야 하는지 유추가 가능하다. 생각해보면 당연하기까지 하다. 근데 2번 조건은 동작 원리를 알아야만 이해가 가능하다. 아래 의사코드를 같이 보도록 하자.
let hooks = [];
export function useHook() {
// ...
hooks.push(hookData);
}
function processThisComponentRendering(component) {
component(); // 여기서 useHook()이 호출됨.
let hooksForThisComponent = [...hooks];
hooks = [];
// ...
}
processThisComponentRendering
은 리액트 내부에서 하나의 컴포넌트를 처리하는 과정이다. 이 함수의 내부에서는 인자로 받은 component
함수를 호출하는데 이 때 내부적으로 useHook
이 불린다.
useHook은 useState
가 될 수도 있고 그냥 이것저것 훅들을 하나의 코드로 간단하게 표현한 것이다. useHook
내부에서는 hooks 배열에 훅을 돌리는데 필요한 데이터들이 추가된다. 만약 useState
라면 초기값이 데이터로 들어갈 것이고, useEffect
라면 콜백과 의존성 배열이 들어갈 것이다. 중요한 점은 훅 데이터들이 배열에 들어간다는 점이다. 다시 말하면, 자바스크립트 인터프리터는 컴포넌트 코드를 위에서부터 읽을 것이고, 훅이 호출된 순서대로 배열에 들어간다. React는 이게 어떤 훅인지 모른다. 오직 순서만 알고 있다.
// 컴포넌트
const Component = () => {
const [value, setValue] = useState(0);
useEffect(() => {
console.log("hihi");
}, [value]);
return <div>Hello world</div>;
}
// 위 컴포넌트를 처리한 후 hooks 배열은 아래와 같아질 것이다.(의사코드)
[0, [() => { console.log("hihi"); }, [value]]
다시 말하지만, 리액트는 어떤 훅인지 모른다. 순서만 기억한다.
2번째 제약조건 이야기로 돌아와서, 훅이 호출되는 순서는 항상 동일해야 한다고 했다. 간단하게 말해서 조건문이나 반복문 안에 훅을 넣지 말라는 소리다. 조건문이나 반복문 안에서 훅을 호출하면 저 hooks
배열이 일그러질 수 있다.
const Component = () => {
if(condition()) {
const [age, setAge] = useState(25);
}
const [name, setName] = useState('');
setName('imnotmoon') // 결과는?
// ...
}
이런 코드가 있다고 하자 2번째 제약조건에 위배되는 코드다.
이게 왜 위험하냐면 condition()
의 리턴값에 따라 첫번째 훅은 호출이 될 수도, 안될 수도 있다.
리액트는 훅이 호출된 순서만을 기억한다고 했다. 내가 setAge
를 호출하든 setName
을 호출하든 리액트는 모른다. 둘을 구분할 수 있는 유일한 단서는 '호출된 순서' 뿐이다. 근데 만약 condition()
이 false라서 첫번째 훅이 호출되지 않았지만, 이후 코드에서 setName
이 호출된다면? 나는 분명 이름을 바꾸려고 했지만 나이가 바뀔 수도 있다. 그리고 최악의 경우 타입이 안맞는 문제로 인해 에러가 날 수도 있다. 그래서 리액트는 2번째 조건을 넣어둔 것이다.
리액트가 훅을 구현한 또 다른 핵심 원리는 클로저다.
// 훅이 하나 호출될 때마다 하나씩 올라감. hooks.length-1 이라고 봐도 된다.
let idx : Number;
let hooks = [];
const Component = () => {
function useState(initialValue) {
hooks.push(initialValue);
idx++;
const currentIdx = idx;
function setState(value) {
hooks[currentIdx] = value;
}
return [hooks[currentIdx], setState];
}
return { useState, render }
}
함수 컴포넌트는 jsx 엘리먼트를 리턴한다. 렌더링이 됐다는 것은 함수가 이미 생명주기가 끝났다는 것을 의미한다. 그럼에도 불구하고 우리는 useState
가 리턴한 state에 접근하거나 setState
를 호출하는 것이 가능하다. 어디서 많이 들어본 말 아닌가? 클로저는 hooks API를 구현한 핵심 원리다.
심지어 훅 내부에서도 클로저가 사용되었다. currentIdx
는 hooks 배열에서 이 훅의 데이터가 몇 번째에 있는지를 기억한다. setState
함수가 호출되었을 때 currentIdx
에 접근할 수 있으므로 훅 사용이 가능하다.
다른 훅도 마찬가지다. 이 훅에 관련된 데이터가 hooks 배열에서 몇 번째에 위치해 있는지를 클로저를 이용해 기억한다.
컴포넌트의 리턴 부분에서도 함수를 리턴한다. 그러면 함수가 생성된 시점의 context를 기억한다. 다른 훅도 마찬가지다. 자기 훅의 데이터가 hooks 배열의 몇 번째에 해당하는지를 클로저를 통해 기억하고, 클로저를 핵심 원리로 해 돌아간다. 이 글은 useState
와 useEffect
를 직접 구현해보면서 원리를 파악하고 있다. 굉장히 좋은 글이니 꼭 읽어보길 추천한다.
함수 컴포넌트가 대세가 되었지만 그렇다고 클래스 컴포넌트가 가치없다는 뜻은 아니다. 여전히 클래스 컴포넌트만 가능한 부분도 있다. componentDidCatch
나 getDerivedStateFromError
메소드는 Suspense와 ErrorBoundary를 구현하기 위한 핵심 기능이고, 이들은 클래스 컴포넌트에만 존재한다. 여전히 클래스 컴포넌트의 생명주기 관리를 함수 컴포넌트는 완벽하게 따라가지 못한다.
그럼에도 불구하고 함수 컴포넌트는 굉장히 좋다. 로직 분리가 된다는 점, 허들이 낮다는 점 덕분에 많은 사랑을 받고 있다. 나 역시도 거의 모든 컴포넌트를 만들 때 함수 컴포넌트를 사용해 만든다.
리액트는 되게 잘 만든 라이브러리다. 감탄한다.