본문 바로가기

FrontEnd

Context API(컨텍스트API)는 Dependency Injection(의존성 주입) 수단이다.

반응형

원문 : React Context for Dependency Injection Not State Management (testdouble.com)

 

React Context for Dependency Injection Not State Management

Dive into the concept that React Context API is primarily a tool for injecting dependencies into a React tree and how we can use that to improve testability, reusability, and maintainability of our …

blog.testdouble.com

TLDR :

  • ContextAPI는 전이적 의존성을 추상화하기 위한 도구다. (ContextProvider를 dependency로 생각할 수 있다.)
  • 각각의 Context를 횡단관심사로 사용한다.

Context API는 상태 관리 도구가 아니다.

  • state(상태) : 애플리케이션의 동작을 설명하는 데이터
  • state managemement(상태 관리) : 애플리케이션의 라이프사이클 동안 상태 변경을 관리
    • 상태 관리의 요구사항
      • 상태 초기화 / 저장
        • 전역 상태
        • (공유) 로컬 상태 - 전역 상태와 로컬 상태의 혼합일 수 있음
      • 현재 상태 읽기
      • 상태 업데이트
      • 상태 변화 알림
        • 상태가 변경되면 새 데이터로 다시 렌더링해야 함을 UI 조각에 알려야 합니다.

너무 당연하게 useReducer와 useState를 함께 써왔지만 둘은 컨텍스트 API와는 별개의 도구다.

CONTEXT API는 상태 업데이트 방법을 제공하지 않는다.
컨텍스트의 상태를 바꾸는 방법은 프로바이더의 value prop을 변경하는 방법 뿐이다.

곰곰히 생각해보면, 이것 때문에 컨텍스트의 prop이 바뀌면, 아래 자식들도 전부 다 리렌더링 되는게 당연하다.

리액트 개발 팀의 코어 아키텍트는 이렇게 말했다 한다

테마나, 로케일, 클라이언트 같이 거의 변하지 않거나, 변하는 경우 전부 다 바뀌는게 당연한 경우에만  context API를 써라.

현실 세계의 React Context

아래는 React-Redux의 예시이다.

실제로 value로 넘어가는 것은 contextValue의 store, subscription인데,

해당 객체들의 메소드를 통해 상태가 업데이트 되는 것이지 context API에서 자체적으로 상태 업데이트를 제공하지 않는다.

import { ReactReduxContext } from './Context'
import Subscription from '../utils/Subscription'

function Provider({ store, context, children }) {
  const contextValue = useMemo(() => {
    const subscription = new Subscription(store)
    subscription.onStateChange = subscription.notifyNestedSubs
    return {
      store,
      subscription,
    }
  }, [store])

  // ... other stuff

  const Context = context || ReactReduxContext

  return <Context.Provider value={contextValue}>{children}</Context.Provider>
}

 

컨텍스트는 React 컴포넌트 트리 아래로 관찰 가능/구독 가능/컨테이너 개체를 전달하기 위한 종속성 주입 메커니즘으로 사용된다.
이는 다른 라이브러리 코드에서 액세스할 수 있다.

React Context를 이용한 의존성 주입

아래와 같은 컴포넌트를 테스트 한다면

// components/Products.jsx
import React from 'react';

const INITIAL_STATE = {
  loading: true,
  error: null,
  products: []
};

function Products() {
  const [response, setResponse] = useState(INITIAL_STATE);

  useEffect(() => {
    fetch('/api/products')
      .then(resp => resp.json())
      .then(data => setResponse({loading: false, products: data}))
      .catch(error => setResponse({loading: false, error}))
  }, [])

  if (response.loading) {
    return <LoadingSpinner />
  }

  if (response.error) {
    return <ErrorPage error={response.error} />
  }

  return (
    <div>
      {response.products.map(product => (
        <Product {...product} />
      ))}
    </div>
  )
}

아래와 같이 모킹, 초기화, jest함수 사용을 하여야 한다.

// components/__tests__/Products.test.jsx
import React from 'react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { render, waitFor, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import {Products} from '../Products.jsx'

