지난 글: TypeScript로 알아보는 의존성 주입에서 의존성 주입에 대해 알아봤습니다. 이번에는 React로 만든 Single Page Application 에서 실제로 의존성 주입을 활용하는 방법을 알아보려 합니다.
크게 두 가지 종류가 있습니다.
하나씩 알아보겠습니다.
React와 관련 없는 로직들은 뭐가 있을까요?
axios
/todos?todoId=1
[${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 컴포넌트 가 제공하는 가장 좋고 간단하고 편리한 의존성 주입 도구는 바로 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는 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>
)
);
요즘은 사실 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
를 활용하여 의존성 주입을 훌륭하게 해낼 수 있으며, 이를 통해 더 변경에 유연하고 테스트가 용이한 코드를 작성할 수 있습니다.