DI 라이브러리 “Koin” 은 DI가 맞을까?

Dependency Injection Pattern 과 Service Locator Pattern에 대해서

kimji1
15 min readJun 10, 2021

🔊이 글은 코인을 왜 지양하는지에 대해서, 안티패턴으로 알려진 서비스 로케이터 패턴은 왜 안티패턴인지에 대해서 찾아가는 글 입니다. DI 패턴과 서비스 로케이터 패턴 설명의 기반이 되는 글은 Inversion of Control Containers and the Dependency Injection pattern -Martin Fowler- 입니다.

https://insert-koin.io/

상황

A :

“대표적인 안티패턴으로 알려진 서비스 로케이터 패턴으로 알고 있어서 코인은 사용하지 않았습니다.”

B :

“서비스 로케이터 패턴은 뭔가요? 서비스 로케이터 패턴을 직접 구현한다면 어떻게 하실건가요?”

A:

“음….…서비스 로케이터 패턴은 한 곳에서 객체 생성을 담당하는 걸로 알고 있습니다. 그래서 만약에 제가 서비스 로케이터를 구현한다면 type 을 받아서 if-else 문으로 타입을 생성해주는 메서드를 만들지 않을까…”

‘엥, 서비스 로케이터 패턴이 뭔지 하나도 모르네?’ 라는 생각이 든다면 제대로 알고 계신 겁니다. ‘음?’, ‘코인?’ , ‘DI?’, ‘서비스 로케이터 패턴?’, ‘직접 구현?’
어느 한 부분이라도 아리송 하시다면 각 부분에 대해 이어서 알아봅시다.

TL;DR

“코인은 안티-패턴이라는 의견이 있는 서비스 로케이터 패턴으로 구현된 구체 클래스에 대한 의존성을 없앨 수 있는 라이브러리다.”

단순히 이 정도로 알고 있어도 충분하지만, 이제는 구체적으로 설명할 수 있으면 더 좋을 것 같다.

“서비스 로케이터 패턴은 로케이터에 객체의 초기화 방법을 등록하고, 해당 객체를 필요로 하는 곳에서 로케이터를 통해 객체를 제공받을 수 있도록 하는 패턴이다. 이를 정적으로 관리하게 되면 로케이터에서는 해당하는 코드를 직접 작성해줘야한다는 문제가 있어 동적으로 관리하도록 수정했다. 동적이기에 당연하게 컴파일 시점에 어떤 문제가 있는지 알 수 없어, 로케이터에 등록되지 않은 타입의 객체를 요구할 경우 런타임에 에러가 발생할 수 있다.”

코인은 서비스 로케이터 패턴으로 구현 되었기 때문에 런타임 에러가 발생하게 된다. 실수를 안 하면 제일 좋겠지만, 사람 일이라는게 어떻게 될 지 모르니 방지할 수 있도록 사용을 지양 해야겠다.

Koin

공식 홈페이지에서는 코틀린 개발자들이 실용적이고, 가볍게 사용할 수 있는 DI 프레임워크라고 소개한다.

실제 Koin을 사용하는 (샘플) 프로젝트는..

아래의 코드처럼 module 블럭 안에서 객체 생성을 정의해준다. single은 module 내 하나의 인스턴스를, factory는 필요로 할 때 마다 새로운 인스턴스를 반환한다.

// koin module 정의
val appModule = module {

// single instance of SampleRepository
single<SampleRepository> { SampleRepositoryImpl()}

// Sample Presenter Factory
factory { SamplePresenter(get()) }
}

간단하게 2가지 Presenter를 만들어준다.

class SamplePresenter(val repo: SampleRepository) {
fun sayHello() = "${repo.giveHello()} from $this"
}
class AnotherSamplePresenter(val repo: SampleRepository) {
fun sayHello() = "${repo.giveHello()} from $this"
}

AnotherSamplePresenter의 경우에는 module에 선언해주지 않았지만, SamplePresenter를 대체해도 컴파일 후 실행된다.

class MainActivity : AppCompatActivity() {
// [1]
val presenter by inject<SamplePresenter>()
// [2]
val presenter by inject<AnotherSamplePresenter>()
//...
}

