리엑트 의존성 주입

jay·2022년 9월 4일
51

react

목록 보기
13/14
post-thumbnail

영상으로 보기

https://youtu.be/ib69nAnMT-U

안녕하세요, 단테입니다.

저는 팀의 다른 엔지니어 분들과 협업을 진행하거나 간혹 혼자 풀스택 작업을 할 때
FE/BE에 구분선을 엄격히 정해두기 보다는 영역을 자유롭게 넘나들며 좋은 코드/디자인 패턴을 눈여겨 보고 한쪽 영역에서 사용되는 좋은 패턴을 반대 영역에 적용해볼 수는 없을지 고민해보는데요,

SPA(Single Page Application)이 세상에 나오기 전에는 백엔드 어플리케이션 내부에 있는 jsp 파일에서 스크립트 작업을 진행하고는 했습니다.
이 기간에는 기존의 많은 소프트웨어 엔지니어 분들이 만든 best practice의 집합체인 design pattern을 코드에 적용해 생산성을 올리는데 집중했다면

SPA가 세상에 나온 이후에는 프레임워크 기반의 개발 방법론이 다각화 되어
FE/BE간의 영역이 이전보다 훨씬 뚜렷하게 분리되고 디자인 패턴 기반의 코드작성에서 더 나아가 프레임워크 도큐먼트를 보고 고유의 best practice 를 코드에 적용했습니다.

기술의 발전에 따라 동일한 FE 영역이라 할지라도 앵귤러, 리엑트, 뷰 프레임워크 간 best practice, 모듈화방법, 차용하는 디자인 패턴의 종류가 상이하게 변했고 FE/BE간 사용하는 코드의 패턴 또한 대단히 큰 간격으로 그 모양과 용어가 다양하게 분리되고 생겨났습니다.

빠른 속도로 늘어나는 개발 기술들로 인해 언제 부터 이러한 간격이 생겨났는지에 대해 무감각해질 때가 많습니다만,
결국 JS 프레임워크의 뿌리가 JS이듯이 모든 언어, 소프트웨어 개발 방법에 있어 모태와 근간이 되는 소프트웨어 개발 기법 / 용어들이 존재할 것이라는 사실을 우리는 유추하고 또 발견할 수 있습니다.

즉, api의 모양이라던가 부르는 명칭이 사용하는 기술에 따라 다른 형태를 띄지만
알고보면 넓은 틀에서 비슷한 철학, 동일한 목표점을 지향하고 설계된 api인 것들이 많습니다.

오늘은 비교적 백엔드에서는 자주 사용되나 리엑트에서는 생소한 주제일 수 있는 의존성 주입에 대해 이야기해보려고 합니다.

의존성 주입

어떻게 생겨난 용어인가요

의존성 주입은 리팩토링 이라는 유명한 책의 저자이기도 한 소프트웨어 엔지니어인 마틴 파울러씨가 2004년 그의 블로그에서 처음 소개한 용어입니다.

본 글에서 다루진 않지만 소프트웨어 엔지니어링에는 IoC라는 개념이 있는데요, 이 개념이 굉장히 폭 넓은 의미를 가지기 때문에 보다 좁은 의미의 Dependency Injection이라는 용어를 제안했습니다.

I think we need a more specific name for this pattern. Inversion of Control is too generic a term, and thus people find it confusing. As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection.
저는 DI 개념에 대한 좀더 구체적인 용어가 필요하다고 생각합니다. IoC의 포괄적인 내용이 사람들에게 혼동을 가져다 주고는 합니다. 따라서 많은 IoC 사용자들과의 토론을 거쳐 Dependency Injection이라는 용어를 만들게 되었습니다. - 마틴 파울러

리엑트 프레임워크에서는 보기 힘든 용어라 처음 보면 막연하고 어렵게만 느껴질 수 있는 개념이지만, 소프트웨어 엔지니어링에서 흔히 쓰이는 용어구나 정도로만 마음에 담고 보면 좋겠습니다.

의존성이 뭔데요?

