작성자: 이현우
작성자의 한마디: DI를 알아봤으면 이제 이걸 활용해서 개발에 적용해볼까요?
Inversion of Control(IoC) is a programming principle.
In traditional programming, the custom code that expresses the purpose of the program calls into reusable libraries to take care of generic tasks.
But with inversion of control, it is the framework that calls into the custom, or task-specific, code.
IoC(Inversion of Control)은 프로그램의 제어 흐름, 즉 호출 구조를 뒤바꾸는 것입니다. 이 말이 이해가 안 될 수 있어서 다음과 같은 예시를 들어보겠습니다.
여러분이 처음 콘솔 프로그램을 작성하다보면 main함수에서 프로그램을 실행하고, main 함수에서 돌아갈 클래스를 정의, 객체를 생성하여 프로그램을 제작합니다. 하지만 이런 구조로 프로그램을 작성할 경우 특정 부분은 기존 팀원과 본인들만 이해할 수 있는 코드로 작성을 할 수 있게 될 것이고 프로그램의 규모가 커져서 다른 사람들이 이 프로그램에 손을 대려고 할 때 처음부터 모든 프로그램의 흐름을 스스로 파악해야하기 때문에 개발 생산성이 크게 떨어질 것이고 또한 어떤 로직은 그 컨텍스트에만 강결합되게 작성되어 유지보수가 힘들게 코드를 작성할 수 있다는 위험성도 높아질 수 있게 될 것입니다.
IoC는 이런 상황을 줄여주기 위해 나온 개념이라고 보면 됩니다. 프로그램의 제어 흐름, 즉 프로그램의 메인 스트림을 미리 작성되었던 코드(프레임워크)에 의 제어되게 하고 개발자가 작성한 코드(어플리케이션 코드)는 프레임워크에 의해 제어되는, 즉 프로그래머가 짠 코드가 프레임워크에 의해 제어되는 제어권의 역전을 활용하여 프로그램 작성에 일관성을 부여하고 유지보수가 가능하게 하는 코드를 작성하는데 도움을 줄 수 있게 하는 것입니다.
이런 개념은 의존성 주입에서도 적용할 수 있습니다. 이전 글에서도 적었다시피 의존성 주입을 활용할 때에는 객체 생성 시 의존성을 어플리케이션 코드(정확히는 객체 생성시의 코드겠죠!)에서 특정하지 않아도 그 기능을 활용할 수 있다였습니다. IoC는 객체를 생성하는 역할을 가지는 오브젝트를 정의하여 객체를 프로그래머가 직접 생성자를 호출하여 생성하는 것이 아니라 그저 의존관계만 설정하면 자동으로 객체를 주입하게 하여 어플리케이션 코드에서 객체 생성/생명주기 관리의 책임을 덜 수 있게 도와줍니다.
대부분의 프레임워크에서는 IoC을 구현한 구현체들이 내장되어 있습니다. 대표적으로 Spring Framework의 Spirng IoC가 대표적입니다.
IoC is also known as dependency injection (DI).
It is a process whereby objects define their dependencies (that is, the other objects they work with) only through constructor arguments, arguments to a factory method, or properties that are set on the object instance after it is constructed or returned from a factory method.
Spring IoC 프레임워크를 활용하면, 객체를 생성하고 생명주기를 관리하는 IoC 컨테이너에서 의존관계가 설정된 객체에 생성자/Setter를 통해 필요한 객체를 주입합니다.
// Kotlin SpringBoot
@RestController
class SampleController(
// Constructor Injection 가능
private val service: SampleService
) {
// SampleService 활용 가능
@GetMapping("")
fun getSomething() = service.getSomething()
}
하지만 제가 현재 사용하고 있는 Express.js에서는 이런 IoC 컨테이너에 해당되는 기능이 내장되어 있지 않아 npm에서 IoC Container 라이브러리를 찾았고 그 중 가장 대중적인 Inversify.js를 이용해보기로 했습니다.
InversifyJS is a lightweight (4KB) inversion of control (IoC) container for TypeScript and JavaScript apps.
A IoC container uses a class constructor to identify and inject its dependencies.
InversifyJS는 NodeJS 기반의 어플리케이션 위에서 동작하는 IoC Container이고 생성자 주입으로 객체간의 의존관계를 설정하는 방법을 제공합니다. InversifyJS에서는 런타임 오버헤드를 최대한 줄이면서 JS 개발 생태계에서 SOLID 원칙을 준수하여 어플리케이션을 개발할 수 있도록 도와주고 궁극적으로는 DX(Development Experience)를 높여주는 데 의의를 두고 있습니다.
이제 본격적으로 InversifyJS를 활용해보도록 하겠습니다.
가장 먼저 InversifyJS를 install합니다.
// npm
npm install inversify reflect-metadata --save
// yarn
yarn add inversify reflect-metadata
그리고 InversifyJS는 TypeScript 2.0 이상 버전에서 동작하고 런타입에서 클래스/함수의 기능을 확장하게 할 수 있는 Decoorator 기능을 사용하기 위해서 tscofig.json
에 아래와 같은 속성을 추가해야합니다.
{
"compilerOptions": {
...,
"types": ["reflect-metadata"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
우선 저와 같은 경우 IoC 기능이 프로그램 전역의 설정을 담고 있다고 생각하가에 config 패키지에 ioc_config.ts
파일에 InversifyJS 설정 코드를 작성하도록 하겠습니다.
import { Container } from "inversify";
import "reflect-metadata";
const container = new Container();
export default container;
이제 객체간의 의존관계를 설정해볼까요? 아래에서 인터페이스와 클래스를 설계를 해보겠습니다. 이번 예제는 전체 SOPT 멤버들 중 벌칙에 당첨되는 멤버 2명을 뽑는 사다리타기 프로그램을 만들어보는 것입니다. 따라서 저희는 아래의 PenaltyManager interface와 구현체를 만들어볼 것입니다.
export default interface PenaltyManager {
member: Member[],
organize(): void
}
멤버 정보를 가져오는 MemberDataSource를 만들어보겠습니다. 지금은 로컬에서 Array로 멤버리스트를 넘기고 있지만, 이후에 서버통신이나 로컬 JSON으로 데이터를 넘길 수 있다는 가정하에 만들어보도록 하겠습니다.
우선 멤버 정보를 interface로 정의해보겠습니다.
/*
* Member.ts
* 프로퍼티를 readonly로 만들어 불변객체로 객체 생성할 수 있게 만들기
* /
export default interface Member {
readonly name: string
readonly group: string
}
이제 Member Array를 넘겨주는 MemberDataSource를 정의해보도록 하겠습니다. 이때 MemberDataSource는 어떤 형태로든 구현될 수 있게 interface로 정의하고 실 구현체는 SoptMemberDataSource 클래스로 정의해보겠습니다.
// SoptMember.ts
export interface MemberDataSource {
retrieve(): Member[]
}
export class SoptMemberDataSource implements MemberDataSource {
retrieve(): Member[] {
return [
{
name: "Choi",
group: "SERVER"
},
{
name: "Kim",
group: "SERVER"
},
{
name: "Kang",
group: "ANDROID"
},
{
name: "Lee",
group: "iOS"
},
{
name: "Park",
group: "WEB"
}
]
}
}
이제 이를 활용해서 PenaltyManager 내에서 member들을 받아올 수 있게 구현을 해볼 것입니다.
이제 organize 함수를 만들어봐야겠죠? 벌칙 당첨의 로직은 초기에 설정된 것이 이후에도 똑같이 유지된다는 보장이 없기에 벌칙 당첨 로직을 전문으로 담당하는 Matcher라는 interface를 활용해서 사다리타기 로직을 돌릴 것입니다.
우선은 가장 간단하게 랜덤키를 활용해서 정렬을 하고 맨 앞에 있는 두 사람을 뽑는 식으로 로직을 구현해보도록 하겠습니다.
// Matcher.ts
export default interface Matcher {
execute(members: Member[]): Member[]
}
export default class RandomMatcher implements Matcher {
execute(members: Member[]): Member[] {
members.sort(() => Math.random() - 0.5);
return members;
}
}
이런식으로 Matcher 역시 인터페이스로 실 구현체와 분리하여 매칭 로직이 달라질 때 기존 로직(RandomMatcher)의 내용만 수정하여 사용하도록 구현을 해보았습니다.
이제 이를 합쳐보도록 하죠!
export default interface PenaltyManager {
member: Member[],
organize(): void
}
export default class SoptPenaltyManager implements PenaltyManager {
private matcher: Matcher;
private memberDataSource: MemberDataSource;
public member: Member[];
public constructor(
matcher: Matcher,
memberDataSource: MemberDataSource
) {
this.matcher = matcher;
this.memberDataSource = memberDataSource;
this.member = memberDataSource.retrieve();
}
organize() {
const sortedMember = this.matcher.execute(this.member);
console.log(`오늘의 벌칙 멤버는 ${sortedMember[0].name}, ${sortedMember[1].name}`);
}
}
이제 위의 구현 클래스 코드를 통해서 클래스 간 의존관계가 설정되어 있다는 것을 볼 수 있을 것입니다.
이제 마지막으로 객체에서 사용될 클래스들을 주입해주도록 하겠습니다.
Inversify.js 공식문서에서는 주입에 필요한 Identifier로 ES6 이후에 추가된 Symbols를 사용하는 것을 권장하고 있습니다. 하지만 String Literal을 키로 사용할 수도 있긴합니다. 일반 클래스를 주입에 사용할 수 있긴 합니다만 이는 구현체를 바로 사용하는 것이니 DIP에 위배되는 구현이라고 볼 수 있어서 권장하는 방법이 아님을 생각할 수 있습니다.
// ./constants/identifier.ts
export default const SERVICE_IDENTIFIER = {
PENALTY: Symbol.for("Penalty"),
MATCHER: Symbol.for("Matcher"),
MEMBER: Symbol.for("Member"),
};
@injectable
& @inject
Inversify.js에서는 @injectable
데코레이터와 @inject
데코레이터를 활용하여 객체를 주입하게 되어있습니다. DI가 사용되는 모든 구현체 클래스 상단에는 @injectable
데코레이터가 부착되어야 하고 생성자 주입을 할 때 주입되는 패러미터 앞에는 @inject
패러미터가 붙어야 합니다.
@injectable
export class SoptMemberDataSource implements MemberDataSource
@injectable
export default class RandomMatcher implements Matcher
@injectable
export default class SoptPenaltyManager implements PenaltyManager {
private matcher: Matcher;
private memberDataSource: MemberDataSource;
public member: Member[];
public constructor(
@inject(SERVICE_IDENTIFIER.MATCHER) matcher: Matcher,
@inject(SERVICE_IDENTIFIER.MEMBER) memberDataSource: MemberDataSource
) {
this.matcher = matcher;
this.memberDataSource = memberDataSource;
this.member = memberDataSource.retrieve();
}
organize() {
const sortedMember = this.matcher.execute(this.member);
console.log(`오늘의 벌칙 멤버는 ${sortedMember[0].name}, ${sortedMember[1].name}`);
}
}
이제 이렇게 interface와 구현체를 설정된 identifier를 활용하여 연결하면 모든 DI 설정이 완료됩니다.
// ioc_config.ts
const container = new Container();
container.bind<MemberDataSource>(SERVICE_IDENTIFIER.MEMBER).to(SoptMemberDataSource);
container.bind<Matcher>(SERVICE_IDENTIFIER.MATCHER).to(RandomMatcher);
container.bind<PenaltyManager>(SERVICE_IDENTIFIER.PENALTY).to(SoptPenaltyManager);
export default container;
마지막으로 index.ts
에서 conatiner에 저장된 PenaltyManager
구현체를 꺼내서 실행을 해보면 됩니다!
// index.ts
const penalty = container.get<PenaltyManager>(SERVICE_IDENTIFIER.PENALTY);
penalty.organize()
누누님 유익한 글 감사합니다