[연재] 코틀린 프로젝트 - 01 오픈소스 라이브러리의 이해

 

Note: (updated: June 2020)
12 June 2020 - 라이브러리 관련 내용이 수정 되었습니다. 
이 연재는 'Do it Kotlin Programming'의 후속 과정으로 진행합니다. 입문자는 먼저 책을 읽어보세요!

완성도 높은 앱을 만들어 내기 위해서는 모든 기능을 직접 설계하는 것보다 서드파티 라이브러리를 활용하는 것이 좋습니다. 경우에 따라서는 상용으로 제공되는 라이브러리도 있으나 대부분 오픈소스 라이브러리 만으로도 충분히 좋은 앱을 만들 수 있습니다. 이 프로젝트에서는 다음과 같은 기본 라이브러리와 서드파티 라이브러리를 활용할 것입니다. 

  • REST API - Retrofit 2: REST API를 사용하기 위해
  • 비동기 작업 - Coroutines, RxJava(RxKotlin): UI와 백그라운드 태스크를 분리하기 위해
  • 이미지 로드 - Picasso: 이미지를 로드하기 위해
  • 의존성 주입 - Dagger 2, Koin: 인젝션에 필요한 라이브러리
  • 안드로이드UI - RecyclerView: infinite scroll을 적용해 정보를 나타내기 위해
  • UI 바인드등 - Kotlin Android Extensions: xml 레이아웃을 코드에 바인드하기 위해
  • 각종 헬퍼 라이브러리 - Android Jetpack: 안드로이드 개발을 위한 각종 라이브러리
  • Anko Extensions(Deprecated): 다이얼로그나 몇가지 유용한 루틴을 위해 (Jectpack의 라이브러리로 대체)

이번 절에서는 코틀린에서 기본제공되는 라이브러리 이외의 위의 라이브러리를 소개 하겠습니다. 

REST API - Retrofit 2

HTTP를 처리하기위해서는 커넥션이나 캐싱, 요청, 스레딩과 파싱, 에러 핸드링 등 만들어야 할 루틴들이 너무나 많습니다. Retrofit은 HTTP의 REST API 구현을 위한 매우 인기 있는 라이브러리로 Square 의 오픈 소스 라이브러리 중 하나입니다. 앞서 언급한 여러가지 루틴을 제공해 프로그래머가 해야할 일을 많이 줄여줍니다. 

https://square.github.io/retrofit
https://github.com/square/retrofit

Square사는 otto, dagger, Picasso, OkHTTP와 같은 라이브러리도 배포하고 있습니다. REST는 Representational State Transfer의 약자로 네트워크상 클라이언트의 통신방식을 말하며 HTTP의 GET, POST, PUT, DELETE와 같은 요청을 처리하는 방법을 제공합니다. 클라이언트의 응답에 대한 처리로 xml, json, text, rss등을 지원합니다. 

 간단한 사용법

먼저 라이브러리를 사용하기 위한 Gradle 파일에 다음과 같이 지정할 수 있습니다. 

implementation 'com.squareup.retrofit2:retrofit:<버전명>'

Retrofit은 자바 8 이상 또는 안드로이드 API 21이상에서 사용할 수 있습니다.

사이트에서 소개된 코드는 자바로 작성되었지만 코틀린에서도 사용 가능합니다. 한번 살펴봅시다.

public interface GitHubService {
  @GET("users/{user}/repos")
  Call<List<Repo>> listRepos(@Path("user") String user);
}

이렇게 인터페이스를 만들고 @GET 어노테이션을 만드는 것으로 GitHub API중에서 user와 관련된 부분을 읽어올 준비가 됩니다. @Path 는 경로상의 {...}를 대체할 수 있습니다.

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .build();

GitHubService service = retrofit.create(GitHubService.class);

Retrofit의 빌더 메서드인 baseUrl()을 통해 URL을 초기화 합니다. 이때 생성된 객체인 retrofit을 통해 앞서 지정된 GitHubService 인터페이스를 통해 하나의 서비스 요청이 생성됩니다. 이 요청을 받은 서버는 XML, JSON등 다양한 형태로 응답할 수 있습니다. 이 응답에 대응하려면 다양한 컨버터들이 있어야 합니다. Retrofit은 다음과 같은 컨버터를 제공합니다. 

Retrofit이 제공하는 컨버터

  • Gson: com.squareup.retrofit2:converter-gson
  • Jackson: com.squareup.retrofit2:converter-jackson
  • Moshi: com.squareup.retrofit2:converter-moshi
  • Protobuf: com.squareup.retrofit2:converter-protobuf
  • Wire: com.squareup.retrofit2:converter-wire
  • Simple XML: com.squareup.retrofit2:converter-simplexml
  • JAXB: com.squareup.retrofit2:converter-jaxb
  • Scalars (primitives, boxed, and String): com.squareup.retrofit2:converter-scalars

