티스토리 뷰

Introducing Hooks(번역문)란 글이 있었고 반박으로 The Ugly Side of React Hooks(번역: React Hook의 어두운면)란 글이 나왔으며 그 번역문을 보고 작성했습니다.

 

훅에 관한 건 요새 뜨거운 감자로 보입니다. 이걸 도입하는 게 타당한 트레이드인지 따지기 어렵다는 방증으로 보입니다.
훅에 맘에 안 드는 점이 있긴 한데 까더라도 제가 까고 싶어서 올려봅니다. 글쓴이가 훅을 잘 서술했다고 생각하지 못했거든요.
언젠가 봤다가 언어장벽으로 반 정도 읽다가 말았는데 역자분의 고생을 생각해봅니다.

이유 1: 클래스는 헷갈린다

hook 함수는 클래스가 아니며, 함수와 클래스의 그 중간 어딘가에 있는 것이다

타당한 지적입니다. 훅은 그 자체로 필요한 제반 지식이 상당하고 언어 차원에서도 이질적입니다.
이 손실을 감내할 이득이 있는지 따지는 걸 건설적인 목표로 잡을 수 있을 것 같습니다.

이제부터는 그것을 Funclass라고 부를 것이다. …Funclass가 클래스보다 개념적으로 이해하기 쉽다고는 생각하지 않는다.
클래스는 잘 알려져 있고 확실한 개념이며, 모든 개발자가 이 개념에 익숙하다.

이 뒤 내용은 클래스만 이해할 수 있다면 리액트 컴포넌트를 수월히 작성, 사용할 수 있다고 전제하고 이어집니다. 여기에 저는 동의하기 힘듭니다.
리액트는 클래스를 이해했다고 끝인 기술이 아니라고 생각합니다. 저는 리액트에 입문할 때 componentDidMountcomponentDidUpdate가 비슷한 코드를 가지는 것을 보면서 이게 과연 올바른 코드인가 의심하기도 했습니다. prop과 상태가 동시에 변해야 하는 상황을 맞닥뜨려서 getDerivedStateFromProps가 필요한 건가 고민하기도 했고, prop이 변하는데 왜 컴포넌트를 부수고 다시 만들지 않는가 생각하기도 했습니다. 그저 너무 익숙해져서 넘어간 부분이 많을 것입니다.
또한 비교 대상이 잘못되었습니다. 훅에 대응하는 클래스 컴포넌트의 요소는 믹스인, higher order component나 render prop입니다. 이유는 재사용 가능성을 얻는다는 이점이 같기 때문입니다. HOC와 render prop만으로 만족할 수 있었다면 훅이 나올 필요가 없었을 겁니다.
클래스가 문제가 아니라 리액트에서 클래스를 활용하기 위해 필요한 제반 지식과 방법에 대해 훅이 다른 시각을 제공한다고 봅니다.

이 개념은 훨씬 더 마법처럼 느껴지고, 엄격한 구문 대신 컨벤션에 너무 많이 의존한다.

'마법'은 훅을 '이해할 수 없는 대상'이라는 의미로 사용한 것 같습니다. 하지만 전 훅 레퍼런스를 보고 얼마 안 돼서 훅 호출을 배열로 추적하겠단 추측을 할 수 있었습니다.

마법처럼 느껴진다는 게 암시 문맥이 존재하는 걸 의미한다고 생각해보겠습니다. 구현을 파고들면 모듈 내부 변수로 이어지는 부분이 있을 테니 그게 꺼림칙하다면 이해가 됩니다.
근데 실제로 엄격한 구문을 쓰는 언어가 있습니다. 하스켈(…)입니다. 처음 훅을 봤을 때부터 하스켈의 모나드 구문과 비슷하다고 느꼈고, 하스켈은 암시 문맥에 관해 좀 더 바람직한 방법으로 훅의 구문을 사용할 수 있습니다. 거긴 너무 깐깐하게 따진 나머지 타입 문제를 해결하지 못해 끙끙대고 있습니다.

이 생각에 비춰볼 때 훅은 모나드 구문의 엄격한 타입 체킹을 컨벤션, 린팅으로 때우고 결과만 체리피킹한 것으로 보였습니다. Dan Abramov가 논문을 언급하기도 했으니 그렇지 않을까 싶어요.

클래스의 구문은 다중 인스턴스의 개념과 인스턴스 스코프의 개념(정확히는 this)을 처리하기 위해 특별히 고안되었다

