10장 리액트 17과 18의 변경 사항 살펴보기
대부분의 리액트 프로젝트들이 최소한 훅과 함수 컴포넌트를 사용하기 위해 16.8 버전을 기반으로 개발됐고, 이것이 고착화되어 더 이상 버전 업하지 않고 운영되고 있다. 그래서 리액트 버전 17과 18 버전은 비교적 많이 사용하고 있진 않다.
10.1 리액트 17 버전 살펴보기
- 16 버전과 다르게 새롭게 추가된 기능이 없으며 호환성이 깨지는 변경 사항, 즉 기존에 사용하던 코드의 수정을 필요로 하는 변경 사항을 최소화했다는 점을 가장 큰 특징으로 꼽는다.
- 그래서 16 버전에서 17 버전의 버전 업은 큰 부담없이 할 수 있는 작업이라 볼 수 있다.
10.1.1 리액트의 점진적인 업그레이드
16버전까지의 업그레이드 전략
- 리액트의 16버전까지는 유의적 버전 전략에 맞춰 업데이트되고 있었다. 그래서 새로운 주 버전이 릴리즈되면 이전 버전에서의 API 제공을 완전히 중단해 버리고, 전체 애플리케이션을 새롭게 업그레이드하기를 요구하고 있었다.
- 따라서 레거시 애플리케이션을 관리하는 개발자는 전체 웹 서비스가 새로운 버전으로 완전히 넘어가버리거나, 계속 현재(과거 버전)에 머물러 있어야 한다.
17버전 이후부터의 업그레이드 전략
- 17버전부터는 점진적인 업그레이드가 가능해진다. 즉, 두 개 이상의 리액트 버전이 웹 애플리케이션에 존재할 수 있다.
- 2개 이상의 버전이 있는 것이 이상적인 방법은 아니지만, 한 번에 버전을 올리기에 부담이 되는 규모가 큰 애플리케이션의 경우에는 충분히 고려해 볼만한 선택지가 될 수 있다.
10.1.2 이벤트 위임 방식의 변경
리액트는 이벤트 핸들러를 해당 이벤트 핸들러를 추가한 각각의 DOM 요소에 부탁하는 것이 아니라, 이벤트(click, change) 당 하나의 핸들러를 루트에 부착한다. 이를 이벤트 위임이라고 한다. 이벤트 위임을 이해하려면 이벤트가 어떤 단계로 구성돼 있는지 먼저 알아야 한다.
이벤트의 단계
- 캡처
이벤트 핸들러가 트리 최상단 요소에 부터 시작해서 실제 이벤트가 발생한 타깃 요소까지 내려가는 것을 의미한다.
- 타깃
이벤트 핸들러가 타깃 노드에 도달하는 단계다. 이 단계에서 이벤트가 호출된다.
- 버블링
이벤트가 발생한 요소에서부터 시작해 최상위 요소까지 다시 올라간다.
<ul>
<li />
<li />
<li />
<li />
</ul>
- 만약 모든 li 요소에 이벤트가 필요다면 이 li에 이벤트를 다 추가할 수 있지만 ul에만 추가해서 이벤트를 위임한다면 ul의 자식에 li가 추가 또는 삭제되더라도 이벤트 핸들러도 똑같이 추가 수정할 필요도 없고, 이벤트 추가를 한 번만 하면 되므로 좀 더 효율적으로 관리할 수 있게 된다.
- 리액트는 최초 릴리스부터 이러한 이벤트 위임을 적극적으로 사용했다. 앞서 언급했듯이 리액트는 이벤트 핸들러를 각 요소가 아닌 document에 연결해서 이벤트를 좀 더 효율적으로 관리한다.
17버전부터 바뀐 이벤트 위임 요소
- 리액트 17부터는 이러한 이벤트 위임이 모두 document가 아닌 리액트 컴포넌트 최상단 트리, 즉 루트 요소로 바뀌었다.
- 그 이유는 점진적인 업그레이드를 지원하고, 바닐라 자바스크립트 코드 또는 jQuery 등이 혼재돼 있는 경우 혼란을 방지하기 위해서다.