Gson은 자바객체와 JSON을 서로 변환 할 수 있는 구글에서 제공하는 라이브러리 입니다. Jackson은 JSON뿐만 아니라 다양한 자료형을 제공합니다. 그 밖에도 기타 사용하는 라이브러리나 목적에 따라 컨버터를 선택할 수 있습니다. 예를 들어 Gson을 사용하기 위해서는 build.gradle에 다음과 같이 추가하고 사용합니다. 

implementation 'com.squareup.retrofit2:converter-gson:<버전명>'

그리고 retrofit 초기화 코드에 addConverterFactory()을 다음과 같이 추가합니다. 

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .addConverterFactory(GsonConverterFactory.create())
    .build();

 

RxJava와 연동

보통 웹서비스는 비동기적으로 응답을 처리해야 합니다. 따라서 이에 필요한 추가 어댑터인 RxJava를 사용하는 경우에 build.gradle에 다음과 같이 추가합니다.

implementation 'com.squareup.retrofit2:adapter-rxjava:<버전명>' // RxJava 1 또는
implementation 'com.squareup.retrofit2:adapter-rxjava2:<버전명>' // RxJava 2 또는
implementation 'com.squareup.retrofit2:adapter-rxjava3:<버전명>' // RxJava 3

그 다음 인터페이스에는 다음과 같이 Observable<Repo> 을 정의 합니다. 코틀린으로 정의하면 다음과 같습니다. 

@GET("/search/users?")
fun searchUser(
        @Query(value = "q", encoded = true) userKeyword: String,
        @Query("page") page: Int,
        @Query("per_page") perPage: Int): Observable<GitHubUserSearchResponse>

따라서 초기화 시에는 추가 어댑터 설정을 다음과 같이 넣어 줍니다. 

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) // 혹은 RxJava2CallAdapterFactory, RxJava3Call...
    .addConverterFactory(GsonConverterFactory.create())
    .build();

이후에는 RxJava나 RxAndroid를 다음과 같이 build.gradle에 추가하고 나머지 루틴을 개발 합니다. 

implementation "io.reactivex.rxjava3:rxandroid:<버전명>" // June 2020 기준: 3.0.0
implementation "io.reactivex.rxjava3:rxjava:<버전명>"  // June 2020 기준: 3.0.0

다음 사이트에서 추가 정보를 확인할 수 있습니다.

https://github.com/square/retrofit/tree/master/retrofit-adapters/rxjava2
https://github.com/ReactiveX/RxAndroid

 

코루틴과 연동 (Dreprecated) 

 

Note:Retrofit 2.6.0 이후 버전에서 suspend를 지원하면서 기존의 어댑터는 이 라이브러리는 더이상 사용되지 않습니다.  

https://github.com/JakeWharton/retrofit2-kotlin-coroutines-adapter

만일 코틀린의 코루틴으로 비동기 루틴을 만들때는 Retrofit 2를 같이 사용하기위해 build.gradle에 다음을 추가하고 어댑터를 변경할 수 있습니다. 

implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'

이후 다음과 같이 사용합니다. 

val retrofit = Retrofit.Builder()
    .baseUrl("https://example.com/")
    .addCallAdapterFactory(CoroutineCallAdapterFactory())
    .build()

인터페이스 설계 시에는 기존 Call 대신에 Deferred 인스턴스를 반환해 사용할 수 있습니다. 

interface MyService {
  @GET("/user")
  fun getUser(): Deferred<User>

  // 또는

  @GET("/user")
  fun getUser(): Deferred<Response<User>>
}

추가 정보는 다음 사이트에서 확인합니다. 

https://github.com/JakeWharton/retrofit2-kotlin-coroutines-adapter

 

코루틴과의 연동

Retrofit 2.6.0 이상에서는 내장된 suspend를 통해서 코틀린의 코루틴과 연동할 수 있게 되었습니다. HTTP 요청의 비동기 표현을 다음과 같이 작성할 수 있습니다. 

@GET("users/{id}")
suspend fun user(@Path("id") id: Long): User

이것은 내부적으로 fun user(...): Call<User> 로 정의되고 Call.enqueue 를 호출합니다. Retrufit 2.9.0에서는 RxJava 3어댑터의 create() 메서드는 기본적으로 비동기 HTTP 요청을 생성합니다. 동기 요청에 대해서는 createSynchronous() 를 사용해 구성하며 스케줄러 상에서 사용하기 위해 createWithScheduler() 를 이용할 수 있습니다.

비동기작업 - RxJava

Reactive 프로그래밍으로 RxJava가 많이 사용되는데 Reactive 프로그래밍이란 데이터를 비동기 및 이벤트 기반 방식으로 처리해 효율성을 높여주고자 나온 자바 VM을 위한 라이브러리입니다. 다음 사이트에서 추가 정보를 확인할 수 있습니다.

https://github.com/ReactiveX/RxJava