// Mock out the entire network layer!
const server = setupServer(
  rest.get('/api/products', (req, res, ctx) => {
    return res(ctx.json([
      {id: 1, title: 'First Product', /* more data */},
      {id: 2, title: 'Second Product', /* more data */}
    ]))
  })
)

// Don't forget these or else your test cases and test suites
// will bleed together :/
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('loads and displays products', async () => {
  render(<Products />)

  // Wait for some UI element that appears when loading finishes
  await waitFor(() => screen.getByText('Products List'))

  // Make assertions on what should be on the screen
  expect(screen.queryByTitle('First Product')).toBeInTheDocument()
  // ... moar assertions ...
})

test('loads a different list of products', async () => {
  // shadow the URL handler with a NEW handler
  server.use(
    rest.get('/api/products', (req, res, ctx) => {
      return res(ctx.json([
        {id: 3, title: 'Third PROMO Product', promotion: {}},
        {id: 4, title: 'Fourth Product'}
      ]))
    })
  );

  render(<Products />)

  // moar awaits and assertions
})

위와 같은 디자인의 문제점.

0. 직접 fetch를 사용하기에 fetch 자체를 모킹 필요 (이건 그렇다 칠 수 있음)

1. 엔드포인드 데이터 포맷이 변경되면, 해당 데이터를 사용하는 모든 컴포넌트를 변경해주어야 한다.

2. 400,500 등의 에러 처리를 컴포넌트에서 직접 해야 함.

주 : 마틴 파울러는 DI 개념을 소개하면서,
메모리 내에서 동작하는 클래스와,
메모리 외부에서 데이터를 가져오는(부수효과) 클래스를 분리한다.
또한 애플리케이션은 이 메모리 내에서 동작하는 클래스의 API에만 의존한다.
즉, js의 경우 fetch같은 API를 절대 직접 호출하지 않으며(클래스로 한번 감쌈), DB의 경우도 Repository 클래스를 통해 DB client의 API를 호출한다.
요점은 부수효과를 일으키는 함수를 사용하는 클래스를 따로 만들고, (client : fetch 호출, query 실행)
해당 함수를 사용하는 클래스를 따로 만들어, 해당 클래스를 주입받을 수 있도록 구현하라는 것이다.
그러면 테스트가 용이한 코드를 작성할 수 있다.

컨텍스트 API로 해당 문제를 해결해 보자.

// DepsContext.js
import {createContext, useContext} from 'react';

const DepsContext = createContext({});

export function useDeps() {
  return useContext(DepsContext);
}

export function DepsProvider({children, ...services}) {
  return (
    <DepsContext.Provider value={services}>
      {children}
    </DepsContext.Provider>
  )
}
// services/productServicesFactory.js

export const productServicesFactory = initialClient => {
  const client = initialClient || new HttpClient();

  return {
    async lookupAllProducts() {
      return client.fetchProducts();
    }

    async lookupNewProducts() {
      const products = await client.fetchProducts();
      return products.filter(product => product.isNew);
    }

    async lookupProductsWithPromo() {
      const products = await client.fetchProducts();
      return products.filter(product => product.promos.length > 0);
    }
  }
};

// Usage:
//   const {lookupNewProducts} = productServicesFactory();
//   lookupNewProducts.then(newProducts => { /* do stuff */ });

App에 컨텍스트 제공

// App.jsx
import React from 'react';
import {DepsProvider} from './DepsContext.js';
import {Products} from './components/Products.jsx';
import {
  productServicesFactory
} from './services/productServicesFactory.js';

export function App() {
  return (
    <DepsProvider productServicesFactory={productServicesFactory}>
      <Products />
    </DepsProvider>
  )
}
// components/Products.jsx
import {useDeps} from '../DepsContext.js';

// ... same ...

function Products() {
  const {productServicesFactory} = useDeps();
  const [response, setResponse] = useState(INITIAL_STATE);

  useEffect(() => {
    const {lookupAllProducts} = productServicesFactory();
    lookupAllProducts()
      .then(data => setResponse({loading: false, products: data}))
      .catch(error => setResponse({loading: false, error}));

    // Add to deps array cuz we're good citizens, but it won't
    // break if we didn't
  }, [productServicesFactory]);

  // ... same ...
}

