[번역] fate

I
Inkyu Oh

Front-End2025.12.26

nkzw-tech - 2025년 2월 12일 (원문 기준)



fate

Logo

fateRelayGraphQL에서 영감을 받은 React 및 tRPC용 현대적 데이터 클라이언트입니다. 뷰 구성(View composition), 정규화된 캐싱(Normalized caching), 데이터 마스킹(Data masking), Async React 기능, 그리고 tRPC의 타입 안전성(Type safety)을 결합합니다.

주요 기능

  • 뷰 구성: 컴포넌트는 함께 배치된(co-located) "뷰"를 사용하여 데이터 요구 사항을 선언합니다. 뷰는 화면당 단일 요청으로 구성되어 네트워크 요청을 최소화하고 워터폴(Waterfall) 현상을 제거합니다.
  • 정규화된 캐시: fate는 가져온 모든 데이터에 대해 정규화된 캐시를 유지합니다. 이를 통해 액션(Action)과 뮤테이션(Mutation)을 통한 효율적인 데이터 업데이트가 가능하며, 오래되거나 중복된 데이터를 방지합니다.
  • 데이터 마스킹 및 엄격한 선택: fate는 각 뷰에 대해 엄격한 데이터 선택을 강제하며, 컴포넌트가 요청하지 않은 데이터는 마스킹(숨김) 처리합니다. 이는 컴포넌트 간의 의도치 않은 결합을 방지하고 오버페칭(Overfetching)을 줄입니다.
  • Async React: fate는 Actions, Suspense, use와 같은 현대적인 Async React 기능을 사용하여 동시 렌더링(Concurrent rendering)을 지원하고 원활한 사용자 경험을 제공합니다.
  • 리스트 및 페이지네이션: fate는 커서 기반 페이지네이션을 포함한 커넥션(Connection) 스타일 리스트를 기본적으로 지원하여, 무한 스크롤과 "더 보기" 기능을 쉽게 구현할 수 있게 해줍니다.
  • 낙관적 업데이트(Optimistic Updates): fate는 뮤테이션에 대한 선언적 낙관적 업데이트를 지원하여, 서버 요청이 진행 중인 동안 UI를 즉시 업데이트할 수 있습니다. 요청이 실패하면 캐시와 관련 뷰는 이전 상태로 롤백됩니다.
  • AI 준비 완료: fate의 최소화되고 예측 가능한 API와 명시적인 데이터 선택은 로컬 추론을 가능하게 하여, 인간과 AI 도구가 안정적이고 타입 안전한 데이터 페칭 코드를 생성할 수 있도록 돕습니다.

React 및 tRPC를 위한 현대적 데이터 클라이언트

fate는 React 애플리케이션의 데이터 페칭과 상태 관리를 더 구성 가능하고, 선언적이며, 예측 가능하게 만들도록 설계되었습니다. 이 프레임워크는 최소한의 API를 가지며, DSL(도메인 특화 언어)이나 마법 같은 기능이 없습니다. 그저 자바스크립트일 뿐입니다.
GraphQL과 Relay는 몇 가지 참신한 아이디어를 도입했습니다. 컴포넌트와 함께 배치된 프래그먼트(Fragment), 글로벌 식별자를 키로 사용하는 정규화된 캐시, 그리고 프래그먼트를 단일 네트워크 요청으로 끌어올리는(Hoisting) 컴파일러가 그것입니다. 이러한 혁신 덕분에 데이터 요구 사항이 모듈화되고 독립적인 대규모 애플리케이션을 구축하는 것이 가능해졌습니다.
Nakazawa Tech는 주로 GraphQL과 Relay를 사용하여 앱과 게임을 개발합니다. 우리는 강연을 통해 이러한 기술을 옹호하고, 개발자들이 빠르게 시작할 수 있도록 템플릿(서버, 클라이언트)을 제공합니다.
하지만 GraphQL은 자체적인 타입 시스템과 쿼리 언어를 가지고 있습니다. 이미 tRPC나 다른 타입 안전 RPC 프레임워크를 사용하고 있다면, 백엔드에 GraphQL을 도입하고 구현하는 것은 상당한 투자입니다. 이러한 투자는 종종 팀이 프론트엔드에서 Relay를 채택하는 것을 가로막습니다.
많은 React 데이터 프레임워크는 Relay의 인체공학적 설계, 특히 프래그먼트 구성, 함께 배치된 데이터 요구 사항, 예측 가능한 캐싱, 그리고 현대적 React 기능과의 깊은 통합이 부족합니다. 낙관적 업데이트는 보통 키를 수동으로 관리하고 명령형으로 데이터를 업데이트해야 하므로 오류가 발생하기 쉽고 지루한 작업이 됩니다.
fate는 Relay의 훌륭한 아이디어를 가져와 tRPC 위에 얹었습니다. 클라이언트와 서버 간의 타입 안전성, 그리고 데이터 페칭을 위한 GraphQL과 같은 인체공학적 설계라는 두 마리 토끼를 모두 잡을 수 있습니다. fate를 사용하는 모습은 보통 다음과 같습니다:
export const PostView = view<Post>()({
author: UserView,
content: true,
id: true,
title: true,
});

export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
const post = useView(PostView, postRef);

return (
<Card>
<h2>{post.title}</h2>
<p>{post.content}</p>
<UserCard user={post.author} />
</Card>
);
};
fate의 핵심 개념에 대해 더 알아보기 혹은 준비된 템플릿으로 시작하기.

시작하기

템플릿

준비된 템플릿으로 빠르게 시작하세요:
npx giget@latest gh:nkzw-tech/fate-template
pnpx giget@latest gh:nkzw-tech/fate-template
yarn dlx giget@latest gh:nkzw-tech/fate-template
fate-template은 간단한 tRPC 백엔드와 fate를 사용하는 React 프론트엔드를 포함하고 있습니다. 매우 빠른 개발 경험을 제공하기 위한 현대적인 도구들을 갖추고 있습니다. 시작하려면 해당 README.md를 따르세요.

수동 설치

