[React] Deep Dive 모던 리액트(25) 리액트 17 변경점
FrontEnd/Deep Dive

[React] Deep Dive 모던 리액트(25) 리액트 17 변경점

728x90

 

 

리액트 17버전은 추가된 기능은 없으며 호환성이 깨지는 변경사항 즉 기존 사용하던 코드의 수정을 필요로 하는 변경사항을 최소화 한 점을 가장 큰 변화로 삼는다.

 

리액트 팀 피셜로 10만개 이상 컴포넌트 중 호환성이 깨지는 변경사항에 영향을 받은건 20개 미만으로 대부분 어플리케이션이 문제 없이 17 버전으로 업그레이드 할 수 있을 것이라고 한다.

 

리액트가 버전을 업그레이드 하는 경우는 이전 버전에서 호환되지 않는 API가 있거나 새 버전을 사용하는데 있어 작동방식이 달라지는 경우다. 즉 리액트팀은 새로운 버전이 릴리스되면 이전 버전의 API 제공을 완전히 중단해버렸다. 이런 전략은 리액트 개발팀에게는 유용하겠지만 오래된 코드를 가지고 돌아가는 실제 웹 애플리케이션에는 그렇게 좋지 못한 일이다. 따라서 레거시 애플리케이션을 관리하는 개발자는 선택의 기로에 항상 서 있었다.

 

리액트 17버전부터는 점진적인 업그레이드를 택했다. 17버전에서 18버전으로 리액트를 업그레이드해도 17버전에서 머물러 있는것이 가능해졌다. 심지어 한 애플리케이션 안에 여러 버전의 리액트가 존재하는 것이 가능해진다.

 

(책 저자가 친절하게 관련 프로젝트를 하나 만들어뒀다. 한번 분석해보는 것도 좋은 것 같다)

https://github.com/wikibook/react-deep-dive-example/blob/main/chapter10/react-gradual-demo/src/legacy/createLegacyRoot.js

 

두가지 버전의 리액트가 동시에 실행된다.

 

 

 

 

이벤트 위임 방식의 변경

리액트에서 이벤트에서 이벤트가 어떻게 추가되는지를 먼저 이해해보자. 

 

import React, { useEffect, useRef } from "react";

const Button = () => {
  const buttonRef = useRef<HTMLButtonElement | null>(null);
  useEffect(() => {
    if (buttonRef.current) {
      buttonRef.current.onclick = function click() {
        alert("안녕하세요!");
      };
    }
  }, []);

  function 안녕하세요() {
    alert("안녕하세요!");
  }

  return (
    <div>
      <button onClick={안녕하세요}>리액트 버튼</button>
      <button ref={buttonRef}>그냥 버튼</button>
    </div>
  );
};

export default Button;

 

위와 같이 같은 기능을 하는 두가지 버튼이 있다.

 

리액트 버튼은 일반적으로 리액트 애플리케이션에서 DOM에 이벤트를 추가하는 방식으로 하였고

그냥 버튼은 직접 DOM을 참조해서 가져온 다음 DOM의 onclick에 함수를 직접 추가하는 이벤트 핸들러 방식을 사용했다.

 

 

 

 

"그냥버튼"은 위와 같이 버튼의 이벤트 리스너의 click에 추가되어 있는 것을 확인할 수 있다. 그렇다면 리액트로 부착한 이벤트는 어떨까?

 

 

 

 

 

핸들러에 noop이라는 핸들러가 추가되어 있다. noop은 무슨 역할을 할까?

 

개발자옵션의 소스탭에서 noop을 검색하면 이름 그대로 아무런 작동을 하지 않는 함수란 것을 알 수 있다.

 

 

즉 리액트는 이벤트 핸들러를 DOM요소에 부탁하는 것이 아니라 이벤트 타입당 하나의 핸들러를 루트에 부착한다. 이를 이벤트 위임이라고 부른다. 먼저 이벤트가 어떤 단계로 구성되어 있는지 다시 한번 생각해보자.

 

1. 캡처(capture) 이벤트 핸들러가 트리 최상단 요소에서부터 시작해서 이벤트가 발생한 타깃까지 내려가는 것

2. 타깃(target) 이벤트 핸들러가 타깃 노드에 도달하는 단계, 이벤트가 호출된다.

3. 버블링(bubbling) 이벤트가 발생한 요소에서부터 최상위 요소까지 다시 올라간다.

 

 

이벤트 위임이란 위와같은 이벤트 단계의 원리를 이용하여 이벤트를 상위 컴포넌트에만 붙이는 것을 의미한다.

 