통신에서 일반적인 비동기 데이터 처리가 끝날 때까지 스레드를 대기시키거나 콜백을 받아서 처리하면 불필요한 리소스 사용이 발생하게 됩니다. 반면 메시징 기반의 Reactive 프로그래밍에서는 필요한 경우에만 스레드를 생성 후 메시지 형태로 전달하기 때문에 더 효율적으로 컴퓨팅 리소스를 사용할 수 있습니다. 물론, 우리는 코틀린의 코루틴을 통해서 비동기 프로그래밍을 손쉽게 적용할 수 있다는 것을 알고 있습니다. 코루틴은 이제 막 안정되기 시작한 라이브러리이고 RxJava는 비교적 오랜 시간에 걸쳐 안정되고 인기 있는 라이브러리이기 때문에 기존의 프로젝트가 RxJava를 사용했을 경우 읽고 이해하기 위해 알아둘 필요가 있습니다.

RxJava 의 라이브러리 추가

RxJava는 스트리밍 형태의 데이터 기반 가공, 변형등의 동작을 제공해주는 라이브러리로써 Reactive Extension 에 대한 Java 구현체입니다. RxAndroid와 함께 사용하면 Android 의 스레드 기법을 사용하지 않고도 손쉽게 멀티 스레드 프로그래밍을 구현할 수 있습니다. 코틀린에서 사용하기 위해서 확장된 RxKotlin도 있습니다. build.gradle에 다음과 같이 지정합니다.

implementation 'io.reactivex.rxjava3:rxandroid:3.x.x'  // June 2020: 3.0.4
implementation 'io.reactivex.rxjava3:rxjava:3.x.x'

 

Observer 패턴

RxJava를 사용하려면 Observer패턴에 대한 이해가 필요 합니다. 

Observer 패턴은 객체의 상태 변화를 관찰하는 관찰자들인 Observer들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 Observer들에게 통지하도록 하는 디자인 패턴 입니다. 발행/구독 모델로 알려져 있기도 합니다. 이벤트 처리 시스템이나 GUI 를 설계할 때 사용되는 패턴이기도 합니다. 


Observer 패턴의 클래스 다이어그램

위 그림에서 Subject 는 이벤트를 발생시키는 주체입니다. RxJava 에서는 Observable 또는 Subject라는 이름으로 표현이 됩니다. Subject 에서 발생되는 이벤트들은 그 Subject 에 관심 있다고 등록한 Observer 들에게 전달됩니다. 여기서 Observer 는 RxJava 에서는 Subscriber 라는 이름으로 표현이 됩니다.


Observer 패턴의 시퀀스 다이어그램

기본적으로 관찰자(Observer) 혹은 구독자(Subscriber)인 o1, o2는 관심주제(Subject) s1에 붙임(attach) 혹은 등록(register) 혹은 구독(subscribe)이라는 과정을 합니다. 이 후에 관심주제 s1에 이벤트가 발생하면 통보(notify)하게 되고 관찰자들은 갱신(update) 할 수 있게 됩니다. 

그러면 RxJava에서 사용하는 용어 기준으로 다시 한번 정리하면 다음과 같습니다. 

  • 이벤트(Event): 구독자들에게 전달되는 데이터 (주로 클릭, 상태, API 응답 등)
  • 구독(Subscribe): Subscriber가 이벤트를 전달받기 위해 하는 행위(등록)
  • 관찰(Observe): RxJava에서는 Observable 컴포넌트들을 서로 연결(map())할 수 있으며 Observable 은 다른 Observable 을 관찰. Subscriber 는 구독(subscribe())을 통해서 Observable 을 관찰.

이벤트의 처리

이벤트가 발생하면 onNext() , 이벤트의 종료는 onCompleted() , 에러가 발생한 경우는 onError() 이 처리합니다. 간단한 자바 코드로 예를 들어보겠습니다. 

// Observable 생성
Observable<String> o1 = Observable.just("One"); // (1)
// 구독 
o1.subscribe(new Subscriber<String>() {
    @Override public void onNext(String text) {
        System.out.println("onNext : " + text);
    }

    @Override public void onCompleted() {
        System.out.println("onCompleted");
    }

    @Override public void onError(Throwable e) {
        System.out.println("onError : " + e.getMessage());
    }
});

(1)번 부분인 Observable.just() 는 누군가가 구독(subscribe)을 하게 되면 "One"이라는 이벤트를 한번 발생시킵니다. 이후 onNext() 로 "One"이 전달되고 그 다음 onCompleted() 가 호출됩니다. 만일 (1)번 부분을 다음과 같이 변경하면 Iterable 요소의 순서대로 이벤트를 발생합니다. 

Observable<String> observable = Observable.from(new String[] { "One", "Two", "Three" });

지속적으로 onNext를 발생하다가 onCompleted를 마지막으로 호출하고 종료됩니다. 

onNext : One
onNext : Two
onNext : Three
onCompleted

세번째로 Observable.defer() 를 사용하면 특정 함수를 실행 시킬 수 있습니다. 