fate는 React 19.2 이상이 필요합니다. 클라이언트의 경우 react-fate를 설치해야 합니다:
npm add react-fate
pnpm add react-fate
yarn add react-fate
그리고 서버의 경우, 핵심 패키지인 @nkzw/fate를 설치하세요:
npm add @nkzw/fate
pnpm add @nkzw/fate
yarn add @nkzw/fate
경고 fate는 현재 알파 단계이며 프로덕션 준비가 되지 않았습니다. 작동하지 않는 부분이 있다면 풀 리퀘스트(Pull request)를 열어주세요.
GitHub Codespaces에서 예제 앱을 사용해보고 싶다면 아래 버튼을 클릭하세요:

핵심 개념

fate는 최소한의 API 표면을 가지며 데이터 페칭의 복잡성을 줄이는 것을 목표로 합니다.

뷰로 생각하기 (Thinking in Views)

fate에서 각 컴포넌트는 뷰를 사용하여 필요한 데이터를 선언합니다. 뷰는 컴포넌트 트리를 따라 루트에 도달할 때까지 위로 구성되며, 루트에서 실제 요청이 이루어집니다. fate는 필요한 모든 데이터를 단일 요청으로 가져옵니다. React Suspense가 로딩 상태를 관리하며, 데이터 페칭 오류는 자연스럽게 React 에러 바운더리(Error boundary)로 전파됩니다. 이를 통해 명령형 로딩 로직이나 수동 에러 처리가 필요 없게 됩니다.
전통적으로 React 앱은 컴포넌트와 훅으로 구축됩니다. fate는 세 번째 프리미티브인 '뷰'를 도입합니다. 이는 컴포넌트가 데이터 요구 사항을 표현하는 선언적인 방법입니다. fate로 구축된 앱은 다음과 같은 모습입니다:
Tree

fate를 사용하면 데이터를 언제 가져올지, 로딩 상태를 어떻게 조정할지, 또는 에러를 어떻게 명령형으로 처리할지 더 이상 걱정하지 않아도 됩니다. 오버페칭을 피하고, 트리 아래로 불필요한 데이터를 전달하는 것을 멈추며, 오직 서버 데이터를 자식 컴포넌트에 전달하기 위해 만들어진 보일러플레이트 타입들을 제거할 수 있습니다.
참고 fate의 뷰는 GraphQL의 프래그먼트와 같은 역할을 합니다.

뷰 (Views)

뷰 정의하기

블로그의 Post 컴포넌트를 위한 간단한 뷰를 정의하는 것부터 시작해 보겠습니다. fate는 컴포넌트에서 사용할 각 필드를 명시적으로 "선택"할 것을 요구합니다. titlecontent 필드를 가진 Post 엔티티에 대한 뷰를 정의하는 방법은 다음과 같습니다:
import { view } from 'react-fate';

type Post = {
content: string;
id: string;
title: string;
};

export const PostView = view<Post>()({
content: true,
id: true,
title: true,
});
필드는 뷰 정의에서 true로 설정하여 선택됩니다. 이는 fate에게 해당 필드들을 서버에서 가져와 이 뷰를 사용하는 컴포넌트에서 사용할 수 있도록 해야 함을 알려줍니다.
참고 위의 Post 타입은 예시입니다. 실제 애플리케이션에서 이 타입은 서버에서 정의되어 클라이언트 코드로 임포트됩니다.

useView로 뷰 해석하기

이제 정의한 뷰를 PostCard React 컴포넌트에서 사용하여 개별 Post의 참조(Reference)에 대해 데이터를 해석할 수 있습니다:
import { useView, ViewRef } from 'react-fate';

export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
const post = useView(PostView, postRef);

return (
<Card>
<h2>{post.title}</h2>
<p>{post.content}</p>
</Card>
);
};
ViewRef는 특정 타입의 구체적인 객체에 대한 참조입니다. 예를 들어 ID가 7Post입니다. 여기에는 객체의 고유 ID, 타입 이름(__typename으로 표시), 그리고 fate 전용 메타데이터가 포함됩니다. fate는 이러한 참조를 생성하고 관리하며, 여러분은 필요에 따라 컴포넌트 간에 이를 전달할 수 있습니다.
useView를 사용하는 컴포넌트는 선택된 모든 필드의 변경 사항을 감지합니다. 데이터가 변경되면 fate는 해당 데이터에 의존하는 모든 필드를 다시 렌더링합니다. 예를 들어 Posttitle이 변경되면 PostCard 컴포넌트는 새로운 데이터로 다시 렌더링됩니다. 그러나 PostView에서 선택되지 않은 likes와 같은 다른 필드가 변경되면 PostCard 컴포넌트는 다시 렌더링되지 않습니다.

useRequest로 데이터 페칭하기

뷰와 컴포넌트를 정의했으므로, 이제 fate의 useRequest 훅을 사용하여 서버에서 데이터를 가져옵니다. 이 훅을 사용하면 특정 화면이나 컴포넌트 트리에 필요한 데이터를 선언할 수 있습니다. 앱의 루트에서 다음과 같이 포스트 리스트를 요청할 수 있습니다:
import { useRequest } from 'react-fate';
import { PostCard, PostView } from './PostCard.tsx';

export function App() {
const { posts } = useRequest({ posts: { list: PostView } });

return posts.map((post) => <PostCard key={post.id} post={post} />);
}
useRequest에 대한 자세한 내용은 Requests 가이드에서 확인하세요.

뷰 구성하기 (Composing Views)

위의 예제에서는 Post를 위한 단일 뷰를 정의했습니다. fate의 핵심 강점 중 하나는 뷰 구성입니다. 포스트와 함께 작성자의 이름을 표시하고 싶다고 가정해 보겠습니다. 이를 수행하는 간단한 방법은 PostView에 구체적인 선택 사항과 함께 author 필드를 추가하는 것입니다:
import { Suspense } from 'react';
import { useView, ViewRef } from 'react-fate';

export const PostView = view<Post>()({
author: {
id: true,
name: true,
},
content: true,
id: true,
title: true,
});

