[Wanted]_Week2-2_의존성

hanseungjune·2023년 7월 7일
0

Wanted

목록 보기
12/21
post-thumbnail

다크 모드로 보는 것을 추천합니다.

의존성 역전 원칙(DIP)

의존성특정한 모듈이 동작하기 위해 다른 모듈을 필요로 하는 것을 의미합니다. 의존성 역전 원칙은 유연성이 극대화된 시스템을 구축하기 위한 원칙으로, 소스 코드 의존성이 추상에 의존하고 구체에는 의존하지 않아야 한다는 개념을 말합니다.

여기서 "추상""구체"라는 용어가 사용됩니다. "추상"은 구체적인 구현 방법이 포함되어 있지 않은 형태를 의미합니다. "추상"은 내부 구현에 대해 신경 쓰지 않고 해야 하는 일과 결과에만 집중할 수 있는 것을 말합니다.

반면에 "구체"는 실제로 동작을 수행하기 위해 필요한 구체적인 일련의 동작과 흐름을 의미합니다. 이러한 구체적인 동작은 자주 변경될 수 있기 때문에, 애플리케이션이 "구체"에 의존하게 되면 "구체"가 변경될 때마다 애플리케이션도 그에 맞춰 변경해야 한다는 의미가 됩니다.

실생활 예시로 스마트폰의 전화 기능을 들어보겠습니다. 우리는 스마트폰의 전화 앱을 실행하고 번호를 입력한 후 통화 버튼을 누르면 통화가 이루어진다는 것을 알고 있습니다. 하지만 내부적으로는 통신사가 요청을 받아 기지국을 찾고, 상대방의 전화번호와 연결된 기지국을 찾아 음성을 전달하는 등 구체적인 과정이 발생합니다.

우리가 어떤 스마트폰을 사용하고 어떤 통신사를 선택하든지 번호를 입력하고 통화 버튼을 누르는 추상은 변하지 않습니다. 그러나 통신사를 변경하면 통화를 연결하는 구체적인 프로세스와 기지국 등은 약간씩 다를 수 있습니다. 만약 통신사를 변경할 때마다 이러한 모든 프로세스를 일일이 변경해야 통화 기능이 동작한다면 사용자들은 통신사 변경을 포기할 가능성이 높아집니다.

이처럼 자주 변경되는 "구체"에 의존하는 것은 애플리케이션의 유지 보수를 어렵게 만들 수 있으므로, "추상"에 의존하고 "구체"에는 의존하지 않는 것이 좋습니다.

하지만, 우리가 일반적으로 코드를 작성하다보면 위와 같이 "구체"에 의존하는 경우가 자주 발생하게 됩니다.

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

위 코드는 두가지 문제가 있습니다.

  1. 현재 코드가 localStorage에 의존하고 있는 경우, 이는 특정 구체적인 저장소인 localStorage에 의존한다는 의미입니다. 이는 추후에 다른 저장소로 변경하기가 어려워지는 문제를 야기할 수 있습니다. 만약 코드에서 localStorage직접 의존하고 있다면, 다른 저장소로 전환하려면 모든 코드를 변경해야 할 가능성이 있습니다. 이는 유지 보수의 어려움과 유연성의 결여로 이어질 수 있습니다.

  2. localStorage브라우저에서 제공하는 API로, 우리가 개발한 애플리케이션 외부의 요소입니다. 이러한 외부 요소는 변화할 수 있으며, 더 큰 문제는 우리가 이러한 변화를 직접적으로 제어할 수 없다는 점입니다. 따라서 이러한 외부 요소에 직접 의존하는 것은 바람직하지 않습니다. 애플리케이션의 코드가 localStorage에 의존한다면, 브라우저 환경이나 다른 저장소로 전환하는 경우에 문제가 발생할 수 있습니다. 코드를 브라우저 외의 환경에서 실행하거나 다른 저장소를 사용하려면, 코드 전체를 수정해야 할 수도 있습니다.

물론, 애플리케이션을 개발할 때 외부 요소에 의존하지 않을 수는 없습니다. 브라우저에서 제공하는 기능을 활용해야 하거나 다른 외부 시스템과의 상호작용이 필요할 수 있습니다.

하지만 우리의 목표는 외부 요소에 직접적으로 의존하는 코드를 최소화하고, 애플리케이션 내에서 전체적인 제어권을 가져오는 것입니다.

먼저, localStorage를 이용해서 최종적으로 어떤 기능이 필요한지 “추상적”으로 정의해봅시다.

  1. 일단, Storage를 이용하는 이유는 Token을 관리하기 위해서입니다. 그렇다면 Token을 관리하기 위해서는 어떤 기능이 필요한지 생각해봅시다.
  2. Token을 관리하기 위해서 일반적으로 진행하는 작업은 크게 아래 3가지입니다.
    1. 저장
    2. 삭제
    3. 저장된 토큰을 가져오기

위 과정을 통해서 토큰을 이용해서 수행할 작업들이 정리되었습니다. 이제 이것을 코드형태로 정의해보겠습니다.