소프트웨어 개발에 있어서 의존성이란 특정 객체가 어떤 요청 및 기능을 수행함에 있어서 외부 객체의 도움을 받을때 이 외부 객체를 특정 객체의 의존성이라고 합니다.

nodejs 개발환경에서는 npm install을 통해 다양한 서드파티 패키지들을 설치해서 사용하잖아요? 우리의 어플리케이션 구현이 의존하는 서드파티 라이브러리들도 의존성입니다.

의존한다는 이야기는 기능 구현에 있어서 필수적으로 사용해야 한다는 의미입니다.

아래 코드에서 createHttp 함수는 인자로 http client를 받고 있습니다.
예를 들어 아래 코드를 브라우저가 아닌 nodejs 환경에서 사용하는 경우, window.fetch가 존재하지 않기 때문에 사용자가 node-fetch를 사용할 수 있습니다.

브라우저 환경일지라도 사용자가 axios를 사용하기 원한다면 의존성으로 axios를 인자에 넣을 수 있죠.

// 인자로 의존성을 받는 createHttp
export const createHttp = (fetch = window.fetch) => {
  return (url, config) => fetch(url, config)
}  

의존성을 사용할때는 위처럼 인자로 건네받을 수도 있고 함수 내부에서 의존성을 인스턴스화 해서 사용할 수 있습니다. 의존성을 사용하는 형태가 다양하게 존재할 수 있다는것이죠.

import FormData from "form-data"

const getUploadForm = () => {
  const form = new FormData(); // 함수 내부에서 인스턴스화 된 의존성
  return form;
}

객체를 클래스 형태로 사용할 때는 다음처럼 생성자를 사용하거나 setter를 사용할 수 있어요.

class Service {
  private http;
  private uploadForm;
  
  constructor(form){ // 생성자로 주입 받은 의존성 
    this.uploadForm = form;
  }
  
  setHttp(http){ // 의존성 setter 
    this.http = http;
  }
}

왜 이게 중요한데요?

리엑트에서는 의존성 주입을 할 수가 없어서 아쉬워요 X

이미 하고 있어요

리엑트가 DX와 UX의 간격을 줄이는데 중점을 둔 UI 컴포넌트 기반의 프레임워크라 SPA 이전에 만들어진 디자인 패턴을 그대로 적용하기에 무리가 있는 것이지 의존성 주입을 안하고 있는게 아닙니다.

React Conf 2021 keynote 중

the communication between designer and developer isn't always seamless. A big reason for this was because in those days, the tools and software patterns that I use to build the UI were very different from the concepts the designers were using to design the UI.
디자이너와 개발자 간의 커뮤니케이션이 항상 원활하지는 않습니다. 그 이유는 당시에 내가 UI를 구축하는 데 사용하는 도구와 소프트웨어 패턴이 디자이너가 UI를 디자인하는 데 사용하는 개념과 매우 달랐기 때문입니다

I was working with tools ans programming concepts that felt far removed from the user experiences. I was trying to pull into reality. It's like the designers and I were speaking different languages.
나는 사용자 경험과 거리가 멀게 느껴지는 프로그래밍 개념으로 도구로 작업하고 있었습니다. 나는 이 문제를 현실로 가져오려고 했습니다. 마치 디자이너와 내가 다른 언어를 사용하는 것과 같습니다.

Truly great developer experience does not sacrifice user experience, it enables it.
진정으로 훌륭한 개발자 경험은 사용자 경험을 희생하는 것이 아니라 가능하게 합니다.

Apps that could have once only been built by a team of seasoned software engineers can now be built by say a designer with limited programming background.
한때는 노련한 소프트웨어 엔지니어 팀만 만들 수 있었던 앱을 이제 프로그래밍 배경이 제한된 디자이너도 만들 수 있습니다.

