10장. 리액트 17과 18의 변경 사항 살펴보기
리액트 17 버전
16 버전과 다르게 새롭게 추가된 기능이 없으며 호환성이 깨지는 변경 사항, 즉 기존에 사용하던 코드의 수정을 필요로 하는 변경 사항을 최소화했다.
- 리액트의 점진적인 업그레이드
- 리액트는 ‘유의적 버전’을 기반으로 업데이트를 거치고 있었다. 즉, 새로운 버전이 릴리스되면 이전 버전에서의 API 제공을 완전히 중단해 버리고, 전체 애플리케이션을 새롭게 업그레이드를 해야 했다.
- 그러나 이제 ‘점진적인 업그레이드’가 가능해진다. 즉, 전체 애플리케이션 트리는 리액트 17이지만 일부 트리와 컴포넌트에 대해서만 리액트 18을 선택할 수 있다.
- 일종의 업데이트를 위한 업데이트
- 리액트 17 애플리케이션은 내부에서 리액트 16을 게으르게(lazy) 불러온다. 불러오는 과정에서 리액트 16을 위한 별도의 루트 요소를 만들고, 여기에 불러온 리액트 16 모듈을 렌더링하는 구조로 구성돼있다. 이렇게 불러온 결과, 서로 렌더링하는 과정에서 버전의 불일치로 인한 에러도 발생하지 않고 하나의 웹 사이트에서 두 개의 리액트가 존재할 수 있다.
- 이벤트 위임 방식의 변경
- 이벤트 위임 : 리액트는 이벤트 핸들러를 해당 이벤트 핸들러를 추가한 각각의 DOM 요소에 부탁하는 것이 아니라, 이벤트 타입(click, change) 당 하나의 핸들러를 루트에 부착한다. 즉, 이벤트 단계의 원리를 활용해 이벤트를 상위 컴포넌트에만 붙이는 것을 의미한다.
- 이벤트 구성 단계:
- 캡쳐(capture) : 이벤트 핸들러가 트리 최상단 요소에서부터 시작해서 실제 이벤트가 발생한 타깃 요소까지 내려가는 것
- 타깃(target) : 이벤트 핸들러가 타깃 노드에 도달하는 단계. 이 단계에서 이벤트가 호출된다.
- 버블링(bubbling) : 이벤트가 발생한 요소에서부터 시작해 최상위 요소까지 다시 올라간다.
- 리액트는 최초 릴리스부터 이벤트 위임을 적극적으로 사용했으나, 16버전까지는 모두 document에서 수행되고 있었다.
- 그러나 17부터는 이벤트 위임이 모두 리액트 컴포넌트 최상단 트리, 즉 루트 요소로 바뀌었다.
- 점진적인 업그레이드 지원, 그리고 다른 바닐라 자바스크립트 코드 또는 jQuery 등이 혼재돼 있는 경우 혼란을 방지하기 위해서다. 즉, 이벤트 버블링으로 인한 혼선을 방지할 수 있다.
- import React from ‘react’가 더 이상 필요 없다: 새로운 JSX transform
-
JSX는 브라우저가 이해할 수 있는 코드가 아니므로 바벨이나 타입스크립트를 활용해 JSX를 실행하기 위해 일반적인 자바스크립트로 변환하는 과정이 꼭 필요하다.
-
16버전 까지는 이러한 JSX 변환을 사용하기 위해 코드 내에서 React를 사용하는 구문이 없더라도
import React from ‘react’
가 필요했다. -
그러나 17부터는 이러한 import 구문 없이도 변환할 수 있게 됐다. 이로써 번들링 크기를 약간 줄일 수 있고, 컴포넌트 작성을 더욱 간결하게 해준다.
-
한 번에 모두 삭제하는 방볍은
npx react-codemod update-react-imports
- 이벤트 풀링 제거
-
리액트에는 이벤트를 처리하기 위한 SyntheticEvent라는 이벤트가 있는데, 이 이벤트는 브라우저의 기본 이벤트를 한 번 더 감싼 이벤트 객체다. 리액트는 이렇게 브라우저 기본 이벤트가 아닌 한 번 래핑한 이벤트를 사용하기 때문에 이벤트가 발생할 때마다 이 이벤트를 새로 만들어야 했고, 그 과정에서 항상 새로 이벤트를 만들 때마다 메모리 할당 작업이 일어날 수밖에 없다. 또한 메모리 누수를 방지하기 위해 이렇게 만든 이벤트를 주기적으로 해제해야 하는 번거로움도 있다.
-
여기서 이벤트 풀링이란 SyntheticEvent 풀을 만들어서 이벤트가 발생할 때마다 가져오는 것을 의미한다.
-
이벤트 풀링의 원리
- 이벤트 핸들러가 이벤트를 발생시킨다.
- 합성 이벤트 풀에서 합성 이벤트 객체에 대한 참조를 가져온다.
- 이 이벤트 정보를 합성 이벤트 객체에 넣어준다.
- 유저가 지정한 이벤트 리스너가 실행된다.
- 이벤트 객체가 초기화되고 다시 이벤트 풀로 돌아간다.
-
비동기 코드로 이벤트 핸들러에 접근하기 위해서는 별도의 메모리 공간에 합성 이벤트 객체를 할당해야 한다는 점, 그리고 모던 브라우저에서는 이와 같은 방식이 성능 향상에 크게 도움이 안 된다는 점 때문에 이러한 이벤트 풀링 개념이 삭제됐다.
- useEffect 클린업 함수의 비동기 실행
- 16버전 까지는 동기적으로 처리됐다. 때문에 이 클린업 함수가 완료되기 전까지는 다른 작업을 방해하므로 불필요한 성능 저하로 이어지는 문제가 존재했다.
- 그러나 17 버전 부터는 화면이 완전히 업데이트된 이후에 클린업 함수가 비동기적으로 실행된다. 이로써 약간의 성능적인 이점을 볼 수 있게 됐다.
- 컴포넌트의 undefined 반환에 대한 일관적인 처리
- 리액트 16에서 forwardRef나 memo에서 undefined를 반환하는 경우에는 별다른 에러가 발생하지 않는다는 문제가 있었다.
- 그러나 17부터는 에러가 정상적으로 발생한다.
리액트 18 버전
‘동시성 지원’이 가장 큰 변경점이다.
- 새로 추가된 훅, useId
- useId : 컴포넌트별로 유니크한 값을 생성하는 새로운 훅
- 17버전 : 컴포넌트 내부에서 사용할 수 있는 유니크한 값을 생성하는 것은 생각보다 쉽지 않은 일이다. 재사용하는 경우도 고려해야 하며, 서버 사이드 렌더링 환경에서 하이드레이션이 일어날 때도 서버와 클라이언트가 동일한 값을 가져야 에러가 발생하지 않으므로 이러한 점도 고려해야 한다.
- 18버전 : useId를 사용해 클라이언트와 서버에서 불일치를 피하면서 컴포넌트 내부의 고유한 값을 생성할 수 있게 됐다.
- useId가 생성하는 값은 :로 감싸져 있는데, 이는 CSS 선택자나 querySelector에서 작동하지 않도록 하기 위한 의도적인 결과다.
- 새로 추가된 훅, useTransition
- useTransition : UI 변경을 가로막지 않고 상태를 업데이트할 수 있는 리액트 훅
- 상태 업데이트를 긴급하지 않은 것으로 간주해 무거운 렌더링 작업을 조금 미룰 수 있으며, 사용자에게 조금 더 나은 사용자 경험을 제공할 수 있다.
- 리액트 18의 변경사항의 핵심 중 하나인 ‘동시성(concurrency)’을 다룰 수 있는 새로운 훅이다.
- useTransition과 같은 동시성을 지원하는 기능을 사용하면 느린 렌더링 과정에서 로딩 화면을 보여주거나 혹은 지금 진행 중인 렌더링을 버리고 새로운 상태값으로 다시 렌더링하는 등의 작업을 할 수 있게 된다. 이는 앱의 성능을 향상시킬 뿐만 아니라 사용자에게 좀 더 자연스러운 서비스를 경험할 수 있게 해준다.
- 컴포넌트에서만 사용 가능한 훅이다. 훅을 사용할 수 없는 상황이라면 단순히 startTransition을 바로 import할 수 있다.
- useTransition 사용시 주의점
- startTransition 내부는 반드시 setState와 같은 상태를 업데이트하는 함수와 관련된 작업만 넘길 수 있다.
- startTransition으로 넘겨주는 상태 업데이트는 다른 모든 동기 상태 업데이트로 인해 실행이 지연될 수 있다.
- startTransition으로 넘겨주는 함수는 반드시 동기 함수여야 한다.
- 새로 추가된 훅, useDeferredValue
- useDeferredValue : 리액트 컴포넌트 트리에서 리렌더링이 급하지 않은 부분을 지연할 수 있게 도와주는 훅
- 특정 시간동안 발생하는 이벤트를 하나로 인식해 한 번만 실행하게 해주는 ‘디바운스’와 비슷하지만 디바운스 대비 useDeferredValue만이 가진 장점이 몇가지 있다.
- 디바운스는 고정된 지연 시간을 필요로 하지만 useDeferredValue는 고정된 지연 시간 없이 첫 번째 렌더링이 완료된 이후에 이 useDeferredValue로 지연된 렌더링을 수행한다. 그러므로 이 지연된 렌더링은 중단할 수도 있으며, 사용자의 인터랙션을 차단하지도 않는다.
- useDeferredValue와 useTransition은 방식에만 차이가 있을 뿐, 지연된 렌더링을 한다는 점에서는 모두 동일한 역할을 하는 것을 알 수 있다.
- 낮은 우선순위로 처리해야 할 작업에 대해 직접적으로 상태를 업데이트할 수 있는 코드에 접근할 수 있다면 useTransition
- 컴포넌트의 props와 같이 상태 업데이트에 관여할 수는 없고 오로지 값만 받아야 하는 상황이라면 useDeferredValue
- 새로 추가된 훅, useSyncExternalStore
- useSubscription의 구현이 리액트 18에 이르러서 useSyncExternalStore로 대체되었다.
- 테어링(tearing) : 리액트에서는 하나의 state 값이 있음에도 서로 다른 값(보통 state나 props의 이전과 이후)을 기준으로 렌더링되는 현상
- 리액트 17에서는 테어링이 일어날 여지가 없었다. 그러나 리액트 18에서는 앞서 useTransition, useDeferredValue 훅처럼 렌더링을 일시 중지하거나 뒤로 미루는 등의 최적화가 가능해지면서 동시성 이슈가 발생할 수 잇다.
- 물론, 리액트에서 관리하는 state라면 useTransition이나 useDeferredValue 예제와 같이 내부적으로 이러한 문제를 해결하기 위한 처리를 할 수 있지만 리액트에서 관리할 수 없는 외부 데이터 소스에서라면 문제가 달라진다.
- 리액트에서 관리할 수 없는 외부 데이터 소스란 리액트의 클로저 범위 밖에 있는, 관리 범위 밖에 있는 값들을 말한다. 글로벌 변수, document.body, window.innerWidth, DOM, 리액트 외부에 상태를 저장하는 외부 상태 관리 라이브러리 등이 모두 여기에 해당한다.
- 이 외부 데이터 소스에 리액트에서 추구하는 동시성 처리가 추가돼있지 않다면 테어링 현상이 발생할 수 있는데, 이 문제를 해결하기 위한 훅이 바로 useSyncExternalStore이다.
- 첫 번째 인수는 subscribe로, 콜백 함수를 받아 스토어에 등록하는 용도로 사용된다. 스토어에 있는 값이 변경되면 이 콜백이 호출돼야한다. 그리고 useSyncExternalStore는 이 훅을 사용하는 컴포넌트를 리렌더링한다.
- 두 번째 인수는 컴포넌트에 필요한 현재 스토어의 데이터를 반환하는 함수다. 이 함수는 스토어가 변경되지 않았다면 매번 함수를 호출할 때마다 동일한 값을 반환해야 한다. 스토어에서 값이 변경됐다면 이 값을 이전 값과 Object.is로 비교해 정말로 값이 변경됐다면 컴포넌트를 리렌더링한다.
- 마지막 인수는 옵셔널 값으로, 서버 사이드 렌더링 시에 내부 리액트를 하이드레이션하는 도중에만 사용된다. 서버 사이드에서 렌더링되는 훅이라면 반드시 이 값을 넘겨줘야 하며, 클라이언트의 값과 불일치가 발생할 경우 오류가 발생한다.
- 새로 추가된 훅, useInsertionEffect
- 기본적인 훅 구조는 useEffect와 동일하다. 다만 차이점은 실행 시점인데, useInsertionEffect는 DOM이 실제로 변경되기 전에 동기적으로 실행된다. 이 훅 내부에 스타일을 삽입하는 코드를 집어넣음으로써 브라우저가 레이아웃을 계산하기 전에 실행될 수 있게끔 해서 좀 더 자연스러운 스타일 삽입이 가능해진다.
- useLayoutEffect는 모든 DOM의 변경 작업이 다 끝난 이후에 실행되는 반면 useInsertionEffect는 이러한 DOM의 변경 작업 이전에 실행된다. 이러한 차이는 브라우저가 다시금 스타일을 입혀서 DOM을 재계산하지 않아도 된다는 점에서 매우 크다고 볼 수 있다.
- 라이브러리를 작성하는 경우가 아니라면 참고만 하고 실제 애플리케이션 코드에는 가급적 사용하지 않는 것이 좋다.
- react-dom/client
- 클라이언트에서 리액트 트리를 만들 때 사용되는 API가 변경됐다.
- createRoot : render 메서드를 대체할 새로운 메서드다.
- hydrateRoot : 서버 사이드 렌더링 애플리케이션에서 하이드레이션을 하기 위한 새로운 메서드다.
- 이 두 API는 새로운 옵션인 onRecoverableError를 인수로 받는다. 이 옵션은 리액트가 렌더링 또는 하이드레이션 과정에서 에러가 발생했을 때 실행하는 콜백 함수다.
- react-dom/server
- rederToPipeableStream : 리액트 컴포넌트를 HTML로 렌더링하는 메서드. 스트림을 지원하는 메서드로, HTML을 점진적으로 렌더링하고 클라이언트에서는 중간에 script를 삽입하는 등의 작업을 할 수 있다. 이를 통해 서버에서는 Suspense를 사용해 빠르게 렌더링이 필요한 부분을 먼저 렌더링할 수 있고, 값비싼 연산으로 구성된 부분은 이후에 렌더링되게끔 할 수 있다. rederToPipeableStream을 쓰면 최초에 브라우저는 아직 불러오지 못한 데이터 부분을 Suspense의 fallback으로 받는다.
- 기존 renderToNodeStream의 문제는 무조건 렌더링을 순서대로 해야 하고, 그리고 그 순서에 의존적이기 때문에 이전 렌더링이 완료되지 않는다면 이후 렌더링도 끝나지 않는다는 것이다. 이에 지연된다는 문제가 있다. 그러나 rederToPipeableStream를 활용하면 순서나 오래 걸리는 렌더링에 영향받을 필요 없이 빠르게 런더링을 수행할 수 있게 된다.
- renderToReadableStream : rederToPipeableStream이 Node.js 환경에서의 렌더링을 위해 사용된다면, renderToReadableStream은 웹 스트림을 기반으로 작동한다는 차이가 있다.
- 자동 배치(Automatic Batching)
- 리액트가 여러 상태 업데이트를 하나의 리렌더링으로 묶어서 성능을 향상시키는 방법을 의미한다.
- 리액트 17 이하의 과거 버전의 경우 이벤트 핸들러 내부에서는 이러한 자동 배치 작업이 이뤄지고 있었지만 Promise, setTimeout 같은 비동기 이벤트에서는 자동 배치가 이뤄지고 있지 않았다. 즉, 동기와 비동기 배치 작업에 일관성이 없었다.
- 이를 보완하기 위해 리액트 18 버전부터는 루트 컴포넌트를 createRoot를 사용해서 만들면 모든 업데이트가 배치 작업으로 최적화할 수 있게 됐다.
- 이러한 자동 배치를 리액트 18에서도 하고 싶지 않거나 이러한 작동 방식이 기존 코드에 영향을 미칠 것으로 에상된다면 flushSync를 사용하면 된다.
- 더욱 엄격해진 엄격 모드
- 리액트의 엄격 모드
- 더 이상 안전하지 않은 특정 생명주기를 사용하는 컴포넌트에 대한 경고
- 문자열 ref 사용 금지
- findDOMNode에 대한 경고 출력
- 구 Context API 사용 시 발생하는 경고
- 예상치 못한 부작용 검사
- 리액트 18에서 추가된 엄격모드
- 컴포넌트가 마운트 해제된 상태에서도 (컴포넌트가 렌더링 트리에 존재하지 않는 상태에서도) 컴포넌트 내부의 상태값을 유지할 수 있는 기능을 제공할 예정
- Suspense 기능 강화
- Suspense : 컴포넌트를 동적으로 가져올 수 있게 도와주는 기능
- React.lazy는 컴포넌트를 첫 번째 렌더링 시에 불러오지 않고, 최초 렌더링 이후에 컴포넌트를 지연시켜 불러오는 역할을 한다.
- Suspense는 React.lazy를 통해 지연시켜 불러온 컴포넌트를 렌더링하는 역할을 한다.
- 즉, 지연 컴포넌트를 로딩하기 전에는 fallback을 보여주고, 이 lazy로 불러온 컴포넌트가 지연 로딩이 완료되면 fallback 대신 비로소 해당 컴포넌트를 보여주게 된다.
- 이처럼 lazy와 Suspense는 한 쌍으로 사용됐고, 애플리케이션에서 상대적으로 중요하지 않은 컴포넌트를 분할해 초기 렌더링 속도를 향상시키는 데 많은 도움을 줬다.
- 그러나 18 이전의 Suspense는 몇 가지 문제점이 있었다.
- 기존의 Suspense는 컴포넌트가 아직 보이기도 전에 useEffect가 실행되는 문제가 존재했다.
- Suspense는 서버에서 사용할 수 없었다.
- 리액트 18에서 변경된 Suspense의 내용은 다음과 같다.
- 아직 마운트되기 직전임에도 effect가 빠르게 실행되는 문제가 수정됐다.
- Suspense로 인해 컴포넌트가 보이거나 사라질 때도 effect가 정상적으로 실행된다.
- Suspense를 이제 서버에서도 실행할 수 있게 된다.
- Suspense 내에 스로틀링이 추가됐다.
-
인터넷 익스플로러 지원 중단에 따른 추가 폴리필 필요
-
그 밖에 알아두면 좋은 변경사항
- 이제 컴포넌트에서 undefined를 반환해도 에러가 발생하지 않는다. undefined 반환은 null 반환과 동일하게 처리된다.
- 이와 마찬가지로
<Suspense fallback={undefined}>
도 null과 동일하게 처리된다. - renderToNodeStream이 지원 중단됐다. 그 대신 renderToPipeableStream을 사용하는 것이 권장된다.