Observable<String> observable = Observable.defer(new Func0<Observable<String>>() {
            @Override public Observable<String> call() {
                System.out.println("defer function call");
                return Observable.just("HelloWorld");
            }
        });

특정 함수를 실행하고 이 함수에 있는 Observable.just() 를 다시 반환 받아 전달하고 있습니다. 

비동기 처리의 경우

비동기 처리를 위한 새로운 스레드를 생성해 처리하고자 하는 경우에는 subscribeOn()observeOn() 을 이용합니다. 

...
System.out.println(Thread.currentThread().getName() + ", create observable");
Observable<String> observable = Observable.defer(new Func0<Observable<String>>() {
    @Override public Observable<String> call() {
        System.out.println(Thread.currentThread().getName() + ", defer function call");
        return Observable.just("HelloWorld");
    }
});
...
System.out.println(Thread.currentThread().getName() + ", do subscribe");
observable
    .subscribeOn(Schedulers.computation()) // 연산을 위한 스레드에서 defer 함수가 실행된다
    .observeOn(Schedulers.newThread()) // 새로운 스레드에서 Subscriber로 이벤트가 전달된다
    .subscribe(new Subscriber<String>() {
        @Override public void onNext(String text) {
            System.out.println(Thread.currentThread().getName() + ", onNext : " + text);
        }

        @Override public void onCompleted() {
            System.out.println(Thread.currentThread().getName() + ", onCompleted");
        }

        @Override public void onError(Throwable e) {
            System.out.println(Thread.currentThread().getName() + ", onError : " + e.getMessage());
        }
    });

System.out.println(Thread.currentThread().getName() + ", after subscribe");
}

 

subscribeOn()은 구독이 이루어지는 스레드로 옵션인 computation()이 정의되었고 observeOn()에는 관찰자에게 전달될 때 사용하는 스레드가 지정되어 실행됩니다. 결과는 다음과 같습니다. 

main, create observable
main, do subscribe
main, after subscribe
RxComputationThreadPool-3, defer function call
RxNewThreadScheduler-1, onNext : HelloWorld
RxNewThreadScheduler-1, onCompleted

안드로이드 UI에서의 처리

안드로이드의 UI스레드인 메인스레드를 관찰하기 위해서는 RxAndroid를 통해 다음과 같이 사용할 수 있습니다. 

Observable.just("one", "two", "three", "four", "five")
        .subscribeOn(Schedulers.newThread())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(/* an Observer */);

안드로이드에는 메시지 전달역할을 하는 루퍼(Looper)라는 개념이 있다고 했었습니다. 메시지큐에서 메시지를 스레드에 전달해 처리하는 역할을 하죠. 그런 루퍼를 관찰하기 위해서는 다음과 같이 지정할 수 있습니다. 

Looper backgroundLooper = // ...
Observable.just("one", "two", "three", "four", "five")
        .observeOn(AndroidSchedulers.from(backgroundLooper))
        .subscribe(/* an Observer */)

RxKotlin 확장의 사용

RxKotlin 확장을 사용하면 코틀린 문법으로 자바의 보일러플레이트한 코드를 확 줄일 수 있습니다. 

https://github.com/ReactiveX/RxKotlin

이 사이트에서 소개된 컬렉션에 사용된 예제를 확인해 봅시다. 

import io.reactivex.rxkotlin.subscribeBy
import io.reactivex.rxkotlin.toObservable

fun main(args: Array<String>) {

    val list = listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon")

    list.toObservable() // 컬렉션의 함수 확장
            .filter { it.length >= 5 }
            .subscribeBy(  // named arguments for lambda Subscribers
                    onNext = { println(it) },
                    onError =  { it.printStackTrace() },
                    onComplete = { println("Done!") }
            )

}

 Reactive Streams와 등장 배경

옵저버 패턴은 발행자(Publisher 혹은Observable)가 구독자(Subscriber 혹은 Observer)에게 데이터나 이벤트를 Push(notifyObservers)하는 방식으로 전달합니다. 이 때 구독자가 처리할 수 있는 데이터나 이벤트 개수를 넘는 경우 다음과 같은 문제가 발생할 수 있습니다.

  • 구독자가 별도 버퍼를 구성할 수 있으나 금방 소모되며 큐의 크기를 넘어간 데이터는 소실된다.
  • 외부에 큐를 너무 크게 생성하면 OOM(Out of Memory)문제가 발생할 수 있다.

그렇다면 이것을 해결하기 위해서는 구독자가 자신이 처리할 수 있는 만큼의 데이터를 요청하는 방식으로 해결할 수 있을 것입니다. 이런 방식을 Back Pressure라고 하며 Reactive Streams는 이런한 배경에 의해 설계되었습니다. 넌블로킹, 비동기 스트림 처리 표준으로 Java 9의 java.util.concurrent패키지에 Flow라는 형태로 JDK에 포함되었습니다. 

Reactive Streams API

구현을 위한 인터페이스가 다음과 같이 정의되어 있습니다. 

public interface Publisher<T> {
   public void subscribe(Subscriber<? super T> s);
}