React Conf 2021을 보면 왜 현대의 프론트엔드 개발자가 FE 뿐만 아니라 BE도 공부해야 하는가 에 대한 고민에 대한 실마리를 얻을 수 있습니다. dx/ux 간격을 줄이기 위한 프레임워크는 legacy에 경험이 풍부한 노련한 소프트웨어 엔지니어들이 만들었습니다. 리엑트의 철학을 제대로 이해한다면 현재 dx에 만족하지 않고 지속적으로 ux/dx를 줄이는 데 노력해야 하는데, 이 간격이 어디서 비롯되었는지에 대한 이해가 부족한 상태로 개발 방법론을 만들다보면 이상한 괴생명체가 탄생할 수도 있습니다. 단순히 현재 웹 사이트를 만들 수 있다 없다에 대한 문제가 아닌 건강한 오픈소스 환경 형성을 위해 모두 공부해야 합니다.

다시 의존성 선언에 대한 주제로 넘어와서

의존성을 선언하고 사용하는데 다양한 방법들이 있지만
하나의 객체 코드가 복잡해지거나 가까운 미래에 다양한 기능이 추가 될 수 있을 때는 우리가 작성하는 코드가 확장 가능한지, 재사용 가능한지에 대해 고민해봐야 합니다.

즉, 내 코드가 변경에 유연한지에 대해 고민하게 됩니다.

리엑트에서는 내가 작성하는 UI 컴포넌트가 변경에 유연한지, 재사용성이 있는지에 대한 코드리뷰를 진행하는 경우가 이러한 고민을 해결하기 위한 과정이라고 할 수 있겠죠.

의존성을 잘못 사용할 때는 두 객체 간의 관계가 강하게 결합(Coupling)되어 변경에 유연하지 않게 되기 때문에 이에 대한 해결 방안이 필요합니다.

의존성을 선언하는데 있어서 객체가 변경에 유연하게 대처하기 위해서는
아래 사항들을 만족해야 합니다.

의존성은 교체 가능해야 합니다.

아래 코드는 마틴 파울러씨의 블로그에 있는 자바 예제코드를 타입스크립트로 포팅한 것입니다.

클래스인 MovieLister은 finder에게 모든 필름 정보를 받아 특정 작품이 아니면 삭제하고 있습니다.

class MovieLister {
  async moviesDirectedBy(arg: sring) {
    const allMovies = await finder.findAll();
   	for(let movie of allMovies){
      if(movie.getDirector() !== arg) {
        movie.remove();
      }
    }
  }
}

moviesDirectedBy에서 finder 객체를 직접 연결하고 있는데 이 finder객체가 어디서 선언되는지 보면 다음과 같습니다.

interface MovieFinder {
  findAll: () => Promise<any[]>;
}

class MovieLister {
  private finder: MovieFinder;
  
  constructor() {
    this.finder = new ColonDelimetedMovieFinder("movies1.txt");
  }
  ...
}

movieFinder 는

MovieFinder 클래스가 인스턴스화될때 여러 finder 중 ColonDelimetedMovieFinder 라는 구체적인 finder가 멤버로 지정됩니다.

MovieLister 클래스를 사용하는 사람은 ColonDelimetedMovieFinder 가 아닌 다른 finder를 사용하기 위해서는 MovieLister 코드를 수정해야 합니다.

이렇게 컴파일 타임(MovieLister를 만드는 사람 측)에 정해졌기에 런타임(MovieLister를 사용하는 사람 측)에서 수정할 수 없게 된 의존성을 hardcoded dependency라고 합니다.

const aLister = new MovieLister();
const bLister = ??? // 음.. 다른 finder를 가져오고 싶은데 

MovieLister에서의 의존성은 쉽게 변경 가능하지 않습니다.
이는 변경에 취약한 구조입니다.

테스트 용이한가

내가 만든 코드가 재사용 가능한지, 사이드이펙트로 부터 자유로운지에 대한 좋은 판단 기준으로
테스트 하기 용이한지 아닌지 살펴볼 수 있습니다..

