DI는 왜 쓸까? 또한 어떻게 작동될까?

DI(hilt)를 쓰는 이유과 작동 원리

Ji Sungbin
성빈랜드

--

Photo by Afif Kusuma on Unsplash

필자는 지금까지 DI를 그냥 기술로만 쓰고 있었다. 그러다가 갑자기 이걸 왜 쓰는거지?? 하는 의문이 들기 시작했고 DI를 쓰는 이유와 작동 원리에 대해 공부하고 이를 기록하고자 이 글을 작성한다.

안드로이드에서 DI는 보통 Koin이나 Hilt로 많이 진행된다. 이 글에서는 예시 코드에 Hilt를 사용하겠다.

DI는 왜 쓸까?

우리에게 없어서는 안될 커피와 사람 클래스가 있다.

이때 Coffee는 기본적으로 카페인 1500을 충전시켜주게 만들어져있고, drinkCoffee 함수를 실행시키기 위해선 Coffee 인스턴스를 직접 만들어야 한다. 이런 경우를 People 클래스가 Coffee 클래스에 의존성을 가졌다고 한다.

카페인 1500을 한 번에 먹다보니 속이 너무 안 좋아 카페인이 적은 커피를 먹고 싶어서 커피의 종류를 다음과 같이 두 가지를 추가했다.

이렇게 커피를 추가하고 나니 Coffee 가 interface로 선언돼, Coffee() 하는 부분에서 오류가 발생한다. 만약 위와 같이 의존성을 갖는 부분이 한 곳이 아니라 여러 곳이였다면 그 부분을 일일이 다 바꿔줘야 했을 것이다.

이를 해결하기 위해, 의존성을 주지 말고 직접 주입해보자.

People 클래스에 Coffee 를 주입하게 바꿔보았다. 이를 통해 의존성을 분리하고 매일 먹고싶은 커피를 골라서 먹을 수 있게 되었다.

이 예시에 쓰인 상황 의외에, DI를 통해 얻을 수 있는 이점은 다음과 같다.

  • 유닛 테스트가 쉬워진다.
  • 코드의 재활용성이 높아진다.
  • 객체간의 의존성을 없애 유연한 코드를 작성할 수 있다.
  • 보일러플레이트 코드를 줄일 수 있다.
  • 스코프를 이용해 객체를 효율적으로 관리할 수 있다.

Hilt의 작동 원리

필자는 안드로이드 DI를 Dagger2로 입문했다. Dagger2…. 끔찍했다. 너무 어려워!! Dagger에서는 주입을 해주려면 Component를 매번 만들어야 했다. 이게 나에게는 너무나 어려웠다. 어려움에 절망하고 있을때, Hilt라는 새로운 DI 프레임워크가 등장했다. 이번엔 너무 쉬웠다!! Hilt는 Dagger에 비해 따로 Component를 만들어줄 필요가 없이, super.onCreate 에서 자동으로 주입이 이루어 졌다. 이게 어떻게 가능한지 어제까지 의문이였다. 의문점을 같이 해결해보자.

우선 간단히 아래와 같이 Hilt를 사용하는 엑티비티를 만들어 주었다.

이제 빌드를 해보면 아래와 같은 여러개의 파일들이 만들어 진다.

Hilt_{앱이름} 파일엔 @HiltAndroidApp 클래스가 재생성 된다. 이 클래스에는 applicationContext를 힐트 모듈에 넣어주는 코드가 추가된다.

private final ApplicationComponentManager componentManager = new ApplicationComponentManager(new ComponentSupplier() {
@Override
public Object get() {
return DaggerHiltPlayground_HiltComponents_SingletonC.builder()
.applicationContextModule(new ApplicationContextModule(Hilt_HiltPlayground.this))
.build();
}
});

이러한 과정이 있어서 @ApplicationContext qualifier로 context를 주입받을 수 있게 해준다. 컴파일을 할때 @HiltAndroidApp이 붙은 클래스의 부모 클래스가 이 클래스로 바이트코드에서 변환이 이루어 진다. (bytecode transformation)

