Enjoy My Posts

Vue에서 Axios를 DI하기(1)-객체 간 의존관계 고민

Posted on By Geunwon Lim

Axios는 node.js 환경에서 사용할 수 있는 Http Client 라이브러리 입니다. 프로젝트에서 Axios를 사용하여 api를 콜하려고 하는데, Vue에서는 DI를 제공하지 않아, 의존할 대상(Axios)을 클라이언트(여기에서는 Axios를 사용하는 코드)가 직접 생성해주거나, 프로그래머가 직접 DI를 해준다고 하더라도 의존 관계를 직접 관리해야하는 문제가 있었습니다. 이 문제를 해결하는 과정을 공유하겠습니다.

전체 코드는 이 github repo에서 확인하실 수 있습니다.

update: 코드가 수정되어 전체 코드를 확인할 수 있는 repo는 없습니다. 어떻게 추상화하려고 했고, 객체 간 의존관계를 어떻게 설정하려고 고민했는 지를 중점으로 봐주시면 좋겠습니다.

프로젝트 구조

프로젝트 구조는 크게

  • src
    • api
    • assets
    • components
    • config
    • provider
    • routes
    • store
    • types
    • views

로 되어있고, 이 구조는 https://vueschool.io/articles/vuejs-tutorials/structuring-vue-components/ 에서 참고한 일반적인 구조입니다.

DI의 필요성

  1. Axios를 사용(필요)할 때마다 새로 생성해서 여러 객체를 만들지 않고 싱글톤으로 관리해주기 위하여
  2. Axios 뿐 아니라 의존성을 가지는 다른 객체들도 특정 객체에 지나치게 의존하지 않도록 추상화해주기 위하여

DI를 해주기로 합니다. DI를 해주지 않는다면 Axios를 다음과 같이 사용해야 했겠죠.

src/api/user.api.ts

export class UserApi {
  public async anyFunc() {
     const axiosInstance = axios.create();
     axiosInstance.someFunc();
  }
}

이 때의 클래스 다이어그램은 다음과 같았을 것입니다.

그림1

이런 방식으로는 UserApi 말고 다른 Api를 새로 만들 경우 또 axiosInstance를 새로 만들어줘야 합니다. 따라서 불필요하게 객체를 여러번 생성할 것이고, 공통적인 baseURL을 가져 한번 생성해줘도 되는데도 코드 중복이 일어나겠죠. 또한 UserApi를 사용하는 객체는 UserApi가 axios를 새로 생성한 것처럼 UserApi를 직접 생성해줘야 하기 때문에, UserApi에 지나치게 의존하게 될 것입니다. 이를 해결해주기 위해 의존성 주입을 해주겠습니다.

DI의 필요성은 이 뿐 아니라 여러가지 있겠지만, DI에 대해서는 다음에 토비의 스프링을 리뷰할 때 자세히 다루도록 하겠습니다.

모듈 사용

찾아보니 의존성 주입을 도와주는 라이브러리 inversifyJS가 있었습니다.

이 부분은 수정되었습니다. 현재는 vue-typedi라는 라이브러리를 사용하고 있습니다.

npm install inversify reflect-metadata --save

설치하시고, readme의 가이드를 따라줍니다. 요약하면 주입할 타입을 지정하고, Container에서 타입과 구현체를 연결해주면 됩니다.

첫 번째 시도 - 지나친 추상화

저는 처음에 지나치게 추상화하여 코드를 다시 수정해야 했습니다. 처음 추상화할 때는 지나치다고 생각하지 않고, 오히려 잘 추상화했다고 생각했죠. DI의 목적 중 하나가 사용하는 대상에 지나치게 의존하지 않게 하는 것으로 알고 있기 때문에, Axios에 직접 의존하지 않고 추상화한 대상에 의존하게 하려고 했습니다. 즉, Axios에 지나치게 의존하지 않고 언제든 다른 http 클라이언트로 바꿀 수 있게 하려고 했습니다. 하지만 팀 리더가 이런 상황에서는 추상화하는 것이 오히려 독이 될 수 있다고 피드백해줘 코드를 수정하게 됩니다. 이 때의 클래스 다이어그램은 다음과 같습니다.

