의존성이란 특정한 모듈이 동작하기 위해서 다른 모듈을 필요로 하는 것을 의미한다.
의존성 역전 원칙은 유연성이 극대화된 시스템
을 만들기 위한 원칙이다. 이 말은 곧 소스 코드 의존성이 추상에 의존
하며 구체에는 의존하지 않는 것
을 의미한다.
추상
이란 구체적인 구현 방법이 포함되어 있지 않은 형태를 의미한다. 내부가 어떻게 구현되어있는지 신경쓰지 않고 그냥 내가해줘야 하는 일
과 결과
만 신경쓸 수 있게 된다는 것이다.
구체
는 반대로 실질적으로 해당 동작을 하기 위해서 수행해야 하는 구체적인 일련의 동작과 흐름을 의미한다. 이런 구체적인 동작들을 굉장히 빈번하게 변경될 여지가 많다. 따라서 이러한 구체에 애플리케이션이 점점 의존하게 된다면 결국 구체가 변할 때 마다, 내 애플리케이션도 그에 맞춰서 변화해야 한다는 의미이다.
실생활의 예를 들면, 우리가 스마트폰의 전화 앱을 실행하고,
의 과정을 거치면 통화가 이루어진다는 것을 알고 있다.
하지만 저 내부적인 과정에서는 우리의 요청을 통신사가 받아서, 기지국을 찾고, 상대방의 전화번호와 연결된 기지국을 찾고 두개의 음성을 연결해서 실시간으로 전달해주는 구체적인 과정이 발생한다.
우리가 어떤 스마트폰을 사용하든, 그리고 어떤 통신사를 사용하든 번호를 입력하고, 통화버튼을 누른다는 추상
은 변하지 않는다. 하지만 통신사가 변경되면 통신사별로 통화를 연결할 때 사용하는 프로세스, 기지국등은 미묘하게 달라질 것이다. 만약 우리가 통신사를 변경할 때 마다 이러한 모든 프로세스를 일일이 맞춰서 변경해야지만 통화기능이 동작하게 되어있다면 대부분의 사용자들은 결국 통신사를 변경하지 않을 것이다.
이처럼 변화가 자주 발생하는 구체에 의존하는 것은 애플리케이션 구조 상 기피해야한다.
하지만, 일반적으로 코드를 작성하다보면 구체에 의존하는 경우가 자주 발생하게 된다.
fetch("todos", {
headers:{
Authorization:localStorage.getItem("ACCESS_TOKEN");
}
}
위 코드는 두가지 문제가 있다.
localStorage
라는 구체적인 사항에 의존하고 있다. 이는 storage를 추후에 다른 저장소로 변경하기 힘들다는 것을 의미한다.
localStorage
는 브라우저
에서 제공하는 API이다. 브라우저는 우리가 개발한 애플리케이션이 아닌 외부 요소이다. 이런 애플리케이션 외부의 요소들은 변화가 발생할 수 있으며, 가장 큰 문제는 어떤식으로 변화할지 컨트롤 할 수 없다는 점이다. 따라서 이런 요소들에 직접적으로 의존하는 것은 좋지 않다고 할 수 있다.
물론, 구체적인 요소에 하나도 의존을 하지 않고 애플리케이션을 만들 순 없다. 실질적으로 브라우저에서 제공하는 기능을 이용해야 한다는 사실을 무시할 순 없기 때문이다.
하지만, 이 외부 요소에 직접적으로 의존하는 코드를 최소화하고, 전체적인 제어권을 우리의 애플리케이션 안으로 가져올 순 있다.
먼저, localStorage를 이용해서 최종적으로 어떤 기능이 필요한지 추상적
으로 정의해보자.
localStorage
를 이용하는 이유는 Token
을 관리하기 위해서이다. 그렇다면 Token을 관리하기 위해서는 어떤 기능이 필요한지 생각해보자.
Token을 관리하기 위해서 일반적으로 진행하는 작업은 크게 아래 3가지 이다.
이것을 코드형태로 정의해보면,
interface TokenRepository {
save(token: string): void
get(): string
delete(): void
}
이 inteface
의 가장 큰 특징은 추상적
이라는 것이다. save, get, delete라는 3가지 메서드가 있어야한다는 것과 각각 어떤 input과 output을 가져야하는지는 정의해두지만 그 외에 구체적인 구현사항들은 하나도 명시되어있지 않다.
즉, 이 inteface를 사용하는 입장에선 inteface에서 정한 약속이 잘 지켜지기만 한다면 내가 해줘야 하는 일
과 결과
만 신경쓰면되고 세부 사항은 신경쓰지 않아도 된다는 것이다.
그리고 interface를 실제로 구체적으로 구현해야하는 입장에서는 interface에서 정한 약속이 잘 지켜지기만 한다면 내가 세부적인 구현방법을 어떻게 바꾸든지 아무런 상관이 없다.
이제 TokenRepository에 맞춰서 실제 우리가 원하는 기능들을 구체적으로 구현해보자.
interface TokenRepository {
save(token: string): void
get(): string
delete(): void
}
class LocalTokenRepository implements 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 LocalTokenRepository();
fetch("todos", {
headers:{
Authorization:tokenRepository.get();
}
}
위와 같은 방식으로 코드를 변경하게 되면 외부 요소
인 localStorage
는 LocalTokenRepository
에 의해서 관리된다. LocalTokenRepository Class
는 우리 애플리케이션 내부의 요소기에 우리가 통제할 수 있게 된다. 그리고 LocalTokenRepository Class는 TokenRepository interface에서 정의된 사항들을 모두 구현해줘야 할 책임이 있다.
즉, LocalTokenRepository Class가 TokenRepository interface에 의존
한다고 볼 수 있다.
외부 요소인 localStorage
에 대한 의존성을 최대한 줄어들었으며 구체적인 요소인 LocalTokenRepository Class는 추상적인 요소인 TokenRepository interface에 의존하게 된다.
만약 이 상황에서 외부 요소들이 변경되게 된다면 어떻게 될까? 외부요소들이 변경되게 된다면 외부 요소들의 동작을 TokenRepository interface에 맞춰서 다시 구현해주면 된다. sessionStorage로 변경되든, cookie로 변경되든 외부요소들이 어떻게 되든 상관없이 외부요소들은 무조건 save, get, remove라는 TokenRepository interface에 구현된 3가지 동작을 할 수 있어야 한다.
예를 들면, 만약 localStorage
가 getItem
이 아닌, 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를 이용한 코드의 의존성의 방향과 실행 흐름은 아래와 같다.
interface TokenRepository {
save(token: string): void
get(): string
delete(): void
}
class LocalTokenRepository implements 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 LocalTokenRepository();
fetch("todos", {
headers:{
Authorization:tokenRepository.get();
}
}
TokenRepository interface를 이용해서 추상적인 요소로 의존성의 방향을 변경해버린 코드는 아래와같은 호출 흐름과 의존성 방향을 가지게 된다.
호출흐름: fetch → TokenRepository interface(추상) → LocalTokenRepository Class(구체) → localStorage
의존성방향: fetch → TokenRepository interface(추상) ← LocalTokenRepository Class(구체) → localStorage
위와 같은 의존성이 설정되어있기에 localStorage가 변경되더라도, TokenRepository interface가 변경되지 않는 한, LocalTokenRepository Class의 내용만 한번 수정해주면 된다.
만약 localStorage
가 getItem
이 아닌, takeItem
으로 메소드명을 바꾼다고 하면, 위 코드는 LocalTokenRepository Class의 내용만 수정하면 되고, interface에 전혀 영향을 주지 않는다.
interface TokenRepository {
save(token: string): void
get(): string
delete(): void
}
class LocalTokenRepository implements TokenRepository {
#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)
이라고도 표현한다.
호출흐름: fetch → TokenRepository interface(추상) → LocalTokenRepository Class(구체) → localStorage
의존성방향: fetch → TokenRepository interface(추상) ← LocalTokenRepository Class(구체) → localStorage
DIP원칙
을 적용하면 애플리케이션이 상대적으로 변경될 여지가 적은 추상적인 요소에 의존하도록 설계할 수 있으며, 변경될 여지가 많은 구체적인 요소에 직접적으로 의존하지 않을 수 있게 된다. 이 말은 곧 유지보수
에 효율적이며 확장 가능성도 증가시킬 수 있다는 의미이다.
의존성 주입
이란 특정한 모듈에 필요한 의존성을 내부에서 가지고 있는 것이 아니라 해당 모듈을 사용하는 입장에서 주입해주는 형태로 설계하는 것을 의미합니다.
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)
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와 연관된 동작을 쉽게 변경해서 다양하게 사용할 수 있게 된다.
이는 곧 프로그램의 유연성, 테스트의 용이성, mocking 등을 쉽게 활용할 수 있게 된다는 의미한다.
여기서, 리액트 애플리케이션을 설계하다보면 컴포넌트에도 의존성을 주입하고 싶은 경우가 생긴다. 하지만 리액트는 props를 통해서 단방향으로만 데이터를 전달할 수 있기에 의존성을 주입하기가 쉽지 않다. 이를 해결하기 위해서 Context API
를 컴포넌트에게 의존성을 주입하는 용도로 활용할 수 있다.
횡단 관심사란 여러 서비스에 걸쳐서 동작해야 하는 코드를 의미한다. 관심사란 코드가 하고자 하는 목적, 동작을 의미한다고 했다. 이 개념을 확장해서 횡단 관심사
란 애플리케이션 내 여러 핵심 비지니스 로직들에 걸쳐서 실행되어야 하는 동작들
을 의미한다.
횡단 관심사의 대표적인 예시를 보자면 아래와 같다.
횡단 관심사를 잘 처리하는 것은 애플리케이션의 유지보수에서 중요하다. 왜냐하면 횡단 관심사는 여러 비지니스 로직들에 걸쳐서 실행되어야하기 때문이다. 만약, 핵심 비지니스 로직과 횡단 관심사가 혼재되어 버리면 추후 비지니스 로직, 횡단 관심사 모두를 수정하기 힘들어지는 상황이 발생한다.
예를 들어, 내가 인증 로직이 핵심 비지니스 로직 안에 혼재된다면 일단 해당 코드의 관심사가 여러개가 된다는 문제가 발생한다. 그리고 추후 인증 로직이 변경되었을때 이를 수정하기 위해서 여러 모듈들을 건드리거나, 수정해야 한다는 문제 또한 발생한다.
프론트엔드에서 가장 흔하게 생각할 수 있는 횡단 관심사는 인증 & 인가이다.
인증 & 인가는 내가 누구인지 증명하고, 그에 걸맞는 권한을 획득하는 행위를 의미한다. 우리는 흔히 백엔드 서버와 통신하고 통신에서 가장 기본적으로 사용하는 프로토콜은 HTTP이다.
HTTP는 stateless
라는 특징으로 인해 매 요청을 별개의 요청으로 처리한다. 따라서 HTTP 통신을 주고받을 때 내가 누구인지 증명하기 위해서는 프론트엔드에서 인증 정보를 매 통신마다 함께 전송
해줘야 한다.
인증은 특정한 비지니스 로직에 포함된 것이 아니라, 대부분의 HTTP 통신을 이용하는 비지니스 로직에 걸쳐서 이루어져야 하는 동작이다.
여러 핵심 비지니스 로직에 걸쳐서 수행되어야 하기에 이를 곧 횡단 관심사로 생각할 수 있다.