자바스크립트에는 추상적인 요소들을 정의할 수 있는 방법이 없기에 주석을 이용해서 표현하겠습니다.

/*
	TokenRepositoryInterface

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

Interface

  • Interface는 두 가지 다른 요소들이 소통하는 지점을 의미합니다. 이는 일종의 가이드라인이며, 약속된 메서드를 통해 상호작용을 정의합니다.
  • Interface는 추상적이며, 세부 구현 사항은 명시되어 있지 않습니다. 약속된 메서드의 이름, 입력 및 출력 형식 등을 정의하지만 구체적인 구현은 개발자의 자유입니다.
  • Interface를 사용하는 입장에서는 약속된 동작이 지켜지기만 하면, 구체적인 구현 방법에 대해 신경 쓸 필요가 없습니다. 중요한 것은 원하는 동작을 수행하고 그 결과를 얻는 것입니다.
  • Interface를 구현하는 입장에서는 약속된 동작을 지키면서 구현 방법을 자유롭게 선택할 수 있습니다. 중요한 것은 약속된 메서드를 제대로 구현하여 정의된 동작을 수행하는 것입니다.
  • Interface의 개념은 백엔드와의 API통신을 생각해보면 이해하기 쉽습니다. API는 다른 애플리케이션 간 소통을 위한 추상적인 Interface로, 약속된 동작을 통해 데이터를 주고받습니다. 세부 구현은 상대방에게 위임되며, 중요한 것은 약속된 동작에만 집중하는 것입니다.
  • 리액트와 같은 라이브러리에서도 API라는 용어를 사용합니다. 라이브러리는 개발하는 애플리케이션과 별개의 다른 프로그램으로, 제공하는 함수를 통해 해당 라이브러리를 사용합니다. 이 경우에도 중요한 것은 API에 정의된 동작에만 관심을 가지는 것입니다.

이제 저 TokenRepositoryInterface에 맞춰서 실제 우리가 원하는 기능들을 구체적으로 구현해보겠습니다.

/*
	TokenRepositoryInterface

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

class LocalTokenRepository {
  #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 LocalTokenRepository();


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

위와 같은 방식으로 코드를 변경하면 localStorage와의 의존성을 최소화하고, TokenRepository 클래스가 애플리케이션 내부 요소로 통제할 수 있게 됩니다. TokenRepository 클래스는 TokenRepository 인터페이스에서 정의된 메서드를 구현해야 하는 책임을 가지게 됩니다. 이는 TokenRepository 클래스가 TokenRepository 인터페이스에 의존한다는 것을 의미합니다.

이러한 변경으로 인해 애플리케이션 내의 의존 관계가 변화하게 됩니다. localStorage와의 의존성이 최소화되었으며, 구체적인 TokenRepository 클래스는 추상적인 TokenRepository 인터페이스에 의존하게 되었습니다.

이를 통해 코드의 유연성과 확장성이 개선되며, 구체적인 구현 사항이 변경되더라도 TokenRepository 인터페이스에 정의된 약속을 지키는 한 TokenRepository 클래스를 수정할 필요가 없어집니다.

만약 이 상황에서 외부 요소들이 변경되게 된다면 어떻게 될까요? 외부요소들이 변경되게 된다면 외부 요소들의 동작을 TokenRepositoryInterface에 맞춰서 다시 구현해주면 됩니다.

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를 그대로 사용하고 있던 코드의 실행흐름과 의존성의 방향은 아래와 같습니다.

  1. 실행 흐름:
    a. fetchlocalStorage
  2. 의존성의 방향:
    b. fetchlocalStorage

위와 같은 의존성이 설정되어있기에 localStorage가 변경되면 API 호출 코드 또한 변경되어야 합니다.

예를들어, 만약 localStoragegetItem이 아닌, takeItem으로 메소드명을 바꾼다고 하면, 위 코드는 다음과 같이 localStorage를 사용하고 있는 fetch 코드가 모두 직접 수정되어야 합니다.

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

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

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

실제 코드에서는 localStorage를 사용하고 있는 부분이 100곳이라면, 그 모든 코드를 다 찾아가며 일일이 수정해줘야 하는 상황이 발생합니다.

반면, Interface를 이용한 코드의 의존성의 방향과 실행 흐름은 아래와 같습니다.

/*
	TokenRepositoryInterface

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

class LocalTokenRepository {
  #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 LocalTokenRepository();


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

tokenRepository Interface를 이용해서 추상적인 요소로 의존성의 방향을 변경해버린 코드는 아래와 같은 호출 흐름과 의존성 방향을 가집니다.

  • 호출흐름: fetchtokenRepository Interface(추상) tokenRepositry Class(구체) → localStorage
  • 의존성방향: fetchtokenRepository Interface(추상) tokenRepositry Class(구체) → localStorage

위와 같은 의존성이 설정되어있기에 localStorage
가 변경되더라도, tokenRepository Interface가 변경되지 않는 한, tokenRepository를 사용하는 코드는 변경할 필요 없이, 구체적인 tokenRepository Class의 내용만 한번 수정해주면 됩니다.

예를들어, 만약 localStorage
getItem이 아닌, takeItem으로 메소드명을 바꾼다고 하면, 위 코드는 tokenRepository Class의 내용만 수정하면 되고, Interface에 전혀 영향을 주지 않습니다. 따라서, 이 interface에 따라서 사용하고 있는 나머지 코드는 전혀 영향을 받지 않게 됩니다.

/*
	TokenRepositoryInterface

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

class LocalTokenRepository {
  #TOKEN_KEY = "ACCESS_TOKEN";

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

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

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

const tokenRepository = new LocalTokenRepository();


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

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

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

이처럼 구체가 아닌 추상에 대한 의존성을 중간에 추가하게 되면 특정 시점에서 코드의 실행 흐름(제어 흐름)과 의존성이 방향이 반대로 뒤집히기에 이를 “의존성 역전 원칙(DIP)”이라고 부르며 IoC(Inversion of Control)이라고도 표현합니다.

  • 호출흐름: fetchtokenRepository Interface(추상) tokenRepositry Class(구체) → localStorage
  • 의존성방향: fetchtokenRepository Interface(추상) tokenRepositry Class(구체) → localStorage

DIP원칙을 적용하면 애플리케이션이 상대적으로 변경될 여지가 적은 추상적인 요소에 의존하도록 설계할 수 있으며, 변경될 여지가 많은 구체적인 요소에 직접적으로 의존하지 않을 수 있게 됩니다. 이 말은 곧 소프트웨어에 필연적인 다양한 변경에 대해서 손쉽게 대응할 수 있다는 의미입니다.

의존성 주입

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

  • 의존성 주입 X
class LocalTokenRepository {
  #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);
  }
}

class HttpClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
    this.tokenRepository = new LocalTokenRepository();
  }

  fetch(url, options = {}) {
    return window.fetch(`${this.baseURL}${url}`, {
      ...options,
      headers: {
        Authorization: this.tokenRepository.get(),
        ...options.headers,
      },
    });
  }
}

const httpClient = new HttpClient(process.env.BASE_URL)
  • 의존성 주입 O
class LocalTokenRepository {
  #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);
  }
}

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);
  }
}

class TestTokenRepository {
  constructor() {
    this.#token = null;
  }

  save(token) {
    this.#token = token;
  }

  get() {
    return this.#token;
  }

  remove() {
    this.#token = null;
  }
}

class HttpClient {
  constructor(baseURL, tokenRepository) {
    this.baseURL = baseURL;
    this.tokenRepository = tokenRepository;
  };

  fetch(url, options = {}) {
    return window.fetch(`${this.baseURL}${url}`, {
      ...options,
      headers: {
        Authorization: this.tokenRepository.get(),
        ...options.headers,
      },
    });
  }
}

// ver1
const localTokenRepository = new LocalTokenRepository();
const httpClient = new HttpClient(process.env.BASE_URL, localTokenRepository);

// ver2
const sessionTokenRepository = new SessionTokenRepository()
const httpClient = new HttpClient(process.env.BASE_URL, sessionTokenRepository);

// ver3
const testTokenRepository = new TestTokenRepository()
const httpClient = new HttpClient(process.env.BASE_URL, testTokenRepository);

의존성 주입을 적용하면 좋은 점은 해당 모듈에서 직접적으로 의존성을 가지고 있지 않기 때문에 모듈 내부의 코드는 전혀 건드리지 않고 모듈 외부의 일부 코드만 수정함으로서 동작을 변경할 수 있게 됩니다.

예를들어 의존성 주입을 하지 않은 경우에는 HttpClient 클래스에서 직접적으로 tokenRepository를 의존하고 있기에 관련된 동작을 변경하려면 HttpClient를 직접 수정해야 합니다.

하지만 의존성 주입을 이용해서 필요한 모듈을 클래스 내부에서 가지고 있는 것이 아니라, 클래스를 생성할 때 외부에서 주입하는 식으로 변경하게 되면 추후에 HttpClient의 코드 수정 없이 HttpClient에서 사용하는 tokenRepository와 연관된 동작을 쉽게 변경해서 다양하게 사용할 수 있게 됩니다. (OCP: Open-Closed Principle)

이는 곧 프로그램의 유연성, 테스트의 용이성, mocking등을 쉽게 활용할 수 있게 된다는 의미입니다.

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

const log = (data) => console.log(data);
log("Hello, World");

// --------------------------

const log = (logger, data) => logger(data);

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

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

그런데 리액트 애플리케이션을 설계하다보면 컴포넌트에도 의존성을 주입하고 싶은 욕구가 생깁니다. 하지만 리액트는 props를 통해서 단방향으로만 데이터를 전달할 수 있기에 의존성을 주입하기가 쉽지 않습니다. 이를 해결하기 위해서 Context API를 컴포넌트에게 의존성을 주입하는 용도로 사용할 수 있습니다.

Context API를 통해서 리액트 애플리케이션에 의존성을 주입하는 방법은 SRP, DIP, OCP의 개념과 함께 실습을 통해 알아보겠습니다.

profile
필요하다면 공부하는 개발자, 한승준

0개의 댓글