의존성 역전 원칙(DIP)과 의존성 주입

2022. 11. 7. 02:27Front-end

의존성 역전 원칙

의존성 역전 원칙(DIP)

의존성이란 특정한 모듈이 동작하기 위해서 다른 모듈을 필요로 하는 것을 의미한다.

의존성 역전 원칙은 유연성이 극대화된 시스템을 만들기 위한 원칙이다. 이 말은 곧 소스 코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 것을 의미한다. 여기서 추상과 구체라는 말이 나오는데, 추상이란 구체적인 구현 방법이 포함되어 있지 않은 형태를 의미한다. 추상이란 말이 어려울 수 있지만, 결국 그 내부가 어떻게 구현되어있는지 신경 쓰지 않고 해줘야 하는 일과 결과만 신경 쓸 수 있게 된다는 말이다.

 

구체는 반대로 실질적으로 해당 동작을 하기 위해서 수행해야 하는 구체적인 일련의 동작과 흐름을 의미한다. 이런 구체적인 동작들은 빈번하게 변경될 여지가 많다. 따라서 이러한 구체에 애플리케이션이 점점 의존하게 된다면 결국 구체가 변할 때마다, 애플리케이션도 그에 맞춰서 변화해야 한다.

 

실생활의 예를 통해 추상과 구체의 개념을 알아보자. 우리는 스마트폰으로 전화를 할 수 있다. 스마트폰의 전화 앱을 실행 후

번호를 입력한다  -> 통화 버튼을 누른다

 

위 과정을 거치면 통화가 이루어진다는 것을 알고 있다. 하지만 저 내부적인 과정에서는 우리의 요청을 통신사가 받아서, 기지국을 찾고, 상대방의 전화번호와 연결된 기지국을 찾고 두 개의 음성을 연결해서 실시간으로 전달해주는 여러 과정이 발생한다.

 

우리가 어떤 스마트폰, 통신사를 사용하든 번호를 입력하고 통화버튼을 누른다는 추상은 변하지 않는다. 하지만 통신사가 변경되면 통신사별로 통화를 연결할 때 사용하는 프로세스, 기지국 등은 미묘하게 달라질 것이다. 만약 우리가 통신사를 변경할 때마다 이러한 모든 프로세스를 일일이 맞춰서 변경해야지만 통화 기능이 동작하게 되어있다면 대부분의 사용자들은 통신사 변경을 꺼려하거나 포기하게 될 것이다.

 

이처럼 변화가 자주 발생하는 추상에 의존하는 것은 애플리케이션 구조 상 기피해야 할 항목이다. 일반적으로 코드를 작성하다 보면 다음과 같이 구체에 의존하는 경우가 자주 발생하게 된다.