그림2

다음은 제가 처음 과도하게 추상화하려고 했던 코드입니다(간결하게 작성하기 위해 import 부분은 생략했습니다).

1. HttpAdapter 추상화

src/api/HttpAdapter/http.adapter.ts

export interface HttpAdapter {
  get<T>(url: string): Promise<T>;
}

먼저 http 클라이언트(여기에서는 Axios)를 추상화했습니다(get, post, put등 http 클라이언트가 가져야 하는 메소드는 모두 정의해야겠지만, 예시로 get만 해봤습니다). 이런 식으로 추상화하면 다른 코드(UserApi 등)이 아래와 같이 HttpAdapter에 의존하기 때문에

src/api/user.api.ts

@injectable()
export class UserApi {

  private httpAdapter: HttpAdapter;

  public constructor(@inject(TYPES.HttpAdapter) httpAdapter: HttpAdapter) {
    this.httpAdapter = httpAdapter;
  }
}

Axios가 좀 이상하다 싶으면 언제든 다른 클라이언트로 바꿀 수 있게 되죠.

그리고 아래와 같이 HttpAdapter의 구현체를 만들어줍니다. 이름을 HatchoutAdapter로 지은 이유는 call 하는 서버 이름이 Hatchout이기 때문이고, Axios를 추상화한 것인데도 AxiosAdapter라고 짓지 않은 이유는 Axios에 의존적이지 않은 이름을 만들어 언제든 다른 클라이언트로 바꿀 수 있게 하기 위함이었습니다.

src/api/HttpAdapter/hatchout.adapter.ts

@injectable()
export class HatchoutAdapter implements HttpAdapter {
  private instance: HttpAdapter;

  constructor(@inject(TYPES.AdapterFactory) adapterFactory: AdapterFactory) {
    this.instance = adapterFactory.create('http://localhost:3000');
  }
  public get<T = any>(url: string): Promise<T> {
    return this.instance.get(url);
  }
}

HatchOutAdapter가 adapterFactory에 의존하고 있는데, 이제 이걸 만들어줍니다. 이것 역시 Axios와 밀접한 연관 없이 만들었습니다.

src/api/HttpAdapter/adapter.factory.ts

export interface AdapterFactory {
  create(baseUrl: string): HttpAdapter;
}

이후 구현체를 만들어줍니다. 이게 진짜로 Axios와 연관된 것입니다.

src/api/HttpAdapter/axios.factory.ts

@injectable()
export class AxiosFactory implements AdapterFactory {
  public create(baseUrl: string): HttpAdapter {
    const axiosInstance: AxiosInstance = axios.create({
      baseURL: baseUrl,
    });
    return axiosInstance ;
  }

}

의존성을 연결해주면 돌아가는 코드가 완성됩니다.

src/provider/types.ts

export const TYPES = {
  HttpAdapter: Symbol.for('HttpAdapter'),
  AdapterFactory: Symbol.for('AdapterFactory'),
};

src/providers/container.ts

export const container = new Container();
container.bind<HttpAdapter>(TYPES.HttpAdapter).to(HatchoutAdapter);
container.bind<AdapterFactory>(TYPES.AdapterFactory).to(AxiosFactory);

처음 코드를 완성했을 때는 강하게 결합된 것들 없이 잘 짰다는 생각을 했지만, 팀 리더로부터 HttpAdapter의 추상화가 수정될 필요가 있다고 피드백을 받습니다. 이유는 HttpAdpater가 Axios의 기능을 전부 포함할 수 있게 추상화하기가 어렵다는 것이었습니다. 만약 정말 자신있어서 HttpAdapter가 Axios를 잘 포함하게 짤 수 있다면, 그렇게 해도 됩니다. 하지만 Axios의 방대한 기능을 전부, 깔끔하게 하기가 어렵다는 거죠. 또한 Axios같은 라이브러리는 chaining을 지원하는 경우도 있는데, 그런 기능을 추가하기는 쉽지 않죠. Axios로 Get 후, 체이닝으로 Header를 추가하려고 한다면? Axios를 직접 사용한다면 쉬운 기능이지만, HttpAdapter라고 추상화하는 순간 어려운 문제가 됩니다. 즉, Axios에 강하게 의존하는 걸 감안하더라도 Axios를 직접 쓰는게 장점이 많은 것이죠. 그래서 최종 코드는 다음과 같습니다.

