leo.works

React 17에는 무슨 일이

· 7분
Contents

React 17은 20년 10월 20일 공개되었다. 날짜로만 따지면 공개된 지 벌써 2달이 다 되어가는 것이다. 이제와서 17의 내용을 다루는 건 조금 뒷북이지만, React 사용자에게 있어 중요한 내용들이 담긴 업데이트인 만큼 늦었더라도 짚고 넘어가본다. 상세한 내용이 궁금하다면 공식 블로그의 이야기를 참고하는 편이 좋다.

No New Features

SemVer 스펙 상 Major version up 은 API breaking change 와 관련된다. 물론 그렇긴 하지만 Major 버전이 올라가면서 새 기능이 추가되지 않는 경우는 드물다(적어도 내 경험상은 그랬다). 대개의 Major update 는 해당 라이브러리 혹은 프레임워크의 새로운 가치나 방향성 등을 사용자에게 전하곤 했다. React 17 은 Breaking change 는 있지만 new feature는 없다. 그렇다면 React 17은 무엇을 위한 업데이트인가? 바로 업데이트를 위한 업데이트이다. 말이 좀 이상하게 들리겠지만 React 17 업데이트는 미래의 업데이트들을 위한 준비작업을 담고 있다고 보면 된다.

Gradual Upgrade

세상엔 두 명의 개발자가 있다고 나는 믿는다. 라이브러리를 꼬박꼬박 업데이트하고 언제나 예의주시하는 개발자와, 어딘가에서 취약점이나 deprecate 에 대한 알람이 오기 전까진 (대체로 바쁘다는 이유로) 차일피일 업데이트를 미루는 개발자. 나는 후자에 속하는 개발자다. 마지막으로 Release Note 를 본게 언제더라 싶을 때 쯤 괜히 겁이 나 Github 을 들여다 보면 아뿔싸, Major update 를 두 개나 놓쳐버렸네! 이런 적이 한 두번이 아니다. 문제는 이럴 때마다 Breaking Change 와 new feature 가 한 두 가지가 아니고, 특히나 버전이 많이 놓쳤을 때는 사실상 코드를 다시 작성하는 수준으로 업데이트를 따라가야 하는 경우가 많았다. 나는 Storybook 을 즐겨 사용하는데, 이쪽은 Major 버전 업이 될 때마다 코드 작성 컨벤션이 거의 천지개벽 수준으로 바뀐다. Storybook 에 대한 이야기는 또 나중에 한 번, 디자인 시스템 주제와 함께 다뤄보겠다.

위에 적은 이야기는 사실 내 개인 성향이자 코딩 습관에 따르는 애로사항에 가깝지만, 아무튼 이런 업데이트와 관련된 문제 -breaking change 는 언제나 두렵고 할 일이 많다- 는 React 도 피해갈 수 없는 문제였다. 나는 애초에 프론트엔드 개발을 시작한지 그리 오래되지 않았으므로 특별하게 이런 상황을 겪어본 적은 없었지만 React 로 작성된 거대한 프로젝트들은 메이저 버전이 올라가는 상황에선 업데이트가 정말 치명적으로 어려웠을 것이다. deprecate 된 수많은 legacy 코드들을 하나하나 점검하며 고쳐야 했을 테니까. React 팀은 당연히 이러한 어려움을 잘 알고 있었고, React 17 에 이르러 이러한 문제를 해결하기 위한 방법을 제안하니, 그것이 바로 Gradual Upgrade이다.

Gradual Upgrade, 한국어로 ‘점진적 업그레이드’로 번역되는 이 방법은 개념은 간단하다. 두 개 이상의 React 가 하나의 어플리케이션에서 함께 구동된다. 이는 프로그램 전체를 한 번에 새로운 버전으로 업데이트하는 것이 강제되지 않음을 의미하고, 결국 새로운 React 버전이 등장할 때마다 눈물을 머금고 코드를 고치는 과정이 불필요해진다. 물론, 대다수의 앱은 이러한 Gradual upgrade 를 굳이 할 필요는 없다. 리액트 팀도 명시했듯 이 기능(이자 주요 변화)은 단순히 네 마음에 드는 React 버전을 골라서 쓰라는 것이 아니라, 한 번에 코드 전체를 업데이트하기 어려운 팀을 위해 새로운 선택지를 제공하는 것으로 이해해야 한다. React 팀은 강조하고 있다: “늘 하나의 React 버전을 사용하도록 노력하십시오. 단일 버전의 React 를 사용하는 것은 많은 경우에 코드 복잡도를 낮추고, 유저가 React core 를 두 번 다운로드하는 경우를 피하도록 도와줍니다.”

How to

