0) 들어가며
최근 프론트엔드 채용 공고를 읽다가 다음과 같은 우대사항을 보았습니다.
GraphQL 기반 아키텍처를 이해하고, Relay를 활용한 개발 경험이 있으신 분
GraphQL은 2022년 코드캠프에서 Apollo Client로 커뮤니티를 구현하며 사용해 본 적이 있습니다. 하지만 이후 프로젝트나 실무에서 다시 다룰 기회가 없어, 몇 가지 개념만 어렴풋이 남아 있었습니다.
그 사이 프론트엔드 진영의 기술과 트렌드도 계속 바뀌었을테니, GraphQL은 지금 어떻게 활용되고 있는지, 이름만 들어본 Relay는 대체 어떤 문제를 해결하는지 궁금해 다시 공부해 보았습니다.
이 글에서는 GraphQL이 어떤 문제를 해결하는지부터 시작해, 왜 GraphQL만으로는 대규모 React 앱의 컴포넌트별 데이터 요구사항까지 관리하기 어려운지, 그리고 Relay의 Fragment, Compiler, Runtime이 어떻게 맞물리는지 하나씩 정리해 보겠습니다.
1) GraphQL의 등장 배경
REST API로 사용자 프로필 화면을 만든다고 생각해 봅시다. 화면에서는 이름과 프로필 사진만 필요하지만, 사용자 조회 API(GET /users/:id)의 응답에는 이메일, 주소, 가입일, 권한처럼 당장 쓰지 않는 정보까지 함께 들어올 수 있습니다.
반대로 게시물 목록 응답에 작성자의 프로필 사진이 없다면, 게시물 카드에 필요한 정보를 얻기 위해 작성자별 추가 요청이 필요할 수도 있습니다.
앞의 상황은 오버페칭(Over-fetching), 뒤의 상황은 언더페칭(Under-fetching) 이라고 합니다.
물론 REST가 반드시 이런 문제를 만든다는 뜻은 아닙니다. 화면의 사용 사례에 맞춘 Endpoint를 제공하거나, 여러 백엔드 응답을 프론트엔드에 필요한 형태로 조합하는 BFF(Backend for Frontend)를 두면 문제를 줄일 수 있습니다. 다만 화면 요구사항이 자주 바뀌고 같은 데이터를 여러 화면에서 서로 다른 형태로 사용하기 시작하면, Endpoint의 응답과 UI가 원하는 데이터 사이를 계속 조정해야 합니다.
GraphQL은 이 문제를 아래의 스탠스로 풀어냅니다.
“적게 줘도 불만, 많이 줘도 불만, 야 그냥 클라이언트 너네가 필요한 데이터를 직접 골라 가져가”
아래 코드처럼, 클라이언트가 중괄호 안에 필요한 필드를 선택 집합(Selection Set) 으로 선언하면, 서버는 그 선택과 같은 모양의 응답을 반환합니다.
유저 이름과 프로필 사진의 선택 집합 예시query ProfileQuery($id: ID!) { user(id: $id) { name profilePicture { uri } } }
뷔페에서 원하는 음식만 골라 담듯, 필요한 필드만 선언하고 원하는 모양 그대로 받을 수 있는 것입니다.
하지만 화면과 컴포넌트 트리가 커지면, 무엇을 가져올지와는 또 다른 문제가 생깁니다.
2) 쿼리를 묶어도, 풀어도 마음에 안 들어.
화면 하나가 수십 개의 React 컴포넌트로 구성되어 있다고 해보겠습니다.
- 프로필 헤더: 이름과 소개
- 아바타: 프로필 사진 URL
- 팔로워 영역: 팔로워 수
- 최근 게시물: 게시물 목록과 작성자 정보
GraphQL을 사용하면 이 화면에 필요한 데이터를 하나의 큰 Query로 묶을 수 있습니다. 문제는 그 Query를 누가 관리하느냐입니다.
페이지 최상단에 모든 필드를 모으면 네트워크 요청은 단순해지지만, 하위 컴포넌트의 데이터 요구사항이 상위 Query에 흩어집니다.
예를 들어 Avatar가 새로운 필드를 필요로 할 때마다 이 컴포넌트를 사용하는 모든 상위 Query를 찾아 수정해야 합니다. 반대로 어떤 필드를 지워도 되는지 확인하려면 컴포넌트 트리를 다시 추적해야 합니다.
각 컴포넌트가 독립적으로 Query를 실행하게 만들면 데이터 소유권은 분명해지지만, 요청 수와 로딩 상태, 중복 데이터, 캐시 일관성을 다시 조율해야 합니다.
Relay는 이 두 요구, 즉 컴포넌트별 데이터 소유권과 화면 단위 Operation 조합을 함께 다루기 위해 등장했습니다.
정리하자면, GraphQL이 무엇을 가져올 것인가를 선언하게 한다면, Relay는 각 컴포넌트의 선언을 어떻게 조합하고 유지할 것인가를 다루는 것입니다.
3) 맞춤 데이터 주문서, Fragment
Relay에서 각 컴포넌트는 자신이 사용할 데이터를 Fragment로 선언합니다.
const AvatarFragment = graphql` fragment Avatar_user on User { profilePicture { uri } } `; function Avatar({ userRef }: { userRef: Avatar_user$key }) { const user = useFragment(AvatarFragment, userRef); return ( <img src={user.profilePicture?.uri ?? ''} alt="" /> ); } const ProfileFragment = graphql` fragment Profile_user on User { name bio ...Avatar_user } `;
이 구조에서 Avatar는 프로필 사진을 컴포넌트 단에서 요구합니다. 부모인 Profile은 ...Avatar_user를 스프레드로 포함하지만, 자식이 선언한 필드라도, 별도로 선언하지 않았다면 읽을 수 없습니다.
Avatar에 전달되는 Avatar_user$key는 원본 JSON 전체가 아니라, 특정 User와 해당 Fragment를 가리키는 불투명한 Fragment reference입니다. Avatar가 useFragment로 이 참조를 읽을 때 비로소 Fragment가 선언한 데이터가 반환됩니다.
여기서 중요한 규칙이 Data Masking입니다.
3-1) 부모가 가져왔더라도, 선언하지 않은 필드라면 읽지 않습니다
서버 응답 안에 어떤 필드가 이미 들어 있더라도, 컴포넌트에는 자기 Fragment에 선언한 필드만 노출됩니다. 컴포넌트가 읽을 수 있는 데이터의 경계를 Fragment 기준으로 제한하는 것입니다.
부모가 이미 받아 온 데이터를 자식에게 그대로 넘겨주면 굳이 Fragment에 선언도 안해도 되고, 코드도 가벼워지니까, 이런 원칙이 다소 깐깐하게 느껴질 수 있습니다.
하지만 만약 이렇게 자식이 우연히 존재하는 부모의 필드에 기대기 시작하면 암묵적인 의존성이 생깁니다. 부모가 자신에게 필요 없어진 필드를 Query에서 제거하는 순간, 그 필드에 몰래 기대고 있던 자식에서 크래시가 터질 수 있습니다.
Data Masking은 이런 의존성을 코드 밖의 컨벤션으로가 아니라 Compiler와 생성 타입, Runtime이 추적할 수 있는 명시적인 계약으로 만듭니다.
- 어떤 컴포넌트가 어떤 필드에 의존하는지 같은 파일에서 확인할 수 있습니다.
- 컴포넌트를 다른 화면으로 옮겨도 필요한 데이터 계약이 함께 이동합니다.
- 상위 Query를 수정할 때 영향 범위를 지역적으로 추론할 수 있습니다.
- 사용하지 않는 필드를 제거할 때 숨어 있는 소비자를 걱정할 일이 줄어듭니다.
Fragment 자체는 단독으로 네트워크 요청을 보내지 않습니다. Query, Mutation, Subscription 같은 Operation의 선택 트리에 포함될 때 실제 요청에 반영되며, 이 지역적인 선언들을 실행 가능한 Artifact로 바꾸는 역할은 Relay Compiler라는 녀석이 맡게 됩니다.
4) 흩어진 주문서를 합치는 Relay Compiler
Relay Compiler는 각 컴포넌트의 지역적인 Fragment 선언을 Operation 단위의 실행 Artifact로 바꿉니다.
컴포넌트마다 Fragment를 두면 유지보수성은 좋아지지만, 여러 Fragment가 같은 필드를 반복해서 선언할 수도 있습니다. Compiler는 각 Root Operation에서 도달 가능한 Fragment를 모으고, 스키마로 검증한 뒤, 중복 선택을 정리해 네트워크 Operation과 Runtime용 Artifact를 생성합니다.
query ProfilePageQuery($id: ID!) { node(id: $id) { ...Profile_user } }
이 ProfilePageQuery에서는 앞선 Profile_user와 그 아래의 Avatar_user까지 도달할 수 있습니다. Relay Compiler는 이 Fragment 트리를 펼쳐 하나의 네트워크 Operation으로 조합합니다.
여기서 자주 생기는 오해가 하나 있습니다.
Compiler의 흐름은 대략 다음과 같습니다.
flowchart LR O["Root Operation"] --> B["Relay Compiler"] F["도달 가능한 Fragment"] --> B S["GraphQL Schema"] --> B B --> C["스키마 검증"] C --> D["Typed IR 변환"] D --> E["Transform · 중복 제거"] E --> G["네트워크 Operation · Runtime Artifact · 타입"] G --> R["Relay Runtime"]
4-1) GraphQL 문서를 수집하고 스키마로 검증합니다
Compiler는 소스 코드의 graphql 태그 템플릿에서 Query, Mutation, Subscription, Fragment 정의를 수집합니다. 존재하지 않는 필드를 선택하거나 타입이 스키마와 맞지 않으면 빌드 과정에서 오류가 발생합니다.
4-2) 문법 이상의 의미를 가진 IR로 변환합니다
일반적인 AST가 문법 구조를 표현한다면, Relay의 IR은 스키마 타입 정보와 @include, @skip 같은 조건의 의미까지 담습니다. 덕분에 GraphQL 문서를 단순한 문자열이 아니라 타입이 부여된 구조로 안전하게 변환할 수 있습니다.
4-3) 최적화하고 Runtime용 Artifact를 생성합니다
Transform 단계에서, 반복된 필드 선택과 불필요한 구조를 정리하고, 최종 네트워크 Operation과 Store를 읽고 쓰는 데 필요한 Runtime Artifact, TypeScript 또는 Flow 타입을 생성합니다.
Relay는 이렇게 정적으로 처리할 수 있는 일을 빌드 시점으로 앞당겨, 개발자가 Query를 어떻게 합칠지보다 각 컴포넌트가 무엇을 필요로 하는지에 집중할 수 있게 합니다.
물론 Runtime 비용이 아예 사라지는 것은 아닙니다. 앱이 실행된 뒤에도 네트워크 요청, 응답 정규화, Store 병합, 구독, GC는 계속 동작합니다. 다만 Compiler가 미리 계산한 Artifact 덕분에 Runtime의 역할이 더 단순하고 예측 가능해지는 것입니다.
5) 중첩 JSON을 객체 그래프로, Relay Runtime
앞에서 Compiler가 컴포넌트의 데이터 요구사항을 실행 가능한 Artifact로 준비했다면, Runtime은 실제 서버 응답을 받아 Store에 쓰고, 읽고, 변경을 구독하는 역할을 맡습니다.
Relay Runtime의 핵심은 서버 응답을 Query별 JSON 덩어리로만 보관하지 않고, 객체의 정체성을 기준으로 정규화된 그래프로 관리한다는 점입니다.
서버에서 다음과 같은 응답을 받았다고 해보겠습니다.
{ "post": { "id": "842", "title": "Relay를 공부하며", "likeCount": 17, "author": { "id": "7", "name": "Jin" } } }
Relay는 이 중첩된 JSON을 하나의 Query 결과로만 저장하지 않습니다. 각 객체를 DataID를 가진 Record로 분리하고, 객체 사이의 관계는 reference로 연결합니다. 실제 내부 표현은 외부에 공개된 계약이 아니지만, 개념적으로는 다음과 비슷합니다.
{ "842": { __id: "842", __typename: "Post", id: "842", title: "Relay를 공부하며", likeCount: 17, author: { __ref: "7" } }, "7": { __id: "7", __typename: "User", id: "7", name: "Jin" } }
여기서 중요한 포인트가 하나 있습니다. Relay는 객체에 id가 있다면 기본적으로 그 값 자체를 Store의 DataID로 사용합니다. 즉 위 예시의 Post는 "842", User는 "7"이 됩니다.
이것이 Relay의 Normalized in-memory object graph입니다. 같은 사용자가 서로 다른 Query 응답에 등장하더라도 동일한 DataID를 가진다면 하나의 Record로 합쳐집니다. 따라서 한 곳에서 그 사용자의 이름이 갱신되면, 같은 Record를 읽는 다른 화면도 일관된 값을 볼 수 있습니다.
5-1) 읽기와 쓰기의 흐름
Runtime의 흐름은 Store를 읽는 과정과 새 데이터를 Store에 반영하는 과정으로 나누어 보면 이해하기 쉽습니다.
읽기는 lookup → subscribe, 쓰기는 normalize → publish → notify로 이어집니다.
먼저 컴포넌트가 데이터를 읽을 때는 다음 흐름을 거칩니다.
lookup(selector)가 Fragment, variables, 시작 DataID로 정의된 subgraph를 읽어 Snapshot을 만듭니다. Snapshot에는 현재 데이터뿐 아니라, 이 결과를 만들기 위해 읽었던 Record 정보도 포함됩니다.subscribe(snapshot, callback)은 해당 Snapshot의 결과가 이후 달라질 때 알림을 받을 수 있도록 구독을 등록합니다.
반대로 Query, Mutation, Subscription 등으로 새 데이터가 들어오면 다음 흐름을 거칩니다.
- 서버 응답이 DataID 기준으로 정규화되어 새로운
RecordSource가 됩니다. publish(source)가 이 RecordSource를 기존 Store에 병합합니다. 이 시점에는 아직 구독자의 callback이 실행되지 않습니다.notify()가 변경된 Record와 각 Snapshot이 읽었던 Record를 비교합니다. 관련 가능성이 있는 구독만 다시 읽고, 실제로 선택한 결과가 달라진 경우에만 callback을 실행합니다.
예를 들어 842번 게시물의 likeCount가 17에서 18로 바뀌었다고 해보겠습니다. Relay는 먼저 변경된 DataID "842"가 각 Snapshot이 읽었던 Record와 겹치는지 확인합니다. 겹치는 Snapshot만 다시 읽은 뒤, 이전 결과와 비교해 실제 값이 17 → 18로 달라진 구독자만 갱신합니다.
따라서 같은 Post Record를 읽고 있더라도 likeCount가 아니라 title만 선택한 컴포넌트라면, 좋아요 수가 바뀌었다는 이유만으로 반드시 다시 렌더링되는 것은 아닙니다.
즉, Relay Runtime의 핵심은 서버 응답을 안정적인 객체 정체성을 가진 그래프로 바꾸고, 각 컴포넌트가 그 그래프에서 실제로 읽은 부분만 추적해 필요한 업데이트만 전달하는 것입니다.
6) TanStack Query vs Relay
TanStack Query는 Promise 기반의 다양한 비동기 데이터 소스를 queryKey 중심으로 캐싱하고 관리합니다. GraphQL 요청에도 사용할 수 있지만, 캐시는 기본적으로 Query 단위이며 정규화 캐시를 제공하지 않습니다.
반면 Relay는 GraphQL 스키마, Compiler, Fragment, 정규화 Store가 하나의 시스템으로 연결되어 있습니다.
| 관점 | TanStack Query | Relay |
|---|---|---|
| 주요 목적 | 다양한 비동기 서버 상태 관리 | 대규모 React 앱의 GraphQL 데이터 아키텍처 |
| 데이터 요구 위치 | queryKey, queryFn, Custom Hook 중심이며 구성 방식은 애플리케이션이 결정 | Fragment를 컴포넌트와 함께 배치하는 것이 핵심 |
| 캐시 정체성 | 주로 queryKey 기준 | DataID 기준으로 정규화된 Record |
| 갱신 방식 | 무효화·재요청·직접 캐시 수정·낙관적 업데이트를 애플리케이션이 구성 | 응답을 Store에 병합하고 Snapshot 구독을 선택적으로 갱신 |
| 정적 분석 | 라이브러리의 필수 전제가 아님 | Compiler가 스키마 검증, 타입과 Artifact 생성, 최적화를 수행 |
- TanStack Query는 REST를 포함한 다양한 API와 비교적 가볍게 결합할 수 있습니다.
- Relay는 더 많은 규칙과 빌드 파이프라인을 요구하는 대신, 컴포넌트별 GraphQL 데이터 계약을 일관된 방식으로 확장할 수 있는 강한 기본값을 제공합니다.
둘 다 서버 데이터를 다루지만, Relay는 단순한 캐시 라이브러리라기보다 컴포넌트의 데이터 계약을 Compiler와 Runtime까지 연결하는 GraphQL 전용 아키텍처에 더 가깝습니다.
7) Relay의 데이터 로딩 패턴
Relay는 정규화 Store뿐 아니라, 언제 요청을 시작하고, 화면의 어느 부분을 다시 가져오며, 기존 UI를 어떻게 유지할지도 일관된 패턴으로 제공합니다.
7-1) 쿼리 미리 요청해 두기
컴포넌트가 렌더링된 뒤 useEffect에서 요청을 시작하면 렌더링과 네트워크 요청이 직렬로 이어집니다. Relay는 이벤트나 라우팅 시점에 loadQuery 또는 useQueryLoader로 요청을 먼저 시작하고, 컴포넌트에서는 usePreloadedQuery로 그 Query reference를 읽는 패턴을 권장합니다.
function Poster({ id }: { id: string }) { const [queryRef, loadQuery] = useQueryLoader(MovieHoverQuery); return ( <> <img src="/poster.jpg" alt="" onMouseEnter={() => loadQuery({ id })} /> {queryRef && ( <Suspense fallback={<HoverCardSkeleton />}> <HoverCard queryRef={queryRef} /> </Suspense> )} </> ); } function HoverCard({ queryRef }: Props) { const data = usePreloadedQuery(MovieHoverQuery, queryRef); return <div>{data.movie?.rating}</div>; }
usePreloadedQuery는 처음 요청을 시작하는 훅이 아닙니다. 앞서 시작된 요청의 reference를 렌더링 과정에서 소비합니다. 네트워크 I/O와 렌더링 준비를 겹쳐 체감 대기 시간을 줄이는 Render-as-you-fetch 패턴입니다.
7-2) 화면 일부만 다시 가져오기
검색어처럼 화면 일부의 variables만 바뀐다면 @refetchable과 useRefetchableFragment를 사용할 수 있습니다. Compiler는 해당 Fragment를 독립적으로 다시 가져올 수 있는 Refetch Query를 생성합니다.
일반적으로 Query, Viewer, 또는 안정적으로 다시 식별할 수 있는 Node 구현 타입의 Fragment가 대상입니다.
Refetch가 Suspense를 발생시키는 업데이트에서 React의 useTransition을 함께 사용하면 기존 UI를 유지한 채 pending 상태를 보여줄 수 있습니다. useTransition은 Relay Directive가 아니라 React의 UI 전환 기능입니다.
7-3) Connection으로 무한 스크롤 구현하기
리스트의 다음 페이지를 이어 붙이는 것은 단순한 Refetch와 다릅니다. Relay에서는 Fragment에 @refetchable을, 페이지네이션 필드에 @connection을 선언하고 usePaginationFragment를 사용합니다.
이 패턴은 cursor 기반 Connection을 바탕으로 이전 항목을 유지하면서 loadNext로 다음 Edge를 이어 붙일 수 있게 합니다.
8) 모든 GraphQL에 Relay를 필수로 써야 할까?
그렇지는 않습니다.
Relay가 특히 잘 맞는 환경을 나열하면 다음과 같습니다.
- 여러 화면에서 같은 엔티티를 반복해 사용합니다.
- 컴포넌트 재사용이 많고, 각 컴포넌트의 데이터 요구사항이 자주 바뀝니다.
- Pagination, Refetch, Mutation, Optimistic Update처럼 데이터 흐름이 복잡합니다.
- 서비스 규모가 커져 Query와 컴포넌트 사이의 데이터 의존성을 사람이 직접 추적하기 어렵습니다.
“엥? 이거 그냥 GraphQL을 도입할 이유 아니야? 그러면 GraphQL을 쓰는 순간 Relay도 무조건 따라오는 거 아니야?”
정답! …이라고 하고 싶지만, 아닙니다.
핵심은 GraphQL을 사용한다는 사실보다, 프론트엔드가 관리해야 하는 데이터 복잡도가 Relay의 규칙과 비용을 정당화하는가입니다.
8-1) 눈 떠보니 GraphQL이 주어진 경우
백엔드 팀이 이미 GraphQL BFF를 운영하고 있거나, 서드파티가 GraphQL API를 제공하는 경우입니다. GraphQL을 사용하는 화면이 몇 개 없고 데이터 흐름도 단순하다면, Compiler 설정과 Fragment 규칙, 생성 Artifact 관리까지 도입하기보다 graphql-request와 TanStack Query 같은 가벼운 조합으로도 충분할 수 있습니다.
8-2) Relay가 기대하는 스키마 패턴을 활용하기 어려운 경우
Relay는 안정적인 객체 식별자를 기반으로 정규화 Store를 구성하고, Refetch와 Pagination에서도 Node와 Connection 패턴을 적극적으로 활용합니다.
서버가 서드파티거나 레거시라 이런 형태를 제공하지 않는다면 Relay를 사용할 수 없는 것은 아니지만, getDataID 같은 추가 설정이나 별도의 스키마 적응이 필요할 수 있습니다. 즉, Relay의 기본 경로에서 벗어날수록 도입 비용이 커집니다.
8-3) 읽기 위주이고 데이터 구조도 단순한 경우
Mutation이 거의 없는 콘텐츠 사이트나 작은 대시보드에서는 Record 단위 정규화가 해결하는 캐시 일관성 문제의 비중이 작습니다. 컴포넌트별 데이터 의존성까지 단순하다면 Query 단위 캐시만으로도 충분할 수 있습니다.
다만 읽기 전용이어도 화면과 컴포넌트 트리가 크고 재사용이 많다면 Fragment, Data Masking, Compiler의 장점은 여전히 유효합니다.
정리하면 GraphQL과 Relay는 해결하려는 문제가 다릅니다.
- GraphQL이 해결하려는 문제: 클라이언트가 필요한 데이터의 필드와 모양을 직접 선언하고, UI의 데이터 요구 변화와 서버 Endpoint 설계 사이의 결합을 줄이는 것
- Relay가 그 위에서 해결하려는 문제: 데이터 요구가 수많은 React 컴포넌트에 퍼졌을 때 Fragment로 소유권을 나누고, Compiler로 조합·검증하며, Runtime의 정규화 Store로 일관성을 유지하는 것
작은 GraphQL 앱에 Relay가 반드시 필요한 것은 아닙니다. 하지만 GraphQL을 사용하는 React 애플리케이션의 규모가 커지고 데이터 의존성이 복잡해질수록, Relay가 해결하려는 문제 자체가 점점 더 선명하게 나타납니다.
반대로 화면과 데이터 흐름이 단순한 작은 프로젝트라면 REST + TanStack Query처럼 더 단순한 구성으로 빠르게 시작하는 편이 수월할 수 있습니다.
9) 빠른 요약
- GraphQL은 클라이언트가 필요한 필드와 응답의 모양을 선언하게 합니다.
- Relay Fragment는 각 컴포넌트의 데이터 요구사항을 컴포넌트 가까이에 둡니다.
- Data Masking은 선언하지 않은 필드에 기대는 암묵적인 의존성을 막습니다.
- Relay Compiler는 Root Operation에서 도달 가능한 Fragment를 조합, 검증, 최적화하고, 타입과 Runtime Artifact를 생성합니다.
- Relay Runtime은 응답을 DataID 기준의 Record로 정규화하고, Snapshot 결과가 실제로 달라진 컴포넌트만 갱신합니다.