const PostCard = ({ postRef }: { postRef: ViewRef<'Post'> }) => {
const post = useView(PostView, postRef);
return (
<Card>
<h2>{post.title}</h2>
<p>by {post.author.name}</p>
<p>{post.content}</p>
</Card>
);
};
이 코드는 Post와 연관된 작성자를 가져와 PostCard 컴포넌트에서 사용할 수 있게 합니다. 그러나 이 접근 방식에는 몇 가지 단점이 있습니다:
  1. author 선택이 PostView에 밀접하게 결합됩니다. 다른 컴포넌트에서 작성자 데이터를 사용하고 싶다면 필드 선택을 중복해서 작성해야 합니다.
  1. author에 다른 컴포넌트에서 사용하고 싶은 필드가 더 많아지면 이를 PostView에 추가해야 하므로 오버페칭으로 이어집니다.
  1. author 필드 선택을 다른 뷰나 컴포넌트에서 재사용할 수 없습니다.
fate에서 뷰는 구성 가능하고 재사용 가능합니다. 선택 사항을 인라인으로 작성하는 대신, UserView를 정의하고 다음과 같이 PostView에 구성할 수 있습니다:
import type { Post, User } from '@your-org/server/trpc/views';
import { view } from 'react-fate';

export const UserView = view<User>()({
id: true,
name: true,
profilePicture: true,
});

export const PostView = view<Post>()({
author: UserView,
content: true,
id: true,
title: true,
});
이제 UserView를 사용하는 별도의 UserCard 컴포넌트를 만들 수 있습니다:
import { useView, ViewRef } from 'react-fate';

export const UserCard = ({ user: userRef }: { user: ViewRef<'User'> }) => {
const user = useView(UserView, userRef);

return (
<div>
<img src={user.profilePicture} alt={user.name} />
<p>{user.name}</p>
</div>
);
};
그리고 PostCardUserCard 컴포넌트를 사용하도록 업데이트합니다:
import { UserCard } from './UserCard.tsx';

export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
const post = useView(PostView, postRef);

return (
<Card>
<h2>{post.title}</h2>
<UserCard user={post.author} />
<p>{post.content}</p>
</Card>
);
};

뷰 스프레드 (View Spreads)

복잡한 UI를 구축할 때, 동일한 데이터 요구 사항을 공유하는 여러 컴포넌트를 만드는 경우가 많습니다. fate에서는 뷰 스프레드를 사용하여 이러한 뷰들을 함께 구성할 수 있습니다. 이는 GraphQL의 프래그먼트 스프레드와 유사하지만, 일반 자바스크립트 객체로 작동합니다.
PostCard에서 작성자의 바이오(Bio)와 같은 추가 정보를 가져와 표시하고 싶다고 가정해 보겠습니다. author 필드에 UserView를 직접 할당하는 대신, 이를 스프레드하고 bio 필드를 추가할 수 있습니다:
export const PostView = view<Post>()({
author: {
...UserView,
bio: true,
},
content: true,
id: true,
title: true,
});
이제 PostCard 컴포넌트는 작성자의 bio 필드에 접근할 수 있습니다:
export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
const post = useView(PostView, postRef);

return (
<Card>
<h2>{post.title}</h2>
<UserCard author={post.author} />
{/* bio 필드에 접근 */}
<p>{post.author.bio}</p>
<p>{post.content}</p>
</Card>
);
};
여러 뷰를 함께 스프레드할 수도 있습니다. 예를 들어 사용자에 대한 통계를 선택하는 UserStatsView라는 다른 뷰가 있다면, 다음과 같이 PostView에 포함할 수 있습니다:
export const UserStatsView = view<User>()({
followerCount: true,
postCount: true,
});

export const PostView = view<Post>()({
author: {
...UserView,
...UserStatsView,
bio: true,
},
content: true,
id: true,
title: true,
});
뷰는 불투명한(Opaque) 객체입니다. 서로 다른 뷰를 통해 동일한 필드를 여러 번 선택하더라도, 구성된 객체는 필드 충돌이 발생하지 않으며 TypeScript 에러도 발생하지 않습니다. fate는 런타임 중에 자동으로 필드를 중복 제거하고 각 필드가 한 번만 페칭되도록 보장합니다.

useView와 Suspense

우리는 useRequest가 서버에서 데이터를 가져오는 역할을 하고, useView가 캐시에서 데이터를 읽는 데 사용된다는 것을 배웠습니다. 어떤 상황에서는 캐시에 데이터가 없을 수 있으며, useView는 누락된 데이터만 가져오기 위해 컴포넌트를 일시 중단(Suspend)해야 할 수도 있습니다. 해당 데이터를 가져와 캐시에 기록하면 컴포넌트는 렌더링을 재개합니다.
팁: 번들러에서 Fast Refresh(HMR)가 활성화된 개발 모드에서 이 동작을 테스트할 수 있습니다. 뷰의 선택 사항을 수정하면 해당 뷰를 사용하는 컴포넌트가 일시 중단되고, 누락된 데이터를 가져온 다음 렌더링을 재개합니다.

타입 안전성 및 데이터 마스킹

fate는 TypeScript와 런타임을 통해 컴포넌트에서 선택되지 않은 데이터에 접근하는 것을 방지하는 보증을 제공합니다. 이를 통해 컴포넌트 트리의 적절한 수준에서 모든 데이터 의존성을 선언하도록 보장하며, 컴포넌트 간의 의도치 않은 결합을 방지합니다.
아래 예제에서는 Postcontent 선택을 잊었습니다. 그 결과, 타입 체크가 실패하고 런타임 중에 content 필드는 undefined가 됩니다:
const PostView = view<Post>()({
id: true,
title: true,
// `content: true`가 누락됨.
});

const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
const post = useView(PostView, postRef);