fetch("todos", {
	headers:{
		Authorization:localStorage.getItem("ACCESS_TOKEN");
	}
}

위 코드는 localStorage라는 구체적인 사항에 의존하고 있다. 이는 Storage를 추후에 다른 저장소로 변경하기 힘들다는 것을 의미한다. localStorage는 브라우저에서 제공하는 API로, 브라우저는 우리가 개발한 애플리케이션이 아닌 외부 요소다. 이런 애플리케이션 외부의 요소들은 변화가 발생할 수 있으며, 가장 큰 문제는 어떤 식으로 변화할지 우리가 컨트롤할 수 없다는 점이다. 따라서 이런 요소들에 직접적으로 의존하는 것은 좋지 않다.

 

물론 구체적인 요소에 하나도 의존을 하지 않고 애플리케이션을 만들 수는 없을 것이다. 실질적으로 브라우저에서 제공하는 기능을 이용해야 하기 때문이다. 하지만 외부 요소에 직접적으로 의존하는 코드를 최소화하고 전체적인 제어권을 우리의 애플리케이션 안으로 가져올 수 있다.

 

localStorage를 이용해서 최종적으로 어떤 기능이 필요한지 추상적으로 정의해보자. 일단 Storage를 사용하는 이유는 토큰을 관리하기 위함이며, 토큰을 관리하기 위해 필요한 기능으로는 토큰을 저장하거나 삭제하고, 저장된 토큰을 가져오는 작업이 있을 것이다. 이제 이러한 작업을 코드 형태로 정의해보자. 자바스크립트에는 추상적인 요소들을 정의할 수 있는 방법이 없으니 주석을 이용해서 표현해보자.

/*
	TokenRepositoryInterface

	  save(token:string):void
	  get():string
	  delete():void
*/

여기서 사용한 Interface란 두 가지 다른 요소들이 소통하는 지점을 의미한다. 일종의 가이드라인이라고 볼 수 있다. 앞으로 Token을 사용할 때는 save, get, delete라는 세 가지 메서드를 통해서 소통하자고 정해두는 것이다.

 

Inteface의 가장 큰 특징은 추상적이라는 것이다. save, get, delete라는 세 가지 메서드가 있어야 한다는 것과 각각 어떤 input과 output을 가져야 하는지는 정의해두지만 그 외에 구체적인 구현 사항들은 하나도 명시되어있지 않다. Inteface를 사용하는 입장에선 Inteface에서 정한 약속이 잘 지켜지기만 한다면 해줘야 하는 일과 결과만 신경 쓰면 되고 세부 사항은 신경 쓰지 않아도 된다는 것이다.

 

Interface를 실제로 구체적으로 구현해야 하는 입장에서는 Interface에서 정한 약속이 잘 지켜지기만 한다면 내가 세부적인 구현 방법을 어떻게 바꾸든지 아무런 상관이 없다. 최종적으로 저 Interface에서 약속된 동작을 지켜주기만 하면 된다. Interface의 개념은 우리(프론트엔드)가 일반적으로 백엔드와 소통하는 API를 떠올려보면 이해하기 쉽다. API는 Application Programming Interface의 약자로, 각기 다른 Application끼리 소통하기 위한 추상적인 Interface를 정리해둔 것을 의미한다. 생각해보면 백엔드와의 통신은 Interface를 통해서만 진행된다.

 

프론트엔드에서 API를 사용할 때 /todo 엔드포인트로 호출을 하면 todolist가 응답으로 온다는 추상적인 동작만 가지고 백엔드와 통신을 시도한다. 세부적으로 백엔드가 어떤 DB를 사용하고 어떤 언어를 사용하고 어떤 프레임워크를 사용해서 저 기능을 구현했는지는 우리의 관심사가 아니다. 단지 우리가 신경 쓰는 것은 /todo로 요청하면 todolist가 응답으로 온다는 동작 자체에만 관심을 가지고 있다.

 

백엔드 입장에서도 API에서 정해둔 약속만 지킨다면 구체적인 사항인 언어나 라이브러리, 프레임워크 등은 얼마든지 변경할 수 있다. 예를 들어 API를 Node.js로 구현했든 Python으로 구현했든 Java로 구현을 했든 결과적으로 /todo에 요청이 들어오면 todolist만 응답을 해주면 되는 것이다.

 

이러한 의미에서 우리는 Interface란 용어를 자주 사용한다. 가장 자주 사용하는 상황인 프론트엔드와 백엔드의 소통 외에도 리액트 같은 라이브러리에서 제공해주는 여러 기능들도 API라고 부른다. 리액트와 같은 라이브러리는 우리가 개발하는 애플리케이션과 별개의 다른 프로그램으로 볼 수 있고, 그 안에서 어떻게 세부 동작이 이루어지는지 몰라도 라이브러리에서 제공해주는 기능들을 사용할 수 있기 때문이다.

 

다음은 예제 코드의 TokenRepositoryInterface에 맞춰서 원하는 기능들을 구체적으로 구현해보자.

/*
	TokenRepositoryInterface

	  save(token:string):void
	  get():string
	  remove():void
*/

class TokenRepository {
  #TOKEN_KEY = "ACCESS_TOKEN";

  save(token) {
    localStorage.setItem(this.#TOKEN_KEY, token);
  }

  get() {
    return localStorage.getItem(this.#TOKEN_KEY);
  }

  remove() {
    localStorage.removeItem(this.#TOKEN_KEY);
  }
}

const tokenRepository = new TokenRepository();

fetch("todos", {
	headers:{
		Authorization: tokenRepository.get();
	}
}

위와 같은 방식으로 코드를 변경하게 되면 외부 요소인 localStorage는 TokenRepository Class에 의해서 관리된다.

  • TokenRepository Class는 애플리케이션 내부의 요소이므로 우리가 통제할 수 있게 된다.
  • TokenRepository Class는 TokenRepository Interface에서 정의된 사항들을 모두 구현해줘야 할 책임이 있다.
  • 즉, TokenRepository Class는 TokenRepository Interface에 의존한다.

 

이제 애플리케이션 내에서의 의존관계는 변경되었다. 외부 요소인 localStorage에 대한 의존성이 최대한 줄어들었으며 구체적인 요소인 TokenRepository Class는 추상적인 요소인 TokenRepository Interface에 의존하게 되었다. 이 상황에서 외부 요소가 변경된다면 외부 요소들의 동작을 TokenRepository Interface에 맞춰서 다시 구현해주면 된다.

 

sessionStorage, cookie로 변경되거나 외부 요소들이 어떻게 변경되든 상관없이 외부 요소들은 무조건 save, get, remove라는 TokenRepositry Interface에 구현된 세 가지 동작을 할 수 있어야 한다.

class SessionTokenRepository {
  #TOKEN_KEY = "ACCESS_TOKEN";

  save(token) {
    sessionStorage.setItem(this.#TOKEN_KEY, token);
  }

  get() {
    return sessionStorage.getItem(this.#TOKEN_KEY);
  }

  remove() {
    sessionStorage.removeItem(this.#TOKEN_KEY);
  }
}

 

localStorage에 구체적으로 의존한 코드의 의존성 방향

API 호출 코드 → localStorage

 

localStorage에 구체적으로 의존한 코드는 localStorage가 변경되면 API 호출 코드 또한 변경되어야 하는 반면, TokenRepository Interface를 이용해서 추상적인 요소로 의존성의 방향을 변경한 코드는 다음과 같은 호출 흐름과 의존성 방향을 가진다.

호출 흐름: API 호출 코드 → tokenRepository Interface → tokenRepositry Class → localStorage
의존성 방향: API 호출 코드 tokenRepository Interface tokenRepositry Class localStorage

 

이처럼 특정 시점에서 코드의 실행 흐름(제어 흐름)과 의존성이 방향이 반대로 뒤집혔기에 이를 의존성 역전 원칙(DIP)이라고 부르며 IoC(Inversion of Control)이라고도 표현한다. 의존성 역전 원칙을 적용하면 애플리케이션의 변경 여지가 추상적인 요소에 의존하도록 설계할 수 있으며, 변경될 여지가 많은 구체적인 요소에 직접적으로 의존하지 않을 수 있게 된다. 이 말은 곧 소프트웨어의 필연적인 다양한 변경에 대해서 효과적으로 대응할 수 있다는 의미다.

 

의존성 주입

의존성 주입이란 특정한 모듈에 필요한 의존성을 내부에서 가지고 있는 것이 아니라 해당 모듈을 사용하는 입장에서 주입해주는 형태로 설계하는 것을 의미한다.

 

의존성 주입을 적용하지 않은 코드

import httpClient from "./httpClient";
import tokenRepository from "./tokenRepository";

class AuthService {
  signup(email, password) {
    httpClient
      .fetch("auth/signup", {
        method: "POST",
        body: JSON.stringify({
          email,
          password,
        }),
      })
      .then((res) => res.json())
      .then(({ access_token }) => tokenRepository.save(access_token));
  }

  login(email, password) {
    httpClient
      .fetch("auth/login", {
        method: "POST",
        body: JSON.stringify({
          email,
          password,
        }),
      })
      .then((res) => res.json())
      .then(({ access_token }) => tokenRepository.save(access_token));
  }

  logout() {
    tokenRepository.remove();
  }
}

const authService = new AuthService();

 

의존성 주입을 적용한 코드

import httpClient from "./httpClient";
import tokenRepository from "./tokenRepository";

class AuthService {
  constructor(httpClient, tokenRepository) {
    this.httpClient = httpClient;
    this.tokenRepository = tokenRepository;
  }

  signup(email, password) {
    this.httpClient
      .fetch("auth/signup", {
        method: "POST",
        body: JSON.stringify({
          email,
          password,
        }),
      })
      .then((res) => res.json())
      .then(({ access_token }) => this.tokenRepository.save(access_token));
  }

  login(email, password) {
    this.httpClient
      .fetch("auth/login", {
        method: "POST",
        body: JSON.stringify({
          email,
          password,
        }),
      })
      .then((res) => res.json())
      .then(({ access_token }) => this.tokenRepository.save(access_token));
  }

  logout() {
    this.tokenRepository.remove();
  }
}

const tokenRepository = new TokenRepositry();
const httpClient = new HttpClient(process.env.BASE_URL);
const authService = new AuthService(httpClient, tokenRepository)

의존성 주입을 적용하면 좋은 점은 해당 모듈에서 직접적으로 의존성을 가지고 있지 않게 되는 것이다. 예를 들어 의존성 주입을 하지 않은 경우에는 AuthService 클래스에서 직접적으로 httpClient, tokenRepositry를 의존하고 있기에 관련된 동작을 변경하려면 AuthService를 직접 수정해야 한다.

 

하지만 의존성 주입을 이용해서 클래스 내부에서 가지고 있는 것이 아니라, 클래스를 생성할 때 외부에서 주입하는 식으로 변경하게 되면 추후에 AuthService의 코드 수정 없이 AuthService에서 사용하는 httpClient, tokenRepositry와 연관된 동작을 쉽게 변경해서 다양하게 사용할 수 있게 된다.

 

이는 곧 프로그램의 유연성, 테스트의 용이성, mocking 등을 쉽게 활용할 수 있게 된다는 의미다. 보통 Class 단위에서 많이 사용되는 용어이기에 어려움을 느낄 수 있는데 익숙한 함수로 생각하면 된다. 함수의 경우에는 인자를 통해서 내부에서 사용할 요소를 전달받을 수 있는데, 동작을 내부에서 전체 다 가지고 있는 것이 아니라 외부에서 받을 수 있게 설정하면 훨씬 더 유용하게 사용할 수 있게 되는 것을 생각해보면 된다.

const handleConsoleLog = (data) => console.log(data);

handleConsoleLog("Hello, World");
const handleLog = (logger, data) => logger(data);

handleLog(console.log, "Hello, World");
handleLog(console.info, "Hello, World");
handleLog(console.warn, "Hello, World");
handleLog(console.error, "Hello, World");
handleLog(customLogger, "Hello, World");

기본적으로 Class의 경우에는 constructor(생성자)를 통해서, 함수의 경우에는 인자를 통해서 의존성을 주입하게 된다.

 

리액트에서 애플리케이션을 설계하다 보면 컴포넌트에도 의존성을 주입하고 싶을 수 있다. 그러나 리액트는 props를 통해 단방향으로만 데이터를 전달할 수 있기에 의존성을 주입하기가 쉽지 않은데, 리액트에서 제공하는 상태 관리 기능인 Context API를 이용해서 컴포넌트에 의존성을 주입할 수 있다.

반응형