orange4glace's profile image

orange4glace

June 18, 2019 00:15

# Dependency Injection과 Event Driven Design

Dependency Injection , Event Driven Design

필자는 개인 프로젝트인 Olive를 개발하면서 디자인 관점에서 일어나는 문제들에 대해 고민해보는 시간을 가졌습니다.

Premiere Pro 의 보급형 레벨을 목표로 하는 현 프로젝트는 수 많은 뷰들이 존재하고, 여러개의 모델들, 이를테면 타임라인 모델, 드로잉 모델, 리소스 모델 등이 한 데 어우러져야만 모양이라도 내볼 수 있는 수준입니다. 지금까지 다양한 웹 어플리케이션들을 개발해왔지만, 대부분이 한 두개의 뷰(View)를 중심으로 한 두개의 핵심 모델만을 구현하는 정도의 레벨이었습니다. 나름 어느정도 수준의 프로그램들을 많이 개발해왔다고 생각했었지만, 지금의 시점에서 그 프로그램들을 생각해보면 여태까지의 필자의 코드 디자인에 대한 능력이 얼마나 부족했는지에 대해 생각하게 됩니다.

프로젝트를 진행하면서 가장 먼저 떠오른 디자인 문제는, “어떤 뷰에서 일어나는 행동이 다른 뷰의 상태에 영향을 끼치게 하려면 어떻게 해야 할까” 였습니다. 간단하게 일정을 관리하는 프로그램으로 예를 들면, 달력을 표시하는 뷰에서 날짜를 선택하면, 상세 일정을 표시해주는 뷰가 해당 날짜의 일정들을 표시하도록 하려면 어떻게 해야하는지 정도로 말할 수 있을 것 같습니다. 물론 가장 쉬운 방법은 달력 뷰상세 일정뷰를 참조하는 상태로 만들어, 달력 뷰에서 직접 상세 일정뷰의 메소드를 호출하면 됩니다. 하지만 이럴 경우 두 뷰 사이에 직접적인 의존 관계가 생기게 됩니다. 다시 말해 커플링이 증가하게 되고, 높은 커플링은 디자인 관점에서 지양해야할 요소이죠.

어떻게 이 디자인 문제를 해결할까 고민하다, 코드 편집기로 사용중이던 VSCode에서 해답을 얻게 됩니다. Olive는 Electron 기반의 Typescript 프로젝트인데, VSCode 역시 Electron 기반의 Typescript 프로젝트입니다. 덕분에 코드를 뜯어보며 많은 지식들을 얻을 수 있었습니다.

문제를 해결할 가장 초석이 되는 키워드는 Dependency Injection입니다. Dependency Injection은 가장 먼저 접하게 되는 디자인 패턴이고, 매우 심플한 디자인이지만 쉽게 와닿지 않는 디자인이라고 생각합니다. 필자는 이 디자인 패턴을 Angular를 배울 때 가장 처음 접했는데, 당시에는 DI를 쓰면서도 DI가 뭔지 제대로 모르고 사용했었습니다. 지금 생각해보면 DI는 Singeton 같은건가? 라는 어처구니 없는 생각을 하면서 썼던 기억이 납니다.

Dependency Injection의 디자인 구조를 살펴보면 정말 간단합니다. 전통적인 예제인 자동차 예제를 통해 살펴보면 다음과 같습니다.

Car는 하나의 Wheel을 가지고 있다고 해보죠.

우선 DI가 없는 버전의 Car 클래스입니다.

class Wheel {
	quality: number;
	roll(): void {
		console.log("I'm Rolling!");
	}

	constructor(quality: number) {
		this.quality = quality;
	}
}

class Car {
	private wheel: Wheel;

	constructor() {
		this.wheel = new Wheel(4);
		this.wheel.roll();
	}
}

const car = new Car();

Car 클래스 내에서 Wheel 객체를 직접 생성해주는 모습입니다.

다음은 DI가 있는 버전의 Car 클래스입니다.

interface IWheel {
	quality: number;
	roll(): void;
}

class Wheel implements IWheel {
	quality: number;
	roll(): void {
		console.log("I'm Rolling!");
	}