return (
<Card>
<h2>{post.title}</h2>
{/* 여기서 TypeScript 에러가 발생하며, 런타임 시 post.content는 undefined입니다. */}
<p>{post.content}</p>
</Card>
);
};
뷰는 해당 뷰를 직접 포함하거나 뷰 스프레드를 통해 포함하는 참조(Ref)에 대해서만 해석될 수 있습니다. 컴포넌트가 연결되지 않은 참조에 대해 뷰를 해석하려고 하면 런타임 중에 에러를 던집니다:
const PostDetailView = view<Post>()({
content: true,
});

const AnotherPostView = view<Post>()({
content: true,
});

const PostView = view<Post>()({
id: true,
title: true,
...AnotherPostView,
});

const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
const post = useView(PostView, postRef);
return <PostDetail post={post} />;
};

const PostDetail = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
// 이 컴포넌트에 전달된 포스트 참조가 `PostDetailView`가 아닌
// `AnotherPostView` 타입이므로 에러를 던집니다.
const post = useView(PostDetailView, postRef);
};
ViewRef는 해석할 수 있는 뷰 이름 세트를 가지고 있습니다. useView는 참조에 필요한 뷰가 포함되어 있지 않으면 에러를 던집니다.

요청 (Requests)

리스트 요청하기

useRequest 훅은 특정 화면이나 컴포넌트 트리에 대한 데이터 요구 사항을 선언하는 데 사용될 수 있습니다. 앱의 루트에서 다음과 같이 포스트 리스트를 요청할 수 있습니다:
import { useRequest } from 'react-fate';
import { PostCard, PostView } from './PostCard.tsx';

export function App() {
const { posts } = useRequest({ posts: { list: PostView } });
return posts.map((post) => <PostCard key={post.id} post={post} />);
}
이 컴포넌트는 일시 중단되거나 에러를 던지며, 이는 가장 가까운 에러 바운더리로 전파됩니다. 컴포넌트 트리를 ErrorBoundarySuspense 컴포넌트로 감싸서 에러 및 로딩 상태를 표시하세요:
<ErrorBoundary FallbackComponent={ErrorComponent}>
<Suspense fallback={<div>Loading…</div>}>
<App />
</Suspense>
</ErrorBoundary>
참고 useRequest는 여러 요청을 발행할 수 있으며, 이는 tRPC의 HTTP Batch Link에 의해 자동으로 단일 네트워크 요청으로 묶입니다.

ID로 객체 요청하기

리스트 대신 단일 객체에 대한 데이터를 가져오고 싶다면, 다음과 같이 id와 관련 view를 지정할 수 있습니다:
const { post } = useRequest({
post: { id: '12', view: PostView },
});
ID로 여러 객체를 가져오고 싶다면 ids 필드를 사용할 수 있습니다:
const { posts } = useRequest({
posts: { ids: ['6', '7'], view: PostView },
});

기타 유형의 요청

다른 모든 쿼리의 경우 typeview만 전달하세요:
const { viewer } = useRequest({
viewer: { view: UserView },
});

요청 인자 (Request Arguments)

useRequest 호출 시 인자를 전달할 수 있습니다. 이는 페이지네이션, 필터링 또는 정렬에 유용합니다. 예를 들어 처음 10개의 포스트를 가져오려면 다음과 같이 할 수 있습니다:
const { posts } = useRequest({
posts: {
args: { first: 10 },
list: PostView,
},
});

요청 모드 (Request Modes)

useRequest는 캐싱과 데이터 최신성을 제어하기 위해 다양한 요청 모드를 지원합니다. 사용 가능한 모드는 다음과 같습니다:
  • cache-first (기본값): 캐시에 데이터가 있으면 캐시에서 반환하고, 없으면 네트워크에서 가져옵니다.
  • stale-while-revalidate: 캐시에서 데이터를 반환하는 동시에 네트워크에서 신선한 데이터를 가져옵니다.
  • network-only: 캐시를 무시하고 항상 네트워크에서 데이터를 가져옵니다.
요청 모드를 useRequest의 옵션으로 전달할 수 있습니다:
const { posts } = useRequest(
{
posts: { list: PostView },
},
{
mode: 'stale-while-revalidate',
},
);

리스트 뷰 (List Views)

useListView를 이용한 페이지네이션

useListView를 사용하여 참조 리스트를 감싸면 페이지네이션을 지원하는 커넥션 스타일 리스트를 활성화할 수 있습니다.
예를 들어, CommentView를 정의하고 이를 CommentConnectionView 내부에서 재사용할 수 있습니다:
import { useListView, ViewRef } from 'react-fate';

const CommentView = view<Comment>()({
content: true,
id: true,
});

const CommentConnectionView = {
args: { first: 10 },
items: {
node: CommentView,
},
};

const PostView = view<Post>()({
comments: CommentConnectionView,
});
이제 PostCard 컴포넌트 내부에서 useListView 훅을 적용하여 댓글 리스트를 읽고 필요할 때 더 많은 댓글을 로드할 수 있습니다:
export function PostCard({
detail,
post: postRef,
}: {
detail?: boolean;
post: ViewRef<'Post'>;
}) {
const post = useView(PostView, postRef);
const [comments, loadNext] = useListView(
CommentConnectionView,
post.comments,
);

return (
<div>
{comments.map(({ node }) => (
<CommentCard comment={node} key={node.id} post={post} />
))}
{loadNext ? (
<Button onClick={loadNext} variant="ghost">
Load more comments
</Button>
) : null}
</div>
);
}
loadNext가 undefined라면 더 이상 로드할 댓글이 없음을 의미합니다. 대신 이전 댓글을 로드하고 싶다면 useListView가 반환하는 세 번째 인자인 loadPrevious를 사용할 수 있습니다. 마찬가지로 로드할 이전 댓글이 없으면 loadPrevious는 undefined가 됩니다.

액션 (Actions)

fate는 전통적인 데이터 페칭 라이브러리처럼 뮤테이션을 위한 훅을 제공하지 않습니다. 대신 뮤테이션은 두 가지 방식으로 노출됩니다:
  • useActionState 및 React Actions와 함께 사용하기 위한 fate.actions.
  • 전통적인 명령형 뮤테이션 호출을 위한 fate.mutations.
