https://www.apollographql.com/blog/apollo-client/architecture/client-side-architecture-basics/
위 글의 번역이다.
"[개발자의] 전문적인 평판과 생계에 대한 위험을 최소화하면서 일관되고 일관성 있게 React 앱을 빌드하기 위해 널리 인정되는 표준"이 많지 않기 때문에 종종 스스로 결정을 내려야 합니다.(Joel Hooks , 2020년 6월 17일)
면책 조항: 애플리케이션을 디자인하고, 상태를 다루고, 관심사를 분리하는 다른 방법이 있지만
이것이 우리(team-apollo)가 좋아하는 개념적 모델입니다.
당신에게 효과가 없더라도, 당신의 팀과 당신의 프로젝트에 의미가 있는 것으로 대화를 안내하는 데 도움이 되기를 바랍니다.
Client-side architecture basics
Structure vs. Developer experience
이상적인 디자인은 문제를 해결하지만 개발자 경험을 희생시키지 않는 것입니다.소프트웨어 디자인 = 구조 대 개발자 경험
Model-View-Presenter
MVP 패턴에서...
- view는 사용자 이벤트를 생성합니다.
- 이러한 사용자 이벤트는 모델의 update 또는 change로 전환됩니다.
- model이 변경되면 새 데이터로 view를 업데이트합니다.
네, 옵저버 패턴입니다.
자세히 들여다보면 대부분의 클라이언트 측 아키텍처가 관찰자 패턴의 구현이라는 것을 알 수 있습니다.
The need for a better client-side architecture standard
가장 큰 문제는 M이 너무 많은 책임을 진다는 것입니다.
결과적으로 적절한 작업에 적절한 도구를 일치시키는 것이 때때로 어려울 수 있습니다.
Tasks of the model
- 네트워킹 및 데이터 가져오기 - 전체 data fetching 레이어를 구축해야 하나요? 비동기 상태를 수동으로 셋업하나요?(isLoading)
- 모델의 동작 — mutation 전에 실행해야 하는 의사 결정 논리가 있으면 어떻게 되나요? 어디로 가나요? 게임 로직이 많은 체스 게임을 만들고 있다면 어떨까요? 코드의 구조를 어떻게 구성합니까? 특정 routes와 actions에 대해 인증을 처리하려면 어떻게 해야 합니까? 유효성 검사 논리를 어떻게 처리합니까?
- 상태 관리(모델 데이터) - 단일 컴포넌트에 속한 상태를 어떻게 처리하나요? 둘 이상의 구성 요소에 속한 상태는 어떻습니까? 상태 관리 라이브러리에 무엇을 사용합니까? 컨텍스트를 사용할 수 있습니까? 데이터가 변경될 때 보기가 다시 렌더링되도록 반응성을 설정하려면 어떻게 해야 하나요?
- … 등
그리고 2020년에는 모호한 모델 문제에 대한 솔루션을 찾기 위해 개발자 도구 상자를 열 때 이것이 보입니다.
- React hooks
- Redux
- Context API
- Apollo Client
- xState
- react-query
Principle #1 – CQS (Command Query Separation)
- commands는 상태를 변경하지만 데이터를 반환하지 않으며,
- query는 데이터를 반환하지만 상태를 변경하지는 않습니다.
이 패턴의 주요 이점은 코드를 더 쉽게 추론할 수 있다는 것입니다.
궁극적으로 두 가지 코드 경로를 만들 것을 촉구합니다. 하나는 읽기용이고 다른 하나는 쓰기용입니다.
Commands
function createUser (props: UserDetails): Promise<void> { ... }
function toggleTodo (todoId: number): void { ... }
function createUser (): Promise<User> { ... }
function toggleTodo (): Todo { ... }
Queries
function getCurrentUser (): Promise<User> { ... }
function getUserById (userId: UserId): Promise<User> { ... }
Principle #2 – Separation of Concerns
앱의 아키텍처 관심사 사이에 논리적 경계를 의식적으로 적용
관심사의 분리가 반드시 물리적으로 코드를 다른 폴더와 파일로 분리하는 것을 의미하지는 않습니다.
코드가 처리하고 있는 문제를 파악하고 올바른 폴더 구조를 통해 이를 반영하는지 확인하는 것이 더 중요합니다.
물리적 분리가 아닌 논리적 분리입니다.
그러나 자주 함께 변경되고 가능한 한 서로 가까운 코드와 파일을 찾는 것이 좋습니다.
이것이 왜 중요할까요?
- 분해. 관심사를 분리하면 수행해야 하는 작업, 해당 작업이 속한 계층 및 이러한 문제를 해결할 수 있는 도구에 대한 가시성을 높일 수 있습니다.
- 기능 구조화. 앱의 모든 기능을 수직 슬라이스 세트로 생각할 수 있습니다. 즉 앱은 "특정 기능을 작동시키는 데 관련된 모든 계층에서 수행해야 하는 작업의 합계"입니다. 구성 요소, 상태 및 동작의 수직 슬라이스는 앱의 기능입니다.
- 위임. 이 레이어를 직접 만들어야 합니까? 아니면 라이브러리나 프레임워크에 작업을 위임해야 합니다. 예를 들어, 대부분의 개발자는 프리젠테이션 구성 요소를 위한 사용자 정의 뷰 레이어 라이브러리를 빌드하지 않습니다. React 또는 Vue.js를 사용합니다. 하지만 많은 사용자가 맞춤 상태 관리 시스템을 처음부터 구축합니다.
A better client-side architecture starting point
CQS와 SoC를 사용하여 우리는 MVP 패턴이 어떻게 현대적인 React 앱으로 나타나는지 보다 명확하게 이해하게 되었습니다.
요약하면 관심사는다음과 같습니다.
- Presentation components — UI를 렌더링하고 사용자 이벤트를 만듭니다.
- UI logic — view의 동작과 로컬 컴포넌트 상태를 포함합니다.
- Interaction layer — 모델 동작 & 공유 상태. 하나의 앱에 다양한 상태 모델이 존재할 수 있습니다. (모달 등). 리액트 훅이나 xState 사용.
- Container/controller —접착제 층. 사용자 이벤트를 올바른 상호 작용/응용 프로그램 계층 구조에 연결하고 반응 데이터를 전역 저장소에서 프레젠테이션 구성 요소로 전달합니다. 이를 페이지 컴포넌트로 생각할 수도 있습니다.
- 🌟 Networking & data fetching — Performs API calls, fetches data, and signals the state of network requests (meta state).
- 🌟 State management & storage — Shared global storage, provides APIs to update data, and configure reactivity.
더 깊이 들어가려면 최신 클라이언트 측 아키텍처의 각 문제에 대한 예와 주장을 확인하고
클라이언트 측 아키텍처 기본 에세이 전체를 읽어보세요.
- 내생각에는 꼭 읽어볼 필요는 없음 -
https://khalilstemmler.com/articles/client-side-architecture/introduction/
The different types of state in client-side apps
- local (component):
- 단일 컴포넌트에 속하는 상태입니다.React hook으로 UI 상태를 관리합니다. ex) useState, useRef
- shared local (global):
- 둘 이상의 컴포넌트에 속하는 상태입니다. ex) recoil, context API
- remote (global):
- 백엔드 서비스와 관련한 상태입니다. ex) react-query, useEffect
- meta:
- 상태에 대한 상태를 나타냅니다. 좋은 예는 네트워크 요청의 진행 상황을 알려주는 비동기 상태 loading 입니다. ex) react-query, suspense
- router state:
- 브라우저의 현재 URL, history 관련 정보입니다. ex) react-router
States and concerns of Apollo Client
Concern — Presentation components
유아이를 그리고 사용자 이벤트를 생성함
- UI에 데이터를 표시하고
- 사용자 이벤트 생성(키 누르기, 버튼 클릭, 호버 상태 등)
프레젠테이션 컴포넌트는 구현 세부 사항입니다.
프레젠테이션 구성 요소는 휘발성(volatile)일 수 있습니다.
const CARD_DESCRIPTION_QUERY = gql`
query CardDescription($cardId: ID!) {
card(id: $cardId) {
description
}
}
`;
const CardDescription = ({ cardId }) => {
const { data, loading } = useQuery({
query: CARD_DESCRIPTION_QUERY,
variables: { cardId }
});
if (loading) {
return null;
}
return <span>{data.card.description}</span>
}
동일한 원인에 의해 변하는 것은 가능한 한 가깝게 둡니다.
(주 : 개인적으로 mocking을 안좋아해서 별로~)
쿼리 성능에 대한 참고 사항: 위에 표시된 것처럼 특정 데이터 청크에 대한 쿼리가 많아도 괜찮습니다. Apollo 클라이언트를 사용하여 Apollo는 데이터가 이미 캐시되었는지 여부를 확인하는 복잡한 논리를 처리합니다. 그렇지 않은 경우에는 가져오기 요청을 내보냅니다.
Queries subscribe to state changes
그러나 Apollo 클라이언트 쿼리는 데이터가 변경될 때 자동으로 알림을 받기 때문에 컨테이너 구성요소를 통해 props를 제공할 필요가 없습니다.
그렇다면 컨테이너 구성요소는 무엇을 위해 필요할까요? 두가지.
- 여러 경로가 있는 앱에서 최상위 페이지 구성요소 역할을 합니다. 페이지 컴포넌트는 해당 페이지의 모든 프레젠테이션 구성요소와 기능을 로드합니다.
- 의사결정 로직을 수행하기 위해 프레젠테이션 구성요소에서 모델(상호작용/애플리케이션) 레이어로 명령을 전달합니다. 때때로, 그 의사 결정 논리는 우리가 돌연변이를 호출해야 한다고 결정할 수 있습니다.
프레젠테이션 구성 요소에 GraphQL mutation를 포함해야 합니까?
CQS가 구성요소에도 적용되는 방식에 대해 생각해 보세요.
프리젠테이션 구성요소가 읽기용인 경우 상태 변경 시기를 결정하는 데 관련해서는 안 된다는 의미가 아닐까요?
프레젠테이션이 쓰기에 대한 책임이 없어야 한다는 것이 아닌가요?
네, 즉 의사 결정 논리가 발생해야 하는 경우 이는 view가 아니라 모델의 관심사 입니다.
React에서는 React 후크를 사용하여 상호작용(애플리케이션이라고도 함) 레이어 로직을 구현합니다.
다음은 잠재적으로 mutation을 호출하기 전에 먼저 일부 의사 결정 논리를 수행하는 Apollo Client 및 React Hooks를 사용하는 createTodoIfNotExists 작업의 예입니다.
function useTodos (todos) {
const createTodoIfNotExists = (text: string) => {
const alreadyExists = todos.find((t) => t === text);
if (alreadyExists) {
return;
}
...
// Validate text
// Perform mutation
}
return { createTodoIfNotExists }
}
// Container
function Main () {
const { data: todos } = useQuery(GET_ALL_TODOS);
const { createTodoIfNotExists } = useTodos(todos);
...
}
상태 머신이 필요한 경우 xState와 같은 도구를 사용하여 대신 상호 작용 논리 모델을 빌드할 수도 있습니다.
Concern — State management and storage
Storage, updates, and reactivity
- 스토리지 - 글로벌 상태를 유지하기 위해 백엔드 서비스에서 검색된 데이터의 원격(전역) 상태, 공유 로컬 상태 또는 이 둘의 혼합일 수 있습니다.
- 상태 업데이트 - 대부분의 경우 작업을 호출한 후 캐시된 상태를 부작용으로 업데이트해야 합니다.
- 반응성 - 상태가 변경되면 새 데이터로 다시 렌더링해야 함을 UI 조각에 알려야 합니다.
Apollo Client에서는 정규화된 캐시를 저장소로 사용하고 캐시 API를 사용하여 상태를 업데이트하고
상태가 변경되면 앱 전체에서 쿼리에 변경 사항을 (자동) 브로드캐스트합니다.
Concern — Networking & data fetching
API 호출 수행 및 메타데이터 상태 보고
많은 상태 관리 라이브러리에서는 데이터 가져오기 및 네트워킹 문제도 처리하는 것이 편리하다고 생각합니다.
Apollo Client에서 이것은 당신을 위해 처리됩니다.
네트워킹 및 데이터 가져오기 계층의 책임은 다음과 같습니다.
- 백엔드 서비스 위치(주소) 알기
- 응답 계산하기
- 응답, 오류 마샬링(직렬화/역직렬화)
- 비동기 상태 Report — this is a form of meta state.
State — Local (component) state
개별 컴포넌트에 대한 데이터 그래프를 모델링하지 마십시오.
State — Remote state
백엔드 서비스에서 검색된 상태
Apollo Client는 원격 데이터 그래프의 캐시된 청크인 원격 상태에 대해 단독으로 책임이 있습니다.
useQuery 훅으로 쿼리를 호출하면 필요한 데이터를 가져와 호출자에게 제공하지만 정규화하고 캐시하기도 합니다.
// `data` holds the remote state of our data
const { data, loading, error } = useQuery(GET_ALL_TODOS);
데이터 그래프의 청크를 캐싱함으로써 다음에 그래프의 동일한 부분을 요청할 때
Apollo Client는 다른 네트워크 요청에서 캐시를 가져오는 대신 캐시로 바로 이동할 수 있을 만큼 똑똑합니다.
State — Meta state
상태에 대한 상태
// The 'loading' variable holds the meta state of the network request.
const { data, error, loading } = useQuery(GET_ALL_TODOS);
export function createTodoIfNotExists (text: string) {
return async (dispatch, getState) => {
const { todos } = getState();
const alreadyExists = todos.find((t) => t === text);
if (alreadyExists) {
return;
}
// Signaling start
dispatch({ type: actions.CREATING_TODO })
try {
const result = await todoAPI.create(...)
// Signaling success
dispatch({
type: actions.CREATING_TODO_SUCCESS,
todo: result.data.todo
})
} catch (err) {
// Signaling Failure
dispatch({ type: actions.CREATING_TODO_FAILURE, error: err })
}
}
}
State — Shared local state
여러 구성 요소에서 사용되는 상태
- remote state,
- client-side only local state,
- or combination of both
switch (action.type) {
...
case actions.GET_TODOS_SUCCESS:
return {
...state,
// Add some local state to the remote state before merging it
// to the store
todos: action.todos.map((t) => { ...t, isSelected: false })
}
}
import { makeVar } from "@apollo/client";
export const currentSelectedTodoIds = makeVar<number[]>([]);
export const cache: InMemoryCache = new InMemoryCache({
typePolicies: {
Todo: {
fields: {
isSelected: {
read (value, opts) {
const todoId = opts.readField('id');
const isSelected = !!currentSelectedTodoIds()
.find((id) => id === todoId)
return isSelected;
}
}
}
}
}
});
export const GET_ALL_TODOS = gql`
query GetAllTodos {
todos {
id
text
completed
isSelected @client
}
}
`
Conclusion
- UI 레이어에서 GraphQL 쿼리를 프레젠테이션 구성 요소와 가깝게 유지하세요.
- 보통 쿼리는 페이지 단위로 배치를 많이 함.
- 공유되지 않는 로컬(구성요소) 상태의 경우 React의 useState 훅이 작업에 가장 적합한 도구입니다.
- 그래프에 UI 컴포넌트에 고유한(또는 이름을 따서 명명된) 개체가 포함되어 있으면 그래프를 잘못된 방향으로 디자인하고 있을 수 있습니다.
- 공유(전역) 상태의 경우 Apollo Client 3의 로컬 상태 관리 API를 사용하거나 다시 React hooks API를 사용할 수 있습니다.
- 컴포넌트 대신 인터렉션 레이어에 mutation(및 모든 모델 동작)을 배치하는 것을 선호합니다.
- Apollo Client가 모든 상태 관리, 네트워킹 및 데이터 가져오기 문제를 처리하도록 하세요.
- Apollo Client는 후속 요청을 위해 데이터를 효율적으로 가져오고, 정규화하고, 캐시할 수 있으므로 원격(전역) 상태를 처리하도록 합니다.
클린 아키텍처 예시 :
https://github.com/apollographql/odyssey-lift-off-part5-client
'FrontEnd' 카테고리의 다른 글
타입스크립트의 타입 대수(type argebra)를 통해 타입 오류 분석하기 (0) | 2022.03.27 |
---|---|
타입스크립트 타입 호환성과 타입 계층 트리 (0) | 2022.03.27 |
타입스크립트의 타입 불건전성에 대하여 2 : 공식 예제 코드 살펴보기 (0) | 2022.02.27 |
타입스크립트의 타입 불건전성에 대하여 1 : 타입시스템의 한계 (0) | 2022.02.25 |
Context API와 React.memo (0) | 2022.02.14 |