	constructor(quality: number) {
		this.quality = quality;
	}
}

class Car {
	private wheel: IWheel;

	constructor(wheel: IWheel) {
		this.wheel = wheel;
		this.wheel.roll();
	}
}

const wheel = new Wheel(4);
const car = new Car(wheel);

이번에는 IWheel 이라는 interface가 있고, Wheel 클래스는 이 IWheel을 implements 키워드를 통해 구현하고 있습니다. 그리고 Car 클래스를 생성할 때 IWheel 객체를 받아, 그 객체를 자신의 wheel로 사용하고 있습니다.

한국어로는 의존성 주입 이라고 하듯이, 필요한 객체를 자기가 직접 생성하는게 아니라, 외부로부터 주입받도록 하는것이 Dependency Injection의 전부입니다. 그렇다면 이 과정에서 왜 IWheel 이라는 인터페이스를 정의해준 것 일까요? Car 의 생성자를 보면 생성자로 Wheel을 받는것이 아니라 IWheel을 받고 있습니다. 즉 IWheel을 구현하는 객체라면 어떤 것이든 받을 수 있죠. 만약 여러분이 해당 Car에 기존의 Wheel과 다른 구현을 가진 새로운 Wheel, 예를 들어 roll 메소드의 작동 방식이 다른 Wheel을 가진 Car을 만들고 싶다면 IWheel을 구현하는 새로운 클래스를 만들고 해당 객체를 주입시켜주기만 하면 됩니다.

class NewWheel implements IWheel {
	quality: number;
	roll(): void {
		console.log("I'm NOT rolling!");
	}

	constructor() {
		this.quality = 0;
	}
}

const newWheel = new NewWheel();
const car = new Car(newWheel);

사실 위 예제만 봐서는 Dependency Injection이 어떻게 유용하게 쓰일 수 있는지 생각하긴 어렵습니다. 이해를 돕기 위해 Dependency Injection을 기반으로 해서 아주 간단한 달력 앱을 하나 만들어보도록 하죠. 최종 완성 소스는 여기서 확인하세요.

1

완성된 앱은 이렇게 생겼습니다. 달력 앱이라곤 했지만 1일부터 30일까지 버튼이 있고, 버튼을 누르면 밑에 메세지가 뜨는 방식이죠. 별 기능이 없는 만큼, 다양한 디자인을 통해 해당 기능을 구현할 수 있을 겁니다. 하지만 여기서는 Dependency Injection과 Event Driven Design을 이용해서 만들어보도록 하겠습니다.

우선 달력을 렌더링하는 Calendar 클래스와 메세지를 렌더링하는 DetailView 클래스를 만듭니다.

// calendar.ts
export class Calendar {

  private numberOfDate_: number;

  constructor(
    numberOfDate: number) {
    this.numberOfDate_ = numberOfDate;
  }

  private renderDate(date: number): HTMLElement {
    const div = document.createElement('div');
    div.className ='date';
    div.textContent = date.toString();
    return div;
  }

  public render(container: HTMLElement) {
    const div = document.createElement('div');
    div.className ='calendar';
    for (let i = 1; i <= this.numberOfDate_; i ++) {
      const dateEl = this.renderDate(i);
      div.appendChild(dateEl);
    }
    container.appendChild(div);
  }

}
// detail-view.ts
import { ICalendarService } from "calendar-service";

export class DetailView {

  private contentContainer_: HTMLDivElement;

  constructor() {
    this.contentContainer_ = document.createElement('div');
    this.contentContainer_.className ='detail';
  }

  public showText(text: string) {
    const el = document.createElement('div');
    el.textContent = text;
    this.contentContainer_.innerHTML = '';
    this.contentContainer_.appendChild(el);
  }

  render(container: HTMLElement) {
    container.appendChild(this.contentContainer_);
  }

}

그리고 메인 스크립트에서 CalendarDetailView를 렌더링하도록 합니다.

// index.ts
import { Calendar } from "calendar";
import { DetailView } from "detail-view";

const calendar = new Calendar(30);
const detailView = new DetailView();