16버전까지는 import React from 'react'가 필요한 리액트
- JSX는 브라우저가 이해할 수 있는 코드가 아니므로 바벨이나 타입스크립트를 활용해 JSX를 실행하기 위해 일반적으로 자바스크립트로 변환하는 과정이 꼭 필요하다.
- 이 변환 작업이 개발자에게 보이지 않는 리액트 단에서 이뤄지고 있다. 16버전까지는 이러한 JSX 변환을 사용하기 위해 코드 내에서 React를 사용하는 구문이 없더라도 import React from 'react'가 필요했다.
const Component = (
<div>
<span>hello world</span>
</div>
);
var Component = React.createElement(
"div",
null,
React.craeteElement("span", null, "hello world")
);
- 변환 결과를 보면 왜 import 문이 필요한지 알 수 있다. JSX 코드를 변경은 해주지만 React.createElement를 수행할 때 필요한 import문까지는 추가해주지 않기 때문이다.
17버전 이후부터는 import 문이 필요없음
- 리액트 17부터는 바벨과 협력해 import 구문 없이도 JSX를 변환할 수 있게 됐다.
- 이 새로운 변환 식은 import문이 필요 없다는 장점 외에도, 불필요한 import 구문을 삭제해 번들링 크기를 약간 줄일 수 있고, 컴포넌트 작성을 더욱 간결하게 해준다.
- 이전 코드는 17버전에서는 아래와 같이 변환된다
"use strict";
var _jsxRuntime = require("react/jsx-runtime");
var Component = (0, _jsxRuntime.jsx)("div", {
children: (0, _jsxRuntime.jsx)("span", {
children: "hello world",
}),
});
- React.createElement가 제거되고, require() 구문이 추가되었다.
- JSX를 변환할 때 필요한 모듈인 react/jsx-runtime을 불러오는 require 구문도 같이 추가되므로 import 문을 작성하지 않아도 된다
10.1.4 그 밖의 주요 변경 사항
이벤트 풀링 제거
- 리액트 16에서는 이른바 이벤트 풀링이라 불리는 기능이 있었다.

