안녕하세요 👏 이번 포스팅은 facebook에서 만든 framework인 Relay의 공식문서중 일부인 Thinking in Relay 를 읽고 번역한 글 입니다. React를 만든 회사에서 만든 제품이다보니 React의 생각과 align 되어있는 점이 많은 framework 입니다. Relay를 사용하지 않더라도 React를 사용한다면 Relay팀이 React 컴포넌트를 구현하면서 맞닿은 문제들을 어떻게 해결해나갔는지 읽어보는 것 만으로도 컴포넌트를 어떻게 구성해야 할지에 대한 도움을 많이 얻을 수 있는 글이라고 생각합니다.
앞으로 이어질 내용으론 공식문서를 최대한 번역체 스럽지 않게(?) 번역해보려고 노력한 공식문서의 번역 내용과 이를 요약하면서 느낀점을 같이 정리해보려고 합니다. 번역에 이상한 부분이 있거나 좀 더 좋은 표현을 제안해주신다면 언제든지 감사히 받아들이고 반영해보도록 하겠습니다 🙏 그럼, 시작할게요!
Relay의 data-fetching에 대한 접근법은 우리가 리액트를 사용할때의 경험에서 크게 영감을 받았습니다. 특히 React는 복잡한 인터페이스를 재사용 가능한 컴포넌트로 분리하여 개발자가 application 내의 이질적인 부분들의 결합도를 줄일 수 있도록 했습니다.
더 중요한 점은 이러한 컴포넌트들은 선언적(declarative)이라는 것 입니다. 컴포넌트는 개발자들로 하여금 UI를 어떻게(how) 보여줄 지를 걱정할 필요가 없고, 다만 주어진 상태를 통해서 UI가 무엇을(what) 보여줄지만 결정할 수 있도록 해줍니다.
명령형(imperative) 작업을 통해서 네이티브 뷰를 조작하는(DOM과 같은) 이전의 접근 방식과는 다르게 React는 UI를 어떤식으로 보여줄건지에 대한 설명만으로 통해 자동으로 작업을 결정합니다.
이런 아이디어를 Relay에 통합시킨 방법을 이해하기 위해 몇가지 유즈케이스를 살펴보겠습니다. React에 대한 기본 지식이 있다고 가정하겠습니다.
뷰를 그리기 위해 필요한 데이터 패칭
우리의 경험상 압도적으로 대다수의 제품들은 (좋은 사용자 경험을 위해) 다음과 같은 특징을 갖고자 합니다.
: 계층 뷰의 모든 데이터를 가져오는 동안 로딩 인디케이터를 보여주고, 데이터가 모두 받아와지면 한번에 전체 뷰를 렌더링 합니다.
하나의 해결책은 루트 컴포넌트가 해당 컴포넌트와 하위 모든 자식 컴포넌트들에게 필요한 데이터를 선언하고 가져오도록 하는 것 입니다. 하지만 이는 부모-자식 컴포넌트 간의 결합도를 높입니다 : 만약, 자식 컴포넌트에 변경점이 생긴다면 이를 렌더링하는 부모 컴포넌트도 같이 변경해야 합니다. 이러한 컴포넌트의 결합도의 증가는 버그가 발생할 가능성을 높이고, 개발 속도를 느려지게 할 수 있습니다.
또 다른 논리적인 접근법은 컴포넌트가 각가 자신이 필요한 데이터를 선언하고 가져오도록 하는 것 입니다. 좋아보입니다! 하지만, 문제는 컴포넌트가 가져온 데이터를 통해 다른 자식 컴포넌트들을 렌더링 할 수도 있다는 점 입니다. 따라서 중첩된 컴포넌트들은 부모 컴포넌트의 쿼리가 완료될 때 까지 렌더링되거나 데이터를 패칭할 수 없습니다. 즉, 이 방법은 데이터 패칭이 다음과 같이 단계적으로 실행되도록 강제하게 됩니다 : 먼저 루트 컴포넌트를 렌더링하고 필요한 데이터들을 패칭해온 다음 leaf 컴포넌트에 도달할 때 까지 자식 컴포넌트를 렌더링하고 자식 컴포넌트의 데이터를 패칭해 오는 과정을 반복합니다. 따라서 렌더링은 여러번의 느린 직렬 왕복이 필요하게 됩니다.
Relay는 컴포넌트가 필요한 데이터를 지정할 수 있도록 해서 위의 두가지 접근 방식의 장점을 가져오는 반면, 전체 컴포넌트의 하위 트리에서 필요한 데이터를 가져오는 하나의 쿼리로 합치고 있습니다. 즉, Relay는 전체 뷰에 대한 요구사항들을 정적으로(i.e. 애플리케이션이 실행되기 전; 코드를 작성할 때) 결정할 수 있습니다!
이것은 GraphQL을 통해서 해결할 수 있었습니다. 함수형 컴포넌트는 하나 이상의 Graphql framgments 를 통해 컴포넌트가 의존하는 데이터를 드러내고 있습니다. 프래그먼트는 다른 프레그먼트에 중첩되어 사용될 수 있으며 궁극적으론 쿼리 내에 포함됩니다. 그리고 쿼리를 패칭하면, Relay는 한번의 네트워크 요청으로 쿼리와 모든 중첩 프래그먼트들을 가져올 수 있습니다.
즉, Relay는 한번의 네트워크 요청으로 뷰에 필요한 모든 데이터를 가져올 수 있다는 것입니다!
Relay가 이런 해결 방법을 가지게 될 수 있던 방법을 이해하기 위해 예시와 함께 좀 더 깊에 들어가 보겠습니다.
컴포넌트가 필요한 데이터를 지정하기 (컴포넌트의 데이터 의존성 드러내기)
Relay를 사용하면 컴포넌트가 필요한 데이터들은 프레그먼트로 지정해야 합니다. 프래그먼트는 특정 타입에 대한 객체에서 필요한 필드들을 지정하는 GraphQL의 스니펫 입니다. 프래그먼트는 GraphQL 리터럴 안에서 작성됩니다.
예를 들어 아래의 예시에서는 Author
라는 객체의 name
과 url
필드를 포함한 프래그먼트를 GraphQL 리터럴로 선언하고 있습니다.
// AuthorDetails.react.js
const authorDetailsFragment = graphql`
fragment AuthorDetails_author on Author {
name
photo {
url
}
}
`;
그 다음 React의 함수형 컴포넌트에서 useFragment
훅을 호출하여 store에서 데이터를 읽어옵니다. 이 데이터를 읽을 실제 author
는 useFragment에 전달된 두 번째 매개변수에 의해 결정됩니다. 예시를 보겠습니다.
// AuthorDetails.react.js
export default function AuthorDetails(props) {
const data = useFragment(authorDetailsFragment, props.author);
// ...
}
두번째 파라미터 (props.author
)는 프래그먼트 reference 입니다. 프래그먼트 reference 들은 다른 프래그먼트나 쿼리에 spreading 함으로써 얻을 수 있습니다. 프래그먼트는 직접 패칭해올 수 없습니다. 하지만, 모든 프래그먼트들은 궁극적으로 쿼리에 spread(직접 혹은 간접적으로)
해당 데이터를 패칭해오기 위해 다음과 같이 AuthorDetails_author
를 퍼뜨리는(spread) 쿼리를 선언할 수 있습니다.
// Story.react.js
const storyQuery = graphql`
query StoryQuery($storyID: ID!) {
story(id: $storyID) {
title
author {
...AuthorDetails_author
}
}
}
`;
이제 const data = useLazyLoadQuery(storyQuery, {storyID})
를 호출하여 쿼리를 패칭해올 수 있습니다. 이 시점에서 data.author
(만약 존재하는 경우 모든 필드는 기본적으로 nullable 입니다.) 는 AuthorDetails
에 전달할 수 있는 fragment reference가 됩니다. (다음의 예시를 참고해주세요)
// 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 />
와 같은 시스템의 다른 부분에서 패칭해오는 경우가 대부분 입니다. 그럴 경우, <AutorDetails />
를 변경하고 해당 데이터 패칭 로직을 제거하게 되면 <Story />
도 즉시 동작하지 않게 됩니다. 이러한 유형의 버그들은 대규모 애플리케이션에서 항상 즉시 발생하는 것은 아닙니다. 수동 및 자동 테스팅은 많은 도움이 될 수 있습니다: 이는 프레임워크가 더 잘 해결할 수 있는 시스템적인 유형의 문제에 해당합니다.
위에서 Relay는 뷰에 대한 데이터를 한번에 모두 가져오는 것을 확인했습니다. 하지만, Relay는 명확히 드러나진 않지만 또 다른 이점으로 데이터 마스킹도 제공합니다. Relay는 GraphQL 프래그먼트에서 요청한 특정한 데이터에만 접근할 수 있도록 하며 그 이상은 허용하지 않습니다. 따라서 한 컴포넌트가 Story의 title
을 요청하는 쿼리를 작성하고, 다른 컴포넌트에선 Story의 text
를 요청하면 각 컴포넌트는 오직 자신이 요청한 데이터 필드에 대해서만 확인할 수 있습니다. 사실, 컴포넌트는 해당 컴포넌트의 자식 컴포넌트 가 요청한 데이터조차도 알 수 없습니다: 확인할 수 있다면 캡슐화도 깨지는 것이죠.
Relay는 더 나아가 이런 역할도 하고 있습니다: props에 불투명한 식별자를 통해서 컴포넌트를 렌더링 하기 전에 명시적으로 데이터를 패칭해왔는지 확인합니다. <Story />
가 <AuthorDetails />
를 렌더링하고 있지만 프래그먼트를 spread 해주는 것을 잊어버린다면, <AuthorDetails />
에 대한 데이터가 누락되었음을 Relay는 경고해줍니다. 실제로 Relay는 심지어 <AuthorDetails />
에 필요한 데이터와 동일한 데이터를 패칭해오는 다른 컴포넌트가 있는 경우에도 경고해줍니다. (해당 경고는 지금은 동작할 수 있지만, 나중에 해당 부분 때문에 동작하지 않게 될 가능성이 높다는 걸 알려주는 것이죠.)
결론
GraphQL은 효율적이고 잘 분리된 클라이언트 애플리케이션을 구축하기 위한 강력한 도구를 제공합니다. Relay는 선언적인 데이터 패칭을 위한 프레임워크를 제공하기 위해 GraphQL을 기반으로 만들어 졌습니다. 어떤(무엇을) 데이터를 패칭해올 것인가와 어떻게 패칭해올 것인가를 분리함으로써 Relay는 개발자들로 하여금 기본적으로 강력하고 투명하며 성능이 뛰어난 애플리케이션을 만들 수 있도록 도와줍니다. Relay는 React의 핵심 컨셉인 컴포넌트 중심 사고 방식을 보완해주는 훌륭한 툴이라고 할 수 있습니다. 이러한 각 기술들(React, Relay, GraphQL)은 그 자체로도 강력한 툴이지만, 이 셋의 조합은 우리가 빠르게 작업 하고, 고품질의 애플리케이션을 대규모로 제공 할 수 있게 해주는 UI 플랫폼 입니다.
It's(Relay) a great complement to the component-centric way of thinking championed by React.
위 결론에서 말했듯이, Relay가 React의 핵심 컨셉들을 지켜서 어떤것들을 보장하고, 또 어떤식으로 보장하는지를 저는 다음과 같이 정리할 수 있을 것 같아요.
선언적으로 코드를 작성해서 개발자들로 UI가 무엇을 보여줄지만 결정하고, 어떻게 보여줄지는 React의 내부에서 처리된다.
개발자들은 자기가 작성한 코드가 아니더라도 선언적으로 작성된 코드를 확인하면 어떻게 화면을 그리고 있는지에 집중할 수 있게 되고, 자세한 구현은 숨기고(어떻게 구현했는지는 중요하지 않습니다. 그리고 외부에 노출시키지 않아 캡슐화도 이뤄낼 수 있겠네요.) 인터페이스만 잘 드러낸다면 다른 개발자의 코드라도 이해하기 쉽고 유지보수하기 쉽습니다.
Relay가 보완한 방식
우리가 마주하는 애플리케이션은 필연적으로 컴포넌트는 데이터와 항상 의존성을 가지고 있습니다. 따라서 컴포넌트는 데이터와 의존성을 가지고 있는 경우가 많고, 데이터를 기반으로 다른 컴포넌트와 의존 관계를 갖게 될 수도 있습니다.
Relay는 GraphQL의 fragment 문법을 통해 선언적으로 각 컴포넌트에 사용되는 데이터 필드들을 작성하여 어떤 데이터를 의존하고 있는지를 명확하게 드러내도록 강제하고 있습니다. 한편, 예시에서 말했듯이 fragment 이름을 통해 AuthorDetails_author
해당 컴포넌트는 어떤 컴포넌트를 포함하고 있고, author 라는 props로 해당 fragment에 선언한 데이터들이 자식 컴포넌트로 흘러간다는 걸 쉽게 확인할 수 있습니다. 자식 컴포넌트 입장에선 author 라는 인터페이스로 어떤 데이터를 의존하고 있고 fragment를 통해 author는 어떤 필드들을 포함하는지 알 수 있겠구요.
컴포넌트 기반으로(Component-Based)
개발해서 컴포넌트가 가진 데이터는 캡슐화 하고, 다른 컴포넌트에게 보여질 인터페이스만 잘 노출시키는 동시에 컴포넌트간의 데이터 의존성을 fragment로 분리하여 결합도는 낮추고 응집도는 높일 수 있도록 한다.
Relay가 보완한 방식
각각의 컴포넌트에서 작성한 fragment
를 view 단에서 query
로 통합하는 방식이기 때문에 각 컴포넌트끼리의 데이터 의존성이 떼어내지고, 결합도가 낮아집니다. 동시에 각 컴포넌트는 캡슐화가 되면서 응집도도 올라가게 됩니다. (본문의 Data Masking 내용) 이렇게 되면 의존하고 있는 데이터에 변경사항이 생기더라도 fragment만 수정하게 되면 변경사항들에 대해 다른 영역까지 살펴볼 필요가 없게 됩니다. (실제로는 변경된 스키마를 받아오고 - 수정된 스키마에 따라 데이터 필드 등을 수정한 뒤 - codegen을 실행하여 type 등 필요한 api들을 새로 생성한 다음 - 이를 relay가 체크해주는 등의 일련의 과정이 필요합니다. 하지만, 예측하지 못한곳에서의 변경까지 모두 신경쓰고 버그가 발생하는 것 보단 훨씬 간편한 과정이라고 생각해요)
제품의 입장에서 봤을 때, 유저에게 좋은 사용자 경험을 제공할 수 있도록 한다.
해당 본문에서 언급했듯이 view는 컴포넌트의 조합으로 이루어져 있고 각각은 데이터에 의존하고 있는데, 각각이 유저에게 보여질 때 로딩 인디케이터가 돌면서 데이터 패칭이 끝나는 대로 하나씩 뷰가 보여진다면 유저에게 좋은 경험을 주지 못합니다. 그것보다는 한번의 로딩 인디케이터를 노출시킨 후 모든 데이터가 다 받아와졌을 때 화면을 한번에 그리는 것이 좀 더 유저에게 좋은 사용자 경험을 줄 수 있습니다.
Relay가 보완한 방식
위에 나온 문제는 fragment들을 각각의 컴포넌트에 작성하고, view를 책임지는 부모 컴포넌트에서 하나의 쿼리에서 spread를 통해 통합시켜 한번의 네트워크 요청으로도 뷰의 컴포넌트들이 필요한 모든 데이터를 가져옴으로써 네트워크 요청도 줄이고 로딩 인디케이터도 한번만 노출시켜줌으로써 사용자에게 훨씬 더 좋은 경험을 줄 수 있습니다.
제가 일하고 있는 조직에서는 GraphQL을 사용하고 있지만, axios와 같은 http fetch module
로 데이터를 패칭하는 함수를 codegen을 통해 만들고 이를 react-query의 훅으로 래핑하여 사용하는 형태를 띄고 있습니다. GraphQL에 특화된 client fetcher인 apollo나 Relay가 아니다보니 id 기반으로 정규화해서 api 요청 이후의 데이터 일관성을 보장해주는 작업 등이 없이 rest 방식과 거의 비슷한 방법으로 사용되고 있습니다.
Relay를 도입하고자 하면 클라이언트 뿐만 아니라 서버쪽도 수정이 필요하기도 하고, Relay와 의존성을 떼어내야 한다면(나중에 그래야 하는 경우가 있는진 모르겠지만..) 그게 어렵다는 이유 등으로 react-query를 도입한걸로 알고 있습니다. 사실 위의 내용들에선 Relay만 할 수 있는 일들은 아니고, GraphQL을 통해 이러한 방식을 프레임워크 단에서 강제해주고 체크해줌으로써 문제들을 해결하고 있습니다. Relay를 사용하지 않더라도 Relay팀이 GraphQL을 통해 문제를 해결한 방식은 충분히 도입해볼 수 있다고 생각합니다. 현재의 구조에서 어떻게 GraphQL을 더 잘 사용할 수 있을지 고민하고 팀의 생산성에 기여할 수 있도록 더 Deep Dive 해보려고 합니다..! 추후에 이런 사고방식을 도입해 회사에서 컨벤션을 가져간다던지 등의 변화를 이끌어내게 된다면, 그런 경험에 대해서도 공유해볼게요 😄
긴 글 읽어주셔서 감사합니다 :)
좋은 글 번역 감사합니다 :)