React 에서 의존성 주입 활용하기

우현민·2024년 1월 12일
8

React

목록 보기
7/10
post-thumbnail

지난 글: TypeScript로 알아보는 의존성 주입에서 의존성 주입에 대해 알아봤습니다. 이번에는 React로 만든 Single Page Application 에서 실제로 의존성 주입을 활용하는 방법을 알아보려 합니다.

크게 두 가지 종류가 있습니다.

  • React 와 관련 없는 것들끼리 의존성 주입하기
  • React 컴포넌트 트리에 비즈니스 로직을 의존성 주입하기

하나씩 알아보겠습니다.

React 와 관련 없는 것들끼리 의존성 주입하기

React와 관련 없는 로직들은 뭐가 있을까요?

  • 데이터 페칭 라이브러리 axios
  • API 엔드포인트 /todos?todoId=1
  • Todo 아이템의 제목 포맷 업무 규칙 [${todo.id}] ${todo.title}

여기는 사실 지난 글과 크게 다르지 않습니다. 가령 api 엔드포인트를 알고 api를 호출해달라고 요청하는 모듈은 실제로 api 를 뭘 이용해서 쏠지와 관련이 없기 때문에, api를 실제로 쏘는 모듈을 주입받아야 합니다. api를 쏴 달라고 호출하는 모듈을 TodoRepository 라고 하고 api를 실제로 쏘는 모듈을 HttpClient 라고 하고 이렇게 설계해 보겠습니다.

// @/clients/HttpClient.ts
export type HttpClient = {
  get: <T>(path: T, options: { params: URLSearchParams }) => Promise<T>
};



// @/repositories/TodoRepository.ts
import { type Todo } from '@/entities/todo';

export type TodoRepository = {
  getTodo: (todoId: number) => Promise<Todo>;
};

그리고 구현체를 만들어 보겠습니다. 먼저 HttpClient의 구현체인 implementFetchClient 모듈입니다.

// @/infrastructures/implementFetchClient.ts
import { type HttpClient } from '@/clients/HttpClient';

export const implementFetchClient = (baseUrl: string): HttpClient => {
  return {
    get: (path, { params }) => {
      const response = await fetch(`${baseUrl}${path}?${params.toString}`);
      const data = await response.json();
      if (!response.ok) throw data;
      return data as T;
    },
  }
}

또한 TodoRepository 의 구현체인 implementHttpTodoRepository 모듈입니다.

// @/infrastructures/implementHttpTodoRepository.ts
import { type TodoRepository } from '@/repositories/TodoRepository';
import { type HttpClient } from '@/clients/HttpClient';
import { type Todo } from '@/entities/todo';

type Deps = { httpClient: HttpClient };
export const implementHttpTodoRepository = ({ httpClient }: Deps) => {
  return {
    getTodo: (todoId) => {
      const params = new URLSearchParams();
      params.set('todoId', `${todoId}`);
      return httpClient.get<Todo>('/todos', { params });
    },
  };
}

그리고 이 구현체들을 조합해줄 곳이 필요한데, 리액트 프로젝트에서는 main.tsx 파일이 진입점 역할을 담당하니 여기서 만들어 주겠습니다. 위에서 굳이 설명하지 않았던 코드들까지 포함하면 대강 이런 식일 것입니다.

// @/main.tsx
import ReactDOM from 'react-dom/client';
import { StrictMode } from 'react';
import { App } from './app/App';

import { implementFetchClient } from '@/infrastructures/implementFetchClient';
import { implementHttpTodoRepository } from '@/infrastructures/implementHttpTodoRepository'
import { implementTodoService } from '@/infrastructures/implementTodoService';
import { implementHttpAuthRepository } from '@/infrastructures/implementHttpAuthRepository'

const httpClient = implementFetchClient(process.env.VITE_API_BASE_URL);

const todoRepository = implementHttpTodoRepository({ httpClient });
const authRepository = implementHttpAuthRepository({ httpClient });

const todoService = implementTodoService({ todoRepository, authRepository });

const root = document.getElementById('root');
if (!root) throw new Error('root not found');

ReactDOM.createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>
);

이런 식으로 하면 React 와 관련 없는 모듈들 사이에서의 의존성 주입은 끝입니다.

그런데 이 로직들을 실제로 사용하려면 이들을 React 컴포넌트 트리로 주입해 줘야 할 것입니다. 다음 섹션에서는 그 대표적인 방법을 알아보겠습니다.



React 로 의존성 주입하기

React 컴포넌트 가 제공하는 가장 좋고 간단하고 편리한 의존성 주입 도구는 바로 props 입니다. props 는 함수 파라미터처럼 작동하고, 어떤 것도 추가적으로 import 하지 않아도 되기 때문에 훌륭한 의존성 주입 도구라고 볼 수 있는데요, 문제는 컴포넌트 트리가 깊어질수록 props의 코드량이 너무 많아진다는 것입니다.

// @/main.tsx
ReactDOM.createRoot(root).render(
  <StrictMode>
    <App todoService={todoService} authService={authService} userService={userService} />
  </StrictMode>
);



