본문 바로가기

FrontEnd+

[React] 함수 컴포넌트를 위한 리액트 훅(hook)

 

'리액트 훅(hook)'은 지금으로부터 2년 전('19)에 새로 추가된 리액트 API이다. '훅'이 무엇인지, 어떻게 사용하면 좋은지 정리해보자. (feat. 로이드의 수업!)

 

 

hook의 등장배경

클래스 컴포넌트가 외면받은 이유

클래스 컴포넌트는 인스턴스의 프로퍼티로 상태를 갖도록 할 수 있고, 컴포넌트의 라이프사이클에 따라 생명주기 메서드도 활용할 수 있다는 확실한 장점이 있다.

그럼에도 클래스 컴포넌트가 외면 받은 이유는 클래스가 함수보다 복잡하기 때문이다. this 바인딩만 생각하더라도 클래스가 함수보다 복잡하다는 것은 반박할 수 없는 사실이다. 클래스의 복잡성은 컴포넌트 활용 및 재사용에 장벽이 되었다.

함수 컴포넌트, hook이라는 날개를 달다

사람들은 단순한 함수 컴포넌트를 찾기 시작했다. 함수 컴포넌트를 사용하니, 복잡한 this 바인딩에 대해 신경쓰지 않아도 되고 재사용하기도 쉬웠다. 하지만 함수 컴포넌트는 (클래스 컴포넌트와 달리) 상태를 가질 수 없었다. 라이프사이클도 가질 수 없었다. 리액트 팀은 함수 컴포넌트에서도 클래스 컴포넌트의 장점을 살리기 위해 고민했고, hook은 이 고민의 결과물이다. hook을 한 마디로 정의하면 '함수 컴포넌트에서도 클래스처럼 상태를 갖게할 수 있는 기능'이다.

이렇게 함수 컴포넌트가 hook이라는 날개를 달게 되자, 일부 커뮤니티에서는 '리액트에서 클래스 컴포넌트가 없어질 것'이라는 시각이 등장했다. 하도 질문이 빗발쳤는지 리액트 공식문서에 따로 '클래스를 없애지 않을 것'이라고 설명하고 있다.

 

리액트 개발로그 - 리액트 최초 발표 (2013년)
리액트 개발로그 - ES6 클래스 지원 (2015년)
리액트 개발로그 - 훅 추가 (2019년)

 

 

 

hook의 핵심 메커니즘

함수와 클래스의 차이

함수와 클래스의 차이를 이해하면 hook의 핵심 메커니즘도 간단하게 이해할 수 있다. 

함수와 클래스의 가장 큰 차이는 'new'를 강제하는지 여부이다. 함수와 달리 클래스는 꼭 new를 붙여 호출해서 반드시 '인스턴스'로서 생성되어야 한다.

클래스는 애초에 인스턴스를 기억하기 때문에 this라는 컨텍스트로 메서드 간에 유지되는 '상태'를 만들  있다. 리액트 입장에서는 클래스의 인스턴스가 처음으로 호출된 것인지, 아니면 다시 호출된 것인지 알 수 있다. 'new Component()'가 최초로 호출된 다음에 다시 호출될 경우, 리액트는 이를 알아채고 다시 새로운 인스턴스를 만들지 않고(new를 생략하고), 이전 인스턴스의 render 메서드를 호출한다. (리액트 컴포넌트는 통일된 규격을 갖는다. render() 메서드를 반드시 가지고, 이 메서드의 반환값은 React.createElement의 결과물일 것으로 기대한다.)

반면에, 함수를 호출한 경우에는 리액트 입장에서 해당 함수가 이전에 호출 되었는지 안되었는지 알 방법이 없다. 함수의 실행이 종료되면 따라 함수의 생애도 끝나버린다. 항상 새로운 함수가 호출된다. 따라서 함수는 태생적으로 상태도, 라이프사이클도 가질 수 없다. (여기서 상태란 함수의 실행이 종료 되고도 살아있는 함수 '외부'의 상태를 말한다. 함수 '내부'에는 함수가 살아있는 동안 당연히 상태를 가질 수 있다.)

함수가 '상태'를 가질 수 있는 방법

이런 함수의 태생적인 한계(?)를 극복하고, 함수가 '상태'를 가질 수 있도록 해주는 것이 바로 'hook'이다. 리액트 팀에서 생각해낸 방법은 다음과 같다.

가상의 인덱스 딕셔너리 (수업 중 로이드의 드로잉)

위와 같이 각 함수의 상태를 맵핑해서 저장할 수 있는 가상의 '딕셔너리'를 가정해보자. (실제로는 더 복잡하지만) 각 컴포넌트를 특정한 index값으로 접근한 수 있다면, 이 별도의 딕셔너리를 통해 함수의 상태를 기억하고 업데이트하는 것도 가능해질 것이다.

리액트 앱은 컴포넌트의 트리(Tree)라고 할 수 있다. 최상위 root 컴포넌트의 하위 컴포넌트로 만들어진다. Virtual DOM의 컴포넌트는 렌더링 될 때 항상 똑같은 구성요소로 만들어져있고, root 컴포넌트로부터 항상 똑같은 순서로 호출이 된다. 안에 내용물은 업데이트 되어도 컴포넌트 트리는 모양이 유지된다. 오른쪽 그림에서 T1이 상태를 가지고 있다고 해보자. T1(2번)에 업데이트가 필요할 경우, T1만 호출하는 것이 아니라 1번, 2번, 3번, 4번, 5번 순으로 일정한 순서로 다시 전체를 호출하게 된다. 매번 똑같은 순서로 호출이 된다

리액트팀은 이렇게 컴포넌트가 호출되는 순서는 매번 똑같다는 '함수의 호출구조' 이용했다. 인덱스 딕셔너리에 함수 컴포넌트가 가지고 있어야 할 상태를 넣어놓고, 필요로할 때 꺼내주는 방식으로 상태를 구현한 것이다. 

 

 

hook의 사용 규칙

함수 컴포넌트에서만 사용해야 한다.

hook은 함수컴포넌트 전용이다.클래스 컴포넌트에서도 훅을 호출할 수 없다. hook은 함수컴포넌트에게 어떻게든 상태를 만들어주기 위한 특별한 장치로, 함수컴포넌트와 hook은 서로 중요한 의존성이 있다. 이를 클래스나 일반 함수에서 사용할 이유는 없다.

hook은 언제나 한번 호출되어야 한다.

앞서 살펴본 것과 같이 hook은 호출위치에 민감하기 때문에 최상위에서만 단 한번 호출되어야 한다. 쉽게 말하면 '반복문, 조건문, 조건문'에서 훅을 호출하면 안된다. 

'반복문'이라면 하나의 컴포넌트에서 같은 훅을 여러 번 호출하게 될 것이다. '조건문'의 경우, 훅이 조건에 따라 호출될 수도 있고 안될 수도 있다. '중첩함수'라면 '조건문'과 비슷하게 그 컴포넌트가 반환될 때까지 호출이 안될 것이다. hook의 호출은 트리구조상 언제나 일정한 위치에서 발생해야 하기 때문에 반복문, 조건문, 중첩함수에서 hook을 호출하는 것은 매우 위험한 일이고 지양해야 한다.

조건부 렌더링에 대해 조금 더 생각해보자. 페이지 라우팅을 위한 조건부 렌더링을 한다면 각 페이지 컴포넌트에서 hook을 호출해도 된다. hook 입장에서는 조건부가 아니기 때문이다. 페이지 컴포넌트가 날아가면 hook도 같이 날아간다. 이렇게 컴포넌트와 라이프사이클이 같으면 hook을 호출해도 문제가 없다.

반면, return <div>{isVisible && <Container/>}</div> 와 같은 코드에서 Container 컴포넌트에서 hook을 호출해서는 안된다. 함수 컴포넌트에서의 상태관리는 함수 호출 순서에 의존적이기 때문에 이런 조건부 렌더링을 넣으면 호출순서를 보장해줄 수 없어 문제가 된다. 

순서가 바뀌는 hook 호출이 일어나도 당장은 아무 문제가 없는 것처럼 보일 수 있다. 하지만 1번 컴포넌트 호출이 일어났을 때 0번 순서의 컴포넌트의 상태가 보내질 수도 있다. 런타임에서 실행시간에 이상한 값이 넘어가게 되는 것이다. 이렇게 상태 맵핑이 꼬이는 것은 컴파일 타임에서 발견되지 않고 디버깅하기 매우 어렵다. 

린터를 반드시 적용한다.

hook의 메커니즘에 따라 생긴 위와 같은 제약사항을 충분히 이해하지 않고 hook을 사용한다면 엄청난 부작용이 따를 수 있다. 이를 방지하기 위해 린터의 도움을 받도록 한다. (eslint-plugin-react-hooks)

또한 커스텀훅을 만든다면 use-* 접미사를 사용하도록 한다.

 

 

참고자료

- 리액트 공식문서 - Hook
- 리액트 개발로그