본문 바로가기
GraphQL

Apollo :: 아폴로 클라이언트, 클라이언트 사이드 아키텍쳐

by kicksky 2021. 3. 23.

Khalil Stemmler, Apollo Client & Client-side Architecture Basics을 읽고 정리한 글

 

2월 한 달, 아주 잠시 실무를 경험한 이후에 단순히 코드가 작동하는 것 말고 더 견고하고 유연한 구조를 가진, 좋은 코드를 작성하는 것에 관심이 생겼다. 관련된 자료를 계속 찾아 읽으려고 노력 중이다. 이번에 GraphQL과 Apollo를 공부하고 쓰면서도 염두에 두었던 부분이고, Apollo Blog에 좋은 자료들이 많아 조금씩 보고 있다.

 

그 중 클라이언트 사이드 아키텍처 베이직이라는, 비단 아폴로 뿐만 아니라 전반적인 클라이언트 단 코드 구성에 대해 설명된 글이 있어서 읽게 되었다.


소프트웨어 디자인이란 구조와 개발자 경험의 결합이라는 테제로 글을 시작한다. 우리가 쓸 도구는 당연히 문제를 해결할 뿐만 아니라 그 도구를 쓰는 사람으로 하여금 통제력, 장악력, 만족감, 자부심 등을 느끼도록 해야한다는 것이다. 더 견고한 구조와 개발자 경험이라는 저울 사이에서 밸런스를 맞추는 것이 중요하지만 상충되기 쉽다고 한다.

 

MVP(Model-View-Presenter)

UI 빌드할 때 쓰이는 구조 패턴 중 하나. MVC(Model-View-Controller)가 서버와 클라이언트의 분리에 관한 패턴이라면, MVP는 클라이언트 단의 구조를 분해한다.

  • view에서 유저 이벤트 발생
  • 유저 이벤트로 인해 model에 업데이트 혹은 변화
  • model 변화하면 새로운 데이터로 view 업데이트

= 옵저버 패턴

사실 대부분의 클라이언트 사이드 아키텍처는 옵저버 패턴이라고 할 수 있다. 

 

 

 

 

 

Model ?

그런데 Stemmler는 MVP가 좋긴 하지만 오늘날에는 너무 포괄적인 면이 있다고 주장한다. 리액트에서 MVC와 MVP의 View와 Controller/Presenter가 presentation component와 container component에 해당하는 건 명백하지만 model을 무엇으로 볼 것인지가 확실치 않다는 것이다. 너무 방대한 개념이 되어버려서 아래 이미지처럼 모델과 도구의 매치가 어려워 진다.

보통 리액트 앱에서 model의 역할이라고 보는 것은 다음과 같다.

  • 네트워킹 & 데이터 불러오기
  • model 행동 
  • state 관리 (model data)
  • 기타 등등

그리고 모호한 모델 문제에 대한 해결책으로는 다음과 같은 것들이 있다.

  • React hooks
  • Redux
  • Context API
  • Apollo Client
  • xState
  • react-query

이에 대해 Stammler는 백엔드 프로그래밍에서 이 모호한 모델 문제를 해결하는 디자인 원칙을 클라이언트에도 도입하려는 시도를 한다. 

원칙 #1 - CQS(Command Query Seperation)

state를 변경하는 메소드와 그렇지 않은 메소드를 분리하기. (메소드 레벨) operation은 command 혹은 query

- commands는 state를 변경하지만 데이터 리턴 X

- queries는 data를 리턴하지만 state 변경 X

이렇게 하면 코드를 읽고 단박에 이해하기 쉬워진다.

=> 읽는 코드이거나 쓰는 코드이거나

 

 

 

Commands

쓰는 코드. 이를테면 void를 리턴하는 createUser, toggleTodo 함수 등.

예외: stack 위에서 아이템을 꺼내거나, mutation 응답 내에서 변경된 데이터 혹은 적어도 변경된 데이터의 id를 반환하는 경우

 

Queries

