useTransition 은 느린 컴포넌트의 성능을 향상시키는 Hook으로 한번 렌더링 연산이 시작되면 멈출 수 없는 렌더링 블로킹 문제를 해결하기 위하여 사용됩니다. 또한 다음 화면으로 전환 작업을 하기 전에 컨텐츠가 로드 될 때까지 대기함으로써 컴포넌트가 바람직하지 않은 로딩 상태를 피할 수 있게 해주며, 더 중요한 업데이트를 컴포넌트가 즉시 렌더링 할 수 있도록 후속 렌더링까지 데이터 가져오기를 지연시킬 수 있습니다. useTransition Hook은 배열에서 startTransition 과 isPending 두 개의 값을 반환합니다.
13.1.2 useTransition 을 사용하는 이유?
추가적으로 useTransition 을 설명하기에 앞서 긴급 업데이트와 전환 업데이트에 대해 알아보겠습니다. React 18 전까지는 아래의 코드와 같이 모든 업데이트가 긴급하게 렌더링되었습니다. 이렇게 모든 업데이트가 동시에 렌더링 되는 경우, 사용자와의 상호작용을 차단하게 되어 렌더링이 되는 동안 페이지는 지연 현상이 발생하여 응답하지 않는 것처럼 느껴질 수 있다는 단점이 있었습니다.
setInputValue(input);
// 긴급 업데이트 : 입력한 값
setSearchQuery(input);
// 긴급하지 않은 업데이트: 결과 값
하지만 최근React 18에 추가된useTransition 을 통해 React에게 긴급한 업데이트와 그렇지 않은 업데이트를 알려줌으로써 사용자의 상호작용을 빠르게 유지할 수 있음과 동시에 불필요한 렌더링을 줄일 수 있는 효과도 얻을 수 있게 되었습니다.
긴급 업데이트: 입력, 클릭과 같은 직접적인 사용자와의 상호작용을 반영하는 업데이트로, 즉각적으로 서비스가 되지 않으면 사용자가 불편함을 느낄 수 있는 영역
전환 업데이트: 하나의 뷰에서 다른 뷰로 UI를 전환하는 업데이트로, 화면에 즉시 나타나지 않아도 사용자가 불편함을 느끼지 않는 영역
예를 들어 사용자가 입력창에 검색어를 입력하면, API에서 데이터를 가져와 화면에 검색 결과 목록을 미리 보여주는 어플리케이션이 있다고 가정해보도록 하겠습니다. 사용자가 입력창에 검색어를 입력(긴급 업데이트)을 하고 있음에도 많은 양의 검색결과(전환 업데이트)가 렌더링이 되어버린다면 렌더링 블로킹 문제로 인해 입력창이 버벅거리는 현상이 나타나 사용자에게 매우 불만족스러운 경험이 될 것입니다.
import { startTransition } from 'react'
setInputValue(input)
// 긴급 업데이트 : 입력한 값
startTransition(() => {
setSearchQuery(input)
// 전환 업데이트: 결과 값
위의 예제와 같이 startTransition 을 사용하여 긴급한 업데이트를 처리할 수 있습니다. 클릭 혹은 키 이벤트가 발생하는 경우 startTransition 으로 감싸인 긴급한 이벤트를 먼저 처리한 이후 완료되지 않은 오래된 렌더링을 폐기하고 최신 업데이트로 렌더링을 하게 되는 것 입니다.
💡
startTransition이란? 리액트에 어떤 상태변화를 지연하고 싶은지 지정할 수 있는 함수입니다.
이전 버전에서는 검색 결과 등에서 사용했던 Debounce, Throttle 기능을 사용하여 일정 시간을 기다리는 것으로 문제를 해결하였습니다.
Debounce 와 Throttle 의 경우 일정 시간을 기다리는 것으로 문제를 잠시 미루는 방식이었다면, useTranstion 은 렌더링 중에도 우선순위가 더 높은 작업이 생긴다면 먼저 처리할 수 있게 해줌으로써 사용자 경험을 향상시킬 수 있다는 차별점을 가지고 있습니다. 즉 긴급 업데이트 함수를 전환 업데이트 함수보다 우선순위를 높게 설정하여 문제를 해결하는 것이라 볼 수 있겠습니다.
useTransition은 두 개의 인자를 받아 배열로 반환합니다. 첫 번째 인자 isPending은 ‘대기’, ‘보류 중’ 상태의 작업이 있는 지 판단하는 Boolean 값을 반환합니다. isPending은 true일 때, 로딩 중임을 표시하여 사용자에게 알리거나 속성을 비활성화하는 용도로 사용하여 UI로 보여줍니다.
두 번째 인자 startTransition은 낮은 우선순위로 실행해도 되는 업데이트 함수를 감쌉니다.
다음은 useTransition Hook을 사용한 기본 예제 코드입니다. count 상태 값을 2씩 더해주는 setCount 함수를 startTransition 으로 감싸주었으며, 앞에서 감쌌던 보류 중인 작업이 남아있을 때 Loading… 문구를 화면에 나타냅니다.
React 18에서는 concurrency(동시성)이 가장 중요한 키워드로 업데이트 되었습니다. 동시성은 ‘동시에 실행한다’ 와는 다른 의미입니다. JavaScript는 싱글 스레드이기 때문에, 하나의 스레드 안에서 여러 작업들을 작은 단위로 쪼개어 ‘빠르게 작업들을 바꿔가며 실행하므로 여러 가지 일을 처리하는 것처럼 보인다’는 의미를 담고 있습니다.
그림 13-1 concurrency
다음은 React 공식 문서에서 발췌한 내용입니다.
In a concurrent render, this is not always the case. … To do this, it waits to perform DOM mutations until the end, once the entire tree has been evaluated. With this capability, React can prepare new screens in the background without blocking the main thread.This means the UI can respond immediately to user input even if it’s in the middle of a large rendering task, creating a fluid user experience.
리액트에서의 동시성은 메인 스레드의 동작을 막지 않고 백그라운드에서 새로운 화면을 준비한다는 의미로, 대규모 렌더링 작업 중 사용자 입력에 즉시 응답 할 수 있습니다.
13.2.3 useTransition의 주의사항
첫 번째, 접근이 가능한 state의 set함수에만 startTransition으로 감쌀 수 있습니다. 만약 props 또는 커스텀 훅의 반환 값을 전환해야 한다면, useDefferedValue를 사용해야 합니다.
두 번째, startTransition으로 감싼 함수는 동기적으로 실행되며, 전환작업이 실행되는 동안 일어나는 모든 상태를 업데이트 표시합니다.단, 낮은 우선순위로 전환했기 때문에 렌더링되는 동안 높은 우선순위의 함수가 업데이트 되면 렌더링을 중단하고, 우선 순위 처리 후 렌더링 작업합니다.
마지막으로, useTransition은 Hook이기 때문에 컴포넌트 외부에서 호출할 수 없습니다.
하지만 startTransition 함수를 단독으로 사용하여 감싼다면 Hook 없이도 전환작업으로 낮은 우선순위에 둘 수 있습니다.
import { startTransition } from 'react'
13.3 (실습1) useTransition을 통해 검색 미리보기 UX 개선하기
10,000개의 데이터를 렌더링하는 과정에서의 지연시간으로 인해 input 요소에 타이핑한 결과가 화면상에 바로 보이지 않습니다. 10,000개의 요소가 화면상에 렌더링이 먼저 처리가 되고 난 후에 반영이 되기 때문입니다.
이를 useTransition 훅을 사용하여 작업의 우선순위를 변경했을 때 어떤 차이가 있는지 확인해보겠습니다.
App.js, Page.js, Result.js 세 가지 파일을 만들어 진행하겠습니다.
13.3.1 useTransition을 사용하지 않았을 경우
import { useState } from "react";
import Page from "./pages/Page";
function App() {
const [keyword, setKeyword] = useState();
return (
<div>
<Page keyword={keyword} setKeyword={setKeyword}/>
</div>
);
}
export default App;
App.js
가장 상위 컴포넌트인 App.js에서는 input 값에 입력되는 value를 담을 state를 선언하고 props로 하위 컴포넌트로 넘겨주었습니다.
import React from 'react'
import Result from '../components/Result'
function Page({keyword, setKeyword}) {
return (
<div>
<input type="text" onKeyUp={(e) => {setKeyword(e.target.value)}} />
<Result keyword={keyword}/>
</div>
)
}
export default Page
Page.js
Page.js에서 input 요소의 value를 setKeyword를 이용하여 state에 값을 저장하는 onKeyUp 이벤트 함수를 작성했습니다.
import React from "react";
function Result({keyword}) {
const bigWork = new Array(10000).fill(0);
return (
<div>
{
bigWork.map(()=> {
return <div>{keyword} {keyword} {keyword}</div>
})
}
</div>
)
}
export default Result
Result.js
state를 받아 div요소에 세번 반복하는 결과값을 출력하도록 작성했습니다. bigWork라고 선언된 배열이 map 함수를 통해 10,000개의 요소를 렌더링하는 과정에서 지연시간이 발생합니다. 이러한 지연시간으로 인해 사용자는 input 요소에 value를 입력할 때의 결과가 즉각적으로 보여지지 않아 불편함을 느낄 수 있습니다.
그림 13-2
직접 브라우저에서 실행해보면 input에 가나다라를 빠르게 입력했지만, 지연시간으로 인해 input에 입력하는 즉각적인 렌더링 반응속도가 매우 느린 것을 확인할 수 있습니다.
💡
크롬 브라우저에서 React Developer Tools 익스텐션을 설치하면 Profiler로 성능을 측정할 수 있습니다. 결과를 보면 useTransition을 사용하기 전에는 세개의 파일이 거의 동시에 렌더링되는 모습을 볼 수 있으며 렌더링 소요 시간이 74.4ms 인 것을 볼 수 있습니다.
가장 상위 컴포넌트인 App.js 에서 useTransition을 선언하고 하위 컴포넌트로 내려주겠습니다.
import React from 'react'
import Result from '../components/Result'
function Page({keyword, setKeyword, isPending, startTransition}) {
return (
<div>
<input type="text" onKeyUp={(e) => {
startTransition(()=>{
setKeyword(e.target.value)
})
}} />
<Result keyword={keyword} isPending={isPending}/>
</div>
)
}
export default Page
Page.js
이벤트 함수로 동작하는 setKeyword를 startTransition로 래핑하면 렌더링 시 우선순위가 설정되어 setKeyword 함수는 다른 코드들보다 나중에 처리됩니다. 때문에 사용자가 input에 입력하는 즉시 반응해야 하는 렌더링을 우선적으로 처리할 수 있습니다.
import React from "react";
function Result({keyword, isPending}) {
const bigWork = new Array(10000).fill(0);
return (
<div>
{
isPending ? (
<div>Loading...</div>
) : (
bigWork.map(() => {
return <div>{keyword} {keyword} {keyword}</div>
})
)
}
</div>
)
}
export default Result
Result.js
isPending을 이용하여 startTransition으로 감싼 setKeyword 업데이트가 진행될 때 Loading 문자가 렌더링 될 수 있도록 처리했습니다. useTransition을 사용하면 작업의 처리 순서를 변경했기에 이전보다 확실히 input에 타이핑할 때 즉각적으로 반응하고, isPending을 이용하여 작업 처리중이라는 의미의 로딩화면도 쉽게 보여줄 수 있는 장점이 있습니다.
그림 13-4
💡
useTransition을 사용하면 렌더링의 우선순위가 확실히 드러나며 무거운 작업이 있는 Result 컴포넌트가 렌더링 우선순위가 가장 낮은 것을 확인할 수 있습니다. 때문에 Page컴포넌트에 있는 input 의 타이핑에 대한 렌더링이 우선되기에 사용자는 버벅임이 줄어든 것 처럼 느낄 수 있습니다.
useTransition을 사용하면 무거운 작업이 동반되어 있는 상황에서도 사용자가 input에 입력하는 렌더링 결과가 우선적으로 처리됩니다. 여기서 주의할 점은 useTransition이 무거운 작업으로 인한 지연시간 자체를 줄이는 것은 아니라는 것입니다.
그림 13-5
13.4 (실습2) useTransition을 통해 페이지 이동 UX 개선하기
13.4.1 useTransition을 사용하지 않았을 경우
다음은 useTransition을 사용하기 전 예제입니다. App.js, PageOne.js, PageTwo.js 세 가지 파일을 만들어 진행하겠습니다.
App.js에서는 setPage가 지정하는 state 값에 따라 해당하는 컴포넌트를 화면에 렌더링합니다. App.js에서 누르는 버튼의 state 값에 따라 각각 PageOne과 PageTwo컴포넌트가 화면에 그려지는데, PageOne은 30,000개가량의 데이터가 렌더링 되도록 하여 데이터 호출 시 로딩 시간이 오래 걸리는 상황을 가정해보도록 하겠습니다. 상대적으로 PageTwo는 렌더링할 요소가 적기 때문에 화면에 바로 그려집니다.
import { useState } from "react";
import PageOne from "./PageOne.js";
import PageTwo from "./PageTwo.js";
function Router() {
const [page, setPage] = useState("/");
function navigate(url) {
setPage(url);
}
let content;
if (page === "/") {
content = (
<>
<button onClick={() => navigate("/page-one")}>
🏋️♀️ 데이터 로드가 많은 페이지
</button>
<button onClick={() => navigate("/page-two")}>🙂 일반 페이지</button>
</>
);
} else if (page === "/page-one") {
content = <PageOne />;
} else if (page === "/page-two") {
content = <PageTwo />;
}
return (
<>
<h1>{`홈`}</h1>
<main>{content}</main>
</>
);
}
export default function App() {
return <Router />;
}
import React from "react";
function PageTwo() {
return <div>일반 페이지입니다.</div>;
}
export default PageTwo;
PageTwo.js
그림 13-6
해당 코드를 실행했을 때, 홈에서 데이터 로드가 많은 페이지로 이동하는 버튼을 누른다면 데이터가 로드가 완료되기 전까지 화면이 멈추는 것과 같은 현상이 나타납니다. 또한 렌더링 요청이 끝나기 전까지는 일반 페이지를 클릭하는 등의 다른 작업을 할 수 없습니다.
13.4.2 useTransition 적용해보기
useTransition Hook을 적용해보기에 앞서 React 18에 도입된 Suspense 개념에 대해 간단히 알아보겠습니다.
💡
Suspense란 ? 컴포넌트가 비동기적으로 렌더링되는 경우 사용합니다. 데이터가 전부 로드되지 않은 상태를 리액트에게 알려주는 기능으로, Suspense 내부의 구성 요소가 데이터를 로드하는 동안에는 렌더링이 일시 중단되며 fallback 프로퍼티에 지정된 컴포넌트가 사용자에게 보여집니다.
여기서 fallback 컨텐츠가 렌더링 될 때에는 사용자에게 기존의 UI 렌더링이 일시적으로 중단되는 것처럼 보여집니다. 이 때 페이지를 보다 더 부드럽게 전환하기 위하여 useTransition 을 Suspense 와 결합하여 사용합니다. useTransition 내부에 지정한 함수로 인한 업데이트의 렌더링이 중단되는 경우, Suspense는 fallback에 지정된 컴포넌트를 보여주는 대신 UI를 유지하며 작업을 진행합니다.
이전 코드에서 이를 구현하기 위해 App.js에서 비동기 작업이 일어나는 Router 컴포넌트를 Suspense로 감싸주겠습니다. 이어서 useTranisiton Hook을 적용하여 우선순위를 설정해보겠습니다. startTransition 으로 setPage를 감싸 해당 함수가 데이터 로드를 완료할 때까지 기다리도록 React에게 알려주는 것입니다.
Suspense 로 감싼 Router 컴포넌트의 업데이트가 일어날 때 fallback에 지정된 ‘🤔 Loading...’이 렌더링될 것이라고 예상할 수도 있습니다. 하지만 React는 startTransition으로 감싼 setPage함수가 우선순위가 낮다고 인지하기 때문에, UI의 변경에 앞서 해당 페이지의 데이터 로드 작업이 백그라운드에서 먼저 진행되며 페이지 컴포넌트 렌더링은 후순위로 진행됩니다.
여기서 startTransition 과 함께 전달되는 프로퍼티인 isPendig 의 상태를 통해 화면 전환이 일어날 때의 로딩 상태를 시각적으로 표현할 수 있습니다. isPending 의 상태에 따라 출력되는 문구를 바꿔보겠습니다.
데이터가 로드 중일 때 Suspense의 fallback 컨텐츠를 보여주는 대신, 위와 같이 전환 업데이트의 isPending의 상태에 따라 조건 처리를 하여 로딩 인디케이터 역할을 하는 UI를 구현할 수 있습니다.
여기서 짚고 가야 할 useTransition의 특징은 startTransition으로 감싼 전환 작업이 중단될 수 있다는 것입니다. isPending 이 true 상태일 때, 일반 페이지를 클릭할 경우 PageOne 컴포넌트를 렌더링하는 작업이 중단되고 PageTwo로의 렌더링 전환이 일어납니다. 기존의 코드에 console.log를 추가하여 확인해보겠습니다. PageOne.js에는 useEffect Hook을 이용하여 데이터 로드가 완료되었을 때를 확인할 수 있는 console.log도 추가하겠습니다.
import { Suspense, useEffect } from "react";
export default function PageOne() {
console.log("🏋️♀️ 데이터 로드가 많은 페이지");
useEffect(() => {
console.log("마운트: 컴포넌트 처음 그려짐");
}, []);
return (
<>
<h1>많은 내용이 있는 페이지</h1>
<Suspense fallback={<h2>Loading...</h2>}>
{Array(30000)
.fill()
.map((v, i) => (
<div key={i}>불러온 데이터</div>
))}
</Suspense>
</>
);
}
import React from "react";
function PageTwo() {
console.log("🙂 일반 페이지");
return <div>PageTwo</div>;
}
export default PageTwo;
그림 13-8
해당 코드를 적용한 후, 데이터 로드가 많은 페이지 버튼을 눌러 pageOne 컴포넌트의 렌더링이 끝나기 전에 일반 페이지 버튼을 눌렀을 경우입니다. 콘솔에는 위와 같이 데이터 로드가 많은 페이지, 일반 페이지가 나란히 찍히며, 일반 페이지로 이동합니다.
이를 통해 startTransition으로 지정된 전환 업데이트 진행 중에 사용자 클릭이라는 긴급 업데이트 발생 시 기존의 전환 업데이트 작업이 중단되며 마지막 업데이트가 렌더링 되는 것을 확인할 수 있습니다.
그림 13-9
만약 전환 작업이 중단되기 전 페이지 로드가 완료되었다면 위와 같이 렌더링이 완료되어 컴포넌트가 그려졌을 것입니다.
이렇게 useTransition의 전환 작업이 중단된다는 성질을 이용하면, 사용자는 무거운 데이터를 불러오는 페이지를 클릭하고 나서 데이터 로드가 완료되기까지 기다리지 않아도 됩니다. 이것은 사용자가 서비스 이용 중 데이터 로드에 제약받지 않게 하여 페이지의 UX 성능 개선에 도움을 줄 수 있습니다.
여기까지 useTransition을 이용하여 UX 개선을 마친 App.js의 최종 코드입니다.
import { Suspense, useState, useTransition } from "react";
import PageOne from "./PageOne.js";
import PageTwo from "./PageTwo.js";
function Router() {
const [page, setPage] = useState("/");
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
let content;
if (page === "/") {
content = (
<>
<button onClick={() => navigate("/page-one")}>
🏋️♀️ 데이터 로드가 많은 페이지
</button>
<button onClick={() => navigate("/page-two")}>🙂 일반 페이지</button>
</>
);
} else if (page === "/page-one") {
content = <PageOne />;
} else if (page === "/page-two") {
content = <PageTwo />;
}
return (
<>
<h1>{isPending ? `⏳ 로딩중(isPending...)` : `홈`}</h1>
<main>{content}</main>
</>
);
}
export default function App() {
return (
<Suspense fallback={<h2>🤔 Loading...</h2>}>
<Router />
</Suspense>
);
}