calendar.render(document.body);
detailView.render(document.body);

이제 Calendar에서 각 Date element의 Click 이벤트를 추가하여 element가 클릭될 때 DetailView가 해당 Date에 해당하는 메세지를 띄우도록 해주면 됩니다.

가장 간단한 방법은 Calendar 클래스가 DetailView 를 멤버 변수로 가지게 하여 클릭 이벤트가 발생하면 Calendar 클래스가 직접 DetailView 에게 텍스트를 표시하도록 지시하면 됩니다.

// calendar.ts
// #우리가 원하지 않는 디자인 패턴
export class Calendar {

  private detailView_: DetailView;
  private numberOfDate_: number;

  constructor(
    // DetailView를 생성 인자로 넘겨줍니다
    detailView: DetailView,
    numberOfDate: number) {
    this.detailView_ = deailView;
    this.numberOfDate_ = numberOfDate;
  }

  private renderDate(date: number): HTMLElement {
    const div = document.createElement('div');
    div.className ='date';
    div.textContent = date.toString();
    // Click 이벤트를 추가합니다.
    div.addEventListener('click', () => {
      this.detailView.showText(`You have clicked ${date}!`)
    });
    return div;
  }

  public render(container: HTMLElement) {
    ....
  }

}
// index.ts
// #우리가 원하지 않는 디자인 패턴
import { Calendar } from "calendar";
import { DetailView } from "detail-view";

const detailView = new DetailView();
// detailView를 생성 인자로 넘겨줍니다
const calendar = new Calendar(30, detailView);

calendar.render(document.body);
detailView.render(document.body);

이제 앱은 우리가 원하는대로 작동할 것입니다. 하지만 이로 인해 CalendarDetailView가 없으면 아무짝에도 쓸모없는 녀석이 되었습니다. 또한, Calendar를 생성하려면 반드시 DetailView 객체에 대한 참조를 어떻게든 가지고 있어야 하죠. 지금과 같이 단순한 구조에서는 CalendarDetailViewindex.ts 파일 내에서 함께 생성되기 때문에 객체에 대한 참조를 자유롭게 가져올 수 있지만, 만약 두 View가 복잡한 구조로 이루어진 트리에서 매우 동떨어진 위치에 있다면, Calendar에게 DetailView를 전달하기 위해 꽤나 복잡하고 더러운 방법을 써야 할 것 입니다.

이 문제를 해결하기 위해, 디자인 구조를 Event Driven Design으로 변경해보겠습니다. 사실 이 디자인의 이름이 Event Driven Design인지 아닌지 정확히는 모릅니다만, 어쨌든 Event를 중심으로 한 디자인이니 적어도 틀린 말은 아닐겁니다.

export class Calendar {

  private readonly onDateClick_: Emitter<number> = new Emitter();
  public readonly onDateClick: Event<number> = this.onDateClick_.event;

  ...

Calendar 클래스의 멤버로 onDateClick이라는 EmitterEvent를 주었습니다. Emitter는 이벤트를 발생시키는 객체이고, Event는 해당 이벤트가 발생했을 때 행동을 등록할 수 있는 리스너입니다. 일반적으로 우리가 알고 있는 이벤트 객체와 다름없습니다. 두 클래스는 VScode 소스에서 가져왔습니다.

private renderDate(date: number): HTMLElement {
  const div = document.createElement('div');
  div.className ='date';
  div.textContent = date.toString();
  div.addEventListener('click', () => this.onDateClick_.fire(date));
  return div;
}

이제 Date element가 클릭됐을 때 행동을 정의합니다. 이전의 디자인에서는 클릭됐을 때 직접 DetailView의 메소드를 호출했지만, 이번에는 onDateClick 이벤트를 발생시키도록 합니다.

그러면 이 onDateClick 이벤트를 누가 들어줘야 하겠죠. 누가 들어줄까요? DetailView가 들어줄까요? 아닙니다. 생각해보세요. DetailViewCalendaronDateClick 이벤트를 들으려면 결국 DetailViewCalendar 객체에 대한 참조를 들고 있어야 합니다. 앞서 살펴본 원하지 않는 디자인 에서 CalendarDetailView 객체를 참조하고 있는 반대의 상황이 됐을 뿐, 두 객체 간 직접적인 참조가 발생해 결국 이전과 같은 문제가 발생합니다.

대신 우리는 CalendarService 라는 하나의 서비스 클래스를 만듭니다.

// calendar-service.ts
import { Event, Emitter } from "base/common/event";
import { Calendar } from "calendar";

export interface ICalendarService {