tRPC 백엔드의 뮤테이션은 fate의 생성된 클라이언트에 의해 액션과 뮤테이션으로 사용 가능해집니다.
Post 엔티티에 포스트를 추천하는 post.like라는 tRPC 뮤테이션이 있다고 가정해 보겠습니다. fate Actions와 비동기 컴포넌트 라이브러리를 사용하는 LikeButton 컴포넌트는 다음과 같은 모습일 것입니다:
import { useActionState } from 'react';
import { useFateClient } from 'react-fate';

const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
const fate = useFateClient();
const [result, like] = useActionState(fate.actions.post.like, null);

return (
<Button action={() => like({ input: { id: post.id } })}>
{result?.error ? 'Oops!' : 'Like'}
</Button>
);
};
비동기 컴포넌트 라이브러리를 사용하지 않는 경우, React의 useTransition을 사용하여 트랜지션 내에서 액션을 시작할 수 있습니다:
const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
const fate = useFateClient();
const [, startTransition] = useTransition();
const [result, like, isPending] = useActionState(
fate.actions.post.like,
null,
);

return (
<button
disabled={isPending}
onClick={() => {
startTransition(() =>
like({
input: { id: post.id },
}),
);
}}
>
{result?.error ? 'Oops!' : 'Like'}
</button>
);
};
useActionState를 사용함으로써 fate Actions는 Suspense 및 동시 렌더링과 통합됩니다.

낙관적 업데이트 (Optimistic Updates)

fate Actions는 기본적으로 낙관적 업데이트를 지원합니다. 예를 들어, 포스트의 추천 수를 낙관적으로 업데이트하려면 액션 호출 시 optimistic 객체를 전달할 수 있습니다. 이는 즉시 캐시를 새로운 추천 수로 업데이트하고 likes 필드를 선택하는 모든 뷰를 다시 렌더링합니다:
like({
input: { id: post.id },
optimistic: { likes: post.likes + 1 },
});
낙관적 업데이트나 다른 방식으로 데이터가 변경될 때, fate는 변경된 필드를 선택하는 뷰만 다시 렌더링합니다. 위의 예제에서는 likes 필드를 선택하는 뷰만 다시 렌더링됩니다. 뷰가 title 필드만 선택한다면 likes 필드가 변경되어도 다시 렌더링되지 않습니다.
뮤테이션이 실패하면 캐시는 이전 상태로 롤백되며, 뮤테이션된 데이터에 의존하는 모든 뷰가 업데이트됩니다.

새로운 객체 삽입하기

뮤테이션이 새로운 객체를 삽입할 때, 서버가 실제 ID로 응답할 때까지 캐시에서 해당 객체를 나타낼 임시 ID를 가진 낙관적 객체를 제공할 수 있습니다. 예를 들어, 포스트에 새로운 댓글을 낙관적으로 추가하려면 다음과 같이 할 수 있습니다:
const content = 'New Comment text';
addComment({
input: { content, postId: post.id },
optimistic: {
author: { id: user.id, name: user.name },
content,
id: `optimistic:${Date.now().toString(36)}`,
post: { commentCount: post.commentCount + 1, id: post.id },
},
});

액션에서 뷰 선택하기

뮤테이션은 뮤테이션 결과에 직접 지정되지 않은 데이터를 변경할 수 있습니다. 예를 들어, 댓글을 추가하면 포스트의 댓글 수가 증가합니다. 이러한 경우, 뮤테이션의 일부로 가져올 필드를 지정하는 view를 액션에 제공할 수 있습니다:
addComment({
input: { content: 'New Comment text', postId: post.id },
view: view<Comment>()({
...CommentView,
post: { commentCount: true },
}),
});
서버는 선택된 필드를 반환하고 fate는 캐시를 업데이트하며 변경된 데이터에 의존하는 모든 뷰를 다시 렌더링합니다. 액션 결과에는 선택된 필드가 포함된 새로 추가된 댓글이 들어 있습니다:
const [result, addComment] = useActionState(fate.actions.comment.add, null);

const newComment = result?.result;
if (newComment) {
// 뷰에서 선택된 모든 필드를 `newComment`에서 사용할 수 있습니다:
console.log(newComment.post.commentCount);
}

뮤테이션 (Mutations)

fate Actions는 React 컴포넌트에서 서버 뮤테이션을 실행하는 권장 방법입니다. 그러나 React 컴포넌트 외부에서 명령형으로 뮤테이션을 호출하고 싶거나, useActionState처럼 이전 액션이 끝나기를 기다리지 않고 호출하고 싶은 경우가 있습니다. 이러한 경우 fate.mutations를 사용하여 명령형으로 뮤테이션을 호출할 수 있습니다:
const result = await fate.mutations.comment.add({
input: { content, postId: post.id },
});
어디서든 뮤테이션을 호출할 수 있으며, 이전 뮤테이션이 끝나기를 기다리지 않아도 됩니다. 뮤테이션 API는 낙관적 업데이트와 뷰 선택을 포함하여 fate Actions의 API와 일치합니다. 뮤테이션을 사용하면 로딩 상태와 에러를 수동으로 처리해야 하며, 결과는 프로미스(Promise)로 반환됩니다.

뮤테이션 서버 구현

fate Actions 및 Mutations는 서버의 일반 tRPC 뮤테이션에 의해 뒷받침됩니다. 다음은 postRouter에서 like 뮤테이션을 구현한 예시입니다.
import { z } from 'zod';
import { connectionArgs, createResolver } from '@nkzw/fate/server';
import { procedure, router } from '../init.ts';
import { postDataView, PostItem } from '../views.ts';

export const postRouter = router({
like: procedure
.input(
z.object({
args: connectionArgs,
id: z.string().min(1, 'Post id is required.'),
select: z.array(z.string()),
}),
)
.mutation(async ({ ctx, input }) => {
const { resolve, select } = createResolver({
...input,
ctx,
view: postDataView,
});

return resolve(
await ctx.prisma.post.update({
data: {
likes: {
increment: 1,
},
},
select,
where: { id: input.id },
} as PostUpdateArgs),
);
}),
});
tRPC 라우터를 fate와 통합하는 방법에 대한 자세한 내용은 서버 통합 섹션을 참조하세요.