<ul>
    <li />
    <li />
    <li />
    <li />
</ul>

 

 

 

만약 모든 li 요소에 이벤트가 필요한 상황이라고 가정해보자. li마다 이벤트를 다 추가할 수도 있겠지만 ul에만 이벤트를 추가하는 방법도 있다. 이 경우 이점이 분명 존재한다.

 

ul의 자식에 li가 추가&삭제되어도 이벤트 핸들러를 수정할 필요가 없고, 이벤트 추가도 한번만 하면 되기 때문이다.

 

리액트는 이러한 이벤트 위임을 적극적으로 사용해왔다. 이벤트 핸들러를 각 요소가 아닌 document에 연결하여 이벤트를 조금 더 효율적으로 관리한다.

 

이러한 이벤트 위임이 리액트 16버전까지는 모두 document에서 수행되고 있었다.

 

import React from "react";

const Component = () => {
  function 안녕하세요() {
    alert("안녕하세요!");
  }

  return <button onClick={안녕하세요}>리액트 버튼</button>;
};

/* STYLE */

export default Component;

 

즉 위와같이 리액트 버튼을 만들면 document에 이벤트 위임이 실행되었다.

 

리액트 17버전부터는 이러한 이벤트 위임이 document가 아닌 루트 요소로 바뀌었다.

 

 

div#root

 

 

이렇게 바꾼 이유는 점진적인 업그레이드 지원과 다른 바닐라 자바스크립트 코드 등이 혼재되어 있는 경우 혼란을 방지하기 위해서이다. 초반에 설명한 것 처럼 여러 리액트 버전이 한 서비스에 공존한다고 생각해보자. 만약 모든 이벤트가 기존 방식대로 document에 달려있는 경우를 생각해 보자.

 

 

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

function React1614() {
  function App() {
    function 안녕하세요() {
      alert("안녕하세요! 16.14");
    }
    return <button onClick={안녕하세요}>dkssudgktpdy</button>;
  }
  return ReactDOM.render(<App />, document.getElementById("React-16-14"));
}


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

function React168() {
  function App() {
    function 안녕하세요() {
      alert("안녕하세요! 16.8");
    }
    return <button onClick={안녕하세요}>dkssudgktpdy</button>;
  }
  return ReactDOM.render(<App />, document.getElementById("React-16-8"));
}

 

위 코드는 아래와 같이 렌더링된다.

 

<html>
	<body>
    	<div id="React-16-14">
        	<div id="React-16-8"></div>
        </div>
    </body>
</html>

 

리액트 16의 이벤트 위임원리에 따라서 모든 이벤트는 document에 부착된다.

 

이 경우 React168컴포넌트가 e.stopPropagation으로 이벤트 전파를 막는다면 이미 모든 이벤트가 document에 있으므로 document의 이벤트 전파는 막을 수 없고 따라서 React1614에서도 해당 이벤트를 전달받게 된다.

 

이 경우 생길 수 있는 문제를 발생하기 위해서 이벤트 위임의 대상을 document에서 컴포넌트의 최상위로 변경했다.

 

이런 특성때문에 생기는 재미있는 현상이 또 있다. 리액트 16버전에서 document와 리액트가 렌더링되는 루트컴포넌트 사이에 이벤트 전파를 막으면 모든 이벤트 핸들러가 작동하지 않았다. 이런 부작용 또한 리액트 17버전이 들어오면서 사라졌다.

 

 

https://ko.legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html

 

 

이러한 변경때문에 코드에서 document.addEventListener를 활용하여 리엑트의 모든 이벤트를 확인하는 코드가 있다면 이벤트가 전파되지 않는 경우가 존재할 수 있으므로 확인해봐야 한다.

 

 

새로운 JSX transform

JSX는 브라우저가 이해할 수 있는 코드가 아니므로 바벨이나 TS를 통하여 일반적인 JS로 변환하는 과정이 꼭 필요하다. 16버전까지는 이러한 JSX 변환을 사용하기 위해서 import React from 'react' 구문이 필수였다.

 

 

17버전부터는 바벨과 협력하여 import 구문 없이도 JSX를 변환할 수 있게 되었다. 편리성 이외에도 번들링 크기를 약간 줄일 수 있게 되었다.

 

리액트 16버전까지는 JSX가 아래와 같은 방식으로 변환되었다고 한다.

 

const Component = (
	<div>
    	<span>hello world</span>
    </div>
)

var Component = React.createElement(
	'div',
    null,
    React.createElement('span',null,'hello world'),
)

 

 