this는 유용합니다. this를 쓰는 클래스 덕분에 컴포넌트에서 필요한 문맥정보를 보존하고 사용할 수 있습니다.
클래스 내부의 재사용은 쉽지만 외부는 반대입니다. HOC와 render prop이 해결하려고 한 문제를 말합니다. 저자는 클래스를 변호하는데, 클래스는 애초에 문제를 외면하고 있다고 봅니다.
순수하게 클래스로만 문제를 해결하면 prop이 복잡해지고 DOM이 깊어지고… 이건 다른 데서 많이 나오는 소재입니다.

Funclass는 실제로 클래스처럼 변장한 것일 뿐

잘 이해가 안 되는 부분입니다. 아마 서로 기대하는 게 다르지 않나 생각합니다. 애초에 왜 funclass라는 이름을 만들었을까 하는 생각이 들고요.
딱히 훅에 대해 클래스의 동작을 기대하지 않습니다. 굳이 빗대자면 저는 모나드의 효과를 기대하는데, 글쓴이는 뭘 기대하고 있었던 걸까요.

필자는 함수 컴포넌트를 읽을 때, 이것이 상태가 없는 "멍청한 컴포넌트"라는 것을 즉시 알 수 있다는 생각이 개인적으로 좋았다.

확실히 컴포넌트의 첫줄만 가지곤 상태와 부수 효과를 가지는지 판단하기 힘들어질 겁니다. 제 경우엔 보통 컴포넌트가 /components에 있는지 /containers에 있는지로 구분했기에 문제를 느끼지 못했습니다. 공감은 안 되지만 이 부분은 제가 dumb component를 구분하기 힘들어 불편했던 경험이 부족한 문제일 수도 있겠다 싶습니다.

이유 #2: 컴포넌트의 상태와 연관된 로직을 재사용하기 어렵다.

Hook은 클래스와 함께 작동할 수 없으므로 코드베이스가 이미 클래스로 작성된 경우, 상태 저장 로직을 공유하기 위한 다른 방법이 필요하게 된다.

애초에 클래스는 서로 함께 작동한 적이 없습니다. HOC나 render prop과 비교해야 합니다. 클래스 컴포넌트를 재사용할 때 한 컴포넌트가 다른 컴포넌트의 메서드를 호출해서 재사용하지 않습니다. HOC를 만들겠죠.

훅을 클래스 컴포넌트에서 쓰고 싶다면 기존에 하던대로 HOC나 render prop을 만들어 쓰면 됩니다. 이렇게 보자니 훅의 문제가 아니라 원래부터 클래스 컴포넌트가 가진 문제같네요.

Hook은 인스턴스별 로직 공유 문제만 해결하지만, 여러 인스턴스 사이에서 상태를 공유하려면 여전히 저장소 및 서드파티 상태 관리 솔루션을 사용해야 한다

전자라도 해결하는 게 감지덕지입니다. 후자도 useContext가 간편함을 제공합니다. 제가 보기엔 상태 공유 문제도 해결되었습니다. 문제는 상태 변경 시 다른 컴포넌트의 리렌더링을 어떻게 일으킬까 하는 것으로 보입니다. 그게 mobx-react가 해결해주는 것이고요.

앞서 말했듯, 여러분이 이미 그런 것들을 사용하고 있다면 Hook은 정말로 필요 없게 된다

스토어나 MobX를 사용하고 있더라도 필요합니다. 응집성이 있어 재사용이 간편하고 데코레이터를 안 쓰거나 DOM 깊이를 줄일 수 있으니까요.

React는 이런 증상을 해결하느라 진땀빼는 대신, 전역 상태 (저장소)와 로컬 상태 (인스턴스 당)을 모두 관리하기 위한 적절한 상태 관리 도구를 만들어서 이런 허점을 완전히 없애야 할 때라고 생각한다

글쓴이는 어떤 상태관리를 선호하는지 알아보니 controllerim이란 게 있었습니다. 거기서 아래 코드를 봤습니다

this.parentController = ParentController.getInstance();

일단 글쓴이는 암시적 문맥(전역 변수 등)을 싫어하는 건 아닌 듯 했습니다. 그럼 훅이 마법같은 부분은 어디인가 의아해졌습니다.

'공유된 상태 관리'가 아닌 그냥 '상태 관리'에도 MobX가 필요하다는 게 거부감이 들기도 했습니다.

이유 #3: 복잡한 컴포넌트는 이해하기 어렵다.

잠깐 번역에 짚고싶은 부분이 있습니다.

원문: If you are already using stores, this argument is almost not relevant. Let’s see why:

번역문: 여러분이 저장소를 사용하고 있다면, 이 인자들은 거의 관련이 없을 것이다. 다음 코드를 보자.

제 번안: 이미 스토어를 쓰고 있다면 이 주장은 의미가 없다. 다음 코드를 보자.

componentDidMount에서 관련 없는 로직을 합칠 수 있지만, 컴포넌트가 더 커지고 있는가? 전혀 아니다. 전체 구현은 클래스 외부에 있고, 상태는 저장소에 있다.

예제 코드는 this에 의존적인 코드가 아닙니다. 컨텍스트나 인스턴스에 종속된 상태, 생명주기에 관련한 부수효과를 쓰는 코드를 예제로 들었어야 했습니다. 그 경우 훅처럼 편리한 재사용은 불가능할 겁니다. 재사용 기능이 클래스 여러 군데에 흩어지겠죠. 하위 컴포넌트에 책임을 분할하는 것도 편하다고는 보기 힘듭니다.

컴포넌트가 마운트 단계에 수행하는 작업을 이해하기 위해서 각 Hook을 찾아가서 useEffect를 찾기 위해 비어있는 디펜던시 배열로 검색해 봐야 한다.

이 부분은 좋은 지적입니다. Introducing Hooks의 해당 부분에서 생명 주기 위주에서 기능 위주로 코드 묶음 단위를 바꿨다고 했습니다. 때문에 초기화 시 실제로 실행되는 코드를 추적하기 어려워집니다. 이건 expression problem과 성격이 비슷해보여 완전히 문제라고 보긴 애매해보입니다. 기존 클래스 컴포넌트에서는 초기화 시 실행되는 코드 추적이 쉽지만 기능의 추가·제거가 힘들었습니다. 훅은 그 반대가 되는 것 뿐입니다.

두 과실을 다 취할 수도 있을 텐데 그건 정말 난이도 있는 문제입니다. 이건 의존성 문제와 연관이 있다고 생각하는데  후술하겠습니다.

대부분의 대형 앱은 이미 상태 관리 도구를 사용하고 있고, 앞서 말한 문제는 이미 완화된 상태다.

글쓴이는 스토어를 주입받아 쓰는 패턴을 소개하면서 문제가 없다고 말합니다. 훅도 마찬가지로 스토어를 사용할 수 있습니다. 문제는 클래스 컴포넌트를 스토어와 같이 사용하려면 군더더기 구문(prop drilling, Context.Consumer, subscription 수단)이 필요하다는 겁니다.

Introducing Hooks에서도 상태 관리 라이브러리의 아쉬운 점을 얘기했습니다.

상태 관리 라이브러리는 종종 너무 많은 추상화를 하고, 다른 파일들 사이에서 건너뛰기를 요구하며 컴포넌트 재사용을 더욱더 어렵게 만듭니다.

react-mobx를 쓸 때 observable 변경으로 생긴 리렌더링은 보고싶지 않은 스택프레임이 길게 생겼습니다. 리덕스는 상태를 관리하는 데 필요한 파일 수부터 곤란했죠. 그렇게 만든 스토어는 라이브러리 의존성 때문에 그다지 쓰고싶지 않게 됩니다.

요점은, useEffect가 Funclass로 갈아타는 이유가 되어서는 안 된다는 것이다.

글쓴이 주장을 요약하자면 useEffect가 편리한 점은 있지만 클래스에서도 비슷하게 만들어 쓸 수 있으므로 훅을 채용할 이유가 되지 못한다는 것입니다.
제가 생각하는 훅을 쓰는 이유는 문맥 의존 코드의 조립가능한 재사용입니다. 딱히 dispose 패턴이 편리해서 쓴다기보단요.

이유 #4: 성능

정확한 수치를 보여달라

성능 문제가 중요한가 싶기도 하지만 굳이 예를 들자면 react-hook-form이 있겠습니다.
react-mobx를 남용(…)해서 폼을 작성한 적이 있는데 모바일에선 확연하게 지연을 느낀 적이 있었습니다. 리렌더링 횟수는 성능에 중요합니다. 그리고 react-hook-form의 수치와 사용성은 훅이 큰 영향을 끼치지 않았을까 합니다.

이유 #5: Funclass가 더 간결하다

그러나 큰 컴포넌트를 비교할 때는 둘 사이의 차이점을 거의 알 수 없으며, 때로는 클래스가 더 깔끔할 수도 있다