// @/app/App.tsx
...
return (
  <>
    <TodoPage todoService={todoService} authService={authService} userService={userService} />
    <UserPage authService={authService} userService={userService} />
    ...
  </>
);

코드를 이렇게 짜야 한다면 의존성 주입이고 뭐고 아무것도 하기 싫어질 것입니다. 실제로 너무 많은 중복과 반복이 일어나기에 좋은 코드라고 보기도 어렵습니다.

대신 React 는 Context API 라는 의존성 주입에 활용하기 최적화된 도구를 제공합니다.

잠깐: 왜 Context API가 의존성 주입 도구인가요?

context API는 Provider 를 통해 값을 주입해 주면, useContext 를 통해 해당 값을 구독하는 형태를 가집니다. 따라서 아래와 같은 구조가 가능합니다.

// @/contexts/MyContext.ts
export const MyContext = createContext<MyContextType>({});



// 어딘가에 있는 부모 컴포넌트
const Parent = () => {
  return (
    <MyContext.Provider value={{ age: 22 }}>
      ...
    </MyContext.Provider>
  );
};



// 어딘가에 있는 자식 컴포넌트
const Child = () => {
  const { age } = useContext(MyContext);
  
  return <div>나이: {age}</div>;
};    

Child 는 age 라는 값을 import 하지 않았고, Context API 를 통해 주입받았습니다. 즉 가령 Child 컴포넌트를 단위 테스트하고 싶다면, 이렇게 하면 됩니다.

  it("renders age", () => {
    const { getByText } = render(
      <MyContext.Provider value={{ age: 70 }}>
		<Child />
      </MyContext.Provider>
    );
    expect(getByText("나이: 70")).toBeInTheDocument();
  });

지난 글에서 살펴봤던 의존성 주입의 장점과 정확히 일치합니다. Context API 를 활용하면 컴포넌트가 해야 하는 일만을 명확하게 테스트할 수 있습니다.


다시 컴포넌트로

돌아와서, 아까의 예시에서 Context API를 의존성 주입에 활용하면 대강 이런 형태가 될 것입니다. 코드가 너무 길어지므로 import 문은 생략하겠습니다.

// @/contexts/ServiceContext.ts
export const ServiceContext = createContext<{
  todoService: TodoService;
  authService: AuthService;
  userService: UserSerivce;
}>(); // 이렇게 하면 타입 에러가 나긴 하는데, 이 글과 무관하니 일단 넘어갑시다
  


// @/main.tsx
const httpClient = implementFetchClient(process.env.VITE_API_BASE_URL);
const keyValueStorageClient = implementLocalStorageClient();

const todoRepository = implementHttpTodoRepository({ httpClient });
const authRepository = implementHttpAuthRepository({ httpClient, keyValueStorageClient });
const permanentStorageRepository = implementLocalStorageRepository();

const todoService = implementTodoService({ todoRepository, authRepository });
const authService = implementAuthService({ authRepository });
const userService = implemetnUserService({ authRepository, userRepository });

const root = document.getElementById('root');
if (!root) throw new Error('root not found');

ReactDOM.createRoot(root).render(
  <StrictMode>
    <ServiceContext.Provider value={{ todoService, authService, userService }}>
      <App />
    </ServiceContext.Provider>
  </StrictMode>
);



// @/.../some/my/component.tsx
export const TodoDetail = ({ id }: { id: number }) => {
  const [todo, setTodo] = useState<Todo>(); 
  const { todoService } = useContext(ServiceContext);
  
  useEffect(() => {
    let ignore = false;
    
    todoService.getTodo(id).then((res) => {
      if (!ignore) setTodo(res);
    });
    
    return () => {
      ignore = true;
    };
  }, [id, todoService]);
  
  return (
    <div>
      {todo ? todoService.formatTodoTitle(todo) : 'loading...'}
    </div>
  )
);

번외: Next.js 에서는?

요즘은 사실 React SPA 보다는 Next.js 가 많이 이용되는데요, 개인적으로는 pages router 기준으로 getServerSideProps 의 메인과 컴포넌트 트리의 메인을 분리해주는 방법이 좋았습니다.

제 경우에는

  • 컴포넌트 트리에서는 _app.tsx 에서 위 예시의 main.tsx 와 같은 일을 해 주었고,
  • getServerSideProps 는 모든 getServerSideProps 를 래핑하는 withServices() HOF 를 만들어서 이 친구를 getServerSideProps 들의 메인이라고 생각하고 페이지에서는 export const getServerSideProps = withServices(async (context, { todoService }) => { } 이런 식으로 활용했습니다.
    • 모든 페이지에서 withServices 를 임포트하기 때문에 어떻게 보면 모든 페이지에서 메인을 임포트하므로 좀 아쉬운 코드이긴 하지만.. Next.js 에서 더 나은 방법을 찾지 못했습니다.



결론

이렇게 React 에서 의존성 주입을 하는 간단한 예시를 알아보았습니다. React 컴포넌트 트리에서도 Context API 를 활용하여 의존성 주입을 훌륭하게 해낼 수 있으며, 이를 통해 더 변경에 유연하고 테스트가 용이한 코드를 작성할 수 있습니다.

profile
프론트엔드 개발자입니다

0개의 댓글