본 글은 RFC 의 번역입니다. Jotai 개발자의 블로그를 보다가 궁금하여 자세하게 찾아보기 위해 작성했습니다.
비동기 처리의 기본 예
예: 서버 구성 요소에서의 await
// This example was adapted from the original Server Components RFC:
// https://github.com/reactjs/rfcs/pull/188
async function Note({id, isEditing}) {
const note = await db.posts.get(id);
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
{isEditing ? <NoteEditor note={note} /> : null}
</div>
);
}
이는 서버의 비동기 데이터에 액세스하는 데 권장되는 방법입니다. 아래의 React의 클라이언트 측 비동기 문제에 대한 솔루션인 use() 훅의 등장이 왜 나왔는지 소개하겠습니다.
use()
Suspense를 사용하여 JavaScript Promise의 결과를 읽기 위한 일급 지원을 위해 use() hook이 등장하였습니다. 이를 통해 React 개발자는 안정적인 API를 통해 Suspense로 임의의 비동기 데이터 소스에 액세스할 수 있습니다.
예: 클라이언트 구성요소 에서의 use() 훅
function Note({id, shouldIncludeAuthor}) {
const note = use(fetchNote(id));
let byline = null;
if (shouldIncludeAuthor) {
const author = use(fetchNoteAuthor(note.authorId));
byline = <h2>{author.displayName}</h2>;
}
return (
<div>
<h1>{note.title}</h1>
{byline}
<section>{note.body}</section>
</div>
);
}
await과 마찬가지로 Promise 값을 unwrap하며 반환하지만 실제로 값이 resolve 될 때 실행되는게 아니라 React.Suspense 처럼 rendering에 끼어들어 에러를 발생시키거나 Promise가 확실하게 resolve 되었을 때 랜더링을 합니다. 또한 로직을 별도로 분리할 필요 없이 조건문, 블록, 반복문 어디서든 사용할 수 있습니다.
Motivation
Javascript 생태계와의 통합
async/await을 통해 일관된 API를 원했지만 모든 비동기 API를 React 전용 바인딩으로 랩핑하는것, 까다로운 특수 바인딩으로 각 데이터 소스를 랩핑해야 하는 것, Server Components의 form submitting이나 서버 응답 관련 비동기 작업의 빈도를 고려했을 때 React 외부에서 여전히 async/await이 필요하여 통합하지 않았습니다.
서버와 클라이언트
서버와 클라이언트에서 데이터에 액세스하는 방법이 서로 다르기에 작업중인 환경을 쉽게 추적할 수 있다는 장점도 있습니다.
가져오기와 렌더링의 불필요한 결합 억제
await과 use()는 데이터가 요청할때는 동일하게 작동하고 비동기 값을 해제하는데에서만 차이가 있습니다. Suspense 기반 Data Fetching API에서 비동기 값을 읽을 수 있는 방법이 없었기에 데이터를 가져오는 부분과 렌더링에 불필요한 결합이 있었습니다. 아래와 같이 간단하게 Fetching Data와 Rendering을 분리할 수 있습니다.
function TooltipContainer({showTooltip}) {
// This is a non-blocking fetch. We initiated the request but we haven't
// yet unwrapped the result.
const promise = fetchInfo();
if (!showTooltip) {
// If `showTooltip` is false, we can return immediately without waiting
// for the data to finish loading.
return null;
} else {
// If `showTooltip` is true, we wait for the promise to resolve by passing
// it to `use`. It probably already loaded, because the request was
// initiated during the previous render.
return <Tooltip content={use(promise)} />;
}
}
너무 규범적이지 않으면서 복잡성을 React로 전환
이 제안의 목표는 데이터 가져오기가 내부에서 어떻게 작동해야 하는지에 대해 너무 규범적이지 않으면서 서로 다른 React 프레임워크에서 사용할 수 있는 데이터 가져오기를 위한 공유 프리미티브 세트를 제공하는 것입니다. 프레임워크를 전환하거나 사용하지 않을 때에도 React 코드와 패턴을 인식할 수 있어야 합니다. 예를들어, Next.js 및 Remix는 라우팅 또는 캐시 무효화에 대해 서로 다른 전략을 가질 수 있지만 상태 로드, 조건부 로드 또는 오류 처리를 위해 자체 API를 발명할 필요는 없습니다. React에 내장된 패턴을 사용할 수 있습니다.
Design
이 Propsoal은 서버 컴포넌트에서는 stateless 하고 구현이 꽤나 직관적인 반면, 클라이언트 컴포넌트 안에서 user input 에 동기적으로 반응하여 렌더가 필요한 promises를 읽을 때의 복잡성에 관한 내용입니다. 본격적으로 사용하기 전에 비동기 서버 컴포넌트가 어떻게 작동하는지 먼저 다룰 것입니다.
비동기 서버 컴포넌트
비동기 서버 컴포넌트에서 React는 Promise가 resolve될 때까지 기다린 다음 이행된 값이 직접 반환된 것처럼 렌더링됩니다. 실제로 우리는 구성 요소 작성자가 약속 객체를 직접 반환하는 대신 async/await 구문을 사용하도록 권장합니다.
비동기 서버 구성 요소는 훅을 포함할 수 없습니다.
비동기 함수가 아니고 일반적으로 정의된 Hook은 일부 허용됩니다.
비동기 서버 구성 요소의 제약 조건은훅을 사용할 수 없다는 것입니다. 이는 부분적으로 기술적인 제한 때문이지만 서버와 클라이언트 구성 요소를 구분하는 데 도움이 되도록 의도적으로 디자인한 결정이기도 합니다 (use client;). useState 와 같은 stateful 한 훅은 허용되지 않습니다. 대신 useMemo 와 같이 실제로 많은 기능을 제공하지 않는 훅은 client와 server에서 같이 사용하여 공유 컴포넌트 수를 늘릴 수 있습니다.
use(promise)
use()는 async/await과 같은 프로그래밍 방식을 제공하도록 디자인 되었고 반면 비동기 함수가 아닌 일반 함수에서도 잘 작동하고 sequential하게 느끼도록 해줍니다.
Javascript spec에서 promise 는 항상 fulfilled or rejected 되야 합니다. 이미 로딩이 완료되었더라도 동기적으로 값을 받아볼 수 없게 디자인하여 애매모한 data race를 방지했지만 React 와 같은 UI 라이브러리에서 prop과 state가 존재할 때 문제가 되었습니다.
실행을 재생하여 일시 중단된 구성 요소 재개
만약 promise가 use()에 로딩 완료 상태를 전달하지 않는다면, exception을 던져 component 실행을 일시 중지시킵니다. 그리고 promise가 최종적으로 resolve() 되었을 때 React 가 다시 component render 를 재개합니다. async/await 이나 generator 와 달리 use() 는 일시 중단 된 시점에서 재개하는게 아니라 모든 코드를 다시 시작하는데 주어진 props, state, context 에 항상 동일한 출력을 반환하여 side-effect를 줄이기 위함입니다. 성능 최적화를 위해 React runtime은 일부 계산을 메모하지만 async/await 에 비해 런타임 오버헤드가 생길 수 있습니다. 그러나 데이터가 미리 로드 되었거나 미리 확인 된 경우 마이크로 태스크 큐를 기다리지 않아 실제로는 오버헤드가 적습니다.
이전에 읽은 약속의 결과 읽기
props 또는 state가 변경된 경우 React는 전달된 약속이 use이전 시도와 동일한 결과를 갖는다고 가정할 수 없습니다. React가 가장 먼저 시도하는 것은 Promise가 이전에 다른 use호출이나 다른 렌더링 시도로 읽혔는지 확인하는 것입니다. 그렇다면 React는 중단 없이 동기식으로 마지막 시간의 결과를 재사용할 수 있습니다.
React는 promise 객체에 추가 속성을 추가하여 이를 수행합니다. (이러한 필드의 이름은 에서 차용했습니다 Promise.allSettled.)
- status 는 다음 중 하나로 설정됩니다 ."pending""fulfilled""rejected"
- promise가 resolve되면 해당 value 필드는 이행된 값으로 설정됩니다.
- promise가 reject되면 해당 reason 필드는 거부 이유(실제로 일반적으로 오류 개체임)로 설정됩니다.
모든 promise 객체가 아닌 use 를 사용한 경우에 해당 필드가 추가되어 전역이나 promise prototype 에는 영향을 주지 않습니다. 이를 통해 완벽한 Javascript convention 은 아니지만 합리적으로 promise 의 결과를 track 할 수 있습니다.
사실 WeakMap 을 사용하여 직접 promise 에 접근하여 구현한다면 훨씬 직관적이고 다른 프레임워크에서도 사용할 수 있는 장점이 있지만 Javascript Standard API 로 전환된다면 이러한 방식으로 변경하려 합니다.
관련 없는 업데이트 중 약속 결과 읽기
Promise 객체에 대한 결과 추적은 렌더링 간에 Promise 객체가 변경되지 않은 경우에만 작동합니다. 새로운 Promise 라면 이전 전략은 통하지 않을 것입니다. 이는 대부분의 Promise 기반 API가 응답 캐시 여부에 관계없이 모든 호출에서 새로운 Promise 인스턴스를 반환하기 때문에 자주 발생합니다. 이것이 JavaScript에서 비동기 함수가 작동하는 방식이기도 합니다. 비동기 함수를 호출할 때마다 데이터가 캐시되고 아무 것도 기다리지 않은 경우에도 완전히 새로운 Promise 이 발생합니다.
async function fetchTodo(id) {
const data = await fetchDataFromCache(`/api/todos/${id}`);
return {contents: data.contents};
}
function Todo({id, isSelected}) {
const todo = use(fetchTodo(id));
return (
<div className={isSelected ? 'selected-todo' : 'normal-todo'}>
{todo.contents}
</div>
);
}
다음과 같은 경우 id prop 이 업데이트 된다면 fetchTodo새 데이터 로드가 완료될 때까지 일시 중단해야 할 수도 있습니다. 그러나 isSelected 업데이트가 id 동일하게 유지되는 경우에는 어떻게 됩니까? 비동기 함수는 항상 새로운 약속 인스턴스를 반환하기 때문에 전달된 Promise는 다를 것입니다. 그러나 캐시에서 데이터를 읽었기 때문에 다시 일시 중지할 필요가 없습니다.
React가 이 경우를 제대로 처리하지 않으면 임의의 업데이트로 인해 새 데이터가 요청되지 않은 경우에도 UI가 일시 중단될 수 있습니다. 따라서 이를 처리할 전략이 필요합니다.
fetchTodo 는 이 경우에 우리가 할 수 있는 것은 에서 반환된 Promise가 마이크로태스크에서 해결될 것이라는 사실에 의존하는 것입니다 . React는 일시 중지 대신 마이크로태스크 대기열이 플러시될 때까지 기다립니다. Promise가 해당 기간 내에 해결되면 React는 Suspense 폴백을 트리거하지 않고 구성 요소를 즉시 재생하고 렌더링을 재개할 수 있습니다. 그렇지 않으면 React는 새로운 데이터가 요청되었다고 가정해야 하며 평소처럼 일시 중단됩니다.
이를 통해 데이터 요청이 캐시되는 한 일시 중단을 피할 수 있습니다.
Promise 객체 자체가 캐싱되면 마이크로 작업을 기다리지 않고 구성 요소를 재생하지 않고 동기식으로 결과를 읽을 수 있기 때문에 여전히 성능면에서 더 좋습니다. 따라서 사용자 입력 이벤트에 대한 응답과 같이 우선 순위가 높은 업데이트 중에 이런 일이 발생하면 React는 성능 경고를 기록합니다. 경고를 수정하는 방법은 비동기 함수를 메모화하거나( with useMemo) 캐시하는 것입니다.
주의 사항: 데이터 요청은 Replay 사이에 캐시되어야 합니다.
일시 중단하기 전에 마이크로태스크가 플러시되기를 기다리는 메커니즘은 데이터 요청이 캐시된 경우에만 작동합니다. 보다 정확하게는 제약 조건은 다음과 같습니다. 새로운 입력을 받지 않고 다시 렌더링하는 비동기 함수는 마이크로태스크 내에서 해결되어야 합니다.
// A cached function returns the same response for a given set of inputs —
// id, in this example. The `cache` proposal will also have a mechanism to
// invalidate the cache, and to scope it to a particular part of the UI.
const fetchNote = cache(async (id) => {
const response = await fetch(`/api/notes/${id}`);
return await response.json();
});
function Note({id}) {
// The `fetchNote` call returns the same promise every time until a new id
// is passed, or until the cache is refreshed.
const note = use(fetchNote(id));
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
</div>
);
}
데이터에 대한 조건부 정지
다른 모든 Hook과 달리 use함수의 최상위 수준으로만 제한되지 않고 조건부로 호출할 수 있습니다. 이에 대한 동기는 데이터를 별도의 구성 요소로 추출할 필요 없이 데이터에 대한 조건부 정지를 지원하는 것입니다.
function Note({id, shouldIncludeAuthor}) {
const note = use(fetchNote(id));
let byline = null;
if (shouldIncludeAuthor) {
// Because `use` is inside a conditional block, we avoid blocking
// unncessarily when `shouldIncludeAuthor` is false.
const author = use(fetchNoteAuthor(note.authorId));
byline = <h2>{author.displayName}</h2>;
}
return (
<div>
<h1>{note.title}</h1>
{byline}
<section>{note.body}</section>
</div>
);
}
React 구성 요소에서 호출할 수 있는 위치에 관한 규칙은 비동기 함수 또는 생성기 함수에서 호출할 수 use있는 위치에 해당합니다 . 블록, 스위치 문 및 루프를 포함한 모든 제어 흐름 구조 내에서 호출할 수 있습니다. 유일한 요구 사항은 부모 함수가 React 구성 요소 또는 Hook이어야 한다는 것입니다.
function ItemsWithForLoop() {
const items = [];
for (const id of ids) {
// ✅ This works! The parent function is a component.
const data = use(fetchThing(id));
items.push(<Item key={id} data={data} />);
}
return items;
}
function ItemsWithMap() {
return ids.map((id) => {
// ❌ The parent closure is not a component or Hook!
// This will cause a compiler error.
const data = use(fetchThing(id));
return <Item key={id} data={data} />;
});
}
조건부 호출이 허용되는 이유는 use 훅은 대부분의 다른 Hook과 달리 업데이트 간에 상태를 추적할 필요가 없기 때문입니다.
기타
클라이언트 구성 요소가 비동기 함수일 수 없는 이유는 무엇입니까?
비동기 서버 구성 요소뿐만 아니라 비동기 클라이언트 구성 요소도 지원하는 것을 강력히 고려했습니다. 기술적으로는 가능하지만 함정과 주의 사항이 많기 때문에 현재로서는 이 패턴을 일반적인 권장 사항으로 사용하는 것이 불편합니다. 계획은 런타임에서 비동기 클라이언트 구성 요소에 대한 지원을 구현하지만 개발 중에 경고를 기록하는 것입니다. 또한 사용을 권장하지 않습니다.
비동기 클라이언트 구성 요소를 권장하지 않는 주된 이유는 single prop이 component를 통해 흐르고 메모를 무효화하여 이전 섹션에서 설명한 마이크로 태스크 댄스를 트리거하기가 너무 쉽기 때문입니다. 새로운 성능 주의 사항을 소개하는 것은 아니지만 위에서 설명한 모든 성능 주의 사항을 훨씬 더 가능성 있게 만듭니다.
Generator가 아닌 이유는 무엇입니까?
제너레이터의 가장 큰 단점은 모든 단일 훅이 제너레이터가 되도록 효과적으로 요구하지 않고는 사용자 지정 훅 내에서 사용할 수 없다는 것입니다. 그러면 데이터를 로드하지 않는 경우 런타임에 많은 추가 오버헤드가 추가되고 개발자에게는 많은 구문 오버헤드가 추가됩니다.
이 문제를 해결해야 하는 한 가지 계획은 자동 메모 컴파일러가 이전 재생 시도의 계산을 재사용하는 것입니다.
또 다른 계획은 async/await 구문을 낮은 수준의 생성기와 유사한 형식으로 컴파일하는 것입니다. 훅 추상화 문제는 아니지만 성능 문제를 해결할 수 있습니다. 추상화 문제를 해결하기 위한 장기적인 아이디어는 훅를 호출하기 위한 사용자 지정 구문을 도입하고 모든 React 함수를 생성기와 같은 런타임으로 컴파일하는 것입니다.
Next13 Documentation
비동기 클라이언트 구성 요소를 지원하지 않기 때문에 Next13 Documentation에서도 use()가 아닌 SWR이나 React Query를 사용을 권장한다.
Jotai 저자의 의견
브라우저나 어딘가에 이전 데이터를 캐싱할 수 있지만 React에서 Promise Value를 갖는다는건 2번 render 되기에 use() hook을 통해 이 문제를 완화시키길 기대한다는 블로그의 표현이 있었습니다.
https://blog.axlight.com/posts/you-might-not-need-react-query-for-jotai/
https://blixtdev.com/all-about-reacts-new-use-hook/
'software engineering > frontend' 카테고리의 다른 글
TL;DR shadow DOM (0) | 2023.04.15 |
---|---|
React Server Component (RSC) (0) | 2023.04.15 |
React- 어떤 상태 관리 라이브러리를 사용할까? (0) | 2023.03.27 |
React v18.0 (1) | 2023.03.09 |
Compression Javascript (0) | 2023.03.04 |