  readonly onDidSelectDate: Event<number>;

  addCalendar(calendar: Calendar): void;

}

export class CalendarService implements ICalendarService {

  static readonly ID = 'CalendarService';

  private readonly onDidSelectDate_: Emitter<number> = new Emitter();
  public readonly onDidSelectDate: Event<number> = this.onDidSelectDate_.event;

  addCalendar(calendar: Calendar): void {
    calendar.onDateClick(date => this.onDidSelectDate_.fire(date));
  }

}

CalendarServiceCalendar와 비슷하게 onDidSelectDate 이라는 EmitterEvent를 가지고 있습니다. CalendarServiceaddCalendar 메소드를 통해 Calendar를 추가하면, CalendarService는 해당 CalendaronDateClick 이벤트가 발생했을 때 자신의 onDidSelectDate 이벤트를 다시 한번 발생시키게 됩니다.

우선 CalendarServiceCalendar 를 등록하도록 해봅시다.

// calendar.ts
export class Calendar {
  ...
  constructor(
    numberOfDate: number,
    calendarService: ICalendarService) {
    this.numberOfDate_ = numberOfDate;
    calendarService.addCalendar(this);
  }
  ...

Calendar의 생성자로 ICalendarService를 인자로 받고, 생성자에서 해당 서비스에 자신을 등록하도록 만들었습니다. 이제 Date가 클릭될 떄 마다, CalendarServiceonDidSelectDate 이벤트가 발생합니다.

거의 다 왔네요. 이제 DetailViewICalendarServiceonDidSelectDate 이벤트에 반응하도록 하면 됩니다.

// detail-view.ts
import { ICalendarService } from "calendar-service";

export class DetailView {
  ...
  constructor(
    calendarService: ICalendarService) {
    this.contentContainer_ = document.createElement('div');
    this.contentContainer_.className = 'detail';
    calendarService.onDidSelectDate(this.didSelectDateHandler.bind(this));
  }

  private didSelectDateHandler(date: number) {
    this.showText(`You have clicked ${date}!`);
  }
  ...
}

마지막으로, CalendarDetailView를 생성할 때 CalendarService를 넘겨주도록 합니다.

import { CalendarService } from "calendar-service";
import { Calendar } from "calendar";
import { DetailView } from "detail-view";

const calendarService = new CalendarService();

const calendar = new Calendar(30, calendarService);
const detailView = new DetailView(calendarService);

calendar.render(document.body);
detailView.render(document.body);

이렇게 우리가 원하는 디자인이 완성되었습니다.

마지막 단계에서 ICalendarService를 각 생성자에 넘겨주는 행위를 Dependency Injection으로 해석할 수 있습니다. 상황이 바뀌어 서비스의 행동을 다르게 변경하고 싶다면, 이를테면 테스트를 위해 addCalendar 에 Mock 데이터를 넣어줘야 한다면, 테스트만을 위한 ICalendarService를 구현하는 클래스를 새로 만들어 넣어주면 됩니다.

하지만 아직 뭔가 좀 찝찝합니다.

우리가 이전 예제에서 ICalendarService라는 서비스를 Dependency Injection한 것 처럼, 서비스는 Dependency Injection과 거의 항상 붙어다니는 단짝입니다. 예를 들면 프로그램 로그를 출력하기 위해 ILogService라는 서비스를 작성했다고 하면, 해당 서비스가 필요한 객체의 생성자에 ILogService를 넘겨주어, 즉 Dependency Injection하여 필요한 부분에서 로그를 찍도록 할 수 있습니다.

그런데 생각해보면 결국 Dependency Injection이 일어나는 시점은 객체의 생성자가 호출되는 시점입니다. 어떠한 객체를 생성자의 인자로 넘겨줌으로써 Dependency Injection이 일어나게 되는데, 어떤 객체가 ILogService를 주입받는 새로운 객체를 생성한다고 하면 그 어떤 객체는 새로운 객체를 생성할 때 필요한 ILogService 객체에 대한 참조를 가지고 있어야 합니다. 설령 그 어떤 객체 스스로에게 있어서는 ILogService가 필요없더라도, 자신이 생성해야하는 객체가 ILogService를 필요로 한다면 ILogService를 가지고 있어야 하는 불편한 상황이 발생하게 되죠.

이를 해결하기 위해 우리는 서비스들을 관리하는 서비스 컬렉션을 만들 수 있습니다.

// service-collection.ts
export class ServiceCollection {