이미 Gradual Upgrade 를 하는 방법은 소개 되어 있다. 여기에 Vercel Next.js 의 데모 링크도 포함되어 있다.

Repository 에 이미 훌륭한 설명이 있지만 그럼에도 간단하게 요약을 해본다. demo repository 는 크게 두 개의 directory 로 구분된다. src/modern(React 17)과 src/legacy(React 16.8) 이다. modern 의 코드는 lazyLoading 으로 legacy components 를 불러온다. React 17 에서 createLegacyRoot 라는 헬퍼 함수를 불러오면 (lazyLegacyRoot) 해당 코드는 React 16.8 을 target DOM 에 연결한다. 두 디렉토리가 구분되어 있으므로 dependency 가 혼선될 우려는 없다. React 17 의 변경사항으로 인해 우리는 서로 다른 버전의 React 를 동시에 사용할 수 있고 이를 통해서 Gradual Upgrade 를 달성할 수 있다.

What happened

물론, 이 이전에도 React 의 두 버전을 동시에 사용할 수는 있었다. React 자체는 특정 DOM 으로부터 Virtual DOM 을 만들고 관리하는 라이브러리이므로, 서로 다른 독립적인 DOM 을 통해 하나의 페이지에서 복수의 React 를 운용할 수 있는 것은 물론 두 React 를 Nest 해서 활용할 수도 있었다. 다만 이 경우 기술적인 문제점이 있었는데, 바로 Event delegation 과 관련된 문제였다.

우리가 React JSX 에 event handler 를 부착하면, 해당 handler 는 실제 우리가 작성한 위치가 아니라 document 레벨로 붙는다. 즉 React 는 event delegation 을 매우 적극적으로 활용한다. 많은 이점이 있지만 치명적인 문제는 e.stopPropagation 와 관련이 있다.

두 개의 React tree 가 서로 nest 된 형태로 존재한다고 가정하자. 그리고 하위와 상위에서 각각 onClick event handler 를 element 에 할당하면, 이 onClick handler 는 모두 document 레벨에 할당된다.

// legacy react root
import React from "react"; // 16.12
import ReactDOM from "react-dom"; //16.12

function modernReact() {
  return ReactDOM.render(
    React.createElement(
      "div",
      {
        onClick: () => console.log("modern react!"),
      },
      null
    ),
    document.getElementById("modernroot")
  );
}

import React from "react"; // 16.8
import ReactDOM from "react-dom"; // 16.8

function legacyReact() {
  return ReactDOM.render(
    React.createElement(
      "div",
      {
        onClick: () => console.log("legacy react!"),
      },
      null
    ),
    document.getElementById("legacyroot")
  );
}
<html>
  <body>
    <div>
      <div id="modernroot">
        <div id="legacyroot"></div>
      </div>
    </div>
  </body>
</html>

이 경우 onClick 핸들러는 모두 document 에 부착된다. 따라서 해당 element 를 유저가 클릭하면, event 는 document 까지 bubbling 되고, document 에 위임되었던 handler 가 동작한다.

이때 문제는, 하위 React 에서 e.stopPropagation 을 요청해도 이미 실제 DOM 에서의 event는 document 레벨까지 올라가버린(bubbled) 상태이고, 그래서 여기서 e.stopPropagation 을 진행해도 다른 React 에서 관리되는 event handlers 에 전파되는 것을 막을 방법이 없게 된다. 이미 Atom 팀은 몇 년전에 이 문제를 직면하고 해결책을 주장한 바가 있다고, React 팀은 이야기 하고 있다.

React 팀은 이 문제만 짚고 넘어갔지만 사실 document 로 event delegation 하는 것은 하나의 문제가 더 있다. 어처구니 없는 경우인데, document 와 React Root 사이의 어떤 DOM 에서 event bubbling 을 막으면 해당 event 와 관련된 React 내부의 handler 가 하나도 동작하지 않게 된다.

그래서 이 문제를 해결하기 위해 React 팀은 Atom 팀이 제안한 방식을 받아들였다. React 17 부터는 Event가 document 가 아닌 React Tree Root 로 delegate 된다. 이는 위에 말한 모든 문제를 해결하고, 각 React instance 들이 더 독립적으로 기능하도록 한다.

물론 side effect 도 있을 수는 있는데, 만약 document 에 개발자가 manuel 하게 등록해둔 handler 가 있다면 (React event 를 받기 위해) 경우에 따라 동작하지 않을 수도 있다. 이 전에는 무조건 document 까지 bubbling 이 완료된 이후에 stopPropagation 이 동작했으므로 해당 Handler 가 trigger 된다는 것이 보장되었지만, 이제는 stopPropagation 을 진행할 경우 document 까지 bubbling 이 되지 않기 때문에 동작이 더 이상 보장되지 않는다.

