[React.js] React 18 뭐가 달라질까?
들어가며
React 팀은 작년 11월 React 18을 Alpha에서 Beta로 Release 했다.
Beta로 넘어오면서 Production으로 Release가 될 때 까지 새로운 API의 추가는 없다고 밝혔다.
즉, 현재 공지된 기능이 Fix 되었다는 것을 의미한다.
관련된 문서로 아래 링크를 참고하면 된다.
https://ko.reactjs.org/blog/2021/06/08/the-plan-for-react-18.html
https://github.com/reactwg/react-18/discussions/112
React 팀은 Working Group 이라는 새로운 개념을 도입해 React 개발자와 소통하며 더 개선할 수 있는 부분과 문제점들을 소통하며 피드백을 진행하고 있다.
React 팀 내부 회의에서 나온 기능만을 공지하는 것이 아닌, 기존 개발자들의 의견을 검토한다는 것이다.
아직 2022년 02월 03일 기준 Production Release 처리가 되지 않았지만, React 18은 정말 혁신적인 변화를 일으킬 것이기에 새롭게 추가되는 기능을 미리 학습하고자 이 글을 작성한다.
먼저 React 18에서는 크게 4가지의 기능을 공지했다.
- 자동배치(Automatic Batching)
- 동시성 기능(Concurrent)
- SSR에서의 Suspense, React.lazy 사용
- RSC(React Server Component) 도입
위 4가지 기능중 RSC를 제외하고 3가지를 설명하고자 한다.
RSC는 현재 아직 공부중이라 제외했다.
React 18의 변화, 첫번째 자동배치(Automatic Batching)
React에서의 Batch란?
배치란 작업은 'React가 더 나은 성능을 위해 여러개의 상태 업데이트를 한번의 Rerendering으로 묶는 작업' 을 뜻한다.
(React를 정말 이전부터 다뤄본 개발자라면, React 내에서는 '배치' 라 하는 작업이 있음을 알 것 이다.)
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const foo = () => {
setCount(count + 1); // 리렌더링이 수행되지 않는다.
setFlag(!flag); // 리렌더링이 수행되지 않는다.
// --- 모든 상태 변경이 진행되고 나서 리렌더링이 1번 수행된다. ---
}
useEffect(() => {
foo();
}, [])
.
.
.
}
export default App;
위 코드는 상태를 총 2번 바꾸게 되는 코드다.
count의 state를 바꾸고 flag의 state를 바꾸게 되는데 이 때 count의 state를 바꾼다 하여 flag의 상태를 바꾸기 전 Rerendering이 수행되지는 않는다.(React v17 기준)
모든 상태 변경 작업을 수행한 후 리렌더링을 진행하게 되는데 이를 '배치' 작업이라고 한다.
React 17, Batch 작업의 문제점
하지만 모든 경우에 대해 React는 배치 작업을 수행하지 않는다.
React 이벤트 핸들러 내부에서 발생하는 업데이트만 배칭을 하였다.
Promise, setTimeout, native 이벤트 핸들러, 그리고 여타 모든 이벤트 내부에서 발생하는 업데이트들은 React에서 배칭되지 않았다.
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const foo = () => {
// fetchSomething --> Promise 형식의 함수라고 가정하자.
fetchSomething().then(() =>{
setCount(count + 1);
setFlag(!flag);
});
}
useEffect(() => {
foo();
}
.
.
.
}
export default App;
위 코드는 상태가 변경될 때 마다 리렌더링을 수행하게 된다.
이는 배치 작업이 이루어지지 않고 있다는 것을 의미한다.
(컴포넌트 내부에 console.log(); 를 통해 리렌더링을 catch해보면 2번 찍히는 것을 확인할 수 있다.)
React 18에서의 변화
React18 에서는 이런 일관적이지 않는 배치 작업을 모두 일관적이게 바꾼다고 했다.
하지만 개발자는 개발을 하다보면 정말 특수한 상황이나, 특수한 요건으로 인해 상태를 매번 업데이트(배치 작동 중지) 해야할 때가 있을 것이다.(정말 극 소수겠지만..)
그 때 사용할 수 있는 API도 추가를 하였다.
'ReactDom.flushSync(() => { ... })' API를 이용하게 되면 배치 작업을 수행하지 않을 수 있다.
React 18의 변화, 두번째 동시성(Concurrent)
지금까지 Concurrent Mode라고 알려졌던 기능 중 일부가 추가 된다.
Concurrent는 Suspense와 같이 사용자 경험을 올리기 위해 나온 개념중 하나다.
Concurrent에 대해 알아보도록 하자.
(공식 문서 : https://ko.reactjs.org/docs/concurrent-mode-patterns.html)
Concurrent가 뭘까?
React는 JavaScript 라이브러리중 하나다.
JavaScript의 특성을 떠올려 보면, 단일 쓰레드(Single Thread)를 꼽을 수 있다.
(물론 Worker나 다른 방식으로 멀티 쓰레드를 구현할 수 있다.)
단일 쓰레드는 한번에 한가지의 일 밖에 수행하지 못한다.
예를 들면, 필자가 컴퓨터 게임을 진행하면서 음료수나 음식을 먹지 못한다는 것이다.
이러한 특성의 영향으로 React는 UI를 렌더링 하는 중 이 외의 모든 작업을 차단하고 있다.
그런데 React를 동작시켜보면 실제로 UI를 렌더링 하는 작업과 기능을 동시에 수행하는 것 처럼 보인다.
동시에 작업을 수행하는 것 처럼 보이는 것은 사실 여러 작업을 작은 단위로 나눈 뒤 작업들간의 우선 순위를 정하고 그에 따라 작업을 번갈아 수행하고 있다.
서로 다른 작업들이 동시에 실행되고 있지 않지만 작업간의 전환이 매우 빠르게 이루어지기 때문에 동시에 수행되는 것이다.
이를 'Concurrent(동시성)' 이라고 한다.
그럼 Concurrent가 뭔지에 대해서는 알았으니, 이제 이를 React에서 어떻게 사용하고 있었는지, 무슨 작업이 있는지 조금 상세하게 알 필요가 있다.
어떻게 사용하고 있었는지에 대해 알아보기 전, 어떤 작업이 있는지에 대해 먼저 알아야 한다.
상태 업데이트에 대한 분류
React에서 상태를 바꾸게 되면 리렌더링 조건에 성립되기 때문에 리렌더링이 발생하게 된다.
하지만 이 때 상태 업데이트 후 리렌더링이 되는 것에 대한 분류가 있다.
(우선순위 느낌이다.)
- 긴급 업데이트(Urgent Update)
직접적인 상호 작용을 반영하는 업데이트다.
이를테면 사용자가 키보드를 타이핑하거나, 스크롤링, 마우스 오버 등을 했을 때 일어나는 업데이트다.
긴급 업데이트는 사용자의 입력에 따라 즉각적으로 업데이트 되지 않으면 사용자 입장에서 문제가 있다고 느끼는 영역이다.(렉, 멈춤 등..) - 전환 업데이트(Transition Update)
하나의 View에서 다른 View를 보여주는 UI 전환을 뜻한다.
사용자의 상호작용에 따라 즉각 업데이트가 되는것을 기대하지 않는 영역이다.
쉽게 예를 들어보면, 구글 검색창으로 들어가보자.
구글 검색창에 키보드로 '리액트' 입력시 검색 Input box에 즉각적으로 반영이 되는것을 기대하고 타이핑 할 것이다.
이를 '긴급 업데이트' 라고 분류한다.
키워드를 입력하게 될 경우 아래 추천 검색어가 나오는 부분을 '전환 업데이트' 라고 분류하고 있다.
추천 검색어 표시는 네트워크 문제로 늦게 나와도 되지만, 검색창에 입력이 되는 것은 사용자가 즉각적으로 반영이 될 것을 기대하고 수행한 상호 작용이기 때문에 느려서는 안된다.
(만약 사용자가 검색창에 타이핑을 진행했는데, 늦게 된다면 키보드 문제를 의심하곤 할 것이다.)
여기서 중요한 부분은 '전환 업데이트' 에 의해 '긴급 업데이트' 가 늦어지거나, 방해되서는 안된다.
(추천 검색어를 띄우느라 키보드 입력을 방해한다면... 사용자 경험이 매우 떨어질 것이다.)
React 17, 현재까지는 어떻게 이를 사용했는가?
React 17까지는 상태 업데이트를 분류할 수 없었다.
모두 다 긴급 업데이트 건으로 취급하여 상태 업데이트를 진행하고 있었다.
그래서 우리 개발자들은 이를 회피하기 위해 setTimeOut, throttle, debounce 등의 기술로 긴급 업데이트 방해를 우회하는 것이 최선이었다.
하지만 React 18에서는 이를 명시할 수 있도록 API가 추가되었다.
React 18에서의 변화
먼저 전제는 동일하다.
모든 상태 업데이트를 긴급 업데이트건으로 분류해 업데이트를 진행한다.
하지만, useTransition 훅을 이용해 '전환 업데이트'로 명시 가능하다.
function App() {
// 전환 업데이트를 명시하기 위한 Hook
const [isPending, startTransition] = useTransition();
// 검색 창에 입력된 값
const [inputValue, setInputValue] = useState("");
// 다른 컴포넌트에 전달해서 추천 검색어를 보여줄 때 사용되는 상태
const [searchQuery, setSearchQuery] = useState("");
// 검색 창에서 입력될 때 사용되는 함수
const handleChange = (e) => {
const input = e.target.value;
setInputValue(input); // --> 긴급 업데이트로 분류
// 아래 함수에서는 전환 업데이트로 분류
startTransition(() => {
setSearchQuery(input); // --> 전환 업데이트
}
}
.
.
.
}
export default App;
isPending은 Transition이 진행되고 있는가에 대한 Boolean 값이다.
만약, 이 상태값이 필요 없다면 React.startTransition(() => { ... }) 으로도 사용 가능하다.
useTransition에는 속성 값을 부여할 수 있다.
timeoutMs 프로퍼티는 트랜지션이 완료될 때까지 얼마나 오랫동안 기다릴 것인지 결정한다.
{timeoutMs: 3000} 를 전달한다면 “추천 검색어를 불러오는 데 3초보다 오래 걸린다면 로딩 상태를 보여주고 그전까진 계속 이전 화면을 보여줘도 괜찮아”라는 의미이다.
function App() {
// 전환 업데이트를 명시하기 위한 Hook
const [isPending, startTransition] = useTransition({
timeoutMs: 3000
});
// 검색 창에 입력된 값
const [inputValue, setInputValue] = useState("");
// 다른 컴포넌트에 전달해서 추천 검색어를 보여줄 때 사용되는 상태
const [searchQuery, setSearchQuery] = useState("");
// 검색 창에서 입력될 때 사용되는 함수
const handleChange = (e) => {
const input = e.target.value;
setInputValue(input); // --> 긴급 업데이트로 분류
// 아래 함수에서는 전환 업데이트로 분류
startTransition(() => {
setSearchQuery(input); // --> 전환 업데이트
}
}
.
.
.
}
export default App;
React 18의 변화, 세번째 서스펜스(Suspense)
Suspense는 React v16.6 이후 부터 사용할 수 있었다.
하지만, Server-side에서는 사용이 불가했는데, 이를 가능하게 해주겠다는 것이다.
(공식 문서 : https://ko.reactjs.org/docs/concurrent-mode-suspense.html)
Suspense, 무슨 기능인가?
특정 컴포넌트에서 사용되고 있는 데이터의 준비가 아직 끝나지 않았음을 React에 알릴 수 있다.
Data-fetching Library와 같이 사용하게 될 경우 효과가 극대화 된다.
데이터의 준비가 끝나지 않았음을 알린다는 것은 즉, 해당 데이터를 사용하는 컴포넌트는 렌더링을 하지 않고 다른 로딩화면을 보여줄 수 있음을 의미한다.
Suspense에 대해 알아보기 전 Data fetching Library의 종류와 그들의 역할에 대해 살펴봐야 한다.
Data fetching Library
React Query, SWR, Apollo, Relay... 와 같은 데이터를 불러올 수 있는 라이브러리를 의미한다.
이들의 주요 목적은 '워터폴(waterfall) 현상을 막는 것이다.
여기서 워터폴 현상은 '이전 개발이 끝나야 다음 과정을 진행할 수 있는 프로젝트 방법론' 을 의미하는 것이 아니다.
'이전 Data fetch 요청에 대한 응답이 와야 다음 fetch 요청을 보낼 수 있는 구조' 를 의미하고 있는 것이다.
Data fetching Library를 사용하지 않고 데이터를 불러올 경우 아래의 구조로 진행된다.
- 컴포넌트 렌더링
- Data fetch 요청
- 컴포넌트에 응답을 반영
데이터를 가져와야 할 것이 총 3개이고, 이 3개에 대한 응답을 자식 컴포넌트 3개에서 사용한다고 가정하자.
그리고, 응답을 받기까지의 소요 시간은 각자 2초, 3초, 4초 걸린다고 하자.
그렇다면 사용자는 총 9초동안 사용자는 빈 화면을 봐야한다.
(워터폴 현상에 의해 2초 기다리고, 3초 기다리고, 4초를 기다려야 한다.)
왜냐면, 렌더링을 진행하는 도중 Data를 가져와야 하기 때문이다.
하지만, Data fetching Libray를 사용하게 될 경우 렌더링 이전에 Data fetch 요청을 실행하도록 중앙화 하여 해결한다.
- Data fetch 요청
- 데이터를 가져오는 동안 로딩 UI 렌더링
- 컴포넌트에 응답을 반영하여 렌더링
사용자는 이제 9초동안 로딩 화면을 볼 수 있다.
하지만, 이 또한 사용자 경험 측면에서 좋지 않다고 볼 수 있다.
각기 다른 자식 컴포넌트에서 사용할 데이터를 불러오는데, 빨리 보여줄 수 있는 컴포넌트는 먼저 보여주는게 당연히 사용자 경험에서 좋다.
이를 간단하고 쉽게 해결하기 위해 'Suspense' 가 등장했다.
Suspense의 역할 2가지
- 모든 요청을 기다리지 않고 컴포넌트를 렌더링 할 수 있다.
- 경쟁 상태(Race condition)을 방지할 수 있다.
* 모든 요청을 기다리지 않고 컴포넌트를 렌더링 할 수 있다
모든 요청을 기다리지 않고, 컴포넌트를 렌더링 할 수 있다는 것은 Suspense가 기존 구조를 변경하기 때문이다.
Suspense는 요청 리소스를 컴포넌트에 주입하는 방식으로 변경해준다.
- Data fetch 요청
- Suspense 하위의 컴포넌트에 요청 리소스를 반영
- Suspense에 의해 로딩 UI 렌더링
- 요청 리소스로 Data 응답 발생
- 컴포넌트에 응답을 반영
이 때 요청 리소스는 Promise로 동작되지 않는다.
이는 일반 객체로 동작된다.
위 구조로 변경이 되었을 경우 데이터의 응답을 기다리지 않고도 렌더링을 진행할 수 있다.
즉, 컴포넌트를 작은 단위로 나누어 렌더링을 진행한다는 것이다.
경쟁 상태(Race condition)을 방지할 수 있다.
경쟁상태란, "공유 자원에 대해 여러개의 프로세스가 동시에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결과 값에 영향을 줄 수 있는 상태" 를 의미한다.
JavaScript에서의 경쟁 상태는 "여러개의 비동기 작업이 수행되면서 나온 응답이 하나의 DOM에 반영 되는 상황" 이다.
하나의 DOM 은 공유 자원 을 의미하고, 여러개의 비동기 작업 은 동시 를 의미한다.
예를 들어 아래와 같은 화면이 있다고 하자.
각 버튼을 눌렀을 때 Promise 형식으로 동작하여 작업을 수행하며 사용자에 대한 정보를 받아온 후 위 요소에 정보를 보여준다고 하자.
3개의 버튼은 비동기로 수행되고, 각자 응답을 받는 시간이 상이하다고 하자.
사용자 A는 조회하는데 3초걸리고, 사용자 B는 조회하는데 10초, C는 1초 걸린다고 하였을 때 3개의 버튼을 막 누른다고 가정하면, 마지막에 사용자 A를 눌렀는데 이전에 누른 사용자 B의 정보가 나올 수 있다.
이는 버튼을 누른 순서대로 응답을 받고 표시될 것이라고 기대하기 때문에 일어나는 현상이다.
과연 비동기 처리이고, 처리 시간이 서로 달랐을 때 그 기대를 만족할 수 있을까?
B를 누르고, A를 눌렀을 때 B의 응답이 A보다 빨리 올 것이라고 확신할 수 없다.
B가 10초걸리고, A가 3초 걸린다고 가정하였으니, B와 A를 빠르게 눌렀을 때 3초 뒤 A의 정보, 10초 뒤 B의 정보가 나올 것이다.
이는 기존에 기대했던 내용과 다른 것이다.
이 현상을 Suspense는 State 설정 시기를 바꾸어 해결했다.
기존 구조가 "프로필 정보 요청 → 로딩 UI 렌더 → 요청에 대한 응답 → 응답 반영" 이었다면,
Suspense는 "프로필 정보 요청 → 컴포넌트에 요청 리소스 반영 → Suspense에 의해 A 요청에 대한 로딩 UI 렌더 → 요청 리소스로 프로필 응답이 들어옴 → 응답 반영" 으로 바꾸었기 때문이다.
Suspense가 응답이 언제오는지 시간에 대한 것은 고려하지 않아도 되기 때문이다.
요청과 동시에 요청 리소스를 반영하기 때문에 이전에 수행하고 있던 요청이 있더라도 해당 요청은 무시되며, 새로운 요청으로 대체 된다.
React 17, 현재 버전에서도 CSR에서 Suspense는 사용 가능하지만 React 18에서는 새로운 SSR 아키텍처가 도입되었다.
React 18, SSR의 새로운 아키텍처
'pipeToNodeWriteable' API가 새로 추가되었으며, 이 API를 통해 사용하면 SSR에서도 'Suspense'를 사용할 수 있게 되었다.
즉, *React.lazy를 사용할 수 있게 된 것이다.
*React.lazy : 컴포넌트를 동적으로 import 할 수 있는 것.
기존의 React SSR은 아래와 같이 동작하였다.
- 서버에서 전체 앱의 데이터를 받는다.
- 서버에서 전체 HTML을 렌더링 하고 응답으로 전송한다.
- 클라이언트에서 전체 앱의 자바스크립트 코드를 로드한다.
- 클라이언트에서 서버에서 생성되고, 응답으로 받은 HTML과 자바스크립트 코드를 연결한다.(Hydration)
앱 전체를 대상으로 각 단계가 완료되어야만 다음 스텝이 진행 가능한 것이다.
전체 컴포넌트 트리중 일부가 나머지 부분보다 느리다면, SSR의 전체 성능은 급격히 저하된다.
각 단계마다 느린 부분에서 '병목현상' 이 발생하기 때문이다.
병목현상의 문제점과 발생지는 무엇일까?
- 특정 컴포넌트를 렌더링하는데 필요한 Data fetching이 오래 걸린다면, 서버에서 Data Fetching이 완료 되어야 컴포넌트 트리를 렌더링 하기에 이용자는 개발자의 기대와 다르게 SSR임에도 불구하고 빈 화면을 오래 봐야한다.
- 특정 컴포넌트의 코드량이 커서 로딩이 오래 걸린다면, 모든 자바스크립트를 로드하기 전에는 Hydration 단계로 넘어갈 수 없다. 이는 상호작용이 불가하다는 것이다.
- 특정 컴포넌트의 로직이 복잡하여 렌더링 하는 시간이 오래걸린다면, 이 역시 이용자는 빈 화면을 오래 봐야한다.
리액트는 앱 전체를 렌더링 완료해야 HTML을 응답으로 주니 말이다.
근본적인 원인은 SSR에서 다음 과정으로 넘어가기 위해 앱 전체가 각각의 단계를 완료해야 하기 때문이다.
React 18에서는 이를 보완할 수 있는 <Suspense> 를 사용할 수 있다는 것이다.
이를 통해 앱의 데이터를 불러오는데 오래 걸리는 컴포넌트를 제외하고, 나머지 부분의 렌더링을 진행할 수 있다.
이 외에도 Suspense와 연계하여 사용할 수 있는 기능이 SSR에 2개 추가 되었다.
HTML Streaming
서버단에서 'renderToString' 대신, 'pipeToNodeWriteable'이 사용 가능하다.
기존에도 스트리밍을 통해 서버사이드 렌더링을 하는 'renderToNodeStream' API가 있었으나 Data Fetching은 기다릴 수 있었다.
이제 'renderToNodeStream' API는 Deprecated 된다.
선택적 수화(Selective Hydration)
앞 서 작성했던 <Suspense> 태그를 통해 앱에서 렌더링 비용이 많이 드는 서브 컴포넌트 트리를 감싸 전체 앱의 Hydration을 방해하지 않고, 별도의 Hydration이 진행 가능하다는 것은 알고 있을 것이다.
하지만, 여기서도 별도의 Hydration 진행 중 사용자의 상호작용을 감지해 조금 더 급한 컴포넌트를 긴급으로 Hydration을 할 수 있는 기능이 적용 된다.
로딩중인 컴포넌트를 클릭하게 되면 긴급 Hydration이 진행된다.
이로써 SSR의 기존 문제였던 1번은 <Suspense> 를 통해 해결하였고, 2번과 3번은 React.lazy를 통해 문제를 해결했다.
React.lazy를 통해 Code Spliting을 적용해 별도의 JavaScript Chunk File로 분리했기 때문이다.
참고 문서
https://immigration9.github.io/react/2021/06/13/new-suspense-ssr-architecture.html
https://ko.reactjs.org/docs/concurrent-mode-suspense.html
https://ko.reactjs.org/docs/concurrent-mode-patterns.html
https://velog.io/@jay/React-18-%EB%B3%80%EA%B2%BD%EC%A0%90