TypeScript로 알아보는 의존성 주입

우현민·2024년 1월 12일
6

dev

목록 보기
7/9
post-thumbnail

의존성 주입 (Dependency Injection) 은 프로그래밍에서 매우 중요하고 흔히 사용되는 개념입니다. 의존성 주입은 모듈간의 결합도를 낮추고 테스트를 쉽게 만들며 시스템의 안정성을 높여 줍니다. 이번 글에서는 의존성 주입의 개념과 React 에서 의존성 주입을 활용하는 법을 알아보겠습니다.

의존성 주입이란?

최근 만들어지는 많은 소프트웨어는 매우 거대합니다. 대부분 천 줄은 거뜬히 넘어가는데, 우리인간의 능력으로는 이 많은 코드를 한번에 관리하기 어렵습니다. 따라서 우리는 소프트웨어를 여러 모듈로 쪼개고, 소프트웨어는 모듈들의 상호작용으로 수행됩니다.

여기서 말하는 모듈이란 하나의 파일일 수도 있고, 함수일 수도 있습니다.

의존

모듈을 지원하는 언어들은 모듈을 불러오는 방법을 지원합니다. 가령 C나 C++에서는 #include , Java 에서는 import, JavaScript 에서는 importrequire 등입니다. 이렇게 모듈을 불러오면, 해당 모듈에 의존할 수 있습니다.

TypeScript 로 api call 을 하는 로직을 예로 들어 보겠습니다.

import axios from 'axios';

export const fetchTodo = (todoId: number) => {
  return axios.get<Todo>(`/todos`, { params: { todoId } });
};

우리가 자주 보던 코드입니다. fetchTodo 함수는 axiosimport 해서 이용합니다. 다시 말해, fetchTodo 함수는 axios 모듈에 "의존"합니다.


의존성 주입

반대로 axios 를 import 하지 않고도 fetchTodo 기능을 구현할 수 있습니다. 가령 아래와 같은 방법이 있겠습니다.

type ApiInstance = { 
  get: <T>(
    path: string,
    options: { params: Record<string, string> }
  ) => Promise<T> 
};

export class TodoService {
  private readonly apiInstance: ApiInstance;
  
  constructor(apiInstance: ApiInstance) {
    this.apiInstance = apiInstance;
  }
  
  fetchTodo(todoId: number) {
    return this.apiInstance.get<Todo>(`/todos`, { params: { todoId } });
  }
}

TodoService 모듈이 생성자에서 파라미터를 axios 가 아닌 apiInstance 라는 이름으로 받고, 타입 역시 다르게 받는 건 의도된 부분입니다. 이는 다음 섹션에서 더 자세히 살펴보겠습니다.

이해를 돕기 위해 class 문법을 썼지만, class 를 싫어하는 저 같은 사람들은 아래와 같은 문법을 더 좋아할 수도 있겠습니다.

type ApiInstance = { 
  get: <T>(
    path: string,
    options: { params: Record<string, string> }
  ) => Promise<T> 
};

export const implementTodoService = (apiInstance: ApiInstance) => {
  return {
    fetchTodo: (todoId: number) => {
      return apiInstance.get<Todo>(`/todos`, { params: { todoId } });
    },
  };
}

사실 class 든 함수든 상관없습니다. 일단은 설명 편의를 위해 많은 분들이 익숙해하시는 class 문법을 계속 활용하겠습니다. 중요한 class 인지 함수인지 아닌 의존 관계인데요,

TodoService 모듈은 axiosInstance 라는 함수에 의존하지 않습니다. 여기서 조금 헷갈릴 수 있는데, 단순하게 - axios 를 import 하지 않았으니 의존하지 않은 것입니다.

TodoService 라는 모듈은 누군가에게서 axiosInstance 를 "주입"받아야만 제 기능을 할 수 있습니다. 외부에서 fetchTodo 를 사용하고 싶은 누군가는 이 함수를 호출하기 위해 axiosInstance 모듈에 의존하여 아래와 같은 코드를 작성해야 할 것입니다.

import axios from 'axios';
import { TodoService } from '@/managers/TodoService';

const todoManager = new TodoService(axios);

이때, 위와 같이 모듈에 직접 의존해서 다른 모듈에 의존성을 주입해주는 로직은 흩어져 있지 않고 모여 있어야 의존성 주입을 극한으로 활용할 수 있습니다. 가령 아래와 같은 식입니다. 이유는 다음 섹션에서 알아보겠습니다.

import axios from 'axios';
import { storageInstance } from '@/utils/storageInstance';
import { TodoService } from '@/services/TodoService';
import { UserService } from '@/services/UserService';
import { AuthService } from '@/services/AuthService';

const todoService = new TodoService(axios);
const authService = new AuthService(axios, storageInstance);
const userService = new UserService(axios);

...

위와 같이 의존성을 주입해주는 로직이 모여 있는 모듈을 우리는 보통 메인 이라고 부릅니다. 메인은 일반적으로 시스템의 진입점이며, C 에서는 int main() {} 로, Java 에서는 public static void main(String[] args) {} 로, JavaScript/TypeScript 에서는 보통 index.ts 파일로 나타나곤 합니다. 리액트에 익숙한 분들이라면 main.tsx 를 생각하셔도 좋을 것 같습니다.