  private services_: Map<string, any> = new Map();

  public addService(serviceID: string, service: any): void {
    this.services_.set(serviceID, service);
  }
  public getService<T>(serviceID: string): T {
    return this.services_.get(serviceID);
  }

}

그리고 이 서비스 컬렉션에 필요한 서비스들을 넣고, 모든 객체들을 생성할 때 ServiceCollection을 인자로 넣어준 뒤, 각 객체들은 전달받은 ServiceCollection에서 필요한 서비스들을 가져와 사용하면 됩니다.

// index.ts
...
const serviceCollection = new ServiceCollection();

const calendarService = new CalendarService();
serviceCollection.addService(CalendarService.ID, calendarService);

const calendar = new Calendar(30, serviceCollection);
const detailView = new DetailView(serviceCollection);
...
// calendar.ts
export class Calendar {
  ...
  constructor(
    numberOfDate: number,
    serviceCollection: ServiceCollection) {
    this.numberOfDate_ = numberOfDate;
    serviceCollection.getService<CalendarService>(CalendarService.ID).addCalendar(this);
  }
  ...

괜찮긴 합니다만, 여전히 몇 가지 문제가 보이네요.

첫 번째로 모든 클래스는 의무적으로 생성자 인자로 ServiceCollection 이라는 객체를 받아야 합니다. 다시 말해 모든 클래스가 ServiceCollection과 직접적인 의존 관계가 생겨버리고 말았습니다.

두 번째로 클래스들이 생성자로 ServiceCollection 받고 이후 생성자 구현체 내에서 필요한 서비스들을 가져다 쓰기 때문에, 각각의 클래스가 어떤 서비스들에 의존하고 있는지 한 눈에 파악하기가 어렵습니다.

이 문제를 해결하기 위해 서비스를 관리하는 서비스를 만들겁니다.

// instantiation-service.ts
import { ServiceCollection } from "service-collection";

export class InstantiationService {

  private serviceCollection_: ServiceCollection;

  constructor(serviceCollection: ServiceCollection) {
    serviceCollection.addService('InstantiationService', this);
    this.serviceCollection_ = serviceCollection;
  }