읽는 코드. data만 리턴하고 다른 사이드 이펙트는 없는 getCurrentUser, getUserById 함수 등.

 

중요성

  • 코드 path 단순화: 리액트의 useState Hook과 GraphQL의 query, mutation이 수행하는 작업에 해당한다.
  • 코드를 보고 Operation을 파악하기 쉽다
  • 기능을 Operation, 즉 command or query처럼 생각할 수 있다.
    • 모든 기능이 통합 테스트를 통과하길 바란다면, 유저가 실행하는 command와 query가 잘 분리되었는지 확인하고 테스트 해라. 
    • 대부분의 pages/route는 하나 이상의 feature를 실행하므로, 기능, 그리고 page/route를 기준으로 모든 관심과 컴포넌트를 같은 곳에 두어야 유지 보수 하기 좋은 폴더 구조를 만들 수 있다.
  • 캐시 무효화는 CS에서 가장 어려운 문제 중 하나이지만 : CQS로 쉬워질 수 있다.

(Using CQS, we can be sure that when if no new commands were executed (against a particular item), we can continue to perform queries for it by sourcing its data directly from the cache. The moment we execute a command against that item, we invalidate it in the cache. Consider how useful this might be for a state management library. As a side note, it’s good to be able to invalidate as precisely as possible, but it’s also generally safe to invalidate “too much” when you can’t be sure. )

 

원칙 #2 - 관심사의 분리

어플리케이션의 구조적 관심사들을 논리적인 경계로 구분해라. 

이를테면, 투두리스트에서 delete를 누른다면 view는 이벤트를 container로 넘긴다. 유저 이벤트를 리액트 훅이나 리덕스 thunk 메소드와 연결할 수도 있다. 이 지점에서 어떤 로직을 실행하고 네트워크 요청을 해야하는지 결정하고, 로컬에 저장된 state를 업데이트하고, UI한테 업데이트 해야한다고 알리고 싶다면? 앞서 말한 로직은 권한, 유효성, 상호작용 등 일 수 있고, 이것들은 다 다루어져야 할 유효한 관심사이다.

 

관심사의 분리란 "해야할 일의 전체 그림을 그리는 것", 이 일들을 특정한 레이어에 위임하는 것, 그 레이어들이 제 할일을, 딱 그 일만을 하는지 확인하는 것.

반드시 폴더와 파일을 물리적으로 분리할 필요는 없다. SoC가 그것을 의미하는 건 아니다. 그보다는 코드가 어떤 관심사를 다루는지 알고, 올바른 구조를 갖추고 있는지를 확인하는 것에 가깝다. 물리적 분리라기 보다 논리적 분리인 셈이다. 물론 함께 바뀌는 코드나 파일을 가깝게, 같이 두는 것은 좋은 생각이다. 

 

왜 중요한가?

  • 해체(Decomposition). 해야할 일, 이 일들이 속한 레이어, 관심사를 해결할 툴 등에 대한 분명한 시야 확보. 
  • 기능 조직. 기능을 일련의 수직적인 단면 (a set of vertical slices) 으로 생각할 수 있음. 
  • 위임. 이를테면 대부분의 개발자가 presentational components를 위해 커스텀 view-layer 라이브러리를 만들진 않는다. 그냥 리액트나 뷰를 쓰면 된다. 그러나 많은 유저들은 커스텀 상태 관리 시스템은 처음부터 직접 구축하려고 한다. 

더 나은 클라이언트 사이드 아키텍쳐의 스타팅 포인트

종합하면 모던 리액트 앱의 MVP 패턴에는 다음과 같은 관심사가 있다. 

  • Presentation components — Renders the UI and create user events.
  • UI logic — Contains view behavior & local component state.
  • Container/controller — The glue layer. Connects user events to the correct interaction/application layer construct, and passes reactive data from global storage to presentation components. We can also think of these as page components.
  • Interaction layer — Model behavior & shared component state. There could be several different stateful models in a single app. The most common way to write code in this layer is using tools like React hooks and/or xState.
  • 🌟 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.