메인은 모든 구현체에 의존하는 가장 위험하고 더러운 로직으로서 구현체들을 호출한 다음 비즈니스 로직에게 제어권을 넘깁니다.

아무튼, 모듈을 import 하지 않고 파라미터든 생성자든 뭐든 전달받아 이용한다는 게 의존성 주입 개념의 전부입니다.



의존성을 주입하면 뭐가 좋을까?

위 코드만 봤을 땐, 왜 이렇게 짜야 하지? 라는 생각이 들 수 있습니다. 단순히 봐도 코드량이 많아진 것에 비해 장점이 체감되지는 않기 때문입니다.

변경이 쉽다

직접 의존하는 것이 많은 코드는 변경하기 어렵습니다. 첫 번째 코드를 다시 보겠습니다.

import axios from 'axios';

export const fetchTodo = (todoId: number) => {
  return axios.get(`/todos`, { params: { todoId } });
};

api call은 많은 곳에서 일어나기 때문에, 이런 로직이 todo, user, auth 등의 기능들에 엮여서 약 10개의 파일, 100개의 api에 구현되어 있을 것입니다.

만약 axios 에서 크리티컬한 버그가 발견되어서 더 이상 axios 를 이용하지 못하고 브라우저가 제공하는 fetch 를 이용해야 하게 되었다고 해 봅시다. 우리는 아래와 같은 마이그레이션을 해야 합니다.

그러니까.. 이 짓을 10개의 파일, 100개의 api에서 반복해야 한다는 것입니다. 상상만 해도 끔찍한 일입니다.

직접적으로 의존하는 코드가 많은 곳에 산재되어 있다면 변경할 때 이렇게 큰 고통이 따릅니다. 시간도 오래 걸릴 뿐더러 뭐 하나 놓쳐서 결함이 발생할 수도 있습니다.

반대로 의존성을 주입받도록 구현한 코드를 다시 볼까요? 의존성 주입을 하면 이렇게 의존성을 주입받는 비즈니스 코드와 의존성을 주입해주는 메인 코드가 있다고 했습니다.

// 비즈니스 코드
export class TodoService {
  private readonly apiInstance: ApiInstance;
  
  constructor(apiInstance: ApiInstance) {
    this.apiInstance = apiInstance;
  }
  
  fetchTodo(todoId: number) {
    return this.apiInstance.get<Todo>(`/todos`, { params: { todoId } });
  }
}
// 메인 코드
import axios from 'axios';
import { TodoService } from '@/managers/TodoService';

const todoManager = new TodoService(axios);

놀랍게도 TodoService 코드는 axios 랑 아무 관련이 없네요. 건드릴 게 하나도 없습니다. 다른 api call 파일들도 마찬가지로, 아무것도 건드리지 않아도 됩니다.

소스코드 전체를 통틀어서 고쳐줘야 하는 것은 의존성을 주입해주는 아래의 심부름꾼 코드 파일 단 한 개 뿐입니다.

앞에서 이야기했던 - 주입받는 것의 이름을 axios 라고 짓지 않은 이유가 여기서 드러납니다.


단위 테스트가 쉬워진다

또한 의존성 주입을 활용하면 단위 테스트를 하기가 쉬워집니다.

axios 에 직접 의존하는 fetchTodo 함수를 단위 테스트하려면 어떻게 해야 할까요? jest 로 구현한다면 이런 식일 것입니다.

jest.mock('axios');

describe('fetchTodo', () => {
  it('fetchTodo', async () => {
	axios.get.mockImplementation(() => Promise.resolve({ id: 1 }));
    const response = await fetchTodo(1);
    expect(axios.get).toBeCalledWith('/todos', { params: { todoId: 1 } });
    expect(response).toEqual({ id: 1 });
  });
});

테스트코드까지 axios 에 종속적이네요! axios의 내부 동작에 따라 테스트가 실패할 수도 있고, fetchTodo 에 대한 단위 테스트가 아닌 사실상 fetchTodo + axios 이렇게 두 개의 묶음에 대한 단위 테스트를 하고 있습니다.

반면 의존성 주입이 된 코드를 단위 테스트하는 것은 훨씬 깔끔하고 명확합니다.

describe('TodoService', () => {
  it('fetchTodo', async () => {
	const apiInstance = { get: jest.fn().mockImplementation(() => Promise.resolve({ id: 1 }) };
	const todoService = new TodoService(apiInstance);
    const response = await todoService.fetchTodo(1);
    expect(apiInstance.get).toBeCalledWith('/todos', { params: { todoId: 1 } });
    expect(response).toEqual({ id: 1 });
  });
});

위 테스트코드는 apiClient 의 로직과 아무 관련 없이 정말 TodoService 가 해야 하는 일만을 테스트합니다.



결론

이렇게 의존성 주입의 의미와 장점에 대해 알아보았습니다. 의존성 주입은 소프트웨어를 유연하고 변경하기 쉽고 테스트하기 쉽게 만들어주는 좋은 패턴입니다. 다음 글에서는 React 에서 의존성 주입을 하는 구체적인 예시를 살펴보겠습니다.

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

0개의 댓글