relay 공식문서 읽으면서 이것저것 메모🥕
Relay 는?
- 리액트 컴포넌트의 composability 이라는 특징을 data fetching 에도 가져옴.
- 즉, 각 컴포넌트가 자신의 데이터 요구사항을 선언하면 Relay가 이를 효율적으로 미리 로드할 수 있는 쿼리로 결합한다구함.
- 주요기능은..
- 각 컴포넌트가 필요한 데이터만 선언하면, Relay 가 로딩상태를 처리함.
- Colocation & composability
- 컴포넌트가 자신의 데이터 요구사항을 선언하고 Relay 가 효율적인 쿼리로 결합함. 다른 화면에서 컴포넌트 재사용 시 쿼리도 자동 업데이트됨!
- prefetching
- 코드 다운로드나 실행 전에 쿼리 페칭 간으함.
- 까다로운 UI 패턴 구현
- 로딩상태, 페이지네이션, 리페칭, Optimistic Update, Rollback 등..
- 일관된 업데이트
- 정규화된 데이터 스토어로 같은 데이터를 컴포넌트들이 다른 쿼리로 접근하여도 동기화를 유지함.
- 스트리밍과 지연 데이터
- 쿼리의 일부를 선언적으로 지연시키고, 데이터가 스트리밍되면서 UI 점진적 리렌더링 가능
- 최적화된 런타임
- 지속적으로 최적화됨. JIT 친화적인 런타임이 예상되는 페이로드를 정적으로 결정하여 들어오는 데이터를 더 빠르게 처리함.
- Relay 는 데이터를 가져오고 관리하는 UI 독립적인 계층 / 로딩상태, 페이지네이션 등을 처리하는 리액트 특화 계층으로 나누어짐.
- 리액트 특화 계층은 Suspense 를 기반으로함.
Relay Compiler 라는놈이 있음
- 컴파일 타임 최적화
- GQL 쿼리를 정적 분석하여 최적화된 코드를 생성함
- 타입 정의 파일 생성
- 불필요한 필드 제거
- 런타임에서는 어떤 이점이 있나용?
- 컴파일된 아티팩트를 사용하여, 런타임에서 더 빨리 실행됨!
- 미리 최적화된 쿼리로 네트워크 요청 최소화
- 타입안정성 보장
- 즉, 빌드 시점에 생성된 아티팩트가 번들에 포함되는 구조임
- 근데 아티팩트가 뭘까?
- 취적화된 GQL 쿼리 코드 + 타입 정의 파일 + 정규화된 데이터 구조
__generated__
폴더 내에 이러한 아티팩트 파일이 생성됨- 아하! Relay 는 자체 컴파일러가 있기때문에 아폴로처럼 별도 Codegen 설정이 필요없구낭
- 코드 아티팩트는 어떻게 생겼나요오
// UserComponent_user.graphql.ts
import { FragmentRefs } from "relay-runtime";
export type UserComponent_user$data = {
readonly id: string;
readonly name: string;
readonly email: string;
readonly " $fragmentType": "UserComponent_user";
};
export type UserComponent_user = UserComponent_user$data;
export type UserComponent_user$key = {
readonly " $data"?: UserComponent_user$data;
readonly " $fragmentRefs": FragmentRefs<"UserComponent_user">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "UserComponent_user",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"type": "User"
};
- 타입 정의
- 정규화된 데이터 구조
- 런타임에서 사용할 최적화된 쿼리 정보 포함함
어떤식으로 데이터를 정규화하나용?
// 정규화되지 않은 구조
{
user: {
id: "1",
name: "John",
posts: [
{ id: "1", title: "Post 1", authorName: "John" },
{ id: "2", title: "Post 2", authorName: "John" }
]
}
}
// 정규화된 구조
{
users: {
"1": { id: "1", name: "John" }
},
posts: {
"1": { id: "1", title: "Post 1", authorId: "1" },
"2": { id: "2", title: "Post 2", authorId: "1" }
}
}
- 데이터가 id 로 참조되어 있어서 한곳만 수정하면 됨
- 중복된 데이터가 없어서 불일치 문제가 없음
- 정규화 되지 않은 구조에서는 posts 안의 authorName 도 모두 각각 수정 필요함
- 캐시 효율이 좋음! 같은 user 데이터를 여러번 요청해도 한번만 저장함
그래서 Relay 가 어떻게 다른데~
- 간단히 말하자면, relay 는 컴포넌트별로 필요한 데이터를 선언함.
- 그리고, 선언된 각 컴포넌트들의 fragments 들을 모아 하나의 큰 쿼리로 조합함 (relay complier)
- 이 때 조합하는 과정에서 데이터 정규화과정이 포함되고, 결국 각 컴포넌트에서 요청한 데이터들은 하나의 큰 store (compiler가 만들어준) query 에 포함됨.
// UserProfile 컴포넌트
fragment UserProfile_user on User {
name
avatar
}
// UserPosts 컴포넌트
fragment UserPosts_posts on User {
posts {
title
content
}
}
- 위와 같이 각 컴포너느에서 필요한 데이터를 정의해주면, 아래와 같이 relay compiler 가 최종 쿼리를 만들어줌!
// 컴파일러가 만드는 최종 쿼리
query UserPageQuery {
user(id: $id) {
...UserProfile_user
...UserPosts_posts
}
}
apollo 와 비교를 해보자!
- apollo 는 상위 컴포넌트에서 모든 데이터를 쿼리해줌 (tanstack-query 랑 크게 다를 바 없음)
- 따라서, 가져온 데이터를 props drilling 으로 해당 데이터가 필요한 모든 컴포넌트에게 상속해준다.
- 반면, Relay 는 각 컴포넌트가 자신의 데이터 요구사항을 선언함.
- relay complier가 자동으로 데이터를 연결해줌.
- relay complier는 해당 작업을 빌드타임에 처리해줌.
간단히말하면 apollo 는 top-down 이고, relay 는 bottom-up 이라고 봐도 되는걸까?!
apollo는..
- 최상위에서 데이터 구조를 결정하고,
- 하위로 데이터를 흘려보냄.
- 따라서 상위컴포넌트가 하위컴포넌트의 데이터 요구사항을 알고 있어야함.
relay 는
- 각 컴포넌트(하위)에서 자신의 데이터 요구사항을 정의하고,
- 컴파일러가 상향식으로 데이터 요구사항 수립
- 컴포넌트의 자율성이 높아짐!
cc. Fragment-Driven-Development
쿼리는 static 하다. (relay compiler 동작방식은..?!)
- 코드 내에 작성된 원본 쿼리는 번들결과물에 고대로 포함되지 않음.
// 우리가 작성한 원본 쿼리
query UserQuery {
user(id: "123") {
name
email
}
}
- 쿼리는 컴파일 과정에서 고유한 ID 값으로 변환됨.
Query_USER_123
- 그리고 이건 서버에 미리 저장됨.
- 어느서버? 우리의 GraphQL 서버에 저장됨! 이를 persisted Queries 라고 부른다.
- 퍼시스팅은 빌드타임에 쿼리를 서버에 등록함
- 런타임에서는 이미 번들결과물에 전체 쿼리 텍스트가 부재하여, 컴파일러가 생성한 id와 쿼리텍스트 맵핑하여 자동등록 불가능함.
- 따라서, 릴레이에서는 자동등록방식(런타임에 한번 찌르고..2번찌를 때 저장하는 방식)은 선택할 수 없고, 빌드/배포 프로세스의 일부러 쿼리를 서버와 동기화해야함.
- relay plugin 등 사용하여 빌드 시 퍼시스팅 처리 자동화 할 수 있지 않을까..?! 하는 생각!
- 그리고 이건 서버에 미리 저장됨.
- 따라서 실제 번들에는 쿼리 텍스트가 아닌, ID 만 포함된다.
// 실제 네트워크 요청시
{
"id": "Query_USER_123", // 쿼리 전체 텍스트 대신 ID만 전송
"variables": {
"userId": "123"
}
}
Fragment 적용하기
-
일단 fragment 는 GraphQL 의 재사용 가능한 쿼리조각으로 생각하면 됨.
-
- 상위에서 특정 Fragment 를 쿼리에 포함해도, 하위 컴포넌트에서 해당 Fragment 를 요청하지 않으면 해당 데이터는 상위 query 에 포함되지 않는다. 이를 데이터 마스킹이라고 함!
- 즉...각 컴포넌트가 다른 컴포넌트의 데이터 종속성에 암묵적으로 의존하지 않고, 모든 종속성을 자체 조각 내에서 선언하도록 하는 것임.
- 즉, 부모 컴포넌트에 의존성을 끊어버림. 오로지 fragment 는 스스로 선언하여 그 어떤것과도 의존성이 없게함
-
왜 데이터마스킹을 하나? 아래와 같은 상황을 떠올려보자
// ParentComponent
const ParentComponent = () => {
const data = useLazyLoadQuery(graphql`
query ParentQuery {
user {
id
name
email # 부모가 가진 모든 필드가
avatar # 자식에게 그대로 전달됨
location
}
}
`);
return <ChildComponent data={data.user} />;
}
// ChildComponent
const ChildComponent = ({ data }) => {
// data 안에 모든 필드가 있어서
// 실제로 어떤 필드를 사용하는지 불명확
return <div>{data.name}</div>;
}
- 문제점은 email 필드를 제거하고 싶을 때, 자식컴포넌트가 email 필드를 사용하고 있는지 불명확함. 자식컴포넌트까지 타고 들어가서 확인해줘야함.
// ParentComponent
const ParentComponent = () => {
const data = useLazyLoadQuery(graphql`
query ParentQuery {
user {
id
...ChildComponent_user
}
}
`);
return <ChildComponent user={data.user} />;
}
// ChildComponent
const ChildComponent = ({ user }) => {
const data = useFragment(graphql`
fragment ChildComponent_user on User {
name # 자신이 실제로 필요한 필드만
avatar # 명시적으로 선언
}
`, user);
return <div>{data.name}</div>;
}
- 이렇게 데이터 마스킹을 사용하게 되면, 각 컴포넌트가 자신이 필요한 데이터만 명시적으로 선언함. 부모는 자식컴포넌트에서 어떤 데이터를 요청하는지 볼 수 없음!
- 따라서 , 부모 필드의 변경이 자식에게 영향을 주지도 않고, 필드 제거 시 어떤 컴포넌트가 영향받는지 즛기 알 수 있음
- 해당 필드를 선언한 fragment 를 찾으면 되기때문에
Fragment 에 대해서 조금만 더...
- fragment 이름이 동일하다면 필요한 모든 필드들을 합쳐서 하나의 쿼리로 최적화 시킴!
- fragment 문법에서
on
은 GraphQL 에서 해당 Fragment 가 어떤 타입에 대한 것인지 지정하는 키워드임.
# 서버의 스키마 정의
type User {
id: ID!
name: String
posts: [Post]
}
type Post {
id: ID!
title: String
content: String
}
# 클라이언트의 Fragment 정의
fragment UserInfo on User { # User 타입에 대한 Fragment
name
}
fragment PostInfo on Post { # Post 타입에 대한 Fragment
title
content
}
- 즉 서버측에서는 스키마를 정의해주고 정의된 스키마를 기반으로 클라에서 Fragment 정의하여 쿼리에 사용가능함.
- 서버의 스키마는 일종의 계약서라고 보면 됨.
- 클라는 이 계약 범위 내에서 필요한 데이터 요청
- Fragment 를 사용하여 쿼리를 모듈화하고 재사용함.
useFragment 훅은 어떤 역할을 하나?
- data masking 을 해주고
- 데이터를 구독
- fragment 데이터가 변경되면, 컴포넌트를 자동으로 리렌더링 시켜줌
- relay store 에서 데이터를 추적함.
- 컴파일 타임을 최적화
// 컴파일 전
const UserProfile = () => {
const data = useFragment(profileFragment, user);
return <div>{data.name}</div>;
}
// 컴파일 후 (개념적 예시)
const UserProfile = () => {
// fragment가 이미 최적화되고 ID로 변환됨
const data = useFragment("Profile_fragment_id", user);
return <div>{data.name}</div>;
}
fragment 에 인수 주입하기
const ImageFragment = graphql`
fragment ImageFragment on Image
@argumentDefinitions(
width: {
type: "Int",
defaultValue: null
}
height: {
type: "Int",
defaultValue: null
}
)
{
url(
width: $width,
height: $height
)
altText
}
`;
- 요런식으로 요청하는 framgent 에 인수를 주입할 수 있고, fragment 사용처에서는 아래와 같음
const StoryFragment = graphql`
fragment StoryFragment on Story {
title
summary
postedAt
poster {
...PosterBylineFragment
}
thumbnail {
...ImageFragment @arguments(width: 400)
}
}
`;
https://github.com/moonheekim0118/relay-examples/commit/2a35d6eb2693ac336ca332347a7fa3c7b2add6b1
^ 자세한건 위의 예시를 보기를,,
GraphQL 에서의 배열 처리
- 스키마정의는 아래와 같이 되지만,
type User {
name: String # 단일 값
friends: [User] # 배열 값 (대괄호로 표시)
}
- 쿼리 작성시에는 단일값과 배열의 문법이 동일함!
{
user {
name # 단일 값 조회
friends # 배열 값 조회
}
}
캐싱 & Relay Store
- Relay 캐싱은 쿼리 단위가 아니라, 그래프 노드 단위로 이루어짐.
- Relay 는 Relay Store에서 가져온 모든 노드의 로컬 캐시를 유지함.
- Store 의 각 노드는 ID 로 식별되고 검색된다.
- 두 개의 쿼리가 노드 ID 로 식별된 동일한 정보를 요청하는 경우, 두번째 쿼리는 첫번째 쿼리에 대해 검색된 캐시된 정보를 사용하여 수행되고 추가로 fetch 하지 않음.
- 이러한 캐싱 동작을 활용하려면 missing field hanlder를 구성해야함
missing field hanlder란?
- relay environment 에 추가해줘야하는 속성임.
- 예를들어 아래의 다른 두개의 쿼리가 동일한 데이터를 바라볼 수가 있음.
// Query 1
query UserQuery {
user(id: 4) {
name
}
}
// Query 2
query NodeQuery {
node(id: 4) {
... on User {
name
}
}
}
- 이 두 쿼리는 다르지만, 정확히 동일한 데이터를 참조함.
- 다만, relay 는 이걸 모름...흠 왜 모를까? 쿼리단위가 아니라 노드단위로 캐싱한다며!!
- relay 는 노드 단위로 정규화된 캐싱을 하지만, 위의 쿼리에는 아래와 같은 문제가 있음.
// 내부적으로 이런 식으로 저장
{
"4": { // ID 기반 캐싱
"__typename": "User",
"name": "John"
}
}
- 문제가 되는 상황은 아래와 같음.
// 첫 번째 쿼리로 데이터를 가져옴
query UserQuery {
user(id: 4) { ... } // 'user' 필드로 접근
}
// 나중에 다른 쿼리로 같은 데이터에 접근하려 함
query NodeQuery {
node(id: 4) { ... } // 'node' 필드로 접근
}
- 여기서 문제는...
- relaly 는
user(id:4)
와node(id:4)
가 동일한 데이터를 가리킨다는 것을 자동으로 알지 못한다. - 필드 이름이 다르기 때문에 (user / node) 캐시 조회 경로가 달라짐.
- relaly 는
- 따라서 missingFieldHandler 를 사용하게 되면,
- 다른 필드 이름으로 접근하더라도 같은 데이터를 가르키는 경우를 처리한다!
- 필드 이름이 달라도, 같은 노드를 참조할 수 있게 해주는 맵핑 역할을 해줌!
usePreloadQuery
- 데이터 페칭과 컴포넌트 렌더링 시점을 분리한다.
- 네트워크 요청을 컴포넌트 렌더링 전에 시작 할 수 있도록 함.
- 이전에는 컴포넌트 렌더링 된 후에, 데이터페칭을 시작함 (당연함)
useQueryLoader 라는 놈과 함께 사용됨
function ProfilePageWrapper() {
const [queryRef, loadQuery] = useQueryLoader(ProfileQuery);
// 라우트 진입 시점에 데이터 로드 시작
useEffect(() => {
loadQuery({id: '123'});
}, []);
if (!queryRef) return null;
return (
<Suspense fallback={<Loading />}>
<ProfilePage queryRef={queryRef} />
</Suspense>
);
}
- 쿼리 로딩을 시작하는 시점을 직접 제어가능하도록 도와줌! (loadQuery 함수로)
- 쿼리 변수를 동적으로 변경할 수 있게 도와줌
- 그리고 queryRef 를 해당 쿼리가 fetch 되는 컴포넌트에 넘기고, 그걸 usePreloadQuery 인자로 주입하면, 쿼리의 실행상태를 추적하는게 됨.
근데 이러면 부모-자식의 결합도가 높아지지 않나? 🤥
- 즉, 자식 쿼리의 요청시점을 부모가 제어하게 되니까
- 그래서 useLazyloading 을 사용하는거구만!
- 사실상 렌더링 시간이 확 길지 않으면 성능상 큰 차이도 없을거고..
인터페이스와 다형성
# Actor는 인터페이스 (공통 특성을 정의)
interface Actor {
name: String
profilePicture: Image
joined: DateTime
}
# Person과 Organization은 Actor를 구현한 구체적인 타입
type Person implements Actor {
name: String # Actor의 필드
profilePicture: Image # Actor의 필드
joined: DateTime # Actor의 필드
location: Location # Person만의 필드
}
type Organization implements Actor {
name: String # Actor의 필드
profilePicture: Image # Actor의 필드
joined: DateTime # Actor의 필드
organizationKind: OrgKind # Organization만의 필드
}
- 인터페이스는 공통 필드를 정의함
- 구체적인 타입(Person, Organization)은 인터페이스를 구현하면서 추가 필드를 가질 수 있음.
...on Type
문법으로 특정 타입에만 있는 필드를 가질 수 있음!
# 기본적인 방법 (Actor의 공통 필드만 가져옴)
fragment BasicActorInfo on Actor {
name
joined
profilePicture {
...ImageFragment
}
}
# 타입별 특수 필드도 가져오고 싶을 때
fragment DetailedActorInfo on Actor {
name
joined
profilePicture {
...ImageFragment
}
# Organization인 경우의 특수 필드
... on Organization {
organizationKind
}
# Person인 경우의 특수 필드
... on Person {
location {
name
}
}
}
(기본문법) 쿼리문 내의 ...
과 ... on
의 차이점
...
: Fragment Spread- 이미 정의된 Fragment 를 재사용할 때 사용함
- Fragment 의 모든 필드를 현재 위치에 포함
- Fragment 가 정의된 타입이 현재 위치의 타입과 호환되어야 함
- "이 Fragment 의 모든 필드를 여기에 넣어줘~"
... on
: Inline Fragment / Type Refinement
query {
node(id: "123") {
... on User { # Type Refinement: 노드가 User 타입일 때만 적용
name
email
}
... on Organization {
name
memberCount
}
}
}
- 특정 타입에만 적용되는 필드를 선택할 때 사용함
- 인터페이스나 유니온 타입을 다룰 때 유용함.
- 조건부로 필드 선택 가능
- "이 타입일 때에만 이 필드들을 선택해줘!"
fragment ActorInfo on Actor {
name
joined
}
query {
node(id: "123") {
... on Actor { # Type Refinement: 노드가 Actor일 때
...ActorInfo # Fragment Spread: ActorInfo의 모든 필드를 포함
... on Organization { # Type Refinement: Actor 중에서도 Organization일 때
memberCount
}
}
}
}
(기본문법) 쿼리문에 붙는 node 의 의미는!?
- 요거는 GraphQL 글로벌 ID 시스템과 관련 있음
# 기본적인 Node 인터페이스
interface Node {
id: ID! # 모든 노드는 글로벌 고유 ID를 가짐
}
type User implements Node {
id: ID!
name: String
}
type Post implements Node {
id: ID!
title: String
}
# ID만 있으면 어떤 타입의 데이터든 조회 가능
query {
node(id: "User:123") {
id
... on User {
name
}
}
node(id: "Post:456") {
id
... on Post {
title
}
}
}
- node 를 사용하는 이유는,
- global ID 로 어떤 데이터든 조회 가능함. 캐싱과 정규화에 유리함.
- 백엔드에서 이미 정규화된 GlobalID 로 데이터를 응답하고, 그 응답된 데이터로 query 요청을 보내게 되는 것임.
- 이러면 Relay 의 캐싱과도 궁합이 잘맞음!
- 즉, 백엔드 자체에서 이미 ID 자체에 타입 정보를 포함시켜서 내려주고, 이 ID 를 사용하여 프론트에서 쿼리 요청을 보내게 됨.
// 1. 백엔드에서 데이터를 처음 받아올 때
query {
posts {
edges {
node {
id // 백엔드가 이미 "Post:1" 형태로 ID를 제공
title
}
}
}
}
// 2. 이 ID를 posterID로 사용
function PostHovercard({ posterID }: { posterID: string }) {
// posterID는 이미 "Post:1" 형태
const data = useLazyLoadQuery(PosterDetailsHovercardContentsQuery, {
posterID // "Post:1"
});
// ...
}
- 참고로 이러한 ID 시스템을 Global Object Identification 이라고 부르고, GraphQL 의 주요 스펙 중 하나임!
(기본문법) 쿼리 문법
// 클라이언트에서 자유롭게 정할 수 있는 부분
query WhateverNameYouWant { // 👈 이 쿼리 이름은 자유롭게 정할 수 있음
topStories { // 👈 이건 백엔드 스키마에 있어야 함!
id // 👈 이것도 백엔드 스키마에 있어야 함!
title // 👈 이것도!
}
}
// 다른 이름으로도 같은 쿼리 가능
query GetMainPageStories { // 👈 다른 이름으로 해도 됨
topStories { // 👈 하지만 이 구조는 백엔드와 일치해야!
id
title
}
}