❗️

당연하지만 실행하면 [2] 의 부분에서 아래와 같은 에러가 발생한다.

Caused by: org.koin.core.error.NoBeanDefFoundException: No definition found for class:'com.example.koinsample.AnotherSamplePresenter'. Check your definitions!

코인이 런타임 에러가 발생한다는 점을 이해하려면 서비스 로케이터 패턴을 먼저 파악해야 한다. 앞으로 다룰 내용은 왜 DI와 서비스 로케이터 패턴이 필요했는지, DI와 서비스 로케이터는 무엇인지에 대해 직접 구현하는 과정이다.

예제를 통해 제어의 역전(IoC)에 대해서 설명하여 DI와 서비스 로케이터 패턴이 어떻게 쓰이는지 보고나면 결론을 이해하기 쉬울 것 같다.

샘플 컴포넌트 코드로 특정 감독의 영화 목록을 제공한다.

MovieLister 는 알고 있는 모든 영화를 반환하기 위해 Finder 객체를 요청한다. 다른 부분은 지금 다루는 문제가 아니라서 수정하지 않을 것이고, 이 글에서는 Finder 객체, 특히 Lister 객체를 특정 Finder 객체와 연결하는 방법이다. 이 부분에 집중한 이유는 moviesDirectedBy() 메서드가 모든 영화가 저장되는 방식과 완전히 독립적 이어질 수 있다. 그래서모든 메서드는 Finder를 참조하고 Finder가 하는 일은 findAll() 메서드에 응답하는 방법을 아는 것 뿐이다. Finder에 대한 인터페이스는 다음과 같다.

Finder 를 구현하고 있는 구체 클래스 객체는 Lister 의 생성자에서 초기화 해준다.

구체 클래스의 이름에서 확인할 수 있듯, source는 colon이 delimiter로 사용된 text 파일이다.

SQL 데이터베이스, XML파일, Web Service 등 다른 형식을 갖는 경우 해당 데이터를 가져오기 위해 다른 Finder 클래스가 필요하다. MovieFinder 인터페이스를 정의했기에 moviesDirectedBy() 메서드는 변경하지 않는다. 그러나 올바른 Finder 객체를 설정하기 위한 부분에는 수정이 필요하다.

MovieLister에서 Finder의 객체를 단순히 생성해서 할당할 때의 의존성

사용자가 어떤 Finder를 사용할 지 모르는 상황이라 Finder의 구체 클래스는 컴파일 타임에 알 수 없게 만들어야 한다. 그렇게 하기 위해 제어의 역전(Inversion of Control)을 사용한다.

제어의 역전(Inversion of Control)

제어의 어떤 측면이 역전된 것일까?

제어의 역전을 이해하기 쉬운 상황은 터미널의 CLI를 통해 명령어를 입력하여 응답을 받는 구조였지만, GUI를 사용하면서 UI 프레임워크에 Main Loop가 포함되고 프로그램은 화면의 다양한 필드에 대한 이벤트 핸들러를 제공한다. 프로그램의 주요 제어가 역전되어 프레임워크로 옮겨졌다고 할 수 있다.

위의 예제와 같은 상황에서 MovieLister는 단순히 MovieFinder 인터페이스로 데이터를 얻어올 수 있기에, MovieFinder를 구현한 구체 클래스의 인스턴스를 제공하는 외부 모듈이 필요하다. 이 외부 모듈을 지칭하기에 IoC는 너무 일반적인 용어여서 외부 모듈의 패턴과 이름에 대한 논의가 진행되었고, 그렇게 정해진 이름이 Dependency Injection이다.

추가로 Dependecy Injection이 MovieLister 클래스 내에서 MovieFinder 구체 클래스에 대한 의존성을 제거하기 위한 유일한 방법이 아니고, 대체할 수 있는 다른 패턴이 서비스 로케이터 패턴이다.

Dependency Injection Pattern

기본 개념은 별도의 객체인 Assembler가 MovieFinder 인터페이스를 구현한 구체 클래스를 갖는 것이다.

아래 세 가지 방법으로 주입받을 수 있다.

