선언형
React는 상호작용이 많은 UI를 만들 때 생기는 어려움을 줄여줍니다. 애플리케이션의 각 상태에 대한 간단한 뷰만 설계하세요. 그럼 React는 데이터가 변경됨에 따라 적절한 컴포넌트만 효율적으로 갱신하고 렌더링합니다.
선언형 뷰는 코드를 예측 가능하고 디버그하기 쉽게 만들어 줍니다.
React – 사용자 인터페이스를 만들기 위한 JavaScript 라이브러리 (reactjs.org)
리액트 공식 홈페이지에서 React는 선언형 라이브러리로 소개되었다.
프로그래밍에서 선언적이라는 것이 어떤 것을 의미할까?
프로그래밍 패러다임
프로그래밍 패러다임은 프로그래머에게 프로그래밍의 관점을 갖게 해 주고, 결정하는 역할을 한다.
대표적인 프로그래밍 패러다임으로는 객체지향 프로그래밍, 함수형 프로그래밍 등이 있다.
객체지향 프로그래밍은 프로그래머들이 프로그램을 상호작용하는 객체들의 집합으로 볼 수 있게 해주며, 함수형 프로그래밍은 상태값을 지니지 않는 함수값(순수함수)들의 연속으로 생각할 수 있게 해준다.
선언형 프로그래밍과 명령형 프로그래밍도 프로그래밍 패러다임의 한 종류다.
명령형 프로그래밍
상태와 상태를 변경시키는 구문의 관점에서 연산을 설명하는 프로그래밍 패러다임의 일종이다.
명령형 프로그래밍은 수행할 명령들을 순서대로 써 놓은 것이다.
ex. A에서 B로 이동하는 과정에서 a 도로를 이용해서 속도를 60km를 유지한 채 우회전 두 번, 좌회전 한 번을 통해 목적지까지 도착해라
const arr = [1, 2, 3, 4, 5];
const result = [];
for (const i in arr) {
result.push(i * 2);
}
console.log(result);
명령형 프로그래밍에서는 "어떻게"에 집중한다.
따라서, 위와 같이 arr 배열에서 각 요소에 2를 곱한 result 배열을 어떻게 구하는지 개발자가 직접 작성해준다.
(for를 사용해서 arr의 각 요소에 접근한 뒤 2를 곱해서 result 배열에 요소를 push 한다)
선언형 프로그래밍
명령형 프로그래밍과 다르게 "어떻게"에 집중하지 않고 "무엇을"에 집중하는 프로그래밍 패러다임이다.
위와 같이 A에서 B로 이동하는 예시가 있다면 어떻게 B로 이동하는지에 관한 내용이 아니라 "A에서 B로 이동해라"로만 작성하는 것이다.
프로그램이 어떻게 작동하는지가 아니라 무엇을 다루는지에 집중하는 프로그래밍 패러다임이다.
const arr = [1, 2, 3, 4, 5];
const result = arr.map((i) => i * 2);
console.log(result);
개발자가 map이라는 함수의 세부 구현에 대해서 모르더라도 배열의 각 요소에 2를 곱한 배열을 만들 수 있다.
이 프로그램에서 "무엇"에 해당하는 것은 result 배열이다.
선언형 프로그래밍에서는 "어떻게"가 아닌 "무엇"에 집중한다고 했다.
따라서, map의 세부 구현이 어떤 것인지 알 수 없지만 result 배열을 얻는데는 문제가 없으며 과정에 대해 관심을 갖지 않는다.
리액트는 선언형?
그렇다면 리액트는 왜 선언형일까?
아래와 같은 컴포넌트가 있다고 가정해보자.
function Nav() {
return (...)
}
function Contents() {
return (...)
}
function Footer() {
return (...)
}
function App() {
return (
<>
<Nav />
<Contents />
<Footer />
</>
)
}
개발자는 App 컴포넌트만 봐도 화면이 어떤 형태를 할 지 예측할 수 있다.
Nav의 상세 구현을 몰라도 Nav가 화면의 최상단에 위치할 것이고, Contents와 Footer가 아래에 올 것이라는 것을 알 수 있다.
만약, 리액트가 명령형이라면 어떻게 될까?
개발자가 Nav라는 컴포넌트를 렌더링하기 위해서는 document 객체에 접근해서 appendChild 등을 사용해서 직접 DOM 요소를 추가해야 할 것이다.
(물론 함수 형태로 생긴 컴포넌트를 일반적인 HTML 요소로 변환하는 과정도 직접 해야 할 것이다)
리액트는 선언형 UI 라이브러리이므로 선언적으로 작성해도 화면을 그릴 수 있다.
개발자는 "어떻게"에 해당하는 리액트의 세부 구현, DOM을 다루는 방식 등에 집중하지 않고 "무엇"에 집중하여 어떤 컨텐츠를 보여줄지만 고려하면 된다.
리액트 with 명령형 프로그래밍
리액트에서 명령형 프로그래밍은 어떤 것을 의미할까?
(리액트의 세부 구현을 직접 작성하는 것은 당연히 아닐 것이다)
리액트는 컴포넌트로 구성되어있다.
각 컴포넌트는 자신의 UI를 보여주는 역할을 한다.
function Component() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
const [data, setData] = useState();
useEffect(() => {
async () => {
try {
setIsLoading(true)
const data = await fetch('url')
setData(data);
} catch (err) {
setError(err)
} finally {
setIsLoading(false)
}
}();
}, [])
if (isLoading) return <Loading />
if (error) return <Error error={error} />
return <Data data={data} />
}
일반적으로 위와 같이 작성하는 경우가 많다.
각 state에 따라 보여줄 내용이 무엇인지 작성했다.
해당 컴포넌트는 UI를 보여주는 역할을 수행하지만, 어떻게 보여주는지에 집중하고 있다.
Component는 data를 보여주는 역할을 해야하며 이 과정에서 어떤 과정을 통해 그 데이터를 보여줄지에 집중했으므로 명령형 프로그래밍 방식으로 작성했다고 볼 수 있다.
리액트 with 선언형 프로그래밍
그렇다면 선언형 프로그래밍으로는 어떻게 작성할 수 있을까?
리액트에서는 선언형 프로그래밍 방식을 사용하기 위해 Suspense와 ErrorBoundary를 제공한다.
(ErrorBoundary는 함수형 컴포넌트로 제공되지 않으며 사용자가 직접 클래스형 컴포넌트를 생성하여 사용해야 한다. 이 과정을 생략할 수 있도록 도와주는 라이브러리를 사용할 수도 있다. react-error-boundary - npm (npmjs.com); 아래 예제에서는 해당 라이브러리 사용)
function Component() {
const [error, setError] = useState();
const [data, setData] = useState();
useEffect(() => {
async () => {
try {
const data = await fetch('url')
setData(data);
} catch (err) {
setError(err);
}
}();
}, []);
if (error) return <Error error={error} />
return <Data data={data} />
}
function App() {
return (
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
)
}
위와 같이 Suspense를 도입할 수 있다.
Suspense의 용도는 코드에서 알 수 있듯이 children이 렌더링 되는 과정이 오래 걸리는 경우(데이터 fetching, 코드 스플리팅으로 인한 lazy loading 등) fallback에 해당하는 컴포넌트를 렌더링 한다.
ErrorBoundary도 같은 방법으로 사용할 수 있다.
import { ErrorBoundary } from "react-error-boundary";
function Component() {
const [data, setData] = useState();
useEffect(() => {
async () => {
const data = await fetch('url')
setData(data);
}();
}, []);
return <Data data={data} />
}
function App() {
return (
<ErrorBoundary FallbackComponent={Error}>
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
</ErrorBoundary>
)
}
react-query (tanstack-query)와 함께 사용하는 경우 아래와 같이 작성할 수 있을 것이다.
import { ErrorBoundary } from "react-error-boundary";
function useDataSuspenseQuery(url: string) {
const authAxios = useAuthAxiosInstance();
const getData = async () => {
const response = await authAxios.get<Data>(url);
return response.data;
};
return useSuspenseQuery({
queryFn: getData,
});
}
function Component() {
const { data } = useDataSuspenseQuery('url')
return <Data data={data} />
}
function App() {
return (
<ErrorBoundary FallbackComponent={Error}>
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
</ErrorBoundary>
)
}
useQuery를 사용하지 않고 useSuspenseQuery를 사용하도록 작성해야 한다.
useQuery vs useSuspenseQuery
useSuspenseQuery | TanStack Query React Docs
useQuery | TanStack Query React Docs
공식문서에서 useSuspenseQuery는 useQuery와 대부분 동일하게 사용할 수 있다.
가장 큰 차이점으로는 반환 값에서 data에 undefined 타입이 없으며 status가 항상 success이다.
위의 이미지는 기존 useQuery를 사용한 경우이며 아래 이미지는 useSuspenseQuery를 사용한 경우이다.
useSuspenseQuery를 사용하면 데이터가 존재하는 경우에 대해서만 고려할 수 있으며 해당 쿼리를 이용하는 컴포넌트는 상태에 따라 화면을 어떻게 보여줄지 고려(명령형)하는게 아니라 데이터가 항상 있다는 가정하에 화면에 무엇을 보여줄지 고려(선언형)할 수 있게 된다.
Concurrent Mode
So what is Concurrency? It is a way to structure a program by breaking it into pieces that can be executed independently. This is how we can break the limits of using a single thread, and make our application more efficient.
(Concurrency는 이처럼 큰 작업을 작은 여러개의 독립적인 작업으로 나누는 프로그래밍 구조로 싱글스레드의 한계를 뛰어넘어 우리의 앱을 더 효율적으로 만들어 준다.)
브라우저의 UI 스레드에서 화면의 변화를 담당하는 것은 JS다.
일반적으로 최고의 사용자 경험을 제공하기 위해서는 프레임당 약 10ms 이하로 렌더링이 되어야 한다.
리액트는 JS로 이루어져 있으며 이는 리액트 또한 JS의 규칙이 적용된다는 뜻이다.
리액트가 효율적인 reconciliation 알고리즘을 갖고 있더라도 서비스의 크기가 커지거나 DOM 트리가 복잡해지면 성능 저하가 쉽게 발생할 수 있다.
(reconsiliation 단계가 끝나야 메인 스레드에 대한 컨트롤을 반환하므로)
리액트에서 concurrent 기능이 추가되기 전에는 중단되지 않는 단일 동기식 트랜잭션으로 렌더링 되었다.
동기식 렌더링에서는 렌더링이 시작된다면 사용자가 화면에서 결과를 볼 수 있을 때까지 어떤 것도 렌더링을 방해할 수 없다.
새롭게 추가된 concurrent 렌더링에서는 렌더링을 시작하고 중간에 일시 중지(또는 완전한 중지)했다가 나중에 계속할 수 있다.
리액트에서는 렌더링이 중단되더라도 UI가 일관되게 표시되도록 보장한다.
이를 위해 트리 전체가 평가된 후 DOM 변경을 수행하기 위해 기다리며, 이를 통해 리액트는 메인 스레드를 차단하지 않고 백그라운드에서 새 화면을 준비할 수 있게 되었다.
UI가 대규모 렌더링 작업 중에도 사용자 입력에 즉시 반응하여 유동적인 사용자 경험을 제공할 수 있다.
Concurrent Mode의 핵심은 렌더링 프로세스를 작은 작업으로 나눈뒤 중요성에 따라 정렬하는 것이다.
그 결과 메인 스레드를 블럭하지 않으며, 여러 작업을 동시에 수행할 수 있으며 우선 순위에 따라 순서 변경이 가능하다.
사용자의 입력같이 중요도가 높은 작업에 우선순위를 부여할 수 있다.
또한, 결과를 DOM에 커밋하지 않고 트리의 일부만 렌더링 할 수 있다.
컴포넌트를 부분적으로 렌더링하고 브라우저에서 동시에 처리할 수 있는 다른 작업을 수행한다.
렌더링 이전에 다른 작업을 수행할 수 있게 되어 사용자 경험이 향상된다.
Concurrent와 Suspense
Suspense가 낯이 익은 이유는 React.lazy를 사용한 코드 분할에서 사용했었기 때문이다.
그 말은 Suspense는 React 18 이전에 이미 존재했다는 뜻이다.
React 18에서는 기존 Suspense에 concurrent 렌더링 기능을 사용하여 기능을 확장했다.
리액트 팀에서 Suspense는 transition API와 함께 사용하는 것을 권장한다.
transition 도중 일시 중단하면, 리액트는 이미 표시된 컨텐츠가 fallback으로 대체되는 것을 방지한다.
대신 리액트는 충분한 데이터가 로드될 때까지 렌더링을 지연시켜 로딩 상태가 나빠지는 것을 방지한다.
Transitions
앞서 Concurrent Mode에서는 렌더링 프로세스를 작은 단위로 나눈 뒤 우선순위에 따라 처리한다고 했다.
이를 위해 React 18에는 Transition 기능이 추가되었으며 급 업데이트와 비긴급 업데이트를 구분할 수 있게 되었다.
긴급 업데이트는 입력, 클릭 등 사용자의 직접적인 상호작용을 반영하는 업데이트를 의미하며, Transition 업데이트는 UI를 한 화면에서 다른 화면으로 전환하는 업데이트다.
일반적으로 최상의 사용자 경험을 위해서는 한 번의 사용자 입력으로 긴급한 업데이트와 긴급하지 않은 업데이트가 모두 이루어져야 한다.
입력 이벤트 내에서 startTransition API를 사용하여 어떤 업데이트가 긴급하고 어떤 업데이트가 transition인지 리액트에 알려줄 수 있다.
import { startTransition } from 'react';
// 긴급: Urgent: 입력한 내용 표시
setInputValue(input);
// 내부의 모든 state 업데이트를 transition으로 표시
startTransition(() => {
// Transition: 결과 표시
setSearchQuery(input);
});
결론
리액트는 선언형 UI 라이브러리다.
선언형 프로그래밍에서 "무엇"에 집중하는 것과 달리 명령형 프로그래밍에서는 "어떻게"에 집중한다.
리액트에서 Loading을 선언적으로 다룰 수 있도록 해주는 Suspense가 있다.
Suspense 기능의 추가는 React 18의 핵심중 하나인 Concurrency (동시성)을 위한 것이다.
리액트에서 선언형 프로그래밍을 어떻게 사용하는지 궁금해서 내용을 찾다가 여기까지 왔다.
리액트 팀에서도 사용자 경험에 관심이 많으며 이를 위해 다양한 기술들을 제시하고 있음을 알 수 있었다.
[참고자료]
https://ko.legacy.reactjs.org/
프로그래밍 패러다임 - 위키백과, 우리 모두의 백과사전 (wikipedia.org)
[번역] What is React Concurrent Mode? | by kelly woo | Medium
rfcs/text/0213-suspense-in-react-18.md at main · reactjs/rfcs (github.com)
'개발 > 개념' 카테고리의 다른 글
[Node.js] Node.js와 V8 (with. ECMAScript) (0) | 2024.10.12 |
---|---|
추상 구문 트리 (AST)란? (0) | 2024.06.13 |
WebRTC란 무엇이며 어떤 과정을 갖는가? (0) | 2023.11.12 |
Vite는 왜 빠를까? (0) | 2023.10.21 |
[객체지향] SOLID 예제(5) - 인터페이스 분리의 원칙(ISP) (0) | 2023.10.03 |