다음으로 {모듈클래스 이름}_Provide{provide 타입}Factory 파일을 보면, 이 파일에서 우리가 주입해줄 값들의 인스턴스화가 진행된다.

public static IntWrapper provideInt() {
return Preconditions.checkNotNullFromProvides(InjectModule.INSTANCE.provideInt());
}

다음으로 핵심이라고 볼 수 있는 Dagger{앱이름}_HiltComponents_SingletonC 파일을 보자. 이 파일에서 Hilt의 주입 기능들이 이루어 진다.

먼저 각각 Provider를 생성해 준다.

private void initialize(final Activity activityParam) {
this.provideStringProvider = DoubleCheck.provider(new SwitchingProvider<String>(singletonC, activityRetainedCImpl, activityCImpl, 0));
this.provideIntProvider = DoubleCheck.provider(new SwitchingProvider<IntWrapper>(singletonC, activityRetainedCImpl, activityCImpl, 1));
}

SwitchingProvider 의 인자로 provider의 스코프와 id가 들어간다.

이 provider의 id는

@Override
public T get() {
switch (id) {
case 0: // java.lang.String
return
(T) InjectModule_ProvideStringFactory.provideString();

case 1: // io.github.jisungbin.hiltplayground.IntWrapper
return
(T) InjectModule_ProvideIntFactory.provideInt();

default: throw new AssertionError(id);
}
}

provider에서 특정 값을 가져오는데 사용된다. 이 get() 은 어디서 사용될까?

@Override
public void injectMainActivity(MainActivity mainActivity) {
injectMainActivity2(mainActivity);
}
private MainActivity injectMainActivity2(MainActivity instance) {
MainActivity_MembersInjector.injectMessage(instance, provideStringProvider.get());
MainActivity_MembersInjector.injectNumber(instance, provideIntProvider.get());
return instance;
}

동일 파일에서 inject{주입될 엑티비티명}2 라는 함수에서 사용된다. 이 함수를 보면 주입될 엑티비티를 인자로 받고 있고, 이 엑티비티를 inject{주입받은 변수명} 메서드를 통해 사용되고 있다. 이 메서드는 {주입될 엑티비티명}_MembersInjector 라는 파일에 다음과 같이 정의돼 있다.

@InjectedFieldSignature("io.github.jisungbin.hiltplayground.MainActivity.message")
public static void injectMessage(MainActivity instance, String message) {
instance.message = message;
}

@InjectedFieldSignature("io.github.jisungbin.hiltplayground.MainActivity.number")
public static void injectNumber(MainActivity instance, IntWrapper number) {
instance.number = number;
}

이로써 inject{주입될 엑티비티명}2 함수로 의존성 주입이 이루어 지는걸 확인할 수 있다. 그렇다면 이 함수는 언제 호출될까? Hilt_{주입받을 엑티비티명} 파일을 보자.

Hilt_MainActivity() {
super();
_initHiltInternal();
}

Hilt_MainActivity(int contentLayoutId) {
super(contentLayoutId);
_initHiltInternal();
}

private void _initHiltInternal() {
addOnContextAvailableListener(new OnContextAvailableListener () {
@Override
public void onContextAvailable(Context context) {
inject();
}
}
);
}

protected void inject() {
if (!injected) {
injected = true;
((MainActivity_GeneratedInjector) this.generatedComponent()).injectMainActivity(UnsafeCasts.<MainActivity>unsafeCast(this));
}
}

다음과 같이 선언돼 있고, 이 파일 역시 컴파일 되면서 @HiltEntryPoint가 붙은 클래스의 부모 클래스가 이 클래스로 바이트코드에서 변환이 이루어 진다.이 파일을 보면 클래스가 초기화 되면서 위에서 봤던 inject{주입될 엑티비티명} 를 호출하는 inject() 함수를 호출하게 된다. 이로써 super.onCreate 에서 주입이 어떻게 가능한지를 알아 보았다.

마무리

이렇게 DI를 사용하는 이유와, 작동 원리에 대해 알아보았다. Hilt… 이걸 만든 구글은 신이야 ㅠㅠ

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.