현재 17버전에서는 아래와 같이 변한다.

 

'use strict'

var _jsxRuntime = require('react/jsx-runtime')

var Component = (0, _jsxRuntime.jsx)('div', {
	childeren : (0, _jsxRuntime.jsx)('span', {
    children : 'hello world',
    }),
})

 

 

 

React.createElement가 사라지고 require구문이 생겼다. JSX구문을 변환할때 필요한 모듈을 자동으로 불러오기 때문에 

import React from 'react'를 작성하지 않아도 되는 것이다.

 

 

 

이벤트 풀링 제거

리액트 16에는 이벤트 풀링리나느 기능이 있었다. 리액트에는 이벤트를 처리하기 위한 객체인 SyntheticEvent라는 이벤트가 있다. 이 이벤트는 브라우저의 기본 이벤트를 한번 더 감싼 이벤트 객체로 리액트는 해당 객체를 사용하기 때문에 이 이벤트를 새로만들어야 했다. 즉 이벤트를 만들 때마다 메모리 할당 작업이 있었고 메모리 누수 방지를 위해 이벤트를 해제해야 하는 번거로움도 있었다.

 

이벤트 풀링이란 SyntheticEvent 풀을 만들어서 이벤트가 발생할 때마다 가져오는것을 의미했다.

 

1. 이벤트 핸들러가 이벤트 발생

2. 합성 이벤트풀에서 합성 이벤트 객체에 대한 참조를 가져옴

3. 이 이벤트 정보를 합성 이벤트 객체에 넣어줌

4. 유저가 지정한 이벤트 리스너 실행

5. 이벤트 객체가 초기화되고 다시 이벤트 풀로 돌아간다.

 

이런 코드는 이벤트 풀의 합성 이벤트를 재사용할 수 있다는 장점이 있지만 풀에서 이벤트를 가져오고 다시 초기화하는 방식은 직관적이지 않다는 단점이 있었다.

 

export default function App(){
	const [value,setValue] = useState('')
    function handleChange(e : ChangeEvent<HTMLInputElement>) {
    	setValue(() => {
    		return e.target.value
        })
    }
    return <input onChange={handleChange} value={value} />
}

 

 

위코드는 에러를 발생시킨다. 리액트 16이하 버전에서는 이벤트 풀링 방식을 통해 서로 다른 이벤트간 이벤트 객체를 재사용하는데 이 재사용하는 사이에 모든 이벤트 필드를 null로 만들기 때문이다.

 

이벤트 핸들러를 호출한 SyntheticEvent는 재사용을 위하 null로 쵝화된다. 따라서 비동기 코드 안에서 SyntheticEvent인 e에 접근하면 초기화된 이후이기 때문에 null을 얻게되었다.

 

이를 해결하기 위해서 e.persist()와 같은 작업이 필요했었다.

 

export default function App(){
	const [value,setValue] = useState('')
    function handleChange(e : ChangeEvent<HTMLInputElement>) {
    	e.persist()
    	setValue(() => {
    		return e.target.value
        })
    }
    return <input onChange={handleChange} value={value} />
}

 

 

이러한 부작용에 더해서 해당 방식이 성능향상에 크게 도움이 안된다는 점이 합쳐져 이벤트 풀링 개념이 삭제되었다. 따라서 이제 이벤트 객체에 접근하는 경우 비동기와 동기 상관없이 일관적인 코딩이 가능해졌다.

 

 

useEffect 클린업 함수의 비동기 실행

 

 

리액트 useEffect의 클린업 함수는 원래 동기적으로 처리했다. 따라서 이 클린업 함수가 실행되기 전에는 어떤 작업도 실행되지 않았기 때문에 성능저하가 발생했었다.

 

리액트 17버전부터는 화면이 완전히 업데이트 된 이후에 클린업 함수가 비동기적으로 실행된다. 조금더 정확하게는 컴포넌트의 커밋단계가 끝날때까지 기다리게 된다.

 

 

컴포넌트 undefined 반환에 대한 처리

 

리액트 16,17버전은 모두 컴포넌트 내부에서 undefined를 반환하면 오류를 발생한다. 이는 의도치않은 실수를 방지하기 위해서였다. 리액트 16에서는 forwardRef나 memo에서 undefined를 반환하는 경우 오류를 뱉지 않았는데 이를 새로 수정했다.

 

참고로 리액트 18부터는 undefined를 반환해도 에러를 뱉지 않는다고 한다.

 

 

 

 

 

 

 

 

 

728x90