입사 후 내게 펼쳐진 거대한 Relay
10년차 시니어 FE 개발자님께 질문을 드렸다
'Relay...가 뭔가요?'
그리고 내가 받은 답변:
https://relay.dev/docs/tutorial/intro/
Tutorial Intro | Relay
This tutorial will get you started with the most important and frequently-used features of Relay. To do that, we’ll build a simple app that displays a newsfeed. We will cover:
relay.dev
???: 자 이제 이걸 보고 Relay가 뭔지 알아보시면 됩니다
GraphQL에 대하여
- Meta가 모바일 앱의 성능 문제를 해결하기 위해 만듦
- 2012년 개발, 2015년 오픈소스 공개
- REST API의 한계 (오버페칭, 언더페칭)을 극복하기 위해 만들어짐
REST API vs GraphQL
예시: 사용자 페이지를 만드는 상황
1. REST API
- 사용자 정보 필요: /api/users/123
- 작성한 글 목록 필요: /api/users/123/posts
- 친구 목록 필요: /api/users/123/friends
3번의 네트워크 요청이 필요함!
각 응답에는 안쓰는 데이터도 포함될 가능성이 있음
2. GraphQL
{
user(id: "123") {
name
avatar
posts { title }
friends { name }
}
}
1번의 요청으로, 원하는 데이터만 정확히 받음
GraphQL 구성 요소
1. 스키마
- GraphQL 서버의 중심 요소
- 어떤 데이터를 요청할 수 있고, 어떤 연산이 가능한지 정의함
- 일종의 계약서(contract) 역할을 하며, 클라이언트와 서버가 서로 어떤 데이터를 주고 받을지 명확히 정의됨
schema {
query: Query
mutation: Mutation
}
2. 타입
- GraphQL은 엄격한 타입 시스템을 가지고 있음
- 서버가 반환하는 데이터의 구조를 명확히 정의함
주요 타입 종류
- Scalar 타입: 기본 자료형 (Int, Float, String, Boolean, ID)
- Object 타입: 실제 데이터 구조를 정의
type User {
id: ID!
name: String!
age: Int
}
- Input 타입: Mutation 등에 입력 값으로 사용하는 타입
input CreateUserInput {
name: String!
age: Int
}
- Enum, Interface, Union 타입도 존재해 다양한 모델링이 가능함
3. 쿼리
- 데이터를 조회(read)하는 요청
- REST의 GET 요청과 유사
예시) 서버 스키마
type Query {
user(id: ID!): User
users: [User]
}
예시) 클라이언트에서 실행하는 쿼리
query {
user(id: "1") {
name
age
}
}
4. 뮤테이션
- 데이터를 변경(create/update/delete) 하는 요청
- REST의 POST, PUT, DELETE와 비슷한 역할
예시) 서버 스키마
type Mutation {
createUser(input: CreateUserInput!): User
}
예시) 클라이언트에서 실행하는 뮤테이션
mutation {
createUser(input: { name: "철수", age: 20 }) {
id
name
}
}
5. 리졸버
- 각 쿼리나 뮤테이션이 어떻게 데이터를 가져오거나 조작할지 정의하는 함수
- 실제 데이터 소스(DB, API 등)와 연결되는 로직을 구현함
const resolvers = {
Query: {
user: (_, { id }, { dataSources }) => dataSources.userAPI.getUserById(id),
},
Mutation: {
createUser: (_, { input }, { dataSources }) => dataSources.userAPI.createUser(input),
},
};
| 구성요소 | 역할 |
| Schema | 전체 API 구조 정의 |
| Type | 데이터 형태 정의 |
| Query | 데이터 조회 |
| Mutation | 데이터 생성/수정/삭제 |
| Resolver | 실제 데이터 처리 로직 |
Relay에 대하여
Relay란?
- Meta 내부에서 GraphQL과 함께 사용하던 도구를 오픈소스화 함 (2015년 공개)
- React 환경에서 GraphQL 데이터를 효율적으로 관리하기 위해 설계됨
핵심 개념: Fragment 기반의 컴포넌트
- 일반적인 GraphQL: 페이지 레벨에서 필요한 모든 데이터를 정의함
- Relay: 각 컴포넌트가 자신이 필요한 데이터를 Fragment로 선언하고, Relay가 이를 자동으로 조합해 최적화된 단일 요청으로 만듦
비유하자면, 레고 블록같음
- 각 컴포넌트는 자신의 '데이터 블록(Fragment)'를 정의함
- Relay가 이 블록을 조합해 완전한 쿼리를 생성함
- 컴포넌트를 추가/제거하면 쿼리도 자동으로 업데이트 됨
예시
- 일반적인 GraphQL: 페이지 레벨에서 데이터 정의, UserProfile 컴포넌트를 수정한다면 이 파일을 찾아와서 수정해야 함
// UserPage.tsx - 모든 데이터를 한 곳에서
const GET_USER_PAGE = gql`
query GetUserPage($userId: ID!) {
user(id: $userId) {
# UserProfile에서 쓸 데이터
name
email
avatar
# UserPosts에서 쓸 데이터
posts {
id
title
content
}
# UserFriends에서 쓸 데이터
friends {
id
name
avatar
}
}
}
- Relay 방식: 컴포넌트가 데이터를 소유함. 수정이 필요하면 각 Fragment에서 수정함
사용하지 않는 Fragment를 제거하면 쿼리도 자동으로 최적화 됨
컴포넌트 재사용 시 데이터 의존성도 함께 이동함
// UserProfile.tsx - 자신이 필요한 데이터만
function UserProfile({ user }) {
const data = useFragment(
graphql`
fragment UserProfileFragment on User {
name
email
avatar
}
`,
user
)
}
// UserPosts.tsx - 자신이 필요한 데이터만
function UserPosts({ user }) {
const data = useFragment(
graphql`
fragment UserPostsFragment on User {
posts {
id
title
}
}
`,
user
)
}
// UserPage.tsx - Fragment만 조합
const query = graphql`
query UserPageQuery($userId: ID!) {
user(id: $userId) {
...UserProfileFragment
...UserPostsFragment
...UserFriendsFragment
}
}
`
주요 특징
1. 선언적 데이터 요청
- 컴포넌트 안에서 '이 데이터가 필요하다'고 명시적으로 선언함
- Relay는 해당 데이터를 자동으로 요청하고, 컴포넌트에 주입함
const UserProfile = graphql`
fragment UserProfile_user on User {
id
name
avatarUrl
}
`;
-> 이 컴포넌트는 User 타입의 id, name, avataUrl 필드를 필요로 한다는 것을 명시적으로 선언함
2. 데이터와 ui 공존 (Colocation)
- ui 코드와 데이터 요구사항을 같은 파일에 둠
- 이 컴포넌트가 어떤 데이터를 사용하는지 코드만 봐도 바로 파악할 수 있도록 함
function UserProfile({ user }) {
return <div>{user.name}</div>;
}
- UserProfile의 데이터 요구사항(UserProfile_user)은 같은 파일에 있어야 함
3. Fragment & Query 체계
- Relay는 GraphQL의 Fragment 기능을 중심으로 동작함
- Fragment: 특정 컴포넌트가 필요한 데이터 조각을 정의함
- Query: 여러 Fragment를 조합해 하나의 큰 요청으로 만듦
- Relay는 Fragment(조각) 단위로 컴포넌트를 관리하고, 실행 시 필요한 조각만 합쳐 한 번의 네트워크 요청으로 보냄
4. Store & Cache 관리
- Relay는 Normalized Cache를 자동으로 관리함
- 동일한 데이터를 중복으로 요청하지 않고, ID 기반으로 데이터를 효율적으로 업데이트함
- 서버에서 응답이 오면, Relay는 클라이언트의 GraphQL Store를 업데이트 해 관련된 모든 컴포넌트를 자동으로 리렌더링함
5. Mutations
- GraphQL Mutation을 실행하고, 결과를 Relay Store에 자동으로 반영함
commitMutation(environment, {
mutation: graphql`
mutation AddFriendMutation($input: AddFriendInput!) {
addFriend(input: $input) {
friend {
id
name
}
}
}
`,
variables: { input: { userId: "123" } },
});
-> 이 요청이 완료되면 Relay Store가 자동 갱신되어 UI도 업데이트 됨
Relay를 왜 썼는가?
이것 역시 내가 입사했을 때는 이미 다 정해지고 개발이 진행 중인 상태였음
정확한 도입 이유는 밝히기 어렵지만, 내가 입사해서 개발하면서 느낀점을 바탕으로 재해석 해보고자 함
내가 개발하는 프로덕트는 모든 기능이 공유하는 '재화'라는 요소를 가지고 있음
- 사용자가 어떤 기능을 수행하면 재화 증가하거나 감소함
- 재화 변화는 다른 여러 기능의 ui에도 영향을 줌
나는 이 재화 시스템을 사용하는 동일한 프로덕트를 두 가지 방법으로 개발해봄
- 레거시 코드 리팩토링(RestAPI)
- 리라이팅 (Relay)
1. 레거시 코드 리팩토링 (RestAPI)
매번 수동으로 상태 업데이트 필요
- 각 기능에서 api 요청을 보낼 때마다 응답에 해당 기능에 대한 응답 뿐 아니라 '재화' 상태 변화도 같이 포함시켜줘야 함
- 클라이언트는 응답을 받아서 해당 기능의 완료 처리를 구현할 뿐 아니라, '재화'의 상태도 업데이트 해야 함
그러나, 이런 상황에 더해서 레거시는 이 '재화'를 전역 상태 라이브러리로 관리하지 않음
그래서 이 값을 업데이트 하기 위해서는 useState를 무한 props drilling 해서 직접 엎어쳐야 함
만약 재화가 전역 상태 라이브러리로 존재했다고 해도,
신 기능을 구현할 때마다 프론트엔드/백엔드 모두 이 재화 상태 업데이트를 신경쓰면서 개발해야 했을 것임
2. 리라이팅 (Relay)
- 클라이언트에서 mutation을 요청할 때, 업데이트 해야하는 Fragment를 포함시켜서 작성함
- 그럼 서버에서 해당 기능에 대한 요청 응답을 가져오는 동시에, 해당 Fragment에 대한 값도 자동으로 fetch해서 업데이트 해줌
- 그럼 클라이언트의 GraphQL Store가 업데이트 되면서 자동으로 관련된 컴포넌트들이 리렌더링 됨
우리가 해야하는 데이터와 ui 업데이트를 쉽게 해결해줌
신경 쓸 포인트가 줄어듦
해야될 것
- Fragment 선언
- Mutation에 Fragment 포함
신경 안써도 되는 것
- 재화 업데이트 로직 구현
- 수동으로 상태 업데이트
- props drilling
다만, 러닝 커브는 확실히 높음
Fragment, Query, Mutation에 대해 이해하고 정확히 사용하는 것 자체가 장벽이 됨
시니어 개발자님께 내가 이해한 로직을 들고가서 여쭤보거나, Relay tutorial 예제를 실행시켜보는 것 정도가 당시 상황에서 최선이었음
결국 기능을 만들어 보면서 이해하게 됨 (백문이 불여일견)
기능을 3개 쯤 만들면서 '오 이게 Relay'인가?했다가
기능을 5개 쯤 만들 때는 '아 아닌가'했다가
기능을 10개 쯤 만들 때 '아 이제 좀 알겠다'했다가
좀 더 깊게 공부하니까 '아 나 하나도 모르네'가 됐다가
무튼 혼자 희노애락을 반복했음
멋쟁이 로직을(나름 내 최선) 시니어 개발자님께 들고가서
'이 로직으로 api 과요청을 막을 수 있지 않을까요??'했는데
'오 아닐 것 같은데?'라고 하셔서
슬펐던 기억도 있음
그만큼 어렵긴 하다~
Relay를 어떻게 썼는가?
기본적인 Relay 사용 케이스
src/
├── pages/
│ └── [feature]/
│ ├── model/
│ │ ├── fragments/
│ │ ├── mutations/
│ │ └── queries/
│ └── ui/
내가 입사했을 때 마주한 폴더 패턴은 이러했음
fsd structure를 쓰고 있었고, 이를 기반으로 Relay 관련 폴더 구조도 세팅되어 있었음
features 아래에도 sub-features들이 존재함
sub-feature의 이름으로 fragments, mutations, queries 폴더 아래 각 파일을 생성함
예를 들어 sub-features의 이름이 userProfile이라고 가정하면 아래와 같음
src/
├── pages/
│ └── dashboard/
│ ├── model/
│ │ ├── fragments/
│ │ │ └── UserProfile.ts
│ │ ├── mutations/
│ │ │ └── UserProfile.ts
│ │ └── queries/
│ │ └── UserProfile.ts
│ └── ui/
│ └── UserProfile/
│ ├── UserProfilePage.tsx
│ └── Header.tsx
Relay 관련 파일을 만들 때는, page나 card처럼 형태에 구애받지 않고 최상단의 근본 '속성'만 파일명으로 지정함
ex) UserProfilePage.ts (x) UserProfile.ts (o)
이건 우리 스쿼드의 원칙 같은 거였는데, 형태까지 붙여서 파일을 만들면 관련 기능을 한 번에 살펴보기 어려웠기 때문임
cmd+p로 파일 검색할 때 딱 속성만 검색해도 fragments, mutations, queries 파일이 모두 나오는게 의도였다고 함
계속 UserProfile이라는 기능을 가정하고 만들어보자
fragments/UserProfile.ts
fragment 파일에는 내가 불러와야 할 정보를 포함시킴
import { graphql } from 'relay-runtime'
export const UserProfileFragment = graphql`
fragment UserProfileFragment_User on User {
user {
id
name
profileImage
}
# 다른 Fragment 조합
currency {
...CurrencyStatusFragment_Currency
}
}
`
Relay에는 명명 원칙이 존재했음. fragment부터 설명하자면
변수명은 (파일명)Fragment
fragment명은 (파일명)Fragment_(GraphQL Type)
GraphQL Type은 graphql schema에 정의된 타입이다
내가 가져오고 싶은 유저라는 정보의 타입이 User라는 뜻
그리고, 이 fragment에 내가 정의해놓은 다른 fragment도 포함시킬 수 있음
예를 들어 UserProfile 정보를 불러올 때 user 정보 뿐 아니라 currency 정보도 불러오고 싶다고 가정하자
그러면 미리 정의해놓은 currency fragment를 위와 같은 형태로 추가해주면 된다
다만, 이 currency라는 정보는 User라는 graphql type 하위에 있어야 한다
User라는 노드 바깥에 있는 정보들을 가져올 수는 없음 (종속 관계 역전 등은 안됨)
queries/UserProfile.ts
이제 선언한 Fragments를 바탕으로 필요한 정보를 query로 요청해보자
import { graphql } from 'relay-runtime'
// Query 정의
export const UserProfileQuery = graphql`
query UserProfileQuery($userId: ID!) {
user(id: $userId) {
# Fragment 조합
...UserProfileFragment_User
}
}
query는 변수명과 query명 둘 다 (파일명)Query 형태로 만들어준다
이제 userId라는 param을 받아서 이 유저에 해당하는 정보를 fragment에 선언한 값을 바탕으로 불러올거임
이제 이 쿼리를 불러오려면 query loader를 써야 함
// queries/UserProfile.ts
import { graphql } from 'relay-runtime'
import { useQueryLoader } from 'react-relay'
import { UserProfileQuery as UserProfileQueryType } from
'@/types/__generated__/UserProfileQuery.graphql'
// Query 정의
export const UserProfileQuery = graphql`
query UserProfileQuery($userId: ID!) {
user(id: $userId) {
# Fragment 조합
...UserProfileFragment_User
}
}
`
// Custom Hook으로 Query Loader 제공
export const useUserProfileQueryLoader = () => {
return useQueryLoader<UserProfileQueryType>(UserProfileQuery)
}
query loader 훅으로 쿼리를 불러줌
이때 쿼리 응답에 대한 타입을 같이 잡아줘야 하는데,
grpahql은 query 파일을 만들면 자동으로 relay compiler 파일이 만들어짐
이때 여기에서 타입을 뽑아서 쿼리 로더의 응답 타입으로 잡아줌
우리 스쿼드에서는 쿼리와 같은 파일에 쿼리 로더를 선언했음
ui에서 이 쿼리를 사용한다면 이렇게 됨
// Level 1: Query Loader
export const UserProfilePage = () => {
const [queryRef, loadQuery] = useUserProfileQueryLoader()
useEffect(() => {
loadQuery({ userId: '123' })
}, [])
if (!queryRef) return null
return <UserProfilePageContent queryRef={queryRef} />
}
// Level 2: Preloaded Query
const UserProfilePageContent = ({ queryRef }) => {
const data = usePreloadedQuery(UserProfileQuery, queryRef)
return <UserProfileComponent user={data.user} />
}
// Level 3: Fragment
const UserProfileComponent = ({ user }) => {
const userData = useFragment(UserProfileFragment, user)
return (
<div>
<img src={userData.user.profileImage} />
<h1>{userData.user.name}</h1>
</div>
)
}
relay query를 사용하는 컴포넌트는 3단계로 나눠져 만들어짐
처음에는 왜 한 컴포넌트를 만드는데 왜 3개로 쪼개는거지??라는 생각을 했음
이유는 리렌더링 최적화 때문
Level 1: 언제 가져올까?
- 데이터를 언제 로딩할지만 결정
- 버튼 클릭 시, 페이지 진입시 등
(비유하자면, 택배 주문 - 언제 시킬까?)
Level2: 데이터 읽기
- query 결과를 실제로 읽어옴
- fragment 데이터는 아직 안 읽음
- reference만 자식에게 전달
(비유 하자면, 택배 수령 - 상자 받음)
Level3: 데이터 사용
- Fragment에서 실제 데이터 추출
- 실제로 화면에서 표시하는 곳
(비유 하자면, 택배 개봉, 실제 물건 꺼냄)
예시를 들어보자,
// ❌ 나쁜 예: Level 2에서 Fragment 사용
const UserProfilePageContent = ({ queryRef }) => {
const data = usePreloadedQuery(UserProfileQuery, queryRef)
const userData = useFragment(UserProfileFragment, data.user)
return (
<div>
<Header />
<Sidebar />
<div>
<img src={userData.user.profileImage} />
<h1>{userData.user.name}</h1>
</div>
<Footer />
</div>
)
}
// 문제: name이 바뀌면 Header, Sidebar, Footer까지 다 리렌더링!
// ✅ 좋은 예: Level 3에서 Fragment 사용
const UserProfilePageContent = ({ queryRef }) => {
const data = usePreloadedQuery(UserProfileQuery, queryRef)
return (
<div>
<Header />
<Sidebar />
<UserProfileComponent user={data.user} /> {/* Reference만 전달 */}
<Footer />
</div>
)
}
const UserProfileComponent = ({ user }) => {
const userData = useFragment(UserProfileFragment, user)
// name이 바뀌어도 이 컴포넌트만 리렌더링!
return (
<div>
<img src={userData.user.profileImage} />
<h1>{userData.user.name}</h1>
</div>
)
}
Reference vs Actual Data
- Level2에서 전달하는 data.user는 실제 데이터가 아니라 Reference
- Level2: 여기 user 정보 있음! (Reference)
- Level3: ㅇㅋ 거기에서 name이랑 profileImage 쓸게 (actual data)
reference는 거의 바뀌지 않기 떄문에 level 2 컴포넌트는 리렌더링 되지 않음
useFragment는 최종 사용처에서만 써야 함!
- useFragment는 데이터를 실제로 화면에서 표시하는 컴포넌트에서만 사용함
- 중간 단계에서 useFragment 사용 금지: 불필요한 리렌더링 발생
필요한 컴포넌트만 리렌더링 하자
Query Loader: 언제 가져올지 제어
Preloaded Query: Reference만 전달
Fragment: 최종 사용처에서 데이터만 추출
mutations/UserProfile.ts
이제 이 UserProfile을 업데이트하는 mutation을 만들어보자
import { graphql } from 'relay-runtime'
import { useMutation, UseMutationConfig } from 'react-relay'
import { UserProfileMutation as UserProfileMutationType } from
'@/types/__generated__/UserProfileMutation.graphql'
// Mutation 정의
export const UserProfileMutation = graphql`
mutation UserProfileMutation(
$userId: ID!
$name: String
$profileImage: String
) {
updateUserProfile(
userId: $userId
name: $name
profileImage: $profileImage
) {
... on UpdateUserProfileSuccess {
user {
# Mutation 후 업데이트할 Fragment
...UserProfileFragment_User
}
}
}
}
`
// Custom Hook으로 Mutation 제공
type MutationConfig = UseMutationConfig<UserProfileMutationType>
export const useUserProfileMutation = (): [
(config: MutationConfig) => void,
boolean, // isPending
] => {
const [commit, isPending] = useMutation<UserProfileMutationType>(
UserProfileMutation
)
const updateUserProfile = (config: MutationConfig) => {
commit(config)
}
return [updateUserProfile, isPending]
}
mutation의 경우 변수명/mutation명을 (파일명)Mutation으로 만들어준다
이 mutation의 커스텀 훅은 use(파일명)Mutation 형태로 해줌
mutation의 응답에서도 fragment를 사용할 수 있음
이때 아까 위에서 서술했던 '자동 fetch의 편의성'이 등장하는데,
내가 query를 호출하기 위해 정의했던 fragment를 mutation에서도 사용할 수 있음
mutation에는 성공 또는 실패 응답을 처리할 수 있는데
성공한 경우 = UpdateUserProfileSuccess인 경우에는
내가 아까 선언한 UserProfileFragment_User 부분의 데이터를 업데이트해 달라는 요청을 이렇게 코드 한 줄로 해결할 수 있음
... on UpdateUserProfileSuccess {
user {
# Mutation 후 업데이트할 Fragment
...UserProfileFragment_User
}
}
정말 감동적인 부분
Relay가 자동으로
Fragment의 데이터를 확인하고
같은 id의 Uesr 캐시를 업데이트하고
UserProfilePageComponent를 자동으로 리렌더링함
실제로 ui에서 사용할 때는
// ui/UserProfile/UserProfilePage.tsx
import { useUserProfileMutation } from '../../model/mutations/UserProfile'
const UserProfilePageComponent = ({ user }) => {
const userData = useFragment(UserProfileFragment, user)
const [updateUserProfile, isPending] = useUserProfileMutation()
const handleSave = () => {
updateUserProfile({
variables: {
userId: userData.user.id,
name: newName,
profileImage: newImage,
},
onCompleted: (response) => {
if (response.updateUserProfile.__typename === 'UpdateUserProfileSuccess') {
// 성공 - Relay가 자동으로 캐시 업데이트!
// 프로필 업데이트 성공에 대한 처리 (토스트를 띄우거나... 등등)
}
},
onError: (error) => {
// 에러에 대한 처리
},
})
}
return (
<div>
<button onClick={handleSave} disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
</div>
)
}
Relay를 구현하며 특히 고민했던 케이스
요구사항
- 특정 데이터의 ID가 4시간마다 바뀜
- 클라이언트에서 4시간 주기를 추적하며 관리해야 함 (서버에서 관리하기 어려운 요소)
왜 중요한가?
- 비즈니스 임팩트와 다이렉트로 연결됨 >> 고쳐야 됨 무조건!
여기서 생긴 고민 포인트
- fetch를 언제 트리거 해야하는가? (페이지 진입 시? 특정 이벤트 발생 시?)
- 4시간 계산을 어떻게 하지? (타임스탬프를 어디 저장하는가? 만료 체크를 어디서 하는가?)
- Relay Query와 어떻게 연동하지? (Query에 ID를 어떻게 전달? 만료되면 어떻게 refetch?)
내가 생각한 방법
- 처음 진입하는 유저: localStorage에 ID가 없음 -> fetch 필요
- 재방문 유저: localStorage에서 ID 읽기 -> 4시간 지났으면 fetch
1. Zustand + localStorage로 상태 관리
// stores/useDynamicDataStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface DynamicDataStoreState {
dataId: string | null
lastFetchedAt: string | null // 타임스탬프!
}
interface DynamicDataStoreActions {
setDataId: (id: string | null) => void
setLastFetchedAt: (date: string | null) => void
}
export const useDynamicDataStore = create
DynamicDataStoreState & DynamicDataStoreActions
>()(
persist(
(set) => ({
dataId: null,
lastFetchedAt: null,
setDataId: (id) => set({ dataId: id }),
setLastFetchedAt: (date) => set({ lastFetchedAt: date }),
}),
{
name: 'dynamic-data', // localStorage key
},
),
)
zustand의 persist 미들웨어를 사용하면 자동으로 localStorage에 저장됨
2. 시간 기반 만료 체크
// 네비게이션 또는 공통 컴포넌트에서
const lastFetchedAt = useDynamicDataStore((state) => state.lastFetchedAt)
const dataId = useDynamicDataStore((state) => state.dataId)
// 4시간 = 14400초
const remainingTime = getRemainingTime(lastFetchedAt ?? '', 14400)
// 만료 체크
const targetIds = useMemo(
() => {
if (remainingTime > 0 && dataId) {
return [dataId] // 아직 유효 → 저장된 ID 사용
}
return [] // 만료됨 → 빈 배열 (새로 fetch)
},
[remainingTime, dataId],
)
- remainingTime > 0: 아직 만료 안됨 -> localStorage에 저장된 ID 사용
- remainigTime <=0: 만료됨 -> 빈 배열 전달 -> 서버가 새 ID 반환
3. Relay Query와 연동
const dataId = useDynamicDataStore((state) => state.dataId)
const variables = useMemo(() => {
return dataId ? { ids: [dataId] } : {}
}, [dataId])
loadQuery(variables, { fetchPolicy: 'network-only' })
- targetIds가 있으면 해당 ID로 query
- 비어있으면, 빈 상태로 query하면 새 ID를 받아올 수 있음
4. 자동 갱신 트리거
const data = usePreloadedQuery<QueryType>(
DataQuery,
queryRef,
)
useEffect(() => {
if (remainingTime > 0) return // 만료 안 됨 → 아무것도 안 함
// 만료됨! 서버 응답에서 새 ID 추출
const newId = data.resource?.id
// localStorage 업데이트
if (newId) {
setDataId(newId)
setLastFetchedAt(new Date().toISOString()) // 타임스탬프 갱신!
}
}, [remainingTime, data])
전체 흐름 정리
1. 페이지 진입
2. localStorage에서 lastFetchedAt 확인
3-1. 4시간 안지남 - 저장된 ID 사용, 기존 데이터 fetch
3-2. 4시간 지남 - 빈 배열로 Query -> 서버가 새 ID 반환 -> localStorage 갱신
어려웠던 이유
- 1, 2, 3단계로 컴포넌트를 나눠서 구현해야하는 Relay 특성 때문에 구조 설계에 대한 낯섦
- usePreloadedQuery와 useLazyLoadQuery 중 무엇을 써야 하는지 고민
그럼 또 여기서 파생 배울점이 생긴다
usePreloadedQuery와 useLazyLoadQuery란?
1. useLazyLoadQuery
function MyComponent() {
const data = useLazyLoadQuery(
MyQuery,
variables,
{ fetchPolicy: 'store-or-network' }
)
return <div>{data.user.name}</div>
}
특징
- 컴포넌트가 렌더링될 때 자동으로 query 실행
- 가장 간단함
- lazy = 컴포넌트 렌더링 시점에 fetch
단점
- 렌더링 시작 -> query 시작 (느림)
- 언제 로딩할지 제어 불가
2. usePreloadedQuery + loadQuery
// Level 1: 언제 로딩할지 제어
function MyPage() {
const [queryRef, loadQuery] = useQueryLoader(MyQuery)
useEffect(() => {
loadQuery(variables)
}, [])
return queryRef ? <MyPageContent queryRef={queryRef} /> : null
}
// Level 2: 데이터 사용
function MyPageContent({ queryRef }) {
const data = usePreloadedQuery(MyQuery, queryRef)
return <div>{data.user.name}</div>
}
특징
- loadQuery로 언제 fetch할지 제어 가능
- 버튼 클릭, 조건부 로딩 등 유연함
- Suspense와 잘 동작
장점
- 사용자 액션에 따라 로딩 시점 조절
- 3단계 컴포넌트 패턴으로 리렌더링 최적화
| useLazyLoadQuery | usePreloadedQuery + loadQuery | |
| 로딩 시점 | 컴포넌트 렌더링 시 자동 | 수동 제어 가능 |
| 복잡도 | 간단 | 복잡 |
| 유연성 | 낮음 | 높음 |
| 사용 예시 | 단순 조회 | 복잡한 인터렉션 |
이 기능에서 usePreloadedQuery를 사용한 이유:
4시간 만료 로직을 구현하려면
- remainingTime 계산 후 조건부 로딩
- variables를 동적으로 변경
- 특정 이벤트 (앱 브릿지)에서 refetch
-> 로딩 시점을 제어해야 했기 때문에 usePreloadedQuery + loadQuery 선택
그리고... 여기서 파생된 또 다른 문제
화면이 렌더링 될 때마다 쿼리가 계속 호출되고 있었음
api 과다 호출 = 비용 발생
기술적으로도 문제가 있을 뿐 아니라 비즈니스 임팩트에도 영향이 있는 부분
처음에는 내 로직에 버그가 있다고 생각했음
useEffect 의존성 배열? useMemo 이슈? 등등
그런데 이쪽 문제가 아니었음
그럼 query를 호출하는 부분에 있을거라고 생각했음
useEffect(() => {
loadQuery(variables, { fetchPolicy: 'network-only' })
}, [loadQuery, variables])
variables가 바뀌는 건가?
-> 콘솔 로그 찍어봤는데 variables는 매번 같음
근데 이 fetchPolicy: 'network-only' <- 이 녀석이 너무 수상함
기존에 이미 짜여진 코드 패턴을 모방하면서 Relay를 익혀와서 코드에 대한 의심을 풀고 있었음
그런데 뭔가 이 network-only라는 부분이 매번 호출하는 것 같다는 생각이 들었음
gpt에게 물어봤더니
network-only는 캐시를 무시하고 항상 네트워크 요청을 보낸다고 함
아뿔싸!
이럴 땐 어떻게 해야하냐
ai 다 비켜
일단 공식 문서 켜
근본으로 돌아가자
Relay Fetch Policy에 대하여
https://relay.dev/docs/guided-tour/reusing-cached-data/fetch-policies/
Fetch Policies | Relay
Relay guide to fetch policies
relay.dev
fetchPolicy: Relay가 데이터를 가져올 때 캐시를 어떻게 활용할지 결정하는 옵션
1. store-or-network (기본값)
- 캐시에 데이터 있으면: 캐시 사용 (네트워크 요청 안 함)
- 캐시에 데이터 없으면: 네트워크 요청
- 가장 효율적, 대부분 이걸 씀
2. store-and-network
- 캐시 먼저 보여주고: 동시에 네트워크 요청
- 빠른 초기 렌더링 + 최신 데이터 보장
- 예시) 사용자 프로필 (일단 캐시 보여주고, 백그라운드에서 업데이트)
3. network-only
- 항상 네트워크 요청 (캐시 무시)
- 최신 데이터가 중요할 때
- 예시: 실시간 데이터, 민감한 정보
4. store-only
- 캐시만 사용 (네트워크 요청 절대 안함)
- 캐시 없으면 에러
- 예시: 오프라인 모드, 이미 fetch한 데이터 재사용
왜 network-only를 썼을까...
4시간이 지났을 때 새로운 ID를 확실하게 가져와야 한다는 것에 대한 두려움
캐시 때문에 옛날 데이터가 업데이트가 안될까봐 고민하다가 network-only를 썼음
하지만 이는 4시간 텀과 상관 없이 계속 query를 호출했음
쿼리를 부르고 캐싱된 값을 써야 하는데, 계속 요청을 해서 데이터를 받아 쓰고 있었던 것
loadQuery(variables, { fetchPolicy: 'network-only' })
여기에서 fetchPolicy를 지우고 기본값인 store-or-network를 쓰게 함
loadQuery(variables)
모든 문제가 해결 됨... 편-안
여담이지만,
이 내용을 시니어 개발자님께 말씀드렸을 때
시니어 개발자님도 fetch policy에 대해 새롭게 알게 되셨던 것 같음 (뭔가 반응이 그랬음)
시니어 개발자님께서도 Relay를 같이 배워가며 쓰고 있었던 상황이라
아마 서로 알려주고 배우는? 그런 상황이었던 것 같음
그때 속으로 뿌듯함을 혼자 느꼈었다
앗싸 기여했다! 같은 감정

당시에 나는 Relay를 더 잘 이해하고, 잘 쓰기 위해
이런 식으로 노트에 로직을 열심히 써서 시니어 개발자님께 여쭤봤음
그리고 그 로직을 피드백 받고,
괜찮은 로직이라는 평가가 나오면 그때 코드로 옮겨보면서
나름대로 교훈을 얻고 성장했던 것 같음
처음에는 '오 아닌 것 같은데?'라는 평가도 받았지만
나중에는 '이대로 하면 될 듯?'이라는 평가도 점점 받기 시작했다
그러면서 성장하는 게 아닐까...
이 세상 모든 주니어 화이팅!
2025.10.23. 23:50
'개발자 강화 > 프론트엔드' 카테고리의 다른 글
| Chrome DevTools MCP 써보기 (0) | 2025.11.29 |
|---|---|
| [매일메일+개발] 쌓임 맥락 + 실무 경험담 (0) | 2025.11.29 |
| [개발] framer motion (motion.div) 사용 경험 (0) | 2025.10.21 |
| [개발] Rive 도입 이유 + 사용 경험 (0) | 2025.10.19 |
| [개발] FECONF25 로띠 세션에서 알게 된 닷로띠를 실무에 적용하기 (0) | 2025.10.09 |