Relay가 React에서 영감을 받아 컴포넌트 단위로 데이터 요구사항을 선언하고, 이를 단일 GraphQL 쿼리로 합쳐 뷰 전체의 데이터를 한 번에 가져오며, 데이터 마스킹으로 컴포넌트 간 결합을 줄이는 방식을 설명합니다.
Relay의 데이터 페칭(data-fetching) 접근법은 React에서의 경험으로부터 큰 영향을 받았습니다. 특히 React는 복잡한 인터페이스를 재사용 가능한 컴포넌트로 분해하여, 개발자가 애플리케이션의 개별 단위를 독립적으로 추론할 수 있게 하고, 애플리케이션의 서로 다른 부분들 사이의 결합도를 낮춥니다. 더 중요한 점은 이러한 컴포넌트가 **선언적(declarative)**이라는 것입니다. 즉, 개발자는 특정 상태에서 UI가 _무엇_처럼 보여야 하는지를 지정하기만 하면 되고, 그 UI를 어떻게 보여줄지에 대해서는 신경 쓰지 않아도 됩니다. 이전의 접근법들이 네이티브 뷰(예: DOM)를 조작하는 명령형(imperative) 커맨드를 사용했던 것과 달리, React는 UI 설명(UI description)을 사용하여 필요한 명령들을 자동으로 결정합니다.
이제 몇 가지 제품 사용 사례를 통해 Relay에 이러한 아이디어를 어떻게 녹여냈는지 살펴보겠습니다. React에 대한 기본적인 친숙함이 있다고 가정합니다.
경험상, 압도적으로 많은 제품들이 원했던 동작은 하나였습니다. 로딩 인디케이터를 표시하는 동안 어떤 뷰 계층(view hierarchy)에 필요한 모든 데이터를 가져오고, 데이터가 준비되면 그 전체 뷰를 렌더링하는 것입니다.
한 가지 해결책은 루트 컴포넌트가 자기 자신과 모든 자식에 필요한 데이터를 선언하고 가져오게 하는 것입니다. 하지만 이는 결합을 초래합니다. 자식 컴포넌트가 바뀔 때마다 그것을 렌더링할 수 있는 모든 루트 컴포넌트를 수정해야 하기 때문입니다. 이런 결합은 버그 가능성을 높이고 개발 속도를 늦출 수 있습니다.
또 다른 논리적인 접근은 각 컴포넌트가 자신이 필요한 데이터를 선언하고 직접 가져오도록 하는 것입니다. 그럴듯해 보이지만 문제가 있습니다. 컴포넌트는 자신이 받은 데이터에 따라 서로 다른 자식들을 렌더링할 수 있습니다. 그러면 중첩된 컴포넌트들은 부모 컴포넌트의 쿼리가 완료될 때까지 렌더링되지 못하고, 자신의 데이터 페칭도 시작할 수 없습니다. 다시 말해, 이 방식은 데이터 페칭을 단계적으로 진행하도록 강제합니다: 먼저 루트를 렌더링하고 루트가 필요한 데이터를 가져온 다음, 자식을 렌더링하고 자식의 데이터를 가져오는 식으로 리프(leaf) 컴포넌트에 도달할 때까지 반복됩니다. 렌더링은 여러 번의 느린 직렬(serial) 왕복(roundtrip)을 필요로 하게 됩니다.
Relay는 이 두 접근법의 장점을 결합합니다. 컴포넌트가 자신이 요구하는 데이터가 무엇인지 명시할 수 있게 하되, 그 요구사항을 하나의 쿼리로 합쳐서 컴포넌트 서브트리 전체의 데이터를 가져옵니다. 즉, 애플리케이션이 실행되기 전에(코드를 작성하는 시점에) 전체 뷰에 대한 요구사항을 정적으로 결정합니다.
이는 GraphQL의 도움으로 가능합니다. 함수형 컴포넌트는 하나 이상의 GraphQL 프래그먼트(fragment)로 데이터 요구사항을 기술합니다. 이 프래그먼트들은 다른 프래그먼트 안에 중첩되고, 궁극적으로는 쿼리 안에 중첩됩니다. 그리고 그러한 쿼리를 페치(fetch)할 때 Relay는 해당 쿼리와 그 안에 중첩된 모든 프래그먼트를 포함한 단일 네트워크 요청을 보냅니다. 다시 말해, Relay 런타임은 어떤 뷰에 필요한 모든 데이터를 위해 _단 한 번의 네트워크 요청_을 수행할 수 있습니다.
이제 Relay가 이를 어떻게 달성하는지 더 깊이 들어가 보겠습니다.
Relay에서는 컴포넌트의 데이터 요구사항을 프래그먼트로 지정합니다. 프래그먼트는 특정 타입의 객체에서 어떤 필드를 선택(select)할지 명시하는, 이름이 붙은 GraphQL 스니펫(snippet)입니다. 프래그먼트는 GraphQL 리터럴(literal) 안에 작성합니다. 예를 들어, 아래 코드는 작성자의 이름과 사진 URL을 선택하는 프래그먼트를 포함한 GraphQL 리터럴을 선언합니다:
js// AuthorDetails.react.js const authorDetailsFragment = graphql` fragment AuthorDetails_author on Author { name photo { url } } `;
이 데이터는 함수형 React 컴포넌트에서 useFragment(...) 훅을 호출하여 스토어(store)로부터 읽어옵니다. 어떤 author 객체에서 이 데이터를 읽어올지는 useFragment에 전달하는 두 번째 파라미터로 결정됩니다. 예를 들면:
js// AuthorDetails.react.js export default function AuthorDetails(props) { const data = useFragment(authorDetailsFragment, props.author); // ... }
이 두 번째 파라미터(props.author)는 프래그먼트 레퍼런스(fragment reference)입니다. 프래그먼트 레퍼런스는 프래그먼트를 다른 프래그먼트나 쿼리에 스프레드(spread) 해서 얻습니다. 프래그먼트는 직접 페치할 수 없습니다. 대신 모든 프래그먼트는 (직접 또는 간접적으로) 궁극적으로 쿼리에 스프레드되어야 데이터가 페치될 수 있습니다.
그러한 쿼리 중 하나를 살펴보겠습니다.
해당 데이터를 가져오기 위해, 아래처럼 AuthorDetails_author를 스프레드하는 쿼리를 선언할 수 있습니다:
js// Story.react.js const storyQuery = graphql` query StoryQuery($storyID: ID!) { story(id: $storyID) { title author { ...AuthorDetails_author } } } `;
이제 const data = useLazyLoadQuery(storyQuery, {storyID})를 호출하여 쿼리를 페치할 수 있습니다. 이 시점에서 data.story.author(존재한다면; 모든 필드는 기본적으로 nullable임)는 AuthorDetails에 전달할 수 있는 프래그먼트 레퍼런스가 됩니다. 예를 들어:
js// Story.react.js function Story(props) { const data = useLazyLoadQuery(storyQuery, props.storyId); return ( <> <Heading>{data?.story.title}</Heading> {data?.story?.author && <AuthorDetails author={data.story.author} />} </> ); }
여기서 어떤 일이 일어났는지 주목해 보세요. Story 컴포넌트와 AuthorDetails 컴포넌트 _둘 모두_에 필요한 데이터를 포함하는 단일 네트워크 요청을 수행했습니다! 데이터가 준비되면 전체 뷰는 한 번의 패스로 렌더링될 수 있습니다.
일반적인 데이터 페칭 방식에서는 두 컴포넌트 사이에 _암묵적인 의존성_이 생기기 쉽다는 것을 발견했습니다. 예를 들어 <Story />가 어떤 데이터를 직접 페치한다는 보장을 하지 않은 채 사용하고 있을 수 있습니다. 그 데이터는 종종 <AuthorDetails /> 같은 시스템의 다른 부분에서 페치되고 있었을 수 있습니다. 그러다 <AuthorDetails />를 변경하면서 그 데이터 페칭 로직을 제거하면, <Story />가 갑자기 설명할 수 없이 깨지게 됩니다. 이런 종류의 버그는 특히 큰 팀이 큰 애플리케이션을 개발할 때 즉시 드러나지 않는 경우가 많습니다. 수동/자동 테스트는 어느 정도 도움은 되지만 한계가 있습니다. 이런 문제는 프레임워크 수준에서 해결하는 편이 더 낫습니다.
Relay가 뷰의 데이터를 한 번에 가져오도록 보장한다는 것을 이미 살펴봤습니다. 하지만 Relay는 즉각적으로 드러나지 않는 또 다른 이점을 제공합니다. 바로 **데이터 마스킹(data masking)**입니다. Relay는 컴포넌트가 GraphQL 프래그먼트에서 구체적으로 요청한 데이터만 접근할 수 있게 하고, 그 외에는 접근할 수 없게 합니다. 그래서 한 컴포넌트가 Story의 title을 쿼리하고, 다른 컴포넌트가 text를 쿼리한다면, 각 컴포넌트는 자신이 요청한 필드만 볼 수 있습니다. 사실 컴포넌트는 자식이 요청한 데이터조차 볼 수 없습니다. 그것 역시 캡슐화를 깨뜨리기 때문입니다.
Relay는 여기서 더 나아갑니다. props에 불투명한(opaque) 식별자를 사용하여, 컴포넌트를 렌더링하기 전에 해당 컴포넌트에 필요한 데이터를 우리가 명시적으로 페치했는지를 검증합니다. <Story />가 <AuthorDetails />를 렌더링하면서도 그 프래그먼트를 스프레드하는 것을 잊었다면, Relay는 <AuthorDetails />의 데이터가 누락되었다고 경고합니다. 심지어 다른 컴포넌트가 우연히 <AuthorDetails />에 필요한 동일한 데이터를 이미 페치했더라도 Relay는 경고합니다. 이 경고는 지금은 우연히 동작할지 몰라도, 나중에는 깨질 가능성이 매우 높다는 것을 알려줍니다.
GraphQL은 효율적이고 결합도가 낮은 클라이언트 애플리케이션을 구축하기 위한 강력한 도구를 제공합니다. Relay는 이 기능 위에 **선언적 데이터 페칭(declarative data-fetching)**을 위한 프레임워크를 더합니다. 어떤 데이터를 가져올지와 어떻게 가져올지를 분리함으로써, Relay는 개발자가 기본값만으로도 견고하고 투명하며 성능 좋은 애플리케이션을 만들 수 있도록 돕습니다. 이는 React가 지향하는 컴포넌트 중심의 사고방식을 훌륭하게 보완합니다. React, Relay, GraphQL 각각도 강력하지만, 이 조합은 우리가 빠르게 움직이고 대규모로 고품질 앱을 출시할 수 있게 해주는 UI 플랫폼입니다.