파이버 (fiber)
파이버는 이미 리액트 버전 16.0에 포함되어 있지만 버전 16.8까지는 비동기 렌더링을 지원하지 않는다. 아직 비동기 렌더링을 위한 작업이 진행 중이며 리액트 버전 16.9부터 비동기 렌더링이 가능할 것으로 보인다.
파이버를 통한 비동기 렌더링
파이버 이전에는 렌더링을 한번 시작하면 중간에 멈출 수 없었다. 따라서 컴포넌트의 개수가 상당히 많은 경우에 렌더링을 시작하면 사용자의 마우스나 키보드 이벤트에 거의 반응할 수 없었다. 리액트는 이렇게 처리해야 할 일이 많은 상황에서도 사용자의 요청에 반응할 방법을 운영체제의 선점형 멀티 태스킹 개념에서 찾았다.
운영체제(OS)는 하나의 프로그램이 CPU를 점유하지 못하도록 하기 위해 일정 시간이 지나면 실행 중인 프로그램을 멈추고 다른 프로그램을 실행시켜 준다. 이를 ‘멀티태스킹’이라 부른다. 멀티태스킹을 하기 위해서는 실행 중인 프로그램의 현재 상태를 저장하고, 나중에 다시 실행될 때 이전 상태를 복원할 방법이 필요하다.
파이버에서는 렌더링 과정을 여러 개의 작업으로 나눠서 실행 중인 작업을 중단하거나 중단된 작업을 재개할 수 있다. 실행 중인 작업을 중단하는 판단의 기준은 크게 두 가지다.
-
작업이 일정 시간을 초과하거나,
-
현재 실행 중인 작업보다 우선순위가 더 높은 작업이 들어오면 현재 작업을 중단한다.
따라서 파이버에서는 우선순위가 낮은 작업의 양이 많다고 하더라도 사용자의 키보드 입력에 빠르게 반응할 수 있다.
작업의 우선순위를 통한 효율적인 CPU 사용
렌더링 작업별로 우선순위를 부여하면 높은 우선순위를 가진 작업을 먼저 처리함으로써 CPU를 효율적으로 사용할 수 있다. 우선순위는 다양한 방식으로 결정될 수 있으며, 리액트 버전 16.9에서 구체적인 윤곽이 드러날 것으로 보인다. 지금까지 드러난 정보를 종합해 보면 우선순위는 다음 세 방식으로 결정될 수 있다.
첫 번째 방식 컴포넌트의 상탯값을 변경할 때, 사용자가 직접 우선순위를 입력하는 것이다. 해당 상탯값의 변경으로 시작되는 렌더링 작업은 입력된 우선 순위로 처리된다. 현재(리액트 버전 16.8)는 우선순위를 숫자로 입력하는 것이 아니라, 높은 우선순위일 때 호출하는 함수와 낮은 우선순위일 때 호출하는 함수를 실험적으로 제공하고 있다.
두 번째 방식 이벤트 처리 함수별로 리액트가 자동으로 우선순위를 결정하는 것이다. 브라우저 환경에서는 돔 요소의 이벤트 처리 함수별로 다른 우선순위를 부여할 것으로 보인다. 대표적으로 onKeyDown 이벤트에는 높은 우선순위가 적용되고, onMouseOver 이벤트에는 낮은 우선순위가 적용된다. onKeyDown 이벤트 처리 함수에서 상태값을 변경하면, 그로 인해 시작되는 렌더링 작업은 높은 우선순위로 처리된다. 따라서 사용자가 키보드로 입력한 내용은 빠르게 화면에 표시될 수 있다. 반대로 마우스를 돔 요소 위에 올렸을 때 돔 요소의 배경색을 변경하는 작업은 다소 느리게 처리될 수 있다.
세 번째 방식 화면에 보이지 않는 영역은 우선순위를 낮게 해서 나중에 처리하는 것이다. 다음은 화면에 하나의 탭만 보여주고 나머지 탭은 hidden 속성 값으로 화면에서 보이지 않게 처리한 코드다.
function App() {
const [currentTab, setCurrentTab] = useState(1);
return(
<div>
<button onClick={() => setCurrentTab(1)}>tab1</button>
<button onClick={() => setCurrentTab(2)}>tab2</button>
<div hidden={currentTab !== 1}>
<p>this is tab 1</p>
</div>
<div hidden={currentTab !== 2}>
<p>this is tab 2</p>
</div>
</div>
);
}모든 돔 요소는 hidden 속성이 있다. hidden 속성에 참을 입력하면 해당 돔 요소는 화면에 보이지 않는다. 리액트는 hidden 속성이 참인 요소를 낮은 우선순위로 렌더링한다. 따라서 화면에 보이는 요소를 먼저 렌더링하고 화면에 보이지 않는 요소는 나중에 렌더링한다. 화면에 보이지 않는 요소를 미리 렌더링함으로써 나중에 탭 전환 시 빠르게 화면을 보여 줄 수 있다.
우선순위에 따른 비동기 렌더링 과정
우선순위에 따라 렌더링 작업이 처리되는 과정을 알아보자. 설명을 위해 컴포넌트의 초기 상탯값이 다음과 같다고 가정해 보자.
backgroundColor = 'red'
name = 'mike'backgroundColor 상탯값은 onMouseOver 이벤트에서 변경되고, name 상탯값은 onKeyDown 이벤트에서 변경된다고 하자. 따라서 backgroundColor 상탯값 변경은 낮은 우선순위로 처리되고, name 상탯값 변경은 높은 우선순위로 처리된다.
먼저 backgroundColor 상탯값이 blue로 변경된다고 하자. 리액트는 상탯값 변경에 따른 렌더링 작업을 수행한다. 그러다 backgroundColor 상탯값 변경에 따른 렌더링 작업을 끝내기 전에 name 상탯값이 변경된다. 우선순위가 더 높은 작업이 들어왔으므로 지금까지 하던 작업을 멈춘다. 그리고 name 상탯값 변경으로 인한 렌더링 작업이 끝난다.
이전에 멈췄던 작업을 이어서 처리해야 한다. 하지만 name 상탯값 변경을 처리하면서 UI 구조(또는 가상 돔)가 변경됐다. 따라서 이전에 backgroundColor 상탯값 변경을 위해 처리했던 작업을 그대로 이어서 작업해도 현재의 돔에 반영할 수 없는 상황이다. 이 상황에서 리액트는 버전 관리 도구의 리베이스(rebase) 전략을 사용한다. 즉, backgroundColor 상탯값 변경을 위한 작업의 시작 상태를 name 상탯값 변경이 완료된 상태로 변경한다. 리베이스를 적용 후 남은 작업을 이어서 처리한다.
서스펜스로 가능해진 렌더 함수 내 비동기 처리
서스펜스(suspense)는 렌더링 과정에서 API 호출과 같은 비동기 처리를 지원하는 기능이다. 서스펜스를 이용하면 서버로부터 데이터를 가져오는 코드와 받은 데이터를 화면에 렌더링하는 코드를 모두 렌더 함수에서 작성할 수 있다.
데이터를 가져오는 동안 사용자에게 적절한 시각 효과를 제공하지 않으면, 사용자는 프로그램이 멈췄다고 생각하기 쉽다. 고맙게도 서스펜스는 비동기 처리가 완료될 때까지 로딩 애니메이션과 같은 시각 효과를 보여 줄 방법을 제공해 준다. 비동기 처리가 진행 중일 때는 렌더링을 중단하고 사용자가 정의한 시각 효과를 보여 준다. 그리고 비동기 처리가 완료되면 중단했던 렌더링을 재개한다.
서스펜스는 리액트 버전 16.6부터 지원되지만, 해당 버전의 리액트는 동기 방식으로 렌더링하기 때문에 서스펜스의 일부 기능만 사용할 수 있다. 리액트 버전 16.7부터는 몇 가지 설정으로 서스펜스의 maxDuration과 같은 기능을 테스트해 볼 수 있다.
실습을 통해 서스펜스의 기능을 사용해 보자.
1. 프로젝트 생성
npx create-react-app suspense
cd suspense2. 파일 정리
src 폴더에서 index.js, App.js 파일을 제외한 모든 파일 삭제.
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));3. 렌더 함수 내에서 비동기로 모듈 가져오기
src 폴더 밑에 VideoPlayer.js 파일을 만드는데, VideoPlayer 컴포넌트가 항상 사용되는 게 아니라면 필요할 때만 내려받는 것이 효율적이다.
VideoPlayer 컴포넌트의 코드를 분할해서 비동기로 내려받으면, 사용자는 VideoPlayer 컴포넌트를 제외한 나머지 내용을 빠르게 확인할 수 있다.
리액트 버전 16.6에 포함된 Suspense 컴포넌트와 lazy 함수를 이용하면 모듈의 비동기 다운로드를 렌더링 과정에 자연스럽게 포함시킬 수 있다.
Suspense 컴포넌트와 lazy 함수의 사용 예
App.js
import React, { lazy, Suspense } from 'react';
import Loading from './Loading';
const VideoPlayer = lazy(() => import('./VideoPlayer'));
export default function App() {
return (
<div>
<h1>suspense example</h1>
<Suspense fallback={<Loading />}>
<h3>watch video</h3>
<VideoPlayer />
</Suspense>
</div>
);
}lazy 함수를 동적 임포트와 함께 호출하면 모듈의 비동기 다운로드를 도와주는 컴포넌트가 반환된다. VideoPlayer 변수는 일반적인 컴포넌트처럼 사용할 수 있다. 동적 임포트가 사용됐기 때문에 웹팩을 사용하고 있다면 자동으로 코드가 분할된다.
Suspense의 자식 컴포넌트에서 비동기 처리가 시작되면 Suspense 컴포넌트 내부의 모든 렌더링이 멈춘다. 그리고 그 자리에 fallback 속성값으로 입력된 컴포넌트가 렌더링된다.
lazy 함수로 만들어진 VideoPlayer 컴포넌트가 렌더링될 때 분할된 코드를 받는다. 코드를 받기 전까지 Suspense 내부는 Loading 컴포넌트가 렌더링된다. 코드를 다 받으면 정상적으로 비디오 플레이어가 렌더링된다.
npm start
실행 후, 브라우저에서 네트워크 항목을 확인해 보면 VideoPlayer.js 파일의 코드를 비동기로 받는 것을 확인할 수 있다. 이처럼 Suspense 컴포넌트 내부에서 렌더링 중간에 비동기 처리를할 수 있다.
렌더 함수 내에서 API로 데이터 받기
API 통신도 비동기 처리의 한 예다. Suspense 컴포넌트 내부에서는 렌더링 중이라고 하더라도 API를 호출할 수 있다. 리액트 버전 16.8까지는 비동기 렌더링을 지원하지 않기 때문에 렌더링 중에 비동기 처리가 발생하면 멈췄다가 나중에 중단된 부분부터 다시 시작할 수 없다. 따라서 동기 렌더링에서의 Suspense는 fallback으로 입력된 컴포넌트가 바로 사용되고, 비동기 처리가 끝나면 다시 한 번 렌더링된다.
렌더링 과정에서 비동기로 데이터를 받는 코드를 작성해 보자. 이를 위해 다음 패키지를 설치한다.
npm install react-cache@2.0.0-alpha.0 react@16.7.0 react-dom@16.7.0
react-cache는 렌더링 과정에서 비동기 처리를 지원하기 위해 리액트에서 제공하는 패키지이다. 아직 실험적인 단계이므로 리액트에서 정식으로 지원하기 전에는 프로덕션에서 사용하지 않기로 한다.
다음과 같이 렌더 함수 내에서 API를 호출하는 코드를 입력해 보자.
Profile.js
import React from 'react';
import { unstable_createResource } from 'react-cache';
function fetchUser(userId) {
return new Promise(resolve =>
setTimeout(() => resolve({ userId, name: 'mike', age: 23 }), 2000),
);
}
const UserCache = unstable_createResource(fetchUser);
function Profile() {
const user = UserCache.read(123);
return (
<div>
<p>name: {user.name}</p>
<p>age: {user.age}</p>
</div>
);
}
export default Profile;unstable_createResource 함수는 렌더링 과정에서 비동기로 데이터를 받을 수 있도록 도와준다.
read 메서드를 호출했을 때 이미 받은 데이터가 있다면 그 데이터를 사용한다. 만약 받은 데이터가 없다면 fetchUser 함수가 실행되고, fetchUser 함수가 반환하는 프로미스 객체와 함께 예외를 발생시킨다.
프로미스 객체와 함께 예외가 발생하면, 부모로 거슬러 올라가면서 가장 가까운 Suspense 컴포넌트를 찾는다. Suspense 컴포넌트는 내부 영역을 fallback으로 대체하고, 추후 프로미스가 처리됨 상태가 되면 다시 렌더링한다.
참고로 lazy 함수로 생성한 컴포넌트도 비동기 처리가 시작되면 프로미스 객체와 함께 예외를 발생시킨다. Suspense 컴포넌트를 렌더링에서의 try catch 문으로 생각할 수 있다.
Suspense 내부에서 여러 개의 프로미스 발생시키기
Suspense 내부에서 여러 개의 프로미스가 발생하도록 App.js 파일을 수정해보자.
App.js
//...
import Profile from './Profile';
const VideoPlayer = lazy(() => import('./VideoPlayer'));
export default function App() {
return (
<div>
<h1>suspense example</h1>
<Suspense>
</Suspense>
</div>
);
}Suspense 컴포넌트 내부에서 예외로 발생하는 모든 프로미스 객체가 처리됨 상태가 되기 전까지는 Loading 컴포넌트가 렌더링된다.
출처
- 실전 리액트 프로그래밍 (프로그래밍인사이트-이재승)