-
이벤트 풀링 시스템 순서
- 이벤트 핸들러가 이벤트를 발생시킨다
- 합성 이벤트 풀에서 합성 이벤트 객체에 대한 참조를 가져온다
- 이 이벤트 정보를 합성 이벤트 객체에 넣어준다
- 유저가 지정한 이벤트 리스너가 실행된다
- 이벤트 객체가 초기화되고 다시 이벤트 풀로 돌아간다
-
언뜻 보기에는 이벤트 풀에 잇는 합성 이벤트를 반복적으로 사용할 수 있어서 효과적으로 보이지만 풀에서 이벤트를 받아오고, 이벤트가 종료되자마자 다시 초기화하는 방식은 분명 사용하는 쪽에서 직관적이지 않았다
-
비동기 코드로 이벤트 핸들러를 접근하기 위해서는 별도 메모리 공간에 합성 이벤트 객체를 할당해야 한다는 점, 그리고 모던 브라우저에서는 이와 같은 방식이 성능 향상에 크게 도움이 안 된다는 점 때문에 이벤트 풀링 개념이 삭제됐다.
useEffect 클린업 함수의 비동기 실행
- 리액트의 useEffect에 있는 클린업 함수는 리액트 16 버전까지는 동기적으로 처리됐다. 동기적으로 실행되기 때문에 다른 작업을 실행할 수 없어 불필요한 성능 저하로 이어지는 문제가 존재했다.
- 리액트 17버전부터는 화면이 완전히 업데이트된 이후에 클린업 함수가 비동기적으로 실행된다. 명확히 말하면 클린업 함수는 컴포넌트의 커밋 단계가 완료될 때까지 지연된다.
컴포넌트의 undefined 반환에 대한 일괄적인 처리
- 리액트 16과 17버전은 컴포넌트 내부에서 undefined를 반환하면 오류가 발생한다. 이는 의도치 않게 잘못된 반환으로 이한 실수를 방지하기 위해서다.
- 하지만 리액트 16에서 forwardRef나 memo에서 undefined를 반환하는 경우에는 별다른 에러가 발생하지 않는 문제가 있고, 리액트 17부터는 에러가 정상적으로 발생한다
10.2 리액트 18 버전 살펴보기
리액트 18에서는 리액트 17에서 하지 못했던 다양한 기능들이 추가됐다. 그중 가장 큰 변경점은 동시성 지원이다.
10.2.1 새로 추가된 훅 살펴보기
- 컴포넌트별로 유니크한 값을 생성하는 새로운 훅이다.
- 기존에는 컴포넌트 내부에서 사용할 수 있는 유니크한 값을 생성하는 것은 어려운 일이었다.
- 하나의 컴포넌트가 여러 군데에서 재사용되는 경우도 고려해야 하며, 리액트 컴포넌트 트리에서 컴포넌트가 가지는 모든 값이 겹치지 않고 다 달라야 한다는 제약도 있다.
- 또한 서버 사이드 렌더링 환경에서 하이드레이션이 일어날 때도 서버와 클라이언트에서 동일한 값을 가져야 에러가 발생하지 않으므로 이러한 점도 고려해야 한다.
- useId를 사용하면 클라이언트와 서버에서 불일치를 피하면서 컴포넌트 내부의 고유한 값을 생성할 수 있게 됐다.
- 같은 컴포넌트 임에도 서로 인스턴스가 다르면 다른 랜덤한 값을 만들어 내며, 이 값들이 모두 유니크한다.
- 또한 서버 사이드와 클라이언트 간에 동일한 값이 생성되어 하이드레이션 이슈도 발생하지 않는 것을 알 수 있다.
- UI 변경을 가로막지 않고 상태를 업데이트할 수 있는 리액트 훅이다.
- 상태 업데이트를 긴급하지 않는 것으로 간주해 무거운 렌더링 작업을 조금 미룰 수 있으며, 사용자에게 조금 더 나은 사용자 경험을 제공할 수 있다.
- 리액트 18의 변경 사항의 핵심 중 하나인
동시성을 다룰 수 있는 새로운 훅이다.
- 과거 리액트의 모든 렌더링은 동기적으로 작동해 느린 렌더링 작업이 있을 경우 애플리케이션 전체적으로 영향을 끼쳤다
- useTransistion과 같은 동시성을 지원하는 기능을 사용하면 느린 렌더링 과정에서 로딩 화면을 보여주거나 혹은 지금 진행 중인 렌더링을 버리고 새로운 상태값으로 다시 렌더링하는 등의 작업을 할 수 있게 된다.
- 예시
import { useState, useTransition } from 'react'
export default function TabContainer() {
const [isPending, startTransition] = useTransition()
const [tab, setTab] = useState<Tab>('about');
function selectTab(nextTab:Tab) {
startTransittion(()=> {
setTab(nextTab)
})
}
return (
<>
{}
{isPending ? (
'로딩 중'
):(
<>
{tab === 'about' && <About />}
{tab === 'posts' && <Posts />}
{tab === 'context' && <Context />}
</>
)}
)
}
- 사용 시 주의점
- startTransition 내부는 반드시 setState와 같은 상태를 업데이트하는 함수와 관련된 작업만 넘길 수 있다.
- startTransition으로 넘겨주는 상태 업데이트는 다른 모든 동기 업데이트로 인해 실행이 지연될 수 있다.
- startTransition으로 넘겨주는 함수는 반드시 동기 함수여야 한다.
- 리액트 컴포넌트 트리에서 리렌더링이 급하지 않은 부분을 지연할 수 있게 도와주는 훅이다.
- 디바운스 기법과 비슷하지만 고정된 지연 시간을 필요로 하는 디바운스와 달리 고정된 지연 시간없이 첫 번째 렌더링이 완료된 이후에 이 useDeferredValue로 지연된 렌더링을 수행한다는 장점이 있다
- useTransition과의 차이점
| 차이점 | useTransition | useDeferredValue |
|---|
| 사용 방식 | state 값을 업데이트하는 함수를 감싸서 사용 | state 값 자체만을 감싸서 사용 |
| 사용하는 상황 | 낮은 우선순위로 처리해야 할 작업에 대해 상태를 직접 업데이트할 수 있는 코드에 접근할 수 있을 때 사용 | 컴포넌트의 props처럼 상태 업데이트에 관여하지 않고 값만 받아야 하는 상황에서 사용 |
- 외부 store를 구독할 수 있는 훅이다
- 애플리케이션 코드에 직접적으로 사용할 일은 많지 않지만 사용 중인 관리 라이브러리가 외부에서 상태를 관리하고 있다면 이 useSyncExternalStore를 통해 외부 데이터 소스의 변경을 추적하고 있는지 반드시 확인해야 한다
- CSS-in-js 라이브러리를 위한 훅으로, layout Effects 가 실행되기 전에 전체 요소를 DOM 에 주입한다
- 실제 애플리케이션 코드를 작성할 때는 사용될 일이 거의 없으며 라이브러리를 작성하는 경우가 아니라면 참고만 하는 것이 좋다
- CSS의 추가 및 수정은 브라우저에서 렌더링하는 작업 대부분을 다시 계산해 작업해야 하는데, 이는 매우 무거운 작업이다. 클라이언트 렌더링 시에 이러한 작업이 발생하지 않도록 서버 사이드에서 스타일 코드를 삽입을 할 수 있도록 도와주는 훅이다
10.2.2 react-dom/client
클라이언트에서 리액트 트리를 만들 때 사용되는 API가 변경됐다. 만약 리액트 18 이하 버전에서 만든 create-react-app으로 프로젝트를 유지보수 중이라면 리액트 18로 업그레이드할 때 반드시 index.{t|j}sx에 있는 내용을 변경해야 한다
creatRoot
- 기존의 react-dom에 있던 render 메서드를 대체할 새로운 메서드다. 리액트 18의 기능을 사용하고 싶다면 craeteRoot와 render를 함께 사용해야 한다
hydrateRoot
- 서버 사이드 렌더링 애플리케이션에서 하이드레이션을 하기 위한 새로운 메서드다.
10.2.3 react-dom/server
클라이언트의 변화와 마찬가지로 서버에서도 컴포넌트를 생성하는 API에 변경이 있었다.
renderToPipeableStream
- 리액트 컴포넌트를 HTML로 렌더링하는 메서드다.
- 스트림을 지원하는 메서드로, HTML을 점진적으로 렌더링하고 클라이언트에서는 중간의 script를 삽입하는 등의 작업을 할 수 있다.
- 이를 통해 서버에서는 Suspense를 사용해 빠르게 렌더링이 필요한 부분을 먼저 렌더링할 수 있고, 값비싼 연산으로 구성된 부분은 이후에 렌더링되게끔 할 수 있다.
renderToReadableStream
- 서버 환경이 아닌 클라우드플레어나 디노 같은 웹 스트림을 사용하는 모던 엣지 런타임 환경에서 사용되는 메서드다
- 실제로 웹 애플리케이션을 개발하는 경우에는 이 메서드를 사용할 일이 거의 없다
10.2.4 자동 배치(Automatic Batching)
- 위 사진은 리액트 17과 18버전에서 버튼 클릭 한 번에 두 개 이상의 state를 동시에 업데이트하는 코드를 실행한 결과이다
- 17에서는 자동 배치가 되지 않아 두 번의 리렌더링이 일어났지만 18에서는 자동 배치 덕분에 리렌더링이 단 한 번만 일어났다
10.2.5 더욱 엄격해진 엄격 모드
리액트의 엄격 모드란?
- 리액트에서 제공하는 컴포넌트 중 하나로, 리액트 애플리케이션에서 발생할 수도 있는 잠재적인 버그를 찾는 데 도움이 되는 컴포넌트다.
- 리액트에서 널리 알려져 있는 Fragment나 Suspense와 마찬가지로 컴포넌트 형태로 선언해서 사용할 수 있다
import { StrictMode } from 'react'
import { craeteRoot } from 'react-dom/client'
const root = createRoot(document.getElementById('root'))
root.render(
<StrictMode>
<App />
</StrictMode>,
)
- 이 엄격 모드에서 수행하는 모드는 모두 개발자 모드에서만 작동하고, 프로덕션 모드에서는 작동하지 않는다.
리액트 엄격 모드에서 하는 작업
-
더이상 안전하지 않은 특정 생명주기를 사용하는 컴포넌트에 대한 경고
- 리액트 클래스 컴포넌트에서 사용되는 생명주기 메서드 중 일부는 더 이상 사용할 수 없게 됐다.
- 이러한 메서드는 16.3부터 UNSAFE_가 붙게 됐고, 이후 16 버전에서는 UNSAFE_가 붙지 않는 생명주기 메서드를 사용하게 되면 경고로그가 기록되고(?) => UNSAFE_가 붙은 생명주기 메서드는 경고가 발생하지 않는건가?,
UNSAFE_가 없는 메서드: 리액트 16.3 이후 모든 환경(엄격 모드 여부와 상관없이)에서 경고가 발생한다
UNSAFE_가 붙은 메서드: 동작은 가능하나, StrictMode에서만 경고가 발생한다
- 17 버전부터는 UNSAFE_가 붙은 세 메서드만 남고 나머지는 다 삭제됐다. 그럼에도 불구하고 UNSAFE_가 붙은 생명주기 메서드를 사용하면서 엄격 모드를 켜게 된다면 경고 메시지가 발생한다
-
문자열 ref 사용 금지
- 과거 리액트에서는 레거시 문자열 ref라 해서 createRef가 없어도 컴포넌트 내부에서 문자열로 ref를 생성하고, 이를 사용해 DOM 노드를 참조하는 것이 가능했다.
- 하지만 아래와 같은 문제점이 있어 사용이 금지됐다
① 문자열로 값을 주는 것은 여러 컴포넌트에 걸쳐 사용될 수 있으므로 충돌의 여지가 있다
② 앞의 코드에서도 볼 수 있듯이 단순히 문자열로만 존재하기 때문에 실제로 어떤 ref에서 참조되고 있는지 파악하기 어렵다
③ 리액트가 계속해서 현재 렌더링되고 있는 컴포넌트의 ref의 값을 추적해야 하기 때문에 성능 이슈가 있다
- findDOMNode에 대한 경고 출력
- findDOMNode는 클래스 컴포넌트 인스턴스에서 실제 DOM 요소에 대한 참조를 가져올 수 있는 현재는 사용하는 것이 권장되지 않는 메서드다
- 구 Context API 사용 시 발생하는 경고
- childContextType와 getChildContext를 사용하는 구 리액트 Context API를 사용하면 엄격 모드에서는 경고를 발생시킨다
- 예상치 못한 부작용(side-effects) 검사
- 리액트 엄격 모드 내부에서 다음 내용을 의도적으로 이중으로 호출한다
- 클래스 컴포넌트의 constructor, render, shouldComponentUpdate, getDerivedStateFromProps
- 클래스 컴포넌트의 setState의 첫 번째 인수
- 함수 컴포넌트의 body
- useState, useMemo, useReducer에 전달되는 함수
- 이중으로 호출하는 이유는 함수형 프로그래밍의 원칙에 따라 리액트의 모든 컴포넌트는 항상 순수하다고 가정하기 때문에 항상 순수한 결과를 내고 있는 개발자에게 확인시켜주기 위함이다
리액트 18에서 추가된 엄격모드
- 향후 리액트에서는 컴포넌트가 마운트 해제된 상태에서도 컴포넌트 내부의 상태값을 유지할 수 있는 기능을 제공할 예정이라고 리액트 팀에서 밝혔다.
- 예를 들어 사용자가 뒤로가기를 했다가 다시 현재 화면으로 돌아왔을 때 리액트가 즉시 이전의 상태를 그대로 유지해 표시할 준비를 하는 기능이 추가되는 것이다.
- 이러한 기능을 지원하기 위해 컴포넌트가 최초에 마운트될 때 자동으로 모든 컴포넌트를 마운트 해제하고 두 번째 마운트에서 이전 상태를 복원하는 기능을 엄격모드의 개발모드에 도입했다
10.2.6 Suspense 기능 강화
- Suspense는 리액트 16.6 버전에서 실험 버전으로 도입된 기능으로 컴포넌트를 동적으로 가져올 수 있게 도와주는 기능이다
리액트 18 버전에서 Suspense가 변경된 내용
- 아직 마운트되기 직전임에도 effect가 빠르게 실행되는 문제가 수정됐다. 이제 컴포넌트가 실제로 화면에 노출될 때 effect가 실행된다
- Suspense로 인해 컴포넌트가 보이거나 사라질 때도 effect가 정상적으로 실행된다.
- Suspense를 이제 서버에서도 실행할 수 있게 된다. 서버에서는 일단 fallback 상태의 트리를 클라이언트에 제공하고, 불러올 준비가 된다면 자연스럽게 렌더링된다
- Suspense 내에 스트롤링이 추가됐다. 화면이 너무 자주 업데이트되어 시각적으로 방해받는 것을 방지하기 위해 리액트는 다음 렌더링을 보여주기 전에 잠시 대기한다. 즉, 중첩된 Suspense의 fallback이 있다면 자동으로 스트롤되어 최대한 자연스럽게 보여주기 위해 노력한다
10.2.7 인터넷 익스플로러 지원 중단에 따른 추가 폴리필 필요
- 이제 리액트를 사용하는 코드에서 아래와 같은 최신 자바스크립트 기능을 사용할 수 있다는 가정하에 배포된다
- Promise : 비동기 연산이 종료된 이후에 실패 또는 결괏값을 확인할 수 있는 객체
- Symbol : 자바스크립트의 새로운 데이터 형식으로, 익명의 객체 속성을 만들 수 있는 특성을 가진 객체
- Object : 객체의 열거 가능한 모든 속성을 다른 객체로 붙여 넣는 메서드
- 이러한 세 기능을 지원하지 않는 브라우저에서 서비스해야 한다면 이 세 가지 기능을 위한 폴리필을 반드시 추가해야 한다.
10.2.8 그 밖에 알아두면 좋은 변경사항
- 이제 컴포넌트에서 undefined를 반환해도 에러가 발생하지 않는다. undefined 반환은 null 반환과 동일하게 처리된다
- 이와 마찬가지로
<Suspense fallback={undefined}>도 null과 동일하게 처리한다
- renderToNodeStream이 지원 중단됐다. 그 대신 renderToPipealbeStream을 사용하는 것이 권장된다.