const MovieListViewer = () => {
  const [ response, setResponse] = useState({
  	loading: true,
    error: null,
    movies: []
  });
  
  useEffect(() => {
    fetch('/api/movielist")
     .then(...)
  },[])
  
  if (response.loading) {
    return <LoadingSpinner />
  }

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

  
  return (
    <div>
      {response.movies.map(...)}
    </div>
  )
}

위 컴포넌트는 테스팅 하기 좋은 컴포넌트일까요?

모킹 해야 할 것들에 무엇이 있는지, 변경점이 발생했을 때 어떻게 될지 생각해봅시다.

컴포넌트를 통합하는데 어려움을 겪지는 않는가

유저가 폼 제출 이후 결제 페이지로 이동하게 하는 컴포넌트가 있습니다.

const OrderSomething = ({Form, redirectToOrder}) => {
  return (
    <Form
     onSubmit={redirectToOrder}
    />
  )
}

OrderSomethingonSubmit 타입을 props로 만족하는 어떠한 형태의 폼 props로 받을 수 있습니다.

이렇게 props를 통해 의존성을 받는 구조는 테스트를 작성하기 용이하게 합니다.

OrderSomething의 기능 테스트는 Form의 UI가 잘 동작하는지에 대한 테스트가 아닌 폼 제출 이후 결제 페이지로 잘 이동하는지에 대한 테스트이기 때문에 props로 전달되는 Form의 내부 구현에 대해서 많은 노력을 기울이지 않아도 됩니다.

it("폼 제출 이후 결제 페이지로 이동하는지 테스트", () => {
    const FakeForm = ({onSubmit}) => {
      return (
        <form onSubmit={onSubmit}>
          <button type="submit">Submit</button>
        </form>
     )
    ...
    const ({testById}) = render(<OrderSomething redirectToOrder={}/>);
    expect(....).not.toBe(null);
  }
})

redirect가 아닌 조건부 렌더링을 테스트 해야 하는 경우에는 children에게 UI 표현의 책임을 위임할 수 있습니다.

function Greeting({children, isLoggedIn})) {
  const isLoggedIn = props.isLoggedIn;

  return isLoggedIn ? children : <GuestGreeting />;
}

fetch api

돔 테스팅, 컴포넌트 통합 테스팅에 있어서 api 호출은 주요 쟁점 사항이 아닙니다.
실제 서버에 데이터를 받아보는 것은 옳지 않으며 테스트를 작성하기에 앞서 항상 리스폰스에 대한 모킹이 선행되어야 합니다.

컴포넌트 깊숙한 곳에 있는 로직일 수록 모킹하기 어렵습니다.
그리고 해당 사항이 사이드이펙트를 야기하는 로직이라면 이는 곧 테스트의 난이도 상승으로 이어집니다.

이에 대한 대안으로 사이드 이펙트 호출 핸들러를 props로 전달받는 방법을 생각해볼 수 있습니다. 이 때 핸들러가 props로 전달되기 때문에 Form 컴포넌트에 대한 테스트를 작성하기 용이합니다.

const postAction = () => {
}

<Form onSubmit={postAction}/>

다른 아이디어는 컴포넌트 내부에서 사이드 이펙트 호출 뿐만 아니라 리스폰스의 재조립(normalization)의 로직을 아예 작성하지 않고 이 역할을 아예 다른 컴포넌트로 위임하는 것입니다. contextAPI를 사용해서 이 문제를 해결해볼 수 있습니다.

리스폰스 normalization에 대한 책임이 Provider의 value의 각 handler에 집중되니 서버 리스폰스 형식이 달라져도 모든 컴포넌트를 수정하지 않아도 됩니다.

const createOrderService = (initialClient = new HttpClient()) => {
  const http = initialClient; // 추후 graphQL로 변경 가능
  
  return {
    async getOrderInfos() {
      return http.fetchOrderInfos();
    },
    async postOrderInfos(infos) {
      const mutatedInfos = await http.mutateOrderInfos(infos);
      return mutatedInfos.infos.filter(({isValidated}) =>isValidated);
    }
  }
}

export const App = () => {
  const orderService = createorderService();
  return (
    <DependencyProvider serviceCreator={createOrderService}>
   		<OrderMovie/>
    </DependencyProvider>
  )
}

const Order = () => {
  const { createOrderService } =useDependency();
  const { postOrderInfos } = createOrderService();
  return (
    ...
  )
}