public interface Subscription {
   public void request(long n);
   public void cancel();
}

public interface Subscriber<T> {
   public void onSubscribe(Subscription s);
   public void onNext(T t);
   public void onError(Throwable t);
   public void onComplete();
}

API를 간단히 정리하면 다음과 같습니다. 

  • Publisher: Subscriber의 구독을 받기 위한 subscribe()메서드를 가진다.
  • Subscriber: 받은 데이터를 처리하기 위한 onNext, 에러 처리를 위한 onError, 작업 완료시 onComplete, 그리고 매개변수로 Subscription을 받는 onSubscribe가 있다.
  • Subscription: n개의 데이터를 요청하는 request와 구독을 취소하는 cancel을 갖는다.

기본적으로 Subscriber는 subscribe()를 통해서 Publisher에게 구독을 요청합니다. 이후 Publisher는 onSubscribe()를 사용해 Subscriber에게 Subscription을 전달합니다. 
이제 Subscription은 Subscriber와 Publisher간 통신의 매개체가 됩니다. Subscriber는 Publisher에게 직접 데이터를 요청하지 않고 Subscription의 request()함수를 통해 Publisher에게 전달합니다. 

이후 Publisher는 Subscription을 통해 Subscriber의 onNext에 데이터를 전달하고, 작업이 완료되면 onComplete, 에러가 발생하면 onError에 시그널을 전달해 처리하도록 합니다. 

추가 정보는 다음 사이트에서 확인합니다.

https://github.com/ReactiveX/RxAndroid
https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/Flow.html 

 

이미지 로드 - Picasso

네트워크상의 이미지를 위한 관련 라이브러리 중 유용한 라이브러리로 Square의 Picasso와 구글의 Glide가 있습니다. 두 라이브러리의 사용법은 거의 동일합니다. 다만 Glide는 빠르게 가져오기 위한 설정이 기본적으로 적용되어 있어 품질은 약간 낮으나 적은 자원과 느린 네트워크에서 좀 더 원활하게 이미지를 가져올 수 있습니다. 두 개의 라이브러리를 비교해 보는 것도 좋겠습니다만 여기서는 Picasso에 대해 살펴보겠습니다. 참조 사이트는 다음과 같습니다.

http://square.github.io/picasso/

Picasso의 기본 사용

Gradle 파일인 build.gradle에 다음과 같이 지정해 라이브러리를 사용할 수 있습니다. 

implementation 'com.squareup.picasso:picasso:<버전명>'

사용법이 간단하며 이미지에 대한 각종 전처리, 후처리, 캐싱, 메모리 관리등이 제공됩니다. 특히 외부 링크로 저장된 이미지를 디스플레이하는 데 효과적입니다. 이 라이브러리는 이미지 로딩 처리 과정의 각 단계인 초기 HTTP 요청부터 이미지를 캐싱하는 데까지의 범위를 다룰 수 있습니다. 이러한 것을 모두 직접 구현하는 것은 꽤 장황한 코드가 될 수 있는데 이 라이브러리를 사용하면 간단히 구현할 수 있습니다. 이 사이트에서 소개하는 코드는 단 한 줄이죠.

Picasso.get().load("http://i.imgur.com/DvpvklR.png").into(imageView);

웹사이트에서 로딩한 png파일을 이미지뷰인 imageView에 나타냅니다. 하지만 대부분 이미지를 그냥 나타내는 경우 원본 이미지를 그대로 가져오려고 하기 때문에 imageView의 사이즈가 고려되지 않습니다. 이때는 resize()나 fit()함수를 사용해 명시적인 크기로 정하는 것이 좋습니다. 

Picasso.get()
        .load("http://i.imgur.com/DvpvklR.png")
        .placeholder(R.drawable.img_default) // 실패 시 기본 이미지 
        .resize(50,50) // 리사이즈
        .centerCrop() // 중앙 크롭 하기
        .into(picassoImageView);

이미지 처리 함수를 통해 크롭 하거나 placeholder()를 사용해 로드 실패 시 기본적으로 지정할 그림을 정해주면 좋습니다. 만일, 비율에 따른 리사이즈가 필요하다면 transformation()을 사용할 수 있습니다.

의존성 주입 - Dagger 2

의존성 주입으로 많이 사용되는 Dagger 는 Square에서 개발되고 현재 Dagger 2가 구글에 의해 지원 개발 되었습니다. 다음 사이트에서 정보를 확인할 수 있습니다.

https://google.github.io/dagger/
https://github.com/google/dagger

그래들 의존성 라이브러리

안드로이드에 이 라이브러리를 적용하기 위해 build.gradle에 다음을 추가 합니다. 

