웹에서의 Lazy Loading이란 필요한 자원을 미리 가져오지 않고 필요할 때 가져오는 전략을 말합니다. 웹에서 필요한 모든 자원들은 Lazy Loading의 대상이 될 수 있습니다.
뿐만 아니라 데이터를 미리 다 불러오지 않고 필요할 때 불러와 화면을 채우게 할 수 있기 때문에 axios나 fetch등의 클라이언트를 사용해 서버에 요청을 보내 가져오는 데이터(AJAX) 역시 Lazy Loading의 한 종류로 볼 수 있습니다.
주로 JS 번들을 스플리팅하고 웹 자원 중 코드를 Lazy Loading하는데 쓰였던 Suspense는 React 18에서 무엇이든 기다릴 수 있는 기능으로 확장됩니다. Suspense는 이제 이미지, 스크립트, 그 밖의 비동기 작업을 기다리는데에 모두 사용 될 수 있는 기능입니다.
가장 일반적인 세 가지 렌더링 아키텍처와 리액트 Suspense의 역할은 아래와 같습니다 :
1️⃣ CSR
React.lazy
가 로드되는 동안 폴백을 표시합니다.
Suspense 호환 프레임워크로 데이터를 불러올 때 로딩/오류를 선언적으로 처리합니다.
2️⃣ SSR
(CSR 내용 포함)
<Suspense />
로 감싸진 SSR 컴포넌트는 클라이언트에서 선택적으로 hydration됩니다.
3️⃣ Server Component
(SSR 내용 포함)
<Suspense />
로 감싸진 비동기 서버 컴포넌트는 단계적으로 클라이언트에서 스트리밍됩니다. fallback → 최종 콘텐츠 순으로 스트리밍됩니다.
CSR은 리액트의 가장 기본적인 렌더링 방법입니다.
① 요청 시 서버는 html 파일로 응답을 보냅니다.
이때 html 파일은 JS bundle을 참조하는 <script>
태그가 있는 기본적인 형태입니다.
② 자바스크립트가 로드되고 실행되면 페이지에 콘텐츠를 생성하고 빈 html 파일을 채웁니다.
③ 네비게이션은 완전히 클라이언트 측에서 이루어지며 서버에 추가 요청을 하지 않으므로 Suspense의 첫 번째 사용 사례로 이어집니다.
JS bundle에는 앱의 모든 부분을 생성하는 데 필요한 코드가 포함되어 있기 때문에 상당히 커질 수 있습니다. 페이지의 콘텐츠가 렌더링되기 전에 전체 자바스크립트 파일을 로드, 구문 분석 및 실행해야 하므로 이는 심각한 성능 병목 현상이 됩니다.
앱을 여러 개의 다른 JS bundle로 분할하여 각각 필요할 때만 클라이언트에 전송하기 위해 Suspense
와 React.lazy
를 사용할 수 있습니다.
React 16.6부터 추가된 Suspense는 주로 JS bundle의 Lazy Loading을 위한 기능이었습니다. React.lazy
를 사용해 컴포넌트를 동적으로 import 후 Suspense로 감싸주면 자동으로 bundle이 분리되고(Code Splitting) 해당 컴포넌트가 렌더링될 필요가 있을 때 React가 비동기적으로 bundle을 가져오는 방식입니다.
// 지연 로딩
const ProfilePage = React.lazy(() => import('./HomePage'));
// HomePage를 불러오는 동안 스피너를 표시
<Suspense fallback={<Spinner />}>
<HomePage />
</Suspense>;
비동기로 load되는 컴포넌트를 감싸는 Suspense 컴포넌트의 fallback prop으로 로딩 UI를 넣어주면, 컴포넌트를 가져오는 동안 보여줄 로딩 UI를 선언적으로 지정할 수 있습니다. JSX를 복잡하게 만들지 않고 직관적으로 로딩 UI를 지정할 수 있다는 점이 특히 편리합니다.
현재 SSR 프레임워크는 선택적 하이드레이션을 지원 ❌
앱 디렉토리를 사용하는 Next.js 애플리케이션에서만 서버에서 html로 렌더링되는 클라이언트 컴포넌트에 대한 선택적 하이드레이션을 지원합니다.
CSR에 비해 SSR은 JS bundle이 로드되고 실행되는 동안 사용자가 서버에서 생성된 일부 html을 볼 수 있으므로 첫 페이지 로드 시 더 나은 사용자 경험을 제공합니다. 하지만 자바스크립트가 없으면 어차피 페이지와 상호 작용할 수 없기 때문에 Suspense의 세번째 사용 사례인 선택적 하이드레이션이 발생하게 됩니다.
Suspense
로 컴포넌트를 감싸게 되면 리액트는 해당 컴포넌트를 페이지의 다른 컴포넌트들과 별개로 hydration을 진행합니다. 언뜻 보기에는 선택적 하이드레이션과 큰 차이가 없어 보입니다. 모든 hydration된 html이 클라이언트에 한꺼번에 전송되면 전체 페이지가 동시에 hydration될 것이기 때문입니다.
하지만 아래 두 가지 상황에서 차이가 드러납니다 :
① 여러 컴포넌트를 Suspense로 감싸는 경우
리액트는 사용자가 현재 어떤 컴포넌트와 상호작용하고 있는지에 따라 어떤 컴포넌트를 먼저 hydration할지 결정할 수 있습니다.
즉, 리액트는 페이지 내에서 우선순위를 정해 사용자와 상호 작용 가능한 부분을 먼저 hydration해 제공한 후 백그라운드에서 페이지의 나머지 부분을 hydration할 수 있다는 겁니다. 자바스크립트 구문 분석 및 실행에 병목 현상이 발생할 수 있는 느린 기기에서는 사용자에게 더 빠른 경험을 제공할 수 있습니다.
② 스트리밍 아키텍처를 사용하는 경우
페이지의 여러 부분을 클라이언트에 개별적으로 전송할 수 있으므로 페이지의 다른 부분이 서버에서 렌더링되는 동안 특정 html 청크를 클라이언트에 전송하여 선택적으로 hydration할 수 있습니다.
스트리밍 아키텍처는 데이터나 콘텐츠를 조각조각으로 전송하는 방식을 가리킵니다.
이러한 방식을 사용하면 전체 데이터나 콘텐츠가 준비되지 않았더라도 일부분을 클라이언트로 전송하여 이를 먼저 사용할 수 있게 됩니다. 특히 SSR에서는 페이지의 일부분을 서버로부터 클라이언트로 스트리밍하여 사용자가 더 빠르게 페이지를 볼 수 있도록 하는데 사용될 수 있습니다.
서버 컴포넌트는 클라이언트로 전송되기 전에 서버에서 html을 렌더링하는 리액트 컴포넌트입니다. SSR과 비슷한 점이 많지만 서버 컴포넌트는 서버 전용이며 클라이언트에서 실행되지 않습니다. 사용자와 상호 작용을 하는 event handler, state 또는 hook을 사용할 수 없습니다. 즉, 서버 컴포넌트는 사용자와의 상호 작용이 필요 없는 정적 데이터를 가져오고 렌더링하는 데 최적화되어 있습니다.
export default async function Post() {
const data = await fetch(...)
return <div>{data}</div>
}
위 예시를 한 번 살펴 봅시다.
데이터가 로드될 때까지 기다린 다음 콘텐츠를 html로 렌더링하여 클라이언트로 전송하고 있습니다. 비동기 작업이 완료될 때까지 컴포넌트는 화면에 렌더되지 않으므로 사용자는 해당 페이지에서 빈 화면 밖에 볼 수 없습니다.
이러한 상황을 위해 로딩 상태가 만들어졌습니다.
Suspense를 사용하면 서버 컴포넌트에 로딩 상태를 부여할 수 있습니다.
async function Post() {
const data = await fetch(...)
return <div>{data}</div>
}
export default function Wrapper() {
return (
<Suspense fallback={<div>Loading ...</div>}>
<Post />
</Suspense>
)
}
비동기 서버 컴포넌트를 <Suspense />
로 감싸면, 리액트는 컴포넌트에서 필요로 하는 데이터들을 불러오는 동안 fallback을 렌더링해 클라이언트로 먼저 보냅니다. 그러면 사용자는 fallback의 loading 화면을 보고 있게 되는 것입니다.
이후 데이터 로딩이 완료되면 컴포넌트 자체에서 렌더링된 콘텐츠를 전송해 사용자가 해당 html의 내용을 보게 됩니다. 이 과정을 스트리밍이라고 합니다.
Suspense는 비동기 작업을 처리하고 사용자 경험을 향상시키는 데 중요한 역할을 합니다. 그러나 애플리케이션에서 발생하는 예기치 못한 런타임 에러는 별도의 처리가 필요합니다. 이때 Error Boundary가 중요한데, 이를 통해 애플리케이션의 안정성을 유지하고 사용자에게 친숙한 오류 화면을 제공할 수 있습니다.
즉, 언젠가는 예상치 못한 곳에서 문제가 생길 수 있다는 사실을 인지하고 이를 다루기 위한 Fault Tolerance이 필요합니다.
내결함성이란 시스템이 시스템을 구성하는 컴포넌트 중 일부가(하나 또는 그 이상) 고장나더라도 계속 동작할 수 있도록 하는 속성입니다.
React 앱에 내결함성을 부여하기 위해서는 결과부터 말하자면 error boundary를 사용하면 됩니다.
Error Boundary를 적용하는 것 자체는 전혀 어렵지 않습니다.
고려해야 하는 것은 error boundary를 어디에 배치할지입니다. 골디락스의 원리를 따라 "딱 적당한" 만큼 error boundary를 설정하는 것이 제일 좋겠지만 다시 적당하다의 기준이 무엇인지 고민해봐야 합니다.
적당하다의 기준을 세우기 위해서는 먼저 극단적인 예시를 통해 각각 어떤 문제가 발생하는지 파악해볼 필요가 있습니다.
최상단에 App 컴포넌트를 감싸는 단 하나의 error boundary만 있다는 것은 아래와 같습니다.
이렇게 코드를 짜게 되면 서버 측에서 렌더링되는 애플리케이션이 고장날 때와 결과가 유사하게 나타나게 됩니다. 최악의 경험은 아니겠지만 최선의 조치도 아닙니다.
이러한 에러 처리가 초래하는 문제는 결국 한 군데만 고장나도 전체가 고장난 것처럼 보인다는 것입니다.
import ErrorBoundary from "react-error-boundary";
import App from "./App.js";
ReactDOM.render(
<ErrorBoundary>
<App />
</ErrorBoundary>
);
내결함성이란 시스템이 시스템을 구성하는 컴포넌트 중 일부가 고장나더라도 계속 동작할 수 있도록 하는 속성입니다.
결과적으로 단일 Error Boundary의 경우 하나의 컴포넌트에서 문제가 발생하게 되면 전체 애플리케이션이 망가지기 때문에 사실상 내결함성을 제공하지 못한다고 볼 수 있습니다.
반대로 모든 컴포넌트를 Error Boundary로 감싸게 된다면 어떻게 될까요?
// 모든 컴포넌트에 Error Boundary를 부여할 경우
<form>
<ErrorBoundary>
<CartDescription items={props.items} />
</ErrorBoundary>
<ErrorBoundary>
<CreditCardInput />
</ErrorBoundary>
<ErrorBoundary>
<CheckoutButton cartId={props.id} />
</ErrorBoundary>
</form>
전의 상황과 반대되기 때문에 내결함성을 확실하게 제공할 수 있겠다는 생각이 들 수 있습니다. 하지만 이 방식의 문제점은 단순히 오류가 미치는 영향을 최소화하는것과 내결함성은 다르다는 것입니다.
예를 들어, CreditCardInput
에서 에러가 발생하는 상황을 살펴봅시다 :
<form>
<ErrorBoundary>
<CartDescription items={props.items} />
</ErrorBoundary>
<ErrorBoundary>
{/* Oops! Error Occured! 😢 */}
<CreditCardInput />
</ErrorBoundary>
<ErrorBoundary>
<CheckoutButton cartId={props.id} />
</ErrorBoundary>
</form>
① <CreditCardInput />
은 자체적인 Error Boundary를 가지기 때문에 해당 오류는 CheckoutForm
컴포넌트로 전파되지 않습니다.
② 하지만 <CheckoutForm />
은 <CreditCardInput />
컴포넌트 없이는 사용이 불가합니다.
③ <CheckoutButton />
과 <CardDescription />
는 여전히 마운팅된 상태이기 때문에 사용자는 장바구니를 확인하고 결제 시도를 진행할 수 있습니다.
위와 같은 상황에서 발생할 문제는 바로 아래 질문들을 통해 확인할 수 있습니다 :
만약 사용자가 카드 정보를 <CreditCardInput />
에 오류가 발생하기 전에 입력을 했다면 그 상태는 보존될까요? 🤔
그 상태로 결제 시도를 하면 어떻게 되죠? 😵💫
사용자는 결국 혼란스러운 사용자 경험을 하게 될 것입니다.
모든 컴포넌트를 error boundary로 감싸는 것의 문제점은 여기서 끝이 아닙니다. 각 error boundary의 fallback 값이 어떤 것이냐에 따라서도 다양한 혼란을 겪게 됩니다.
fallback 값이 없어서 해당 컴포넌트가 그냥 보여지지 않는 상황도 혼란스러울 것이고, 공유 fallback UI를 사용할 때에도 마찬가지입니다.
UI가 부재 중인 상황보다는 낫지만 모든 컴포넌트를 다 error boundary로 감싼 상황이라면 각각의 컴포넌트의 오류 상황에 맞게 fallback을 제공해야 합니다.
각각의 컴포넌트들은 서로 다른 레이아웃/크기를 갖을 가능성이 높기 때문에 이를 올바르게 구현하는 것도 어려울 뿐더러 구현했다고 하더라도 정말 조잡할 것입니다.
자세히 설명하자면 페이지 단위의 컴포넌트에 맞춘 fallback UI는 input이나 button과 같은 컴포넌트에는 적용할 수 없을 것입니다. (그 반대도.)
결과적으로 모든 컴포넌트를 error boundary로 감싸는 것은 혼란스럽고 조잡한 사용자 경험을 제공하게 됩니다. 애플리케이션 상태의 일관성을 해쳐 사용자들에게 혼동과 실망을 초래하기 때문입니다.
애플리케이션의 특성마다 상황이 다르기 때문에 정확한 수치를 제시하는 것은 어렵습니다. 가장 좋은 접근방식은 애플리케이션의 기능 경계를 파악하고 error boundary를 그곳에 배치하는 것입니다.
⚠️ 성능 패널티
Error Boundary는 오버헤드를 가지고 있어서, 과도하게 사용할 경우 성능에 부정적인 영향을 줄 수 있으므로 남용하면 안 됩니다.
대부분의 애플리케이션은 독립적인 섹션들을 조합해서 만들어집니다. 각 요소의 컴포넌트들의 집합이 하나의 페이지를 그려내고 개별 컴포넌트들은 어느 정도의 독립성을 유지하게 됩니다.
layout으로 나누는 구조를 떠올리면 편합니다.
기본적으로 header, content(main혹은 section), footer로 나뉘죠. content는 다시 side menubar와 같이 고정된 컴포넌트와 바뀌는 컴포넌트들로 나뉠 수 있습니다.
아래 인스타그램 홈페이지를 만든다면 박스 친 부분들이 각각 컴포넌트가 될 겁니다. 일반적으로 딱 봤을 때 분리된 section들은 독립적인 기능을 갖기 때문에 이런 부분들에 각각 error boundary를 배치하는 것이 좋습니다.
위의 컴포넌트들 중 하나에 문제가 생겼다고 다른 부분에 지장이 가서는 안 된다고 볼 수 있습니다. 위 페이지에서 이어 예를 들자면, 위 친구들의 목록이 뜨는 부분에 에러가 발생했다고 좌측의 menubar가 화면에 렌더되지 않으면 안 된다는 겁니다.
error boundary의 배치를 더욱 정석에 가깝게 하고 싶다면 아래와 같은 질문을 하는 것이 가장 좋습니다.
"이 컴포넌트에서 발생할 에러가 형제 컴포넌트들에게 어떻게 영향을 미쳐야 할까?"
각 컴포넌트들 간의 관계를 생각하며 error의 boundary를 정하는 것이 가장 적절한 위치일테니까요.
위 인스타그램 페이지에서 계속 이어 예시를 들어보겠습니다.
인스타그램 친구들의 게시물을 불러오는 과정에서 지연이나 에러가 발생한 상황입니다. 이러한 에러 상황이 좌측 menubar에 영향을 끼쳐야 할까요?
더 자세히 풀어보자면 :
① 친구들의 게시물의 부재로 인해 좌측 menubar도 같이 화면에 렌더되지 않는 것이 맞을까요?
② 친구들의 게시물의 부재가 menubar와 연관이 있을 필요가 있나요?
각 컴포넌트들 간에 영향을 주고 받아야 한다면 같은 경계 안에 넣어두는 것이 좋겠죠? 🙂
Chaos Engineering
내결함성을 테스트하기 위해 의도적으로 오류를 발생시키는 것은 카오스 엔지니어링의 가장 가벼운 예제 중 하나입니다.
컴포넌트들 간의 주고 받을 영향을 고려하는 것이 어렵다면 실제로 에러를 발생시켜 직접 확인하는 것도 하나의 확실한 방법이 될 수 있습니다.
function OnePost(props) {
// ✅ throw new Error on purpose
throw new Error("oops, Error Occured!");
return (
<main>/*...코드 생략...*/</main>
);
}
일반적으로 비동기 요청은 로딩, 실패(에러), 성공의 3가지 상태를 갖습니다. 이 3가지의 상태에 해당하는 UI를 Suspense와 ErrorBoundary를 함께 사용해 모두 선언적으로 표현할 수 있습니다.
선언형 프로그래밍 : 비동기 상태값에 따른 UI를 Prop으로 주입하기
function OnePost() {
const { data } = apiClient.read('api/post');
return (
<div>{data.content}<div/>
)
}
function App() {
return (
<ErrorBoundary fallback={<Error/>}> // 실패 UI
<Suspense fallback={<Spinner/>}> // 로딩 UI
<OnePost /> // 성공 UI
</Suspense>
</ErrorBoundary>
)
}
Suspense는 데이터 가져오기와 같은 비동기 작업을 처리하고, Error Boundary는 예기치 못한 에러를 적절하게 처리하여 애플리케이션의 안정성을 유지할 수 있도록 도와줍니다.
결과적으로 사용자가 원활하게 상호작용할 수 있도록 데이터를 로딩하고 오류를 처리하여 더욱 향상된 사용자 경험을 제공하기 때문입니다.
Suspense와 Error Boundary를 함께 활용함으로써 비동기 처리를 통해 더 빠르고 부드러운 사용자 경험을 제공할 수 있습니다.
References.