변경 전,후의 코드를 살펴보세요. 테스트 코드에서 msw와 같은 서드 파티 라이브러리의 의존성을 피할 수 있고 바닐라 자바스크립트로만 api 모킹을 구성할 수 있습니다.

변경 전

import { setupServer } from 'msw/node'

const server = setupServer(
  rest.get('/api/movielist', (req, res, ctx) => {
    return res(ctx.json([
      {id: "s32v", year: 2021, title: 'Apple'},
      {id: "jbr2sv", year: 2022, title: 'Banana',}
    ]))
  })
)

...
beforeAll(() => server.listen());
afterAll(() => server.close());

변경 후

const createOrderServiceMock = () => {
  return {
    async getOrderInfos() {
      return Promise.resolve([
        {id: "s32v", year: 2021, title: 'Apple'},
        {id: "jbr2sv", year: 2022, title: 'Banana',}
      ])
    }
  }
}

it("OrderMovies 테스트", () => {
 const { getbyTestId } = render(
   <DependencyProvider serviceCreator={
     createOrderServiceMock
   }>
     <OrderMovies/>
   </DependencyProvider>
 )
})

response type

MovieListViewer는 setResponse를 컴포넌트 내부에서 호출하기 때문에 response 타입이 변경되면 이에 따라 리스폰스를 받아오는 로직과 타입 정보를 변경해줘야 합니다. 이런 구조의 컴포넌트가 10개면 10개, 100개면 100개에 변경점이 생깁니다.

리엑트에서는 어떻게 의존성 주입을 할 수 있나요?

앞서 예시코드에서 props, contextAPI를 사용하는 방법을 살펴 보았습니다.
이를 포함하여 리엑트에서 어떤 패턴들로 의존성 주입이 가능한지에 대해 간략하게 정리해보려고 합니다.

props

import 문을 통해서 바로 의존 객체를 참조할 수 있지만 props로 전달받아보겠습니다.

import { DependencyProvider as DI } from "./provider";
const App = ({ DI = DependencyProvider,  }) => {
  return (
    <DI>
      <Order {...props}/>
    </DI>
  )
}

App 컴포넌트에서는 default props 를 설정함으로 인해 DI를 명시적으로 주입할 경우 특정 용도로 사용할 수 있습니다.

<App DI={MockProvider} />

다음과 같이 static object/function으로도 주입이 가능합니다.

static object

const App = () => {
  const { useMovies } = App.dependencies;
}

App.dependencies = {
  useMovies 
}

static function

const App = () => {
  const { useMovies } = App.dependencies();
}

App.dependencies = injectDependencies({
  useMovies
})

context api

provider에서 상태를 제공할 수 있기에 이를 redux와 같은 스토어로 착각하는데에서 context api는 상태 관리 툴이다 라는 오해가 비롯된 것 같습니다.

상태 관리 툴이 되려면 상태 값을 업데이트 할 수 있어야 하는데 context api는 이러한 기능을 제공하지 않기 때문에 상태 관리 툴이 아닙니다.

context api는 상태관리 툴이 아닌 의존성 주입 도구로 보는 것이 더 타당합니다.
앞서 테스트 코드에서 살펴본 것 처럼 다음과 같이 의존성을 주입할 수 있습니다.

 <DependencyProvider serviceCreator={
     createOrderServiceMock
   }>
     <OrderMovies/>
   </DependencyProvider>

오늘은 리엑트에서의 의존성 주입에 대해 알아보았습니다.

긴 글 읽어주셔서 감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

3개의 댓글

comment-user-thumbnail
2022년 9월 4일

한 개발관련 유튜브로부터 관련주제를 다루고 난 후, 요즘 정말 관심을 가지고 있는 주제였는데요. 좋은 글 감사합니다. 영상도 모두 시청해야겠군요!

1개의 답글
comment-user-thumbnail
2022년 9월 12일

After dealing with a related topic from a development related YouTube, it was a topic I was really interested in these days. T SEO AGENCY IN KARACHI hanks for the good article. You should watch all the videos too!

답글 달기