서비스를 사용하는 유저들에게 더 나은 좋은 경험을 제공하기 위한 기술적 고민을 했다.
그 결과, 디자인 시스템을 도입함으로써 사용자경험보다는 "개발자 경험"을 향상시켜주었다면, Concurrent Rendering을 도입함으로써 개발자보다는 "사용자 경험"을 향상시켜주었다. (사실 디자인 시스템이나 Concurrent나 모두 사용자, 개발자 경험을 향상했지만 굳이 따지자면 그렇다는 것이다.)
React 18 버전에서 release된 Concurrent Rendering이라는 기능은 사실 Concurrent Mode로서 처음 세상에 나오게 됐었는데, 현재는 Concurrent Mode 라는 단어는 다음과 같은 이유로 사용하지 않는다고 React 팀에서 발표했다.
React 18 공식 문서에서 아래처럼 내용을 다루고 있다.
- 점진적 채택 가능성: “Mode”라는 단어는 전체 애플리케이션에 대한 on/off 스위치와 같은 느낌을 주지만, “Rendering”은 더 유연하고 점진적인 도입을 암시한다.
- 혼란 감소: 많은 개발자들이 Concurrent Mode를 하나의 기능으로 오해했다. 실제로는 여러 기능의 집합이며, 이를 “Concurrent Rendering”으로 표현하는 것이 더 정확하다.
그럼 먼저 이 Concurrent Rendering이 무엇인지 살펴보도록 하겠다.
Concurrent Rendering (React 18)
동시성
Concurrent는 동시성이라는 뜻으로 흔히 운영체제에서 컴퓨터가 동시에 여러가지 일들을 처리하는 것처럼 보이도록 하기 위해 작은 단위로 테스크를 쪼개고 이를 번갈아가면서 실행하는 방식을 의미한다.
React 18 이전에는 렌더링은 개입할 수 없는 하나의 동기적인 처리로만 이뤄지는 병렬적 방식이었다. 그래서 브라우저에 렌더링이 시작되면 중단/재개/폐기할 수 없었다.
만약 동시성이 지원되지 않을 경우에 렌더링이 오래 걸린다면 다음에 수행해야하는 작업은 블로킹되어 버벅거리는 현상이 발생할 수 있다. 이는 네트워크의 영향, 수많은 DOM element를 생성하는 등으로 인해 발생할 수 있다.
따라서 이런 동시성의 개념은 리액트의 복잡한 UI 상호작용을 처리하기 위해서도 필요해진 것이다.
React 18 에서 도입된 동시성(Concurrency)은 렌더링 엔진의 성능을 개선시키고, 사용자 경험을 향상시켰다.
Concurrent Rendering은 동시성 렌더링으로 React 17에서 사용했던 render
대신 createRoot
를 사용하면 된다.
- React 17
import ReactDOM from 'react-dom';
import App from 'App';
const container = document.getElementById('app');
ReactDOM.render(<App />, container);
- React 18
import ReactDOM from 'react-dom';
import App from 'App';
const container = document.getElementById('app');
const root = ReactDOM.createRoot(container);
root.render(<App />);
이렇게 설정하면 ReactDOM.createRoot 실행시 내부적으로 "Concurrent Features"를 사용할 때만 "Concurrent Rendering"를 수행하게 된다.
더불어, 개선된 기능들과 동시 처리를 위한 startTransition
, useTransition
, useDeferredValue
훅들을 사용할 수 있다.
전까지 프론트엔드 비동기 처리는 어땠나?
그동안 프론트엔드에서 복잡한 비동기 처리를 그나마 동기적으로 보이게 하기 위한 노력과 진화 과정을 거쳤었다.
예컨데, Promise의 단점을 보완한 Async-await 방식을 적용하는 등의 예시를 살펴볼 수 있을 것이다. 이 경우 어떠한 문제와 해결점이 있었을까?
Promise의 문제로는 콜백 지옥, 호출이 성공하는 경우와 실패하는 경우가 섞여서 처리됨, 비동기 호출 시 매번 에러 처리 등의 불편한 점이 많았다. 이러한 로직은 좋은 코드라고 할 수 없을 것이다.
반대로, Async-await 방식은 호출을 '성공하는 경우'를 다루고, '실패하는 경우'는 catch절에서 분리해서 외부에 위임할 수 있다. 이렇게 성공, 실패의 경우를 분리하고 처리해서 함수의 책임이 명확하게 드러나도록 하였다. 이는 좋은 코드의 예시를 적절히 만족한다.
💡 좋은 코드 예시
- 성공, 실패의 경우를 분리해서 처리한다.
- 비즈니스 로직을 한 눈에 파악할 수 있다.
- 함수의 책임이 명확하게 드러난다.
하지만 더 나아가 컴포넌트를 작성할 때는 어땠을까? 보통 아래와 같이 컴포넌트에서 로딩과 에러 처리를 동시에 수행했었다.
function Page() {
const foo = useAsyncValue(()=> {
return fetchFoo();
})
if (foo.error) return <div>로딩에 실패했습니다.</div>
if (!foo.data) return <div>로딩 중입니다..</div>
return <div>{foo.data.name}님 안녕하세요!</div>
}
이렇게 컴포넌트를 작성하는 것은 성공하는 경우와 실패하는 경우가 섞여서 처리되는 것이다. 또한 실패하는 경우에 대한 처리를 외부에 위임하기 어렵다.
살펴보면 앞서말한 문제점들이 거의 그대로 나타난다.만약 여기서 여러 개의 비동기 작업이 동시에 실행된다면? 상태는 더욱 심각해진다. 콜백 지옥과 비슷하게 문제점이 나타날 것이다.
왜냐하면 1개의 호출은 로딩/에러/완료 상태를 갖는다. 그런데 2개의 비동기 호출이 있다고 생각해봤을 때, 총 나타나는 상태는 3의 제곱으로 9가지 상태를 가질 수 있다. 그렇다면 비동기 호출이 3,4개가 된다면 상태는 더욱 복잡해진다.
이는 위에서 말한 좋은 코드의 예시가 될 수 없을 것이다.
성공하는 경우에만 집중해 컴포넌트를 구성하기 어렵고, 2개 이상의 비동기 로직이 개입할 때 비즈니스 로직을 파악하기 점점 어려워진다.
그래서 이러한 문제 때문에 리액트의 비동기 처리는 어렵다고 느낄 수 있다.
이런 문제를 해결해주는 도구가 바로 "React Suspense of Data Feteching"이다. 이는 '우아하게 비동기 처리'를 할 수 있도록 해준다.
React Suspense of Data Feteching
React Suspense of Data Feteching은 React 팀에서 다음과 같은 목표를 추구한다.
- 간단히 말해, async-await 급으로 비동기 처리하며 간단하고 가독성 좋은 리액트 컴포넌트를 만드는 것이다.
- 즉, 컴포넌트는 성공한 상태만 다루고, 로딩 상태와 에러 상태는 외부에 위임(분리) 함으로써 동기적인 코드와 큰 차이가 없는 코드를 만든다.
사용 방식은 아주 간단하다. 함수의 에러 처리를 감싸는 catch문에서 하는 것처럼 로딩 상태와 에러 처리도 컴포넌트를 사용하는 곳에서 해주면 된다.
try-catch문과 ErrorBoundary, Suspense 구문 유사성
-
try-catch
try { await fetchFooBar(); } catch (error) { // 에러처리구간 }
-
ErrorBoundary, Suspense
App 전체에서 로딩 상태와 에러 상태를 처리해주는 핸들러를 선언한 것이다.
로딩 상태는 가장 가까운 Suspense의 fallback으로 그려지고, 에러 상태는 ErrorBoundary의 fallback으로 처리된다.
<ErrorBoundary fallback={<ErrorPage />}> <Suspense fallback={<Loader />}> <App /> </Suspense> </ErrorBoundary>
Suspense
Suspense를 사용하기 위한 비동기 처리 라이브러리인 SWR이나 Tanstack-query에서는 {suspense: true}
옵션을 사용해주면 된다.
<Suspense>
를 사용하면 자식 컴포넌트가 완료될 때까지 fallback UI를 표시할 수 있다. React 16.6에서 실험적 기능으로 추가됐었는데 18 버전에서 정식 기능으로 업데이트 되었다.
Suspense를 사용하면 비동기 데이터가 로딩중일 때와 사용 가능할 때를 "선언적"으로 분리해서 처리할 수 있다. 명령형이 아닌 선언형으로 컴포넌트를 분리하여 처리함으로써 관심사의 분리와 더욱 간결한 코드를 작성할 수 있게 되었다. 여기서 말하는 명령형, 선언형은 다음 카카오페이의 기술 블로그 글을 보면 이해가 빠를 것이다!
사용 후기
실제로 직접 웹 서비스에 React Suspense of Data Feteching를 적용해보니 사용자 경험 측면에서도 데이터가 준비되는 대로 단계적으로 보여줄 수 있었다.
많은 비동기적인 문제를 깔끔하고 우아하게 처리할 수 있게 되었고, 코드의 복잡도도 줄일 수 있었다.
이렇게 코드 조각을 감싸는 맥락으로 책임을 분리하는 방식을 "대수적 효과(Algebraic Effects)"라고 한다.
객체 지향의 의존성 주입(DI), 의존성 역전(IoC)과도 유사하다. 대수적 효과를 지원하는 언어에서 함수는 필요한 코드 조각을 선언적으로 사용한다. 실제로 관련된 처리는 함수를 감싸는 부모 함수나 런타임이 대신 처리하는 형식이다.
프로젝트에 적용한 방식을 다음글로 이어서 다뤄보도록 하겠다! useApiError
hooks을 만들어 ErrorBoundary의 활용도를 높인 방식까지 다뤄보도록 하겠다 🚩☺️
React 19의 지향점
24년 12월 React 팀이 출시한 React 19 버전에서 위의 대수적 효과 및 선언적 기능들을 Hooks으로 새롭게 출시했다.
useActionState, useOptimistic, useTransition, useFormStauts, use hooks들인데, 이것과 관련해서는 시간이 되면 다뤄보도록 하겠다!
이렇게 새로운 hooks으로 도입된만큼 가치가 있는 기능인 듯 하다.
React 팀은 계속해서 Concurrent Mode, Concurrent Rendering을 강조하고 있기 때문에, 앞으로 더 많은 기능과 최적화가 이루어질 것으로 예상된다.
향후 React 19 버전으로 프로젝트를 진행한다면, 해당 이해도를 가지고 좀 더 수월하게 Concurrent UI를 구축할 수 있을 것 같다! 🤭