Any other changes?

이 외에도 몇가지 변경 사항이 더 있는데, 개인적으로 흥미로운 것 위주로 세 가지 정도를 가져왔다.

No more event pooling

React 는 성능 상의 이유로 SyntheticEvent 객체를 재사용해왔다. 이로 인해 Event 객체의 property 에 직접 접근하는 것은 금지되었고 (사용하더라도 원하는 데이터를 얻을 수 없다) 객체의 데이터를 여러번 재사용할 필요가 있을 경우 event.presist() 메서드를 사용하거나 property 의 데이터를 빠른 시간 안에 복제해야 했다.

React 17 부터는 더 이상 이러한 SyntheticEvent 객체 재사용을 하지 않는다. 구형 브라우저에서의 성능 이슈를 보완하기 위해 이런 트윅성 코드가 들어갔었지만, 더 이상 이러한 동작이 전체 프로덕트 개발 과정에서 이점을 가질 수 없다고 판단한 것 같다. 또한 Facebook 개발 팀은 이러한 변경 사항이 몇 가지 버그를 해결하기도 했단다(…).

물론 event.persist() 메서드 자체는 여전히 남아있다. invoke 해도 아무런 동작을 하지 않을 뿐!

No more ‘import React…’

JSX 를 사용하기 위해선 늘 React 를 import 해야 했다(그것을 명시적으로 사용하지 않더라도). 왜냐하면 JSX 코드는 babel, typescript 등을 통해 React 를 사용하는 코드로 transpile 되었기 때문이다.

function sample() {
  return <div>hello, world!</div>;
}
// is equivalent to
function sample() {
  return React.createElement("div", null, "hello, world!");
}

그러나 이제는 더 이상 import React... 구문을 직접 입력하지 않아도 된다. CRA, Next 등의 framework 는 이미 해당 기능을 잘 지원하고 있으며, 직접 babel 을 설정한 경우에도 몇가지 세팅만 잡아주면 react 를 직접 가져오지 않고도 JSX 를 사용할 수 있다.

다만 이것은 React 17 자체의 주요 변경점이라기 보단, React 17 을 공개하면서 함께 넣어준 기능에 가깝다. 나도 이번 기회에 블로그를 React 17 로 업데이트하면서 이런 변경 사항을 Gatsby 에 적용해 보았는데, 만약 궁금하다면 gist 를 참고해보길 바란다.

Effect hook cleanUp

useEffect hook 은 rendering 이후 비동기적으로 동작한다. 그래서 동기적인 effect 동작을 위해 useLayoutEffect 를 별도로 제공했었다. 문제는 unmount 시의 cleanUp function 은 동기적으로 동작했다는 것인데, 이는 의도하지 않은 rendering block 을 일으켜서 UX 상의 문제를 가져왔다. 예를 들어 페이지 전환 시에 무거운 동작을 하는 cleanUp function 이 있다면, 이것이 모두 마무리 된 이후에 render stage 로 진입하였던 것이다.

React 17 부터는 cleanUp function 이 항상 비동기적으로 fire 되고, 이를 원치 않을 경우 useLayoutEffect 를 사용하는 것을 고려해야 한다. 추가로 이제 모든 useEffect hook 은 모든 cleanUp function 이 처리된 이후 invoke 된다.

Conclusion

React 17 은 hook 이후로 나온 React 의 큰 변화이다. event delegation을 비롯해 내부적인 동작이 크게 바뀌었다. 물론 React 팀은 breaking change 를 최소화 하려고 노력했으며 (나 또한 그렇게 느끼고 있다) 실제 사용자가 느끼기에 바꾸어야 하는 코드는 그리 많지 않을 것이다. 단적으로 나는 블로그를 React 17 로 업데이트 하는 과정에서 babel, typescript 설정 등을 제외한 소스 코드를 토시 하나 바꾸지 않아도 되었다. React 팀은 앞으로 나올 Concurrent mode 등 새로운 React 의 기능을 더 빠르고 유연하게 도입할 수 있도록, 많은 React 사용자가 이를 대비할 수 있도록 적절한 포석을 두었다. 아마도 React 개발팀과 contributors의 이런 고민과 발전이 다른 Frontend framework / library 와 궤를 달리하는 인기, 사용률을 React에 가져다 준 것은 아닐까 생각이 된다.

시간이 허락한다면 이후엔 Concurrent mode 에 대해 심도있는 글을 써보겠다. 아직 stable 버전에 포함되어 있지 않지만 분명 hook, React 17 이후로 또 한 번 큰 변화를 가져올 업데이트로 예상하고 있다.