평범한 문장인데 오히려 와닿습니다. 이 주장이 가장 공감됩니다. 훅은 디테일을 숨겨주지만 드러내기도 합니다. 의존성과 콜백 선언 순서는 클래스에선 신경쓸 대상이 아니었죠.
그래도 각각 성능과 정적 타입 검증에 도움을 주니 납득할 수 있지만 훅을 사용한 컴포넌트가 커질 때 읽기 힘들어지고(이건 후술합니다), 간혹 장황해집니다.

<Text>{someContext.helloText}</Text>

someContext를 무슨 타입으로 보면 좋을까요. TS 사용자도 상당할테니 무시할 수 없는 문제입니다.
someContext를 리액트 클래스 바깥에서도 {helloText: string} 타입으로 볼 수 있을까요?
구현에 Proxy같은 게 필요할 수도 있습니다.

숨어있는 사이드 이펙트

Funclass는 훨씬 더 오류가 발생하기 쉬우며, 엄격하게 정의된 라이프 사이클 메서드 구조가 없어서 좋지 않은 코드를 작성하기 훨씬 쉽다.

예시의 버그는 린트 룰에 걸리기 때문에 오류가 발생하기 쉽다는 근거가 되기엔 억지가 있습니다.

라이프사이클의 경우엔 좋지 않은 코드가 무엇인지 예시를 들어주지 않았지만 아마 제가 나중에 보여드릴 훅의 순서에 관련된 코드가 아닐까 싶기도 합니다.

늘어난 API

useEffect, useState, useContext만 알면 요구 사항의 대부분이 커버 가능한데 이 정도면 깔끔하다고 해도 좋지 않나 싶습니다.

Google의 도움 없이 이전 prop을 얻기 위한 Hook을 작성할 수 있는가?

componentDidUpdateprevProps를 말한 건가 싶은데 왜 이건 구글링하지 않는다고 생각하는 건지 모르겠습니다.

부족한 선언성

return 문을 찾기가 어려울 수 있다.
…componentDidMount, componentDidUpdate등 …구성 요소 내부에서 방향을 찾기 훨씬 더 어려웠다.

IDE 지원을 얘기하는 게 맞을 진 모르겠지만 vscode에는 Fold Level 2라는 커맨드가 있습니다. 사용하면 훅이나 return 단위로 추적하기 쉽도록 코드가 접힙니다. 글쓴이가 말하는 Go to symbol in editor 커맨드로 해당 훅 이름을 알면 사용처로 바로 갈 수도 있고요.

아마 이미 말한 Expression problem 문제랑 조금 겹치는 게 아닐까도 싶습니다. componentDidMount 로직이 몰려있지 않은 게 불편하다고 하는 것도 같아서요. 그런데 기존의 componentDidMount는 선언적이라기보다 절차적이라고 해야하지 않을까 싶습니다.

모두 React에 묶임

추상화, 재사용이 필요 없다는 것으로 들립니다. 이건 이견이 있을 수 있겠네요.

뭔가 잘못된 느낌

좋은 개념을 발견 했을 때는, 그냥 그것이 멋지게 작동하는 것을 이해할 수 있다. 그러나, 잘못된 개념으로 어려움을 겪고 있을 때는 작업을 수행하기 위해 점점 더 구체적인 내용과 규칙을 추가해야 한다.

훅의 API 표면이 더 작다고 생각하는데 그러면 더 멋진 게 되지 않나 생각해봅니다.
그리고 구체적인 게 붙는 건 제가 보기엔 클래스 쪽이 더 가깝습니다. 상태관리 라이브러리없인 조금만 규모가 커져도 prop drilling, prop 선언과 컨텍스트 getter HOC에 들어가는 수고가 많아집니다. 훅은 언급했다시피 이 문제를 경감해줍니다.

결론

Funclass는 React 커뮤니티에서 두 번째로 최악의 일이 될 수 있다고 생각한다(1위는 여전히 Redux 이다)

저도 리덕스를 쓰고싶진 않지만 리덕스는 리액트 API 범위 내에서 명시적으로 상태를 드러내고 조립할 수 있었다고 생각합니다.

MobX는 Symbol, Proxy 도입으로 나아지긴 했지만 그 작동 방식면에서 오히려 흑마법에 가깝지 않을까 생각합니다. dart의 MobX는 codegen이 필요하고 다른 언어에선 리플렉션이 필요할 수도 있겠죠. 때문에 리액트 코어에서 채용하는 건 주저됩니다. 리액트가 Symbol, Proxy 기능에도 의존하는 게 되니까요.