apply plugin: 'kotlin-kapt'
...
dependencies {
    implementation 'com.google.dagger:dagger:2.x'
    kapt 'com.google.dagger:dagger-compiler:2.x'
    compileOnly 'org.glassfish:javax.annotation:10.0-b28'
...

의존성 주입이란?

Dagger는 의존성 주입 혹은 DI(Dependency Injection)이라는 프레임워크 라이브러리 입니다. 의존성 주입이란 구성요소간의 의존 관계가 소스코드 내부가 아닌 외부의 설정파일 등을 통해 정의되게 하는 디자인 패턴 중의 하나입니다. 다음 그림을 통해 확인해 봅시다.


기존 방식과 의존성 주입 방식

전통적인 방법에서는 Main이 여러 코드에 의존되어 있어 하나가 변경되면 차례로 변경을 해야 합니다. 의존성 주입은 Main에서의 의존은 최대한 줄이되 필요할 때마다 의존성 주입을 통해 동적으로 구조를 적용하는데 있습니다. 

필요한 이유를 먼저 생각해 봅시다. 먼저 모듈간의 의존성이 낮아져 프로그램이 대형화 되면 유지보수나 재사용성이 늘어나는데 있습니다. 하지만 규모가 작은 프로그램에서는 오히려 코드량이 많아져 읽기 어려워질 수 있습니다. 안드로이드는 특히 context의 영향을 많이 받게 되는데 만일 Activity나 Fragment의 인스턴스를 만들고 그 생명주기에 진입하면 당연히 Activity나 Fragment의 context에 영향을 받을 수 밖에 없습니다. 하지만 역으로 생각해 인스턴스를 Activity나 Fragment 밖에서 만들고 받아서 사용하면 범용적으로 재사용이 가능하게 됩니다. 이것을 '제어의 반전'이라는 용어로 Inversion of Control이라는 디자인 개념으로 설명하고 있습니다. 소프트웨어 공학을 위한 용어로 꽤 어려운 개념으로 정의되어 있는데 간단히 말하면 '다른 누군가가 대신 해주는' 개념으로 생각 하면 좋을 것 같습니다. 자바 EE 기반의 개발을 위한 애플리케이션 프레임워크인 Spring 같은 것은 이것을 철저하게 따르고 있습니다. 

Spring 프레임워크와 코틀린의 사용이 궁금한 경우 다음 내용을 참조해 볼 수 있습니다. 

hyper-cube.io: http://hyper-cube.io/2017/11/27/spring5-with-kotlin/
spring.io: https://spring.io/guides/tutorials/spring-boot-kotlin/
kotlinlang.org: https://kotlinlang.org/docs/tutorials/spring-boot-restful.html

의존성 주입의 방법


Activity에 인스턴스를 주입하는 컴포넌트

의존성을 주입하기 위해서는 일단 3가지 방식의 주입을 이해해야 합니다.

  • 생성자 주입: 필요한 의존성을 포함하는 클래스의 생성자를 만들고 생성자를 통해 의존성 주입
  • 세터를 통한 주입: 의존성을 입력 받는 세터 메서드를 만들고 이것을 통해 의존성 주입
  • 인터페이스를 통한 주입: 의존성을 주입하는 메서드를 포함한 인터페이스를 작성하고 이 인터페이스를 구현하도록 해 실행 시에 이를 통해 의존성 주입

Dagger 2는 어노테이션 표기법을 이용해 다음과 같이 표현됩니다. 


Dagger 2의 어노테이션 표기법

먼저 @Module, @Provides의 공급자와 이것을 @Inject주입해 소비하는 소비자클래스, 연결을 담당하는 @Component 인터페이스로 이루어져 있습니다. @Provides 어노테이션은 특정 의존성을 제공하는 메서드에 사용됩니다. 아래의 코드에서처럼 @Provides가 붙은 provideContext()는 Application의 의존성 객체인 컨텍스트를 제공하게 됩니다. 

@Module
class AppModule(private val app: Application) {
  @Provides
  @Singleton
  fun provideContext(): Context = app
}

가장 상위 개념인 앱의 Application을 공급자로 설정합니다. 앱 전반의 의존성을 제공할 수 있게 됩니다. 주 생성자에 private로 선언된 프로퍼티인 app 객체와 이 객체를 반환하는 provideContext()를 통해 설정될 수 있습니다. Application 클래스는 보통 context객체를 통해 안드로이드의 여러 요소 간 사용할 공통의 내용을 접근할 수 있습니다. Application 클래스의 이름은 AndrodiManifest.xml에 <application> 태그에 정의되어 있습니다. 따라서 @Provides 어노테이션은 의존성의 특정 자료형, 여기서는 Context 객체를 제공할 수 있다는 것을 나타냅니다. 

@Singleton은 Dagger API는 아니고 javax.annotation 패키지에 포함된 어노테이션으로 인스턴스가 오로지 하나 이어야만 한다는 것을 나타냅니다. 이것을 다시 @Module 어노테이션을 통해 의존성을 주입할 수 있는 클래스로 @Component에 제공할 수 있는 Dagger 모듈이 만들어 졌습니다. 

의존성 주입의 연결과 빌드

이제 이것을 사용하기 위해서는 @Component 어노테이션을 사용하는 연결 매개체가 필요합니다. 

@Singleton
@Component(modules = [AppModule::class])
interface AppComponent

@Component은 다수의 모듈을 가질 수 있습니다. [AppModule::class] 표현법은 배열 표현법이 됩니다. 이 컴포넌트는 의존된 두 객체간의 연결을 담당합니다. 보통 inject() 메서드를 사용해 주입하고자 하는 대상을 지정합니다. Application의 하위 클래스에서 이것을 할 수 있습니다. 예를 들어 myApplication이 있다면 그 안의 프로퍼티와 초기 메서드를 다음과 같이 작성할 수 있습니다. 

...
lateinit var myComponent: AppComponent
...
private fun initDagger(app: myApplication): AppComponent =
      DaggerAppComponent.builder()
          .appModule(AppModule(app))
          .build()
...

빌드 전까지는 DaggerAppComponent에서 에러를 발생하는데 빌드 후 DaggerAppComponent.builder()에 의해DaggerAppComponent.java가 생성되면서 에러는 없어집니다. 모든 의존성이 제대로 만족하는지 컴파일 타임에 검사하게 되는 것입니다. 이것은 초기 Dagger 1이나 기존의 DI 프레임워크는 컴파일 타임에 검사하지 못하고 런타임에 의존성 적합 여부를 알기 때문에 잘못된 의존성이 있을 경우 런타임에서 에러가 날 수 있었습니다. Dagger 2는 이것을 방지합니다. 

 초기화와 주입

이제 myApplication에서 onCreate()를 다음과 같이 초기화 함수를 호출 시킵니다. 

override fun onCreate() {
    super.onCreate()
    wikiComponent = initDagger(this)
  }

이제 주입을 위해 AppComponent 인터페이스에 다음을 선언 합니다. 

fun inject(target: HomepageActivity)

이제 HomepageActivity는 AppComponent로부터 주입이 필요한 클래스가 됩니다. 공급자 모듈을 하나 더 만들어 봅시다.

@Module
class PresenterModule {
  @Provides
  @Singleton
  fun provideHomepagePresenter(): HomepagePresenter = HomepagePresenterImpl()
}

이 모듈은 메서드를 통해 HomepagePresenterImpl()을 반환 하게 됩니다. 그러면 다음과 같이 @Component에 모듈을 더 추가할 수 있게 됩니다. 

@Component(modules = [AppModule::class, PresenterModule::class])

이제 HomepageActivity 에는 다음과 같은 프로퍼티를 선언합니다. 

...
@Inject lateinit var presenter: HomepagePresenter // 주입 되어야 함을 나타냄
...

여기에 있는 @Inject도 사실 Dagger의 어노테이션이 아닌 javax의 어노테이션으로 주입 되어야 함을 나타냅니다. 실제로는 onCreate()에서 inject()를 호출하며 주입 합니다. 

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_homepage)

    (application as myApplication).myComponent.inject(this)
    ...
  }