무엇이 바뀌었는가?

fetch API를 메서드(함수)로 변경

const {productServicesFactory} = useDeps();

 

테스트가 얼마나 간단해졌는지 보고가자.

// components/__tests__/Products.test.jsx
import React from 'react'
import { render, waitFor, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import {Products} from '../Products.jsx'
import {DepsProvider} from '../../DepsContext.jsx'

const getFactory = data => () => {
  return {
    async lookupAllProducts() {
      return data
    }
  }
}

test('loads and displays products', async () => {
  render(
    <DepsProvider
      productServicesFactory={getFactory([
        {id: 1, title: 'First Product', /* more data */},
        {id: 2, title: 'Second Product', /* more data */},
      ])}
    >
      <Products />
    </DepsProvider>
  )
  // Wait for some UI element that appears when loading finishes
  await waitFor(() => screen.getByText('Products List'))

  // Make assertions on what should be on the screen
  expect(screen.queryByTitle('First Product')).toBeInTheDocument()
  // ... moar assertions ...
})

test('loads a different list of products', async () => {
  render(
    <DepsProvider
      productServicesFactory={getFactory([
        {id: 3, title: 'Third PROMO Product', promotion: {}},
        {id: 4, title: 'Fourth Product'}
      ])}
    >
      <Products />
    </DepsProvider>
  )

  // moar awaits and assertions
})

1. no MSW (fetch 모킹)

2. no import 모킹

3. 테스트 스위트 기능 불필요.

스토리북을 사용한다면?

// components/__stories__/Products.stories.js
import React from 'react';
import { DepsProvider } from '../../DepsContext.jsx'
import { Products } from '../Products';

const productServicesFactory = () => {
  return {
    async lookupAllProducts() {
      return [
        {id: 1, title: 'First Product', /* more data */},
        {id: 2, title: 'Second Product', /* more data */},
      ]
    }
  }
}

export default {
  title: 'Products',
  component: Products,
  decorators: [
    (Story) => (
      <DepsProvider productServicesFactory={productServicesFactory}>
        <Story />
      </DepsProvider>
    ),
  ],
};

의존성 주입을 통해 다음과 같은 문제를 해결했음

  • 테스트
  • 프리뷰
  • 종속성

또한 서버와의 인터페이스의 변경의 컴포넌트의 변경을 초래하지 않기 때문에 굿이다.

일반적인 의존성 주입

보통 의존성 주입은 소스코드들의 의존성 관리를 의해 사용한다.

의존성이라는 것은 같이 변화해야 할 위험(커플링)을 수반한다. (ex 리액트 API가 바뀌면 앱을 통째로 뜯어고쳐야 함.)

스프링이 좋아하는 단방향 의존성 관리. Movie는 DiscountPolicy라는 추상적인 클래스(인터페이스)에만 의존하면 된다.

낮은 결합도, 높은 응집도

의존성 주입이 없는 경우의 테스트

모킹이 많이 필요함

각 테스트 별 모킹의 초기화가 필요함

테스트 러너(jest)의 기능이 필요함

// services/HttpClient.js

export class HttpClient {
  async fetchProducts() {
    const resp = await fetch('/api/products');
    return resp.json();
  }

  async fetchOrders() {
    const resp = await fetch('/api/orders');
    return resp.json();
  }

  // ... more API endpoint stuff ...
}

// services/ProductsService.js
import {HttpClient} from './HttpClient.js'

export class ProductsService {
  constructor() {
    this.client = new HttpClient();
  }

  async lookupNewProducts() {
    const products = await this.client.fetchProducts();
    return products.filter(product => product.isNew);
  }

  async lookupProductsWithPromo() {
    const products = await this.client.fetchProducts();
    return products.filter(product => product.promos.length > 0);
  }

  // ... more product specific stuff ...
}


// services/__tests__/ProductsService.test.js
import {ProductsService} from '../ProductsService.js';
import {HttpClient} from './HttpClient.js';

// 목 객체 설정
const mockFetchProducts = jest.fn();
jest.mock('./HttpClient.js', () => {
  return jest.fn().mockImplementation(() => {
    return {fetchProducts: mockFetchProducts}
  })
});


// 모킹 초기화 - 테스트 간 상호 영향 방지
// 테스트 러너의 기능
beforeEach(() => {
  HttpClient.mockClear();
  mockFetchProducts.mockClear();
})

describe('ProductsService', () => {
  describe('lookupNewProducts()', () => {
    it('filters for only new products', async () => {
      // 리턴 밸류 모킹
      mockFetchProducts.mockReturnValueOnce([
        { id: 1, isNew: true },
        { id: 2, isNew: false }
      ]);
      const service = new ProductsService();

      const result = await service.lookupNewProducts();

      expect(result).toHaveLength(1)
      expect(result[0]).toEqual({id: 1, isNew: true})
    })
  })

  // ... etc.
})

 

의존성 주입을 활용한 테스트

모킹이 필요없음

모킹 초기화 불필요

테스트러너 기능 불필요

import {HttpClient} from './HttpClient.js'

// 클라이언트 객체를 주입 받음.
export class ProductsService {
  constructor(client) {
    this.client = client || new HttpClient();
  }

  // ... everything else the same ...
}


// services/__tests__/ProductsService.test.js
import {ProductsService} from '../ProductsService.js';

describe('ProductsService', () => {
  describe('lookupNewProducts()', () => {
    it('filters for only new products', async () => {
      // 테스트에 필요한 기능만 있는 가짜 객체를 주입함.
      const service = new ProductsService({
        async fetchProducts() {
          return [{ id: 1, isNew: true }, { id: 2, isNew: false }]
        }
      });

      const result = await service.lookupNewProducts();

      expect(result).toHaveLength(1)
      expect(result[0]).toEqual({id: 1, isNew: true})
    })
  })

  // ... etc.
})

주 : 테스트에 대해 더 읽어볼만한 자료 단위 테스트 작성에 대한 모범 사례 - .NET | Microsoft Docs

 

의존성 주입 응용

특별한 역할 추가하기

const client = new HttpClient()
// 특별한 역할 추가
client.setDefaultHeaders({'X-APP-ROLE': 'cool user'});
const service = new ProductsService(client);

rest 백엔드에서 graphQL 백엔드로 마이그레이션

const client = checkIfBetaAccount()
  ? new GraphQLClient()
  : new HttpClient();
const service = new ProductsService(client);

난 객체지향이 싫어요

HOF와 클로저를 활용하면 됩니다.

// services/productServicesFactory.js

export const productServicesFactory = initialClient => {
  const client = initialClient || new HttpClient();

  return {
    async lookupAllProducts() {
      return client.fetchProducts();
    }

    async lookupNewProducts() {
      const products = await client.fetchProducts();
      return products.filter(product => product.isNew);
    }

    async lookupProductsWithPromo() {
      const products = await client.fetchProducts();
      return products.filter(product => product.promos.length > 0);
    }
  }
};

// Usage:
//   const {lookupNewProducts} = productServicesFactory();
//   lookupNewProducts.then(newProducts => { /* do stuff */ });

 

추가로 읽어보기

testdouble/react-decoupler (github.com)

 

GitHub - testdouble/react-decoupler

Contribute to testdouble/react-decoupler development by creating an account on GitHub.

github.com

https://martinfowler.com/articles/injection.html#UsingAServiceLocator

 

Inversion of Control Containers and the Dependency Injection pattern

Explaining the Dependency Injection pattern, by contrasting it with Service Locator. The choice between them is less important than the principle of separating configuration from use.

martinfowler.com

결론

  • 컨텍스트 API는 상태 관리 도구 아니다.
  • 컨텍스트 API는 MVC의 M에 해당한다.
  • 의존성 주입 도구로 생각하자.
    • (리액티비티는 다른 모듈, 혹은 해당 컴포넌트의 state를 통해 해결한다.)
반응형