클래스를 남겨두고 Funclass에만 더 많은 기능을 추가한 채로 강제로 지원을 종료하지 않아야 한다

훅 API엔 ErrorBoundary에 해당하는 요소가 없습니다. 클래스 컴포넌트로만 쓸 생각이기 때문입니다. 클래스를 버릴 생각이 없다는 건 Introducing Hooks에도 나온 얘기인데 왜 불안을 가진지 모르겠습니다.

React를 위한 간단하고 깔끔한 내장 상태 관리 솔루션을 제안하는 RFC 작업을 시작할 예정이다.

RFC는 매력을 느끼지 못했습니다.


위에서 제가 납득한 것을 모아보겠습니다.

  1. 전역변수로 이어지는 부분이 있다 (하지만 글쓴이가 지적한 건 아닌 것 같네요)
  2. 클래스에서 사용할 수 없다
  3. 코드가 때론 장황해진다
  4. 읽기 힘들어진다
  5. 원하는 코드를 검색하기 어려울 수 있다

다시 살펴보니 애매한 부분은 '장황해진다', '읽기 힘들어진다'는 부분입니다. 이제 제가 생각하는 훅의 아쉬운 점을 다뤄봅니다.

훅의 의존성 요구

여기에서 위의 3~5번이 파생한다고 생각합니다. 의존성 요구는 의존성 배열만 의미하는 건 아닙니다.

장황해짐

의존성 목록이 군더더기처럼 보이는 건 자명합니다. 대충 짜는데 의존성이 달려오는 걸 보면 유쾌할 수 없습니다.

const childRenderer = useCallback( 
    (field, index) => { 
      const props = { field, record, basePath, resource }; 
      const [start, end] = selectionRange; 
      return ( 
        <DraggableGridCell 
          onMouseDown={startColumnSetters[index]} 
          onMouseEnter={endColumnSetters[index]} 
          selected={start <= index && index <= end} 
          {...props} 
        /> 
      ); 
    }, 
    [ 
      record, 
      basePath, 
      resource, 
      startColumnSetters, 
      endColumnSetters, 
      selectionRange 
    ] 
  );

코드 순서 제약

코드 순서는 가독성에 직결되기 때문에 중요합니다. 코드 순서가 예측 가능하면 자연히 검색하기도 쉬워집니다. 초기화 코드도 마찬가지입니다.
클래스 컴포넌트에서는 componentDidMount가 표식 역할을 합니다. 두괄식이란 느낌도 있고요. 훅을 사용하는 컴포넌트에서 비슷하게 형식을 고정하자면 아래가 될 겁니다.

const [a, setA] = useState();
const b = useCallbackOrMemo(a);
const c = useCustom(a, b);
const d = useCallbackOrMemo(a, b, c);
useEffect(() => { a, b, c, d; });

일단 클래스 컴포넌트에 익숙한 사람 입장에서 초기화 구문이 맨 아래 위치하는 게 이질적입니다. 저도 두괄식을 선호해 useEffect 구문이 가능한 위로 올라갔으면 합니다.
더 마음에 안 드는 부분은 useCustom입니다. 내부적으로 useEffect를 쓸지도 모르는데 성질이 다른 콜백, 메모 훅과 인접해있습니다. 즉 개발자가 원하는 대로 코드를 배치할 수 없습니다.

나머지 훅들은 리턴하도록 커스텀 훅으로 분리하면 useEffect가 항상 마지막 return 직전에 오도록 만들 순 있겠지만 분리하지 않았을 때 문제가 있단 점이 일단 좋진 않습니다.

어찌보면 그 복잡한 하스켈 이펙트 시스템의 결과물만 가져오는데 아무 부작용도 없으면 하스켈이 고민하는 문제는 뭐가 되겠냐 싶기도 하네요.
반면 리렌더링 방지와 타입 체킹이 좀 더 엄밀하다는 장점이 있기에, 결국 트레이드 문제로 볼 수 있습니다.


이 글은 수정될 수 있습니다. 계속 수정해왔지만 그랬음에도 부족한 점을 느꼈거든요.

여기까지 읽어주셔서 감사합니다.

'지식저장소 > 개발' 카테고리의 다른 글

git 다중 계정 사용  (1) 2021.06.05
Mocking is a Code Smell을 읽고  (0) 2020.03.22
로딩 스피너 컴포넌트 고찰  (0) 2019.06.30
React Hooks를 사용할 이유  (6) 2019.06.19
깃 3-way merge IntelliJ 설정  (0) 2019.03.19
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함