클라이언트 사이드 어플리케이션의 서로 다른 타입의 state

Jed Watson: local (component), shared local (global), remote (global), meta, and router.

  • local (component): State that belongs to a single component. UI state. We can extract UI state from a presentation component into a React hook.
  • shared local (global): As soon as state belongs to more than one component, it’s shared state. Components shouldn’t need to know about the existence of each other (ex: a header shouldn’t need to know about a todo).
  • remote (global): State that lives behind backend services. When we work with remote state, we typically pull chunks of it from a remote API and work with local copies of data from our client app. remote state is typically another form of shared state.
  • meta: Meta state refers to state about state. The best example of this is the loading async states that tell us the progress of our network requests.
  • and router state: The current URL of the browser.

후반부에서는 state와 관심사에 관해 다룬다. Apollo Client가 내재하고 있는 문제들을 해결하는 데 초점을 둔다. 

Apollo Client의 States와 Concerns

1. 관심사 Concern ( 어떤 일을 할까 )

1) Presentational components

UI를 렌더링 하고 유저 이벤트를 생성한다. MVP에서 View 부분의 경계 에 위치한다. 

이 컴포넌트들은 main goal은 아니지만 이를 달성하기 위해 필요한 low-level의 관심사이다. 이를 implementation detail, 즉 (무언가 다른 것을) 실행하는 데 필요한 디테일이라고 하는 것 같다. Todo를 추가하기가 목표라면 - 버튼, 스타일링, 텍스트 등은 디테일인 것이다.

한편 자주 바뀌는 어떤 것에 포함되는 모든 것을 volatile이라고 하는데, 이 문제는 일련의 재사용 가능한 컴포넌트를 지정해서 그것을 토대로 view를 만들어서 관리할 수 있다. 

 

GraphQL 쿼리를 presentational 컴포넌트에 포함해도 될까?

GraphQL 쿼리를 presentational 컴포넌트와 최대한 가까이 두는 것은 좋은 방식이다. 불필요하게 파일을 왔다갔다 하는 것을 줄여준다.  GraphQL을 더 이상 쓰지 않을 경우(transport-layer technology로 변경)에는 컴포넌트가 순수하지 않기 때문에 모든 컴포넌트를 리팩토링 해야한다는 단점이 있다. 또 하나의 단점은 이 컴포넌트들을 테스트할 때 mock Apollo Client provider로 감싸주어야 한다는 점이다. 그치만 저자는 이를 오히려 장점이라고 본다. mockedProvider API가 강력한 테스팅 툴 역할을 하기 때문이다.

state 변화를 구독하는 쿼리

일반적으로 state가 변하면 controller/container 컴포넌트는 알림을 받아서 presentational 컴포넌트props를 넘겨준다. (Redux with Connect) 그러나 Apollo Client는 구독하고 있는 데이터가 변화할 때 자동으로 알림을 받기 때문에 container 컴포넌트를 통해 props를 전달할 필요가 없다.

 

그렇다면 container는 왜 필요한지?

  • 여러 라우트를 가진 어플리케이션의 top-level page 컴포넌트 역할. 모든 presentational 컴포넌트와 기능들을 포함.
  • presentational 컴포넌트에서 명령어를 model(interaction/application) 레이어로 올려줌으로써 결정을 내리는 로직(decision-making)이 수행되도록 한다. 이 로직은 때때로 mutation을 발생시켜야 하는 결정을 내리기도 한다.

GraphQL mutation을 presentational 컴포넌트에 포함시켜도 될까요? 글쎄요.

presentation 컴포넌트에서 결정이 내려지는 일이 없도록 유지하고, 그 작업은 어플리케이션의 model 부분에서 이루어지도록 해라. 왜냐하면 CQS는 읽기와 쓰기 통로를 구분하는 것이 전부이다. 읽을 땐 읽기 말고 다른 사이드 이펙트가 있어선 안 된다. 

 