액션 및 뮤테이션 에러 처리

fate Actions 및 Mutations는 에러 처리를 "호출 지점(Call site)"과 "바운더리(Boundary)"라는 두 가지 범위로 나눕니다. 호출 지점 에러는 액션이나 뮤테이션이 호출되는 위치에서 처리될 것으로 예상되는 에러입니다. 바운더리 에러는 상위 수준의 에러 바운더리에서 처리되어야 하는 예기치 않은 에러입니다.
서버가 코드 404와 함께 NOT_FOUND 에러를 반환하면, 액션이나 뮤테이션의 결과에 호출 지점에서 처리할 수 있는 에러 객체가 포함됩니다:
const [result] = useActionState(fate.actions.post.delete, null);

if (result?.error) {
if (result.error.code === 'NOT_FOUND') {
// 호출 지점에서 not found 에러 처리.
} else {
// 다른 *예상된* 에러 처리.
}
}
그러나 코드 500과 함께 INTERNAL_SERVER_ERROR 에러가 발생하면, 이는 에러를 던지며 가장 가까운 React 에러 바운더리에서 포착될 수 있습니다:
<ErrorBoundary FallbackComponent={ErrorComponent}>
<Suspense fallback={<div>Loading…</div>}>
<PostPage postId={postId} />
</Suspense>
</ErrorBoundary>
에러 분류 동작은 mutation.ts에서 확인할 수 있습니다.

레코드 삭제하기

fate Actions를 사용하여 레코드를 삭제하고 싶을 때, 액션 호출 시 delete: true 플래그를 전달할 수 있습니다. 이 플래그는 캐시에서 객체를 제거하고 삭제된 데이터에 의존하는 모든 뷰를 다시 렌더링합니다:
const [result, deleteAction] = useActionState(fate.actions.post.delete, null);

deleteAction({
input: { id: post.id },
delete: true,
});

액션 상태 초기화하기

useActionState를 사용할 때, 액션의 결과는 해당 액션을 사용하는 컴포넌트가 언마운트될 때까지 캐시됩니다. 뮤테이션이 에러와 함께 실패했을 때, 액션을 다시 호출하지 않고 에러 상태만 지우고 싶을 수 있습니다. fate Actions는 액션 상태를 초기화하기 위해 'reset' 토큰을 받습니다:
const [result, like] = useActionState(fate.actions.post.like, null);

useEffect(() => {
if (result?.error) {
// 3초 후 액션 상태 초기화.
const timeout = setTimeout(
() => startTransition(() => like('reset')),
3000,
);
return () => clearTimeout(timeout);
}
}, [like, result]);

리스트 삽입 동작 제어하기

리스트에 새로운 객체를 삽입할 때, 기본 동작은 리스트 끝에 새 객체를 추가하는 것입니다. insert 옵션에 before, after, 또는 none 값을 제공하여 이 동작을 커스터마이징하고 새 객체가 리스트의 어디에 삽입될지 지정할 수 있습니다:
addComment({
input: { content: 'New Comment text', postId: post.id },
insert: 'before', // 리스트의 시작 부분에 새 댓글 삽입.
});
또는, 새로운 객체를 어떤 리스트에도 삽입하지 않고 무시하고 싶다면 none 옵션을 사용하세요:
addComment({
input: { content: 'New Comment text', postId: post.id },
insert: 'none', // 어떤 리스트에도 새 댓글을 삽입하지 않음.
});

서버 통합

지금까지는 fate의 클라이언트 측 API에 집중했습니다. fate의 CLI를 사용하여 타입이 지정된 클라이언트를 생성하려면 몇 가지 컨벤션을 따르는 tRPC 백엔드가 필요합니다. 현재 fate는 tRPC 및 Prisma와 함께 작동하도록 설계되었지만, 프레임워크가 특정 ORM이나 데이터베이스에 결합되어 있지는 않습니다. 단지 우리가 시작하는 지점일 뿐입니다.

컨벤션 및 객체 식별 (Conventions & Object Identity)

fate는 데이터가 다음 컨벤션을 따르는 tRPC 백엔드에 의해 제공될 것을 기대합니다:
  • 고유 식별자(id)로 개별 객체를 가져오기 위한 각 데이터 타입별 byId 쿼리.
  • 페이지네이션을 지원하는 리스트를 가져오기 위한 list 쿼리.
객체는 ID와 타입 이름(__typename, 예: Post, User)으로 식별되며, 클라이언트 캐시에는 __typename:id(예: "Post:123") 형식으로 저장됩니다. fate는 백엔드 프로시저와 인자에서 파생된 안정적인 키 아래에 리스트 순서를 유지합니다. 관계(Relations)는 ID로 저장되며 컴포넌트에는 ViewRef 토큰으로 반환됩니다.
fate의 타입 정의는 언뜻 보기에 장황해 보일 수 있습니다. 하지만 fate의 최소한의 API 표면 덕분에 AI 도구가 이 코드를 쉽게 생성할 수 있습니다. 예를 들어, fate는 클라이언트를 위한 타입을 생성하는 최소한의 CLI를 가지고 있지만, 원한다면 LLM이 직접 작성하게 할 수도 있습니다.
참고 기존 프로시저와 함께 이러한 쿼리들을 추가함으로써, 기존 스키마를 변경하지 않고도 기존 tRPC 코드베이스에 fate를 점진적으로 도입할 수 있습니다.

데이터 뷰 (Data Views)