문제점 보완 - Axios에 직접 의존

이제 Axios를 언제든 갈아치울 수 있게 하고싶다는 욕심은 버리고 Axios에 직접 의존하게 합니다. 목표는 다음과 같았죠.

src/provider/container/

export const container = new Container();
container.bind(AxiosInstance)(TYPES.AxiosInstance).to([Axios구현체]);

이런식으로 하는게 베스트라고 생각했는데, 어려움에 봉착합니다. axios.create()를 하던, 뭘 하던 다 타입이 AxiosInstance(interface)였기 때문에 Axios구현체에 넣을 수가 없었습니다. AxiosInstance를 implements하는 구현체를 만들면 되겠지만, 그건 또 힘겨운 과제가 될 거라고 생각해서 다음과 같이 해결합니다.

src/provider/container/

export const container = new Container();
container.bind<AxiosService>(TYPES.AxiosService).to(AxiosService);
container.bind<AxiosFactory>(TYPES.AxiosFactory).to(AxiosFactory);
container.bind<UserApi>(TYPES.UserApi).to(UserApiImpl);

이 때의 클래스 다이어그램은 다음과 같습니다.

그림3

먼저 AxiosService를 만들어주는데, Axios를 쓰고싶은 객체는 AxiosService에 의존해서 Axios를 받아오게 합니다. 이것이 Container의 역할을 하기 때문에 AxiosContainer라고 이름 지을까도 생각했는데, Service가 좀 더 일반적인 네이밍인 것 같아 AxiosService라고 지었습니다. 그리고 이제 Axios에 완전히 종속되는 걸 감안했기 때문에, 이름에 Axios를 넣기도 하고 interface로 추상화하는 과정도 없앴습니다.

다음은 이를 구현한 코드입니다.

src/api/axios/axios.service.ts

@injectable()
export class AxiosService {
  private axiosInstance: AxiosInstance;
  constructor(
    @inject(TYPES.AxiosFactory) axiosFactory: AxiosFactory,
  ) {
    this.axiosInstance = axiosFactory.create('http://localhost:3000/');
  }
  public get(): AxiosInstance {
    return this.axiosInstance;
  }
}

그리고 AxiosFactory를 만들었는데, 사실 AxiosService에서 Axios객체를 만드는 역할까지 해도 큰 문제 없겠지만, AxiosService가 Get이라는 역할에만 충실하게 하기 위해, 즉 관심사를 분리시키기 위해 factory를 만들었습니다.

src/api/axios/axios.factory.ts

@injectable()
export class AxiosFactory {
  public create(baseUrl: string): AxiosInstance {
    return axios.create({
      baseURL: baseUrl,
    });
  }
}

이제 UserApi같이 Axios를 쓰고싶은 객체는 다음과 같이 주입받으면 되죠.

src/api/user/

@injectable()
export class UserApiImpl implements UserApi {
  private axios: AxiosInstance;
  private domain = 'users';
  constructor(
    @inject(TYPES.AxiosService) axiosService: AxiosService,
  ) {
    this.axios = axiosService.get();
  }
}

이번 포스트에서는 Vue에서 Axios를 DI하는 과정을 다뤘습니다. 이 과정에서 라이브러리처럼 추상화하기 어려운 것을 어느 수준으로 추상화하는 게 좋은 지 고민해보았습니다. 또 객체 간 의존관계를 어떻게 설정하여 관심사를 분리할 지 고민해보았습니다.

읽어주셔서 감사합니다.