CQS가 어떻게 컴포넌트에 적용되는지도 생각해보세요.

presentational 컴포넌트가 읽기 전용이라면 이는 state가 언제 변화해야하는지 결정하는 일에는 포함되어선 안 된다는 것 아닌가? presentational은 쓰기에 대해서는 아무 책임도 없다는 것이지 않나? 

- 맞다. 결정을 내리는 로직이 있어야 한다면 이는 view가 아니라 model의 관심사이다. 

 

리액트에서는 리액트 훅으로 interaction (때로는 application이라고 부르는) 레이어 로직을 사용한다.

 

2) 상태 관리와 저장소

Apollo Client는 전체 상태 관리를 핸들링 하는 상태 관리 라이브러리이다. 그 중 크게 세 가지,

  • Storage : 저장소. 전역 상태를 가지고 있다. 백엔드에서 받은 데이터인 remote (global) state 거나 shared local state이거나, 혹은 둘의 혼합일 수도 있다.  
  • Updating state : 대부분 동작을 실행한 이후에 사이드 이펙트인, 캐시된 state를 업데이트할 필요가 있다.
  • Reactivity : state가 변할 때, 새 데이터로 리렌더링 해야 한다고 일부 UI에게 알려주어야 한다.

Apollo Client에서는 스토리지로 normalized cache를 사용하고, state를 업데이트하기 위해 cache APIs를 사용한다. state가 변할 때 어플리케이션 전체의 쿼리에 변화를 (자동으로) 알린다.

3) 네트워킹 & 데이터 불러오기

API 요청을 수행하고 metadata 상태를 보고하기

 

네트워킹과 데이터 불러오는 레이어에 할당된 일은

  • 엔드 서비스가 어디에 있는지 아는 것
  • 응답을 형식에 맞게 만드는 것
  • 응답 데이터 혹은 에러를 모아서 통제하는 것
  • isLoading, errors 같은 비동기 상태를 보고하는 것 - 이는 meta state의 형식이다.

2. State

1) Local (component) state

단일 컴포넌트에 속하는 state. 리액트의 useState 같은 툴.

data graph를 개별 컴포넌트에 맞추어서 모델링 하지 마라. GraphQL는 data graph를 모델링하고 유틸라이징 하긴 하지만, 무엇보다도 개별 컴포넌트에 한정되지 않는 데이터와 상호작용하는 것이기도 하다. 이를테면 투두 리스트에서 isToggled state가 쓰여도 data graph와는 아무 상관 없어야 하는 것.

 

2) Remote state

백엔드 서비스로부터 받는 state. remote state는 Apollo Client가 온전히 담당하는 상태이고(위의 로컬은 리액트의 useState로 충분했음), 이 상태는 원격 data graph의 캐시된 데이터 덩어리들을 말한다. 캐시되었기 때문에 동일한 요청에 대해서 Apollo Client는 네트워크 요청이 아니라 캐시를 확인한다.

 

3) Meta state

다섯 타입의 상태 중에서 Apollo Client가 관여하는 세 가지 타입이 있다.

  • meta : state에 관한 state. useQuery와 useMutation Hook을 사용할 때 요청의 현재 상태에 관한 디테일을 담은 객체를 받는다. data, error, loading 등

axios와 redux를 사용했을 때:

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 })
    }

  }
}

4) shared local state

여러 컴포넌트에서 사용되는 state. remote state가 보통 어플리케이션의 여러 컴포넌트에서 활용되므로 이를 shared state 형태라고 생각할 수 있다. 

  • remote state
  • 클라이언트 사이드에서만 쓰이는 local state
  • 둘의 조합

remote state + 클라이언트 전용 local state

remote state에 데이터를 추가해야하는 경우. 투두 앱에서 클라이언트 전용 shared local state를 각각의 todos에 추가할 수 있다. 

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;
          }
        }
      }
    }
  }
});

 


www.apollographql.com/blog/apollo-client-client-side-architecture-basics/

 

댓글