클라이언트 예제를 이어가기 위해, ID로 객체를 선택하기 위한 byId 쿼리와 포스트 리스트를 가져오기 위한 루트 list 쿼리를 노출하는 tRPC 라우터가 포함된 post.ts 파일이 있다고 가정해 보겠습니다.
클라이언트는 서버에 임의의 선택 객체를 보낼 수 있으므로, 원시 데이터베이스 쿼리와 비공개 데이터를 클라이언트에 노출하지 않고 이러한 선택 객체를 데이터베이스 쿼리로 변환하는 방법을 구현해야 합니다. 클라이언트에서는 각 타입의 필드를 선택하기 위해 뷰를 정의합니다. 서버에서도 @nkzw/fate/serverdataView 함수와 fate 데이터 뷰를 사용하여 동일하게 할 수 있습니다.
루트 tRPC 라우터 옆에 각 타입에 대한 데이터 뷰를 내보내는 views.ts 파일을 만듭니다. Prisma의 User 모델에 대한 User 데이터 뷰를 정의하는 방법은 다음과 같습니다:
import { dataView, type Entity } from '@nkzw/fate/server';
import type { User as PrismaUser } from '../prisma/prisma-client/client.ts';

export const userDataView = dataView<PrismaUser>('User')({
id: true,
name: true,
username: true,
});

export type User = Entity<typeof userDataView, 'User'>;
참고: 현재 fate는 Prisma와 통합하기 위한 헬퍼를 제공하지만, 프레임워크가 특정 ORM이나 데이터베이스에 결합되어 있지는 않습니다. 향후 더 직접적인 통합을 제공하기를 희망하며, 기여는 언제나 환영합니다.

tRPC 라우터 구현

위의 데이터 뷰를 tRPC 라우터에 적용하고 createResolver를 사용하여 클라이언트의 선택 사항을 해석할 수 있습니다. 다음은 id로 여러 사용자를 가져올 수 있는 User 타입의 byId 쿼리 구현 예시입니다:
import { byIdInput, createResolver } from '@nkzw/fate/server';
import { z } from 'zod';
import type { UserFindManyArgs } from '../../prisma/prisma-client/models.ts';
import { procedure, router } from '../init.ts';
import { userDataView } from '../views.ts';

export const userRouter = router({
byId: procedure.input(byIdInput).query(async ({ ctx, input }) => {
const { resolveMany, select } = createResolver({
...input,
ctx,
view: userDataView,
});

const users = await ctx.prisma.user.findMany({
select: select,
where: { id: { in: input.ids } },
} as UserFindManyArgs);

return await resolveMany(users);
}),
});
이제 byId 쿼리에 userDataView를 적용했으므로, 서버는 선택 사항을 데이터 뷰에 정의된 필드로 제한하여 비공개 필드를 클라이언트로부터 숨기고 클라이언트 뷰에 대한 타입 안전성을 제공합니다:
const UserData = view<User>()({
// 타입 에러 발생 및 런타임 중 무시됨.
password: true,
});

tRPC 리스트 구현

페이지네이션된 포스트 리스트를 가져오기 위한 list 쿼리를 구현하기 위해 fate의 createConnectionProcedure 헬퍼를 사용할 수 있습니다. 이 헬퍼는 페이지네이션 구현을 단순화합니다. 다음은 list 쿼리가 포함된 postRouter 구현 예시입니다:
import { createResolver } from '@nkzw/fate/server';
import type { PostFindManyArgs } from '../../prisma/prisma-client/models.ts';
import { createConnectionProcedure } from '../connection.ts';
import { router } from '../init.ts';
import { postDataView } from '../views.ts';

export const postRouter = router({
list: createConnectionProcedure({
query: async ({ ctx, cursor, direction, input, skip, take }) => {
const { resolveMany, select } = createResolver({
...input,
ctx,
view: postDataView,
});
const findOptions: PostFindManyArgs = {
orderBy: { createdAt: 'desc' },
select,
take: direction === 'forward' ? take : -take,
};

if (cursor) {
findOptions.cursor = { id: cursor };
findOptions.skip = skip;
}

const items = await ctx.prisma.post.findMany(findOptions);
return resolveMany(direction === 'forward' ? items : items.reverse());
},
}),
});

데이터 뷰 구성

클라이언트 측 뷰와 마찬가지로, 데이터 뷰도 다른 데이터 뷰로 구성될 수 있습니다:
export const postDataView = dataView<PostItem>('Post')({
author: userDataView,
content: true,
id: true,
title: true,
});

데이터 뷰 리스트

리스트 필드를 정의하려면 list 헬퍼를 사용하세요:
import { list } from '@nkzw/fate/server';

export const commentDataView = dataView<CommentItem>('Comment')({
content: true,
id: true,
});

export const postDataView = dataView<PostItem>('Post')({
author: userDataView,
comments: list(commentDataView),
});
어디서나 사용하는 것과 동일한 뷰 구문을 사용하여 views.ts 파일에서 Root 객체를 내보냄으로써 추가적인 루트 수준 리스트와 쿼리를 정의할 수 있습니다:
export const Root = {
categories: list(categoryDataView),
commentSearch: { procedure: 'search', view: list(commentDataView) },
events: list(eventDataView),
posts: list(postDataView),
viewer: userDataView,
};
list(...)로 뷰를 감싼 항목은 리스트 리졸버(Resolver)로 취급되며, 해당 라우터 프로시저를 호출할 때 procedure 이름을 사용합니다(기본값은 list). list(...)를 생략하면 fate는 해당 항목을 표준 쿼리로 취급하고 뷰 타입 이름을 사용하여 라우터 이름을 추론합니다.
위의 Root 정의에 대해 useRequest를 사용하여 다음과 같은 요청을 할 수 있습니다:
const query = 'Apple';

const { posts, categories, viewer } = useRequest({
// 명시적 Root 쿼리:
categories: { list: categoryView },
commentSearch: { args: { query }, list: commentView },
events: { list: eventView },
posts: { list: postView },
viewer: { view: userView },

// 해당 엔티티에 `byId` 쿼리가 정의된 경우 ID로 쿼리:
post: { id: '12', view: postView },
comment: { ids: ['6', '7'], view: commentView },
});

데이터 뷰 리졸버