이제 Dagger는 HomepagePresenter 객체를 HomepageActivity 에 주입할 것입니다. 

커피 제조기 예제

또 다른 예제로, 좀 더 이해해 보려면 다음 사이트를 참조합니다. 앞서 우리가 코틀린 코드로 만들었던 커피 제조기 코드가 Dagger 2가 적용된 예제로 다음 위치에서 볼 수 있습니다.

Java 예제: https://github.com/google/dagger/tree/master/examples/simple/src/main/java/coffee
Kotlin 예제: https://github.com/JetBrains/kotlin-examples/tree/master/maven/dagger-maven-example/src/main/kotlin

 

CoffeeMaker의 연관 관계 

소스를 연관 관계에 맞춰 그림으로 구성했습니다. @Module, @Provides에서 제공자가 있고, 이것을 통해 연결 역할을 하는 Coffee 인터페이스가 있습니다. 이 인터페이스는 어노테이션 컴파일러 kapt에 의해 Dagger가 붙은 클래스를 생성하며 여기에 Builder 클래스와 build() 메서드를 통해 의존성에 맞는 인스턴스를 생성해 @Inject가 있는 생성자에 주입 합니다. 

결론과 DI 프레임워크

사실, 이 프로그램은 그다지 크지 않기 때문에 지나치게 모듈화 하면 코드 읽기가 나빠집니다. 단, 만일 확장성과 프로그램이 더 커져 많은 모듈을 추가해야 할 경우에는 기존의 코드를 수정하지 않고도 새로운 모듈을 주입할 수 있고, 테스트를 위해 Mock 코드를 주입해 손쉽게 테스트 할 수도 있습니다. 따라서, 향후 확장성을 고려해 적절하게 도입하는 것이 중요합니다.

개념에 대한 것은 이 정도로 마무리 하겠습니다. DI프레임워크를 완전하게 이해하는 것은 상당히 어렵습니다. 자세한 것은 관련 서적이나 Square사의 개발자인 Jake Wharton의 비디오와 슬라이드를 살펴볼 것을 추천합니다. 

