이번 포스팅에서는 제어의 역전(IoC; Inversion of Control)과 의존성 주입(DI; Dependency Injection)에 대해 알아보고자 한다. (예제 코드는 TypeScript를 사용하였다.)
< 목차 >
- IoC (Inversion of Control) - 제어의 역전
- DI (Dependency Injection) - 의존성 주입
- IoC & DI
1. IoC (Inversion of Control) - 제어의 역전
제어의 역전(IoC)이란, 소프트웨어 설계 원칙 중 하나로 프로그래밍에 있어 객체의 생성 및 관리 책임을 개발자에서 전체 애플리케이션 또는 프레임워크에 위임하는 디자인 원칙을 일컫는다.
프레임워크 없이 개발을 진행할 때는 개발자가 객체의 생성 및 관리 등의 흐름을 직접 제어한다. 객체의 생성, 설정, 초기화, 메소드 호출, 소멸을 프로그래머가 직접 제어하고 관리하는 것이다. 그러나 IoC를 적용하면 이러한 "제어의 흐름"을 "역전"시켜 객체의 생성 및 관리를 외부 컨테이너 또는 프레임워크가 담당하도록 한다. "제어의 역전(IoC)"이라는 말 뜻 그대로 제어에 대한 권한이 프로그래머에서 외부 환경으로 역전 되는 것이라고 할 수 있다. IoC의 한 형태로 의존성 주입(DI; Dependency Injection)을 들 수 있는데 이는 객체가 필요로 하는 의존성을 외부에서 주입받는 방식을 사용한다. 객체는 자신의 의존성을 직접 생성하거나 관리하지 않고 외부에서 해당 의존성을 제공 받는 것이다.
여기까지의 설명만으로 IoC와 DI에 대해 이해하기에는 다소 난해한 감이 있다. 필자의 경우 처음 제어의 역전(IoC)과 의존성 주입(DI)을 접하고 나서 IoC와 DI에 대한 기술 부채를 해소하기까지 많은 시간과 노력을 들였던 것 같다. 지금부터 필자가 고군분투하며 이해한 IoC와 DI에 대해 조금 자세히 이야기해 보고자 한다.
👉 IoC는 "어떻게" 가 아니라 "누가"
IoC의 핵심 아이디어는 "어떻게"가 아니라 "누가" 객체의 생성 및 관리 책임을 가지느냐에 있다. 즉, 우리는 어떻게 객체들이 생성되고 관리되는지 보다 누구에 의해서 생성되고 관리되는지에 초점을 맞출 필요가 있다. 앞에서도 언급했지만, IoC는 객체의 생명 주기를 모두 프레임워크에 위임하는 것이라고 생각하면 된다.
전통적인 프로그래밍(프레임워크 없이 개발하는 방식)에서 제어의 주체에 대해 얘기해보면 다음과 같다.
- 개발자(또는 특정 코드)가 직접 객체를 생성한다.
- 해당 객체의 생명 주기 및 다른 관련 동작들을 명시적으로 제어한다.
- 객체 간의 관계나 의존성은 코드 내에 직접 정의되어 있다. (코드의 구체적인 부분에서 명시적으로 표현)
하지만 IoC, 제어의 역전을 도입하는 순간 다음과 같이 변화한다.
- 프레임워크가 객체의 생성 및 생명 주기를 관리한다.
- 개발자는 단지 필요한 인터페이스나 규약을 따르며 그것을 구현한다.
- 객체 간의 의존성은 외부에서 정의되며 프레임워크가 이러한 의존성을 주입하여 연결한다.
개발자가 직접 객체의 생성, 생명 주기 및 제어 흐름을 관리하던 것을 IoC를 통해 프레임워크가 그 역할을 대신 수행하게 된다. 즉, 프로그램의 제어 흐름이 개발자가 아닌 프레임워크에 의해 결정되고 관리되는 것이다. 이것을 우리는 "IoC, 제어의 역전"이라고 한다. 그리고 이런 IoC 원칙을 실현하기 위한 여러 디자인 패턴 중 하나가 바로 "DI, 의존성 주입"이다.
추가로 "토비의 스프링"에서 발췌한 제어의 역전에 관한 좋은 글이 있어 소개해본다.
제어의 역전이라는 건, 간단히 프로그램의 제어 흐름 구조가 뒤바뀌는 것이라고 설명할 수 있다.일반적으로 프로그램의 흐름은 main() 메소드와 같이 프로그램이 시작되는 지점에서 다음에 사용할 오브젝트를 결정하고, 결정한 오브젝트를 생성하고, 만들어진 오브젝트에 있는 메소드를 호출하고, 그 오브젝트 메소드 안에서 다음에 사용할 것을 결정하고 호출하는 식의 작업이 반복된다. 이런 프로그램 구조에서 각 오브젝트는 프로그램 흐름을 결정하거나 사용할 오브젝트를 구성하는 작업에 능동적으로 참여한다.
(중략)
제어의 역전이란 이런 제어 흐름의 개념을 꺼꾸로 뒤집는 것이다. 제어의 역전에서는 오브젝트가 자신이 사용할 오브젝트를 스스로 선택하지 않는다. 당연히 생성하지도 않는다. 또 자신도 어떻게 만들어지고 어디서 사용되는지를 알 수 없다. 모든 제어 권한을 자신이 아닌 다른 대상에게 위임하기 때문이다. 프로그램의 시작을 담당하는 main() 과 같은 엔트리 포인트를 제외하면 모든 오브젝트는 이렇게 위임받은 제어 권한을 갖는 특별한 오브젝트에 의해 결정되고 만들어진다.
(중략)
프레임워크도 제어의 역전 개념이 적용된 대표적인 기술이다. 프레임워크는 라이브러리의 다른 이름이 아니다. 프레임워크는 단지 미리 만들어둔 반제품이나, 확장해서 사용할 수 있도록 준비된 추상 라이브러리의 집합이 아니다. 프레임워크가 어떤 것인지 이해하려면 라이브러리와 프레임워크가 어떻게 다른지 알아야 한다. 라이브러리를 사용하는 애플리케이션 코드는 애플리케이션 흐름을 직접 제어한다. 단지 동작하는 중에 필요한 기능이 있을 때 능동적으로 라이브러리를 사용할 뿐이다. 반면에 프레임워크는 거꾸로 애플리케이션 코드가 프레임워크에 의해 사용된다. 보통 프레임워크 위에 개발한 클래스를 등록해두고, 프레임워크가 흐름을 주도하는 중에 개발자가 만든 애플리케이션 코드를 사용하도록 만드는 방식이다. 최근에는 툴킷, 엔진, 라이브러리 등도 유행을 따라서 무작정 프레임워크라고 부르기도 하는데 이는 잘못된 것이다. 프레임워크에는 분명한 제어의 역전 개념이 적용되어 있어야 한다. 애플리케이션 코드는 프레임워크가 짜놓은 틀에서 수동적으로 동작해야 한다.
– 토비의 스프링 3.1 1권. 92쪽. –
2. DI (Dependency Injection) - 의존성 주입
앞서 IoC는 소프트웨어 설계 원칙 중 하나로, 객체의 생성과 흐름 제어에 대한 책임을 개발자에서 프레임워크로 옮기는 것을 의미한다고 얘기하였다. 이제부터 알아볼 "DI, 의존성 주입"은 IoC의 한 형태로 IoC 원칙을 실제로 어떻게 적용할 것인가 에 대한 하나의 해결책이라고 볼 수 있다.
👉 DI 는 "어떻게"를 구체화 한 것
전통적인 프로그래밍에서는 상위 레벨의 모듈이 직접적으로 하위 레벨의 모듈을 생성하고 관리한다. 이렇게 되면 상위 레벨의 모듈은 하위 레벨의 모듈에 강하게 의존하게 된다. 이 의존성 때문에 코드는 덜 유연해지고 재사용이 어려워질 뿐만 아니라 테스트 하기도 힘들어진다. 코드를 살펴보면서 좀 더 이해해 보도록 하자.
// Lower-level module
class Database {
connect() {
console.log("Connected to database!");
}
}
// Higher-level module
class UserService {
private database: Database;
constructor() {
this.database = new Database();
}
addUser() {
this.database.connect();
console.log("User added!");
}
}
const userService: UserService = new UserService();
userService.addUser();
위의 코드에서 UserService는 Database 클래스의 구체적인 구현에 의존하고 있다. 다시 말해, UserService 클래스의 생성자 함수 안에서 Database 클래스의 구체적인 인스턴스를 직접 생성하고 있는 것이다. 어떻게 보면 Database 인스턴스를 사용하는 UserService 안에서 해당 인스턴스를 생성하는 것이 보다 직관적일 수 있지만, 이러한 방식의 코드는 다음과 같은 문제점을 가지게 된다.
(1) 강한 결합도
UserService는 Database 클래스의 구체적인 구현에 강하게 의존하게 된다. 이로 인해 Database의 구현이 변경되면 UserService도 영향을 받을 수 있다.
(2) 테스트의 어려움
UserService의 단위 테스트(유닛 테스트)를 수행하고자 할 때 테스트 격리 문제, 모킹 문제 등의 어려움을 겪게 된다.
- 유닛 테스트는 격리된 환경에서 수행되어야 한다. 하지만 UserService는 Database의 실제 인스턴스에 의존하고 있기 때문에 UserService 만의 기능을 독립적으로 테스트 하는 것이 어려워진다.
- UserService가 직접 Database의 인스턴스를 생성하기 때문에 다른 데이터베이스 구현체나 모의 객체(Mock Object)를 사용하기가 어렵다. 이로 인해, Database의 특정 행동을 모킹(mocking)하거나 스텁(stubbing)하여 UserService를 테스트 하는 것이 복잡해진다.
(3) 재사용성의 제한
UserService는 Database의 특정 구현과 강하게 결합되어 있기 때문에 다른 데이터베이스 구현체와 함께 사용하기가 어렵다.
(4) 확장성의 제한
UserService 내부에서 Database의 인스턴스를 직접 생성하고 있기 때문에, 나중에 다른 종류의 데이터베이스나 다른 저장 메커니즘을 사용하려면 UserService 코드를 변경해야 한다.
(5) 단일 책임 원칙 위반
UserService가 사용자 관련 로직 뿐만 아니라 Database 객체의 생성 책임까지 지니게 되므로 이는 단일 책임 원칙(Single Responsibility Principle)을 위반하게 된다.
하지만, IoC가 도입되면서 객체의 생성 및 관리 책임을 프레임워크에 위임하였고 객체의 의존성을 외부에서 주입받는 방식인 "의존성 주입"을 채택하게 된다. "DI, 의존성 주입"은 객체 간의 의존성을 코드 내부에서 직접 지정하는 것이 아닌 외부에서 주입받는 방식을 일컫는다. 외부에서 의존성을 주입받게 되면 객체는 자신이 사용하는 의존성에 대해 알 필요가 없어져 결합도가 낮아지며 코드의 유연성과 확장성이 향상된다. 그리고 이러한 의존성은 IoC 컨테이너 혹은 DI 컨테이너에 의해 주입된다.
IoC 컨테이너 혹은 DI 컨테이너는 객체의 생성과 생명 주기를 관리하며 필요한 의존성을 해당 객체에 주입해주는 역할을 하는 프레임워크의 구성 요소이다. 이로써 객체는 자신의 의존성을 직접 생성하거나 관리하지 않고 별도의 IoC 컨테이너 혹은 DI 컨테이너에게서 해당 의존성을 제공받게 된다. 많은 프레임워크에서는 이러한 컨테이너를 통해 의존성 관리와 주입을 자동화하고 이를 통해 개발자는 프레임워크에게 의존성의 생성과 연결 책임을 위임하게 되는 것이다.
의존성 주입을 통해 IoC를 실현하면 이제 상위 레벨의 모듈은 하위 레벨의 모듈의 구체적인 구현이나 생명 주기에 대해 알 필요가 없어진다. 상위 레벨의 모듈은 단순히 필요한 의존성을 요청하고 컨테이너는 이를 제공하는 것이다. 의존성 주입(Dependency Injection)을 적용한 예제 코드를 살펴보면서 좀 더 이해해보도록 하자.
interface IDatabase {
connect(): void;
}
// Lower-level module
class Database implements IDatabase {
connect() {
console.log("Connected to database!");
}
}
// Higher-level module
class UserService {
private database: IDatabase;
constructor(database: IDatabase) {
this.database = database;
}
addUser() {
this.database.connect();
console.log("User added!");
}
}
const database = new Database();
const userService = new UserService(database);
userService.addUser();
의존성 주입(DI)으로 인해 다음과 같은 내용들이 변경되었다.
(1) 객체의 생성 및 관리 책임 분리
위 코드에서 UserService는 IDatabase 인터페이스에 의존하며 실제 구체적인 Database 클래스의 인스턴스는 UserService 밖에서 생성되어 주입된다. 이로 인해 UserService는 Database의 구체적인 생성 및 관리에 대한 책임을 지니지 않게 된다. 해당 책임은 IoC 컨테이너 혹은 DI 컨테이너가 수행하게 된다. (여기서는 직접 인스턴스를 생성하여 주입하고 있다.)
(2) 의존성 역전 (Dependency Inversion)
UserService는 구체적인 Database 클래스에 직접적으로 의존하지 않고 추상화된 IDatabase 인터페이스에 의존하게 된다. Database 클래스 역시 IDatabase 인터페이스를 구현함으로써 추상화된 인터페이스에 의존하게 된다.
(3) 코드의 유연성 및 테스트 용이성
UserService는 IDatabase 인터페이스의 구체적인 구현에 의존하지 않기 때문에 코드의 유연성이 증가하게 된다. 예를 들어 나중에 다른 데이터베이스 구현체를 사용하려면 IDatabase를 구현하는 새로운 클래스만 추가해주면 된다. 또한 UserService의 생성자에 IDatabase 타입의 객체를 주입하는 방식으로 의존성이 주입되기 때문에 코드의 재사용성과 테스트 용이성이 향상된다. 테스트 시, IDatabase를 구현한 모의 객체를 쉽게 주입하여 UserService를 독립적으로 테스트 할 수 있다.
3. IoC & DI
제어의 역전(IoC)과 의존성 주입(DI)은 오늘날 대부분의 프레임워크와 애플리케이션에서 광범위하게 사용되고 있는 소프트웨어 디자인 원칙과 패턴이다. 이 두 개념은 코드의 유연성과 재사용성을 증가시키며 테스트 용이성을 향상시킨다. 마지막으로 간단한 비유를 통해 IoC와 DI에 대해 이해해보고자 한다. 우선 코드를 살펴보자.
직접 의존성을 생성하는 방식
class LightBulb {
turnOn() {
console.log('LightBulb turned on');
}
turnOff() {
console.log('LightBulb turned off');
}
}
class Switch {
constructor() {
this.bulb = new LightBulb();
}
operate() {
this.bulb.turnOn();
// other logics ...
this.bulb.turnOff();
}
}
const switchObj = new Switch();
switchObj.operate();
우리가 일상에서 전구를 구입하는 경우를 상상해보자. 우리는 전구를 사면 그 전구를 어떤 스위치에 연결할지, 언제 전구를 켜고 끌지 우리 스스로 결정한다. 이것은 전통적인 제어의 흐름을 따르는 방식이다. 일련의 행위들은 코드의 세계에서 Switch 클래스가 어떤 LightBulb 객체를 사용할지 스스로 결정하고 직접 생성해서 사용하는 것으로 비유될 수 있다. 하지만, IoC를 도입하면 이러한 제어의 흐름이 바뀐다.
DI 를 통한 IoC 구현 방식
interface ILightBulb {
turnOn(): void;
turnOff(): void;
}
class LedBulb implements ILightBulb {
turnOn() {
console.log("LedBulb turned on");
}
turnOff() {
console.log("LedBulb turned off");
}
}
class Switch {
private bulb: ILightBulb;
constructor(bulb: ILightBulb) {
this.bulb = bulb;
}
operate() {
this.bulb.turnOn();
// other logics ...
this.bulb.turnOff();
}
}
const ledBulb = new LedBulb();
const switchObj = new Switch(ledBulb);
switchObj.operate();
예를 들어, 이미 전구 설치 서비스를 제공하는 회사와 계약이 체결되어 있다고 가정해보자. 우리가 전구를 구입해서 연결하는 것이 아니라, 집에는 이미 전구가 설치되어 있고 전구가 고장나면 서비스 회사가 새 전구로 교체해주는 것이다. 다시 말해, 전구는 어떤 특정 서비스 회사에 의해 설치되었으며, 해당 서비스 회사는 언제든지 전구를 교체할 수 있다. 여기서 우리는 전구를 직접 선택하거나 관리하는 대신 외부의 서비스 회사에게 제어를 위임하게 되는 것이다. 코드에서는 Switch에 어떤 LightBulb 객체를 사용할 것인지 주입해주는 것과 비슷하다고 볼 수 있다. Switch는 그저 주어진 LightBulb(여기서는 ILightBulb를 구현한 LedBulb)를 사용하기만 하면 된다.
그렇다면 왜 제어의 흐름이 "역전" 되었다고 얘기하는 것인가? 전통적인 방식에서는 Switch가 스스로 결정하고 제어했다. 즉, Switch가 주체였다. 그러나 IoC가 도입되면서 Switch는 더 이상 전구를 선택하거나 제어하는 주체가 아니게 된다. 대신 외부(서비스 회사, IoC 컨테이너 혹은 DI 컨테이너)에서 그 역할을 대신한다. 이렇게 주체가 바뀌면서 제어의 흐름이 역전되었다고 말할 수 있게 된 것이다. 원래는 객체 자신이 내부적으로 어떻게 동작할지, 그리고 그것을 위해 어떤 의존성이 필요한지를 결정하고 제어했는데 IoC를 통해 그 제어가 외부로 이동되면서 제어의 흐름이 바뀌었다고 볼 수 있다.
'ABOUT CS' 카테고리의 다른 글
ABOUT.Series (11) 인터페이스; interface (0) | 2023.06.11 |
---|---|
ABOUT.Series (10) 디자인 패턴 (0) | 2023.05.01 |
ABOUT.Series (9) 운영체제(OS) (0) | 2023.04.10 |
ABOUT.Series (7) HTTP 헤더 - 인증 / 쿠키 (0) | 2023.03.08 |
ABOUT.Series (6) HTTP 헤더 (0) | 2022.12.30 |