  createInstance<T>(ctor: any, ...args: any[]): T {
    const dependencyIDs = ctor['DEPENDENCIES'] || [];
    const dependencies: any[] = [];
    for (let i = 0; i < dependencyIDs.length; i ++) {
      const dependencyID = dependencyIDs[i];
      const dependency = this.serviceCollection_.getService(dependencyID);
      if (!dependency) throw new Error('Unknown dependency! ' + dependencyID);
      dependencies.push(dependency);
    }
    args = args.concat(dependencies);
    return new ctor(...args)
  }

}

InstantiationServiceServiceCollection을 생성 인자로 받습니다. 즉 서비스 컬렉션을 가지고 있는 서비스입니다. 이 서비스는 createInstance라는 메소드를 가지고 있습니다. 우리는 이 메소드를 호출하여 CalendarDetailView 클래스를 생성할 것 입니다.

createInstance 는 첫 번째 인자로 생성하고자 하는 클래스의 생성자를, 나머지 인자로 해당 클래스 생성자가 받는 인자를 넘겨 받습니다. 넘겨받은 생성자의 DEPENDENCIES 값을 가져옵니다. 해당 값은 string 배열로, 해당 클래스가 필요로 하는 서비스 아이디들이 저장되어 있습니다. 해당 서비스 아이디들을 ServiceCollection에서 조회하여 서비스들을 가져옵니다. 가져온 서비스 배열들을 넘겨받은 기존 인자 배열뒤에 붙여서, 최종적으로 해당 클래스를 new 키워드를 통해 생성하게 됩니다.

그러면 생성자가 가지고 있는 DEPENDENCIES 는 언제 정의될까요? 비밀은 Typescript decorator에 있습니다.

// calendar-service.ts
export function ICalendarService(ctor: any, methodName: string, paramIndex: number): any {
  if (ctor['DEPENDENCIES']) ctor['DEPENDENCIES'].push(CalendarService.ID);
  else ctor['DEPENDENCIES'] = [CalendarService.ID];
}
...

위와 같이 ICalendarService라는 parameter decorator를 정의해줍니다. 이 데코레이터가 하는 일은 데코레이터가 정의된 클래스의 생성자에 DEPENDENCIES 배열을 만들고, 해당 배열에 CalendarService의 ID를 넣어주는 작업을 합니다.

// calendar.ts
export class Calendar {
  ...
  constructor(
    numberOfDate: number,
    @ICalendarService calendarService: ICalendarService) {
    this.numberOfDate_ = numberOfDate;
    calendarService.addCalendar(this);
  }

이제 Calendar의 생성자에 @ICalendarService 데코레이터를 가진 인자를 생성자 인자 마지막에 정의해줍니다. 이로써 Calendar[DEPENDENCIES]배열에는 CalendarService.ID가 들어가게 됐습니다.

마지막으로 InstantiationService를 만들고, 해당 서비스를 통해 객체를 생성해주게 됩니다.

// index.ts
import { CalendarService } from "calendar-service";
import { Calendar } from "calendar";
import { DetailView } from "detail-view";
import { ServiceCollection } from "service-collection";
import { InstantiationService } from "instantiation-service";

const serviceCollection = new ServiceCollection();

const calendarService = new CalendarService();
serviceCollection.addService(CalendarService.ID, calendarService);

const instantiationService = new InstantiationService(serviceCollection);

const calendar = instantiationService.createInstance<Calendar>(Calendar, 30);
const detailView = instantiationService.createInstance<DetailView>(DetailView);

calendar.render(document.body);
detailView.render(document.body);

InstantiationService의 구현을 다시 살펴보면 생성자에서 ServiceCollection을 인자로 받고, 해당 컬렉션에 자기 자신을 직접 컬렉션에 넣어주는 모습을 볼 수 있습니다.

export class InstantiationService {
  private serviceCollection_: ServiceCollection;
  constructor(serviceCollection: ServiceCollection) {
    serviceCollection.addService('InstantiationService', this);
    this.serviceCollection_ = serviceCollection;
  }
  ...

이렇게 자기 자신을 컬렉션에 추가함으로써 InstantiationService 자체도 Dependency Injection 행위를 통해 인자로 넘겨줄 수 있게 됩니다.

constructor(
  @IInstantiationService instantiationService: InstantiationService) {
  instantiationService.createInstance(...)
}

이런 Decorator를 이용한 Dependency Injection이 가능한 이유는 Javascript의 특성과 Typescript의 강력한 능력을 이용하기 때문에 가능한 일입니다. 다른 언어에서는 다른 과정으로 이와 비슷한 구현이 가능합니다. 예를 들어 C++은 다음과 같이 구현되어 있습니다.

위 예제에서 보여주는 서비스 매니징은 가장 기초적인 구현에 불과합니다. 실제로는 서비스 트리 작성을 통한 서비스간 의존 관계 파악이 필요하고, 제너릭 프로그래밍을 통해 Syntax checking을 더 강화할 필요가 있습니다. 이러한 기능들을 추가해놓은 VSCode에서는 이 InstantiationService더 아름답고 편리하게 구현해놓았습니다.