Youtube: https://youtu.be/plK0zyRLIP8
Speakerdeck: https://speakerdeck.com/jakewharton/dependency-injection-with-dagger-2-devoxx-2014

 

의존성 주입 - Koin

Dagger에 비해 좀 더 가볍게 사용할 수 있는 순수 코틀린DSL로 만들어진 의존성 주입 라이브러리입니다. Dagger와 달리 어노테이션 프로세스나 리플렉션을 사용하지 않습니다. 따라서 컴파일과정에서 DI를 사용하지 않으므로 컴파일 과정이 빨라질 수 있지만 런타임에서 의존성을 주입하게 되므로 실행 과정에서 오류를 발생할 수 있고 Dagger보다 약간 성능이 떨어질 수 있습니다. 단, 사용하기 더 쉽다는 장점이 있죠. 다음 사이트에서 확인할 수 있습니다. 

https://insert-koin.io/

안드로이드에서 사용하는 경우 다음과 같이AndroidX를 위한 라이브러리를 포함할 수 있습니다. 

...
// Stable Koin Version
koin_version = "2.1.5"
...
dependencies {
    // Koin for Android
    compile "org.koin:koin-android:$koin_version"
    // or Koin for Lifecycle scoping
    compile "org.koin:koin-androidx-scope:$koin_version"
    // or Koin for Android Architecture ViewModel
    compile "org.koin:koin-androidx-viewmodel:$koin_version"
    // or Koin for Android Fragment Factory (unstable version)
    compile "org.koin:koin-androidx-fragment:$koin_version"
}

그러면 먼저Koin에서 사용하는 DSL로 정의된 내용을 정리해 보겠습니다. 

  • applicationContext: Context의 주입
  • module: 모듈의 생성하고 정의
  • factory: inject할 때마다 새로운 인스턴스(객체)를 생성
  • single: 싱글톤 개념으로 앱 전역에 사용 가능한 인스턴스 생성
  • bind: 생성할 객체를 종속시켜 다른 형식으로 바인딩할 때 사용
  • get: 필요한 곳에 의존성 주입

Koin 간단히 사용해보기

공식사이트에서 제공하는 간단한 예를 통해 이해해 보겠습니다. 먼저 데이터를 제공하는 HelloRepository 인터페이스와 클래스를 만들어 보겠습니다. 

interface HelloRepository {
    fun giveHello(): String
}

class HelloRepositoryImpl() : HelloRepository {
    override fun giveHello() = "Hello Koin"
}

이제 이 데이터는 프레젠터 클래스에서 사용하게 될 것입니다.

class MySimplePresenter(val repo: HelloRepository) {

    fun sayHello() = "${repo.giveHello()} from $this"
}

이제 DSL의 module을 이용해 모듈을 선언합니다. 이것은 첫번째 컴포넌트가 될 것입니다.

val appModule = module {

    // single instance of HelloRepository
    single<HelloRepository> { HelloRepositoryImpl() }

    // Simple Presenter Factory
    factory { MySimplePresenter(get()) }
}

factory로 정의된 MySimplePresenter는 액티비티가 요구할 때마다 매번 새로운 인스턴스를 생성합니다. 반면에 single은 앱이 살아있는동안 유지되는 싱글톤으로 정의됩니다.

이제 이 모듈을 시작하기 위해 애플리케이션 클래스에 startKoin()함수를 사용할 것입니다. 

class MyApplication : Application(){
    override fun onCreate() {
        super.onCreate()
        // Start Koin
        startKoin {
            androidLogger()
            androidContext(this@MyApplication)
            modules(appModule)
        }
    }
}

 이때 AndroidManifest.xml에도 정의되어야 합니다.

...
   <application
        android:name=".MyApplication"
...

이렇게 해서 MySimplePresenter 컴포넌트는 HelloRepository 인스턴스를 생성할 것입니다. 이것을 액티비티의 by inject()에 의해서 의존성이 주입됩니다. 

class MySimpleActivity : AppCompatActivity() {

    // Lazy injected MySimplePresenter
    val firstPresenter: MySimplePresenter by inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        ...
        tv_message.text = firstPresenter.sayHello()
        ...
    }
}

by inject()는 액티비티나 프레그먼트, 서비스등을 실행시간에 인스턴스를 생성해 주입하게 됩니다. 앞서 사용된 get()은 lazy형태가 아닌 직접 그 즉시 인스턴스를 생성하게 됩니다. 

의존성 주입을 위한 어떤 라이브러리를 사용할지 선택할 수 있습니다. 학습곡선이 다소 높은 Dagger 대신에 Koin을 먼저 사용해보고 익숙해지면 Dagger를 도전해도 되겠지요? 다음 은 Android Jetpack에 대해서 소개해볼까 합니다. 

 

youngdeok의 이미지

Language

Get in touch with us

"어떤 것을 완전히 알려거든 그것을 다른 이에게 가르쳐라."
- Tryon Edwards -