fate 데이터 뷰는 계산된 필드(Computed fields)를 위한 리졸버를 지원합니다. Post 데이터 뷰에 commentCount 필드를 추가하고 싶다면, resolve 함수와 함께 데이터베이스 쿼리를 위한 Prisma 선택 사항을 정의하는 resolver 헬퍼를 사용할 수 있습니다:
export const postDataView = dataView<PostItem>('Post')({
author: userDataView,
commentCount: resolver<PostItem, number>({
resolve: ({ _count }) => _count?.comments ?? 0,
select: () => ({
_count: { select: { comments: true } },
}),
}),
comments: list(commentDataView),
id: true,
});
이 정의는 commentCount 필드를 클라이언트 측 뷰에서 사용할 수 있게 해줍니다.

리졸버에서의 권한 부여 (Authorization)

현재 사용자나 다른 컨텍스트 정보에 따라 특정 필드에 대한 접근을 제한하고 싶을 수 있습니다. 리졸버 정의에 authorize 함수를 추가하여 이를 수행할 수 있습니다:
export const userDataView = dataView<UserItem>('User')({
email: resolver<UserItem, string | null, { sessionUser: string }>({
authorize: ({ id }, context) => context?.sessionUserId === id,
resolve: ({ email }) => email,
}),
id: true,
});

타입이 지정된 클라이언트 생성하기

이제 클라이언트 뷰와 tRPC 서버를 정의했으므로, 이들을 연결할 접착 코드가 필요합니다. 편의를 위해 fate의 CLI를 사용하는 것을 권장합니다.
먼저, tRPC router.ts 파일이 appRouter 객체, AppRouter 타입, 그리고 정의한 모든 뷰를 내보내는지 확인하세요:
import { router } from './init.ts';
import { postRouter } from './routers/post.ts';
import { userRouter } from './routers/user.ts';

export const appRouter = router({
post: postRouter,
user: userRouter,
});

export type AppRouter = typeof appRouter;

export * from './views.ts';
참고: 우리는 마법 같은 기능을 최소화하려고 노력하며, 원한다면 생성된 클라이언트를 직접 작성할 수도 있습니다.
pnpm fate generate @your-org/server/trpc/router.ts client/src/fate.ts
참고: fate는 지정된 서버 모듈 이름을 사용하여 필요한 서버 타입을 추출하고, 동일한 모듈 이름을 사용하여 생성된 클라이언트에 뷰를 임포트합니다. CLI를 실행하는 루트와 클라이언트 패키지 모두에서 해당 모듈을 사용할 수 있는지 확인하세요.

fate 클라이언트 생성하기

이제 클라이언트 타입을 생성했으므로, 남은 일은 fate 클라이언트 인스턴스를 생성하고 FateClient 컨텍스트 프로바이더를 사용하여 React 앱에서 사용하는 것입니다:
import { httpBatchLink } from '@trpc/client';
import { FateClient } from 'react-fate';
import { createFateClient } from './fate.ts';

export function App() {
const fate = useMemo(
() =>
createFateClient({
links: [
httpBatchLink({
fetch: (input, init) =>
fetch(input, {
...init,
credentials: 'include',
}),
url: `${env('SERVER_URL')}/trpc`,
}),
],
}),
[],
);
return <FateClient client={fate}>{/* 컴포넌트가 여기에 위치합니다 */}</FateClient>;
}
이제 모든 준비가 끝났습니다. 즐겁게 개발하세요!

자주 묻는 질문 (FAQ)

이것은 진지한 소프트웨어인가요?

다른 현실에서 fate는 다음과 같이 묘사될 수 있습니다:
fate는 Relay 스타일의 아이디어를 tRPC와 혼합하려는 야심 찬 React 데이터 라이브러리로, 비전과 분위기(Vibes)가 절반씩 섞여 유지되고 있습니다. 명령형 로딩 상태와 에러 처리를 포함하여 세 군데의 서로 다른 장소에 동일한 페치 로직을 작성하는 것을 즐긴다면 절대 겪지 않을 문제들을 해결하는 것을 목표로 합니다. fate는 예측 가능한 데이터 흐름, 최소한의 API, 그리고 "마법 없음"을 약속하지만, 가끔은 그렇지 않다고 의심할 수도 있습니다.
fate는 실제 동기화 엔진보다는 거의 확실히 나쁘지만, 결국 기존의 React 데이터 페칭 라이브러리보다는 나아지기를 희망합니다. 고통에 대한 내성이 높고 React의 데이터 페칭 미래를 형성하는 데 도움을 주고 싶다면 사용해 보세요.

fate가 Relay보다 나은가요?

전혀 아닙니다.

fate가 GraphQL을 사용하는 것보다 나은가요?

아마도요. 언젠가는요. 어쩌면요.

fate는 어떻게 만들어졌나요?

참고 fate 코드의 80%는 OpenAI의 Codex가 작성했습니다. 각 작업당 네 가지 버전을 생성하고 인간이 신중하게 큐레이팅했습니다. 나머지 20%는
cnakazawa
가 작성했습니다. 어느 부분이 좋은 부분인지는 여러분이 결정하세요! 문서는 100% 인간이 작성했습니다. fate에 기여하려면 AI 도구 사용 여부를 공개해야 합니다.

미래

fate는 아직 완성되지 않았습니다. 이 라이브러리에는 가비지 컬렉션(Garbage collection), 뷰 정의를 미리 정적으로 추출하는 컴파일러와 같은 핵심 기능이 부족하며, 백엔드 보일러플레이트가 너무 많습니다. 현재 fate의 구현은 tRPC나 Prisma에 묶여 있지 않으며, 단지 우리가 시작하는 지점일 뿐입니다. fate를 개선하기 위한 기여와 아이디어를 환영합니다. 추가하고 싶은 기능은 다음과 같습니다:
  • Drizzle 지원
  • tRPC 이외의 백엔드 지원
  • 오프라인 지원을 위한 영구 저장소
  • 캐시를 위한 가비지 컬렉션 구현
  • 더 나은 코드 생성 및 타입 반복 감소
  • useLiveView 및 SSE를 통한 라이브 뷰(Live views) 및 실시간 업데이트 지원

감사의 말

fate
cnakazawa
에 의해 만들어졌으며 Nakazawa Tech에서 유지 관리합니다.
0
3

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글