type 1 IoC — interface injection

type 2 IoC — setter injection

type 3 IoC — constructor injection

MovieLister의 생성자는 MovieFinder 객체를 넘겨 받아 finder 객체를 설정해주도록 수정한다.

구체 클래스의 inject 과정

1. 구체 클래스와 연결해주기 위해 Configuration 을 정의해준다.

Container에 Configuration 정보인 Component와 Injector의 등록을 진행한다.

2. 컴포넌트를 등록해서 어떤 컴포넌트를 제공해주는지 Look Up Table 역할을 하도록 한다.

3. 주입 가능한 클래스는 Injector를 구현하고 있고,

4. Injector 를 구현하는 클래스는 inject() 메서드를 통해 target에 호출 객체 혹은 실제 데이터를 inject 해준다.

5. container 에 등록했던 Injector 인터페이스를 사용하여 종속성을 파악하고 Injector를 사용하여 주입한다.

Service Locator Pattern

DI를 고안했던 목적인 MovieLister가 MovieFinder 의 구체 클래스에 의존하지 않도록 하는 것은 지금부터 설명할 서비스 로케이터 패턴으로도 만족된다.

서비스 로케이터는 응용 프로그램에서 필요한 모든 서비스를 확보하는 방법을 알고있는 객체이다.

#1 Service Locator의 간단한 구현

1. ServiceLocator에서 movieFinder 객체를 가져온다

2. DI 에서처럼 ServiceLocator의 configuration을 설정한다.

3. MovieLister 내부에서 ServiceLocator 싱글톤 객체를 통해 MovieFinder 객체를 얻어오게 되어있다. 따라서 MovieLister의 사용 코드는 아래와 같다.

이 구현에서의 문제점은 MovieLister는 하나의 서비스만 사용하지만 모든 서비스가 등록된 서비스 로케이터 객체 의존한다는 점이다. 이를 해결하기위해 로케이터 인터페이스를 분리하여 필요한 서비스 로케이터 객체에만 의존하도록 한다.

#2 Locator 인터페이스 분리

아래와 같은 인터페이스를 만들어 MovieLister가 전체 서비스 로케이터 인터페이스 대신 필요로 하는 인터페이스만 선언할 수 있다.

#1의 간단한 구현에서 static 메서드를 사용해서 ServiceLocator 객체를 가져오던 부분을 더이상 사용할 수 없게 되었다. MovieFinderLocator 객체를 얻기 위해서 래퍼 클래스를 사용해야 하며, MovieLister 에서 필요한 MovieFinder를 얻기 위해서 이 래퍼 클래스를 사용해야 한다.

서비스 로케이터 클래스에는 필요한 각 서비스에 대한 메서드가 있다는 점에서 이 방법은 정적(static)이다. 필요한 서비스를 숨기고 런타임에 선택할 수 있는 동적 서비스 로케이터를 만들어보자.

#3 동적 서비스 로케이터

서비스 로케이터는 각 서비스에 대한 필드 대신 맵을 사용하고, 서비스를 가져오고 로드하는 일반적인 방법을 제공한다.

“DI” vs “Service Locator”

가시적인 차이점은 서비스 로케이터는 서비스를 사용하는 모든 부분에서 로케이터에 대한 의존성을 갖는다는 점이다. 로케이터는 다른 구현체에 대한 의존성을 감출 수 있지만 반드시 로케이터를 알아야한다. 따라서 이 부분에 대해서는 로케이터와 인젝터는 해당 종속성이 문제인지 여부에 따라 선택할 수 있다.

또 다른 차이점은 DI를 사용하면 컴포넌트의 의존성을 쉽게 파악할 수 있다. DI를 사용하면 생성자와 같은 Injection 방법을 보고 종속성을 알 수 있다. 서비스 로케이터를 사용하면 로케이터 호출에 대한 소스코드를 찾아야 한다.

서비스 로케이터 패턴은 안티패턴인가?

서비스 로케이터 패턴이 적용된 프로젝트에서는 제공될 클래스를 반드시 생성해야 하고, 생성 후 로케이터에 등록해주는 과정이 필요하다. 이 부분이 유지보수 입장에서는 모든 코드를 파악하고 어디서 객체를 생성해서 등록해주고 있는지를 알아야 기능의 확장이 가능하다. 자세한 내용은 여기에서 확인이 가능하다.

안티-패턴인 이유는 아래 두가지 이유이다.

SOLID 원칙인 ISP(인터페이스 분리 원칙) 위반

ISP —사용하지 않는 메서드에 대한 의존이 강제되면 안 된다.

예를 들어, a(), b(), c()를 가지는 IA 인터페이스를 구현하는 구체 클래스 Foo와 Bar 가 있을 때 클래스 Foo에서는 a(), b()만 필요하고, 클래스 Bar에서는 a(), c()만 필요하다면 IA인터페이스는 a()만 가지고 IA의 하위 인터페이스인 IB, IC를 정의하여 각 각 b(), c()를 가지도록 분리되어야 한다는 원칙이다. 이 원칙은 하나의 클래스는 하나의 책임을 가져야 한다는 SRP를 지키는 것을 돕는다.

서비스 로케이터 패턴에서 로케이터는 동적으로 등록되는 경우 거의 무한한 메서드를 노출하고, 로케이터를 사용하는 곳에서 사용하지 않는 무한한 메서드에 의존해야 한다.

캡슐화 위반

캡슐화를 통해 얻을 수 있는 이점은 추상화이다. 세부적인 구현에 대해 이해해야 하는 부담을 덜고, 추상화된 인터페이스로 상호작용 할 수 있다. 객체 사이의 상호작용은 메서드 호출을 의미하고, 메서드는 파라미터와 반환값으로 정의된다. 파라미터는 사전 조건(precondition)이 되고, 반환값은 사후 조건(postcondition)이 된다. 클라이언트가 사전을 충족하면 해당 메서드를 정의한 객체는 사후 조건을 보장한다. 따라서 캡슐화는 파라미터와 반환값이 명확하게 정의 되어 서로 상호작용 할 때 부족한 정보가 없어야 한다.

동적 서비스 로케이터 구현에서 봤듯이 로케이터에 MovieFinder가 등록되어 있는 상황이라 “가정”하고 MovieLister에서 로케이터를 사용한다. MovieLister에서 로케이터를 사용할 당시에 로케이터 내에 MovieFinder가 등록되어 있다는 것을 컴파일 타임에는 보장할 수 없기 때문에 캡슐화 되었다고 보기 어렵다.

다시 결론,

이 긴 글을 다 읽고 나서, 머릿속으로 대답을 정리해보면 아래와 같다.

A :

“서비스 로케이터 패턴은 로케이터라는 싱글톤 객체내에 미리 정의한 방법으로 객체를 생성하게 되는데 로케이터를 사용하는 모든 클래스에서 로케이터에 등록된 객체를 안다는 점에서 SOLID 원칙의 ISP에 위배되고, 로케이터를 사용하면 명확한 파라미터와 반환값을 정의하지 않기 때문에 캡슐화에 위배된다고 알고 있습니다. 이런 부분에서 안티-패턴이라는 의견이 있고, 사용하게 되면 런타임 에러 발생 가능성을 갖게 되기 때문에 사용하지 않았습니다.”

🎁 논외로 Koin의 기능을 그대로 구현해보고 싶다면 이 글을 따라 해보자.

용어 설명

컴포넌트

응용 프로그램에서 변경 없이 사용할 수 있도록 의도된 소프트웨어 덩어리를 의미한다. “변경 없이” 란 컴포넌트의 작성자가 허용하는 방식으로 확장하여 컴포넌트의 동작을 변경할 수 있지만 사용중인 응용 프로그램에서는 컴포넌트의 소스 코드를 변경하지 않는 다는 것을 의미한다.

서비스

서비스는 외부 응용프로그램에서 사용되는 컴포넌트와 유사하다. 주요한 차이점은 컴포넌트는 import 해서 사용하는 것과 같이 프로젝트 내에서 사용되고, 서비스는 원격 interface 를 통해 synchronous/asynchronous 하게 사용된다는 것이다.

--

--