Dagger2는 자바, 코틀린, 안드로이드에서 사용할 수 있는 compile-time dependency injection framework이다. 외부로부터 필요한 의존성을 주입받으면 테스트하기에 용이할 뿐더러 한 번 생성한 의존성을 재사용할 수 있으며 유지보수성이 뛰어나다. 러닝커브가 상대적으로 낮은 Koin이나 Hilt와 같은 다른 프레임워크도 있지만 이미 많은 앱에서 검증되어 자주 쓰이고 있는 Dagger2를 공부해보고 싶어 이번 프로젝트의 DI 프레임워크로 채택하게 되었다.
이번 포스팅에서는 dependency injection을 기반으로 회원가입 혹은 로그인에 성공했을 때 받아오는 토큰을 SharedPreference에 저장하고 그 외 요청을 할 때마다 이를 헤더에 실어보내는 로직을 중점적으로 다루어 볼 것이다.
Implementing Authentication logic with Dagger2
Dagger2는 생성자에 @Inject annotation이 붙은 클래스와 @Provides annotation이 붙은 메소드들로 구성된 모듈을 통해 객체들의 그래프를 내부적으로 생성한다. 공식 문서에 의하면 모듈을 install하는 것보다는 @Inject annotation과 함께 constructor injection을 통해 Dagger graph에 타입을 추가하는 것을 권장하고 있다. 따라서 인터페이스, third-party 클래스, configurable objects와 같이 constructor injection이 불가능한 경우에만 @Provides 메소드를 통해 모듈 안에 정의해주었고 가급적 constructor injection 방식으로 구현해주었다. 이렇게 생성된 그래프에 @Component annotation이 붙은 인터페이스, 바로 AppComponent을 통해 접근할 수 있다.
AppComponent.java
@Singleton
@Component(modules = {AndroidInjectionModule.class, StoragesModule.class, ActivitiesModule.class, NetworkModule.class, DataModule.class})
public interface AppComponent {
@Component.Builder
interface Builder {
@BindsInstance
Builder application(DebuggingApplication application);
AppComponent build();
}
void inject(DebuggingApplication app);
}
공식 문서의 지침대로 AppComponent에 AndroidInjectionModule을 설치해주었다. 그 외 각각의 모듈의 기능에 따라 StoragesModule, AcitiviesModule, NetworkModule, DataModule로 나누었다. 이렇게 모듈들은 앱 컴포넌트에 include 되어 해당 scope 내에 존재하는 모든 object들과 상호 의존적인 관계를 지니지만 각각을 분류함으로써 semantically differentiated encapsulation 효과를 가지게 된다. 또한 AppComponent는 커스텀 어플리케이션인 DebuggingApplication과 상호 인터랙션을 하여 @BindsInstance에 의해 자기 자신 컴포넌트 안에 어플리케이션 타입의 객체를 주입받고 어플리케이션은 컴포넌트에 정의된 inject 메소드를 호출함으로써 자기 자신 내부에서 필요한 필드를 주입받을 수 있게 된다.
DebuggingApplication.java
public class DebuggingApplication extends Application implements HasAndroidInjector {
@Inject
DispatchingAndroidInjector<Object> dispatchingAndroidInjector;
@Override
public void onCreate() {
super.onCreate();
DaggerAppComponent
.builder()
.application(this)
.build()
.inject(this);
}
@Override
public AndroidInjector<Object> androidInjector() {
return dispatchingAndroidInjector;
}
}
어플리케이션은 HasAndroidInjector을 구현하고 있으며 androidInjector 메소드를 통해 앱 컴포넌트에 의해 주입받은 dispatchingAndroidInjector를 리턴한다. Dagger는 컴파일 타임에 앱 컴포넌트 이름 앞에 Dagger가 붙은 형태로 implementation class를 생성해준다. 따라서 DaggerAppComponent의 빌더를 통해 자기 자신의 타입을 앱 컴포넌트에 넘겨주고 빌드하여 인스턴스를 생성해주었다. 만일 앱 컴포넌트 내부에서 application을 주입받아올 필요가 없다면 DaggerAppComponent.create()로 api를 간략화해줄 수 있다.
Tip: Any module with an accessible “default” constructor can be elided as the builder will construct an instance automatically if none is set. And for any moudle whose @Provides methods are all static, the implementation doesn’t need an instance at all. 이는 곧 static이 아닌 @Provides 메소드를 하나라도 포함하는 모듈의 constructor가 인자가 하나 이상이라면 반드시 AppComponent.Builder 인터페이스 내부에 필요한 인자 파라미터를 받고 Builder를 반환하는 메소드를 정의해준 후 앱 컴포넌트의 implementation 인스턴스를 빌드하기 전에 설정을 해줘야 한다는 의미이다.
NetworkModule.java
@Module
public class NetworkModule {
@Provides
@Singleton
Gson provideGson() {
GsonBuilder gsonBuilder = new GsonBuilder();
return gsonBuilder.create();
}
@Provides
@Singleton
HttpLoggingInterceptor provideHttpLoggingInterceptor() {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
return logging;
}
@Provides
@Singleton
OkHttpClient provideOkhttpClient(AuthenticationInterceptor authenticationInterceptor, HttpLoggingInterceptor httpLoggingInterceptor) {
OkHttpClient.Builder client = new OkHttpClient.Builder();
client.addInterceptor(httpLoggingInterceptor);
client.addInterceptor(authenticationInterceptor);
return client.build();
}
@Provides
@Singleton
Retrofit provideRetrofit(Gson gson, OkHttpClient okHttpClient) {
return new Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.baseUrl("http://10.0.2.2:8080/")
.client(okHttpClient)
.build();
}
@Provides
@Singleton
BugService provideBugsService(Retrofit retrofit) {
return retrofit.create(BugService.class);
}
@Provides
@Singleton
UserService provideUsersService(Retrofit retrofit) {
return retrofit.create(UserService.class);
}
@Provides
@Singleton
CompanyService provideCompanyService(Retrofit retrofit) {
return retrofit.create(CompanyService.class);
}
@Provides
@Singleton
ProductService provideProductService(Retrofit retrofit) {
return retrofit.create(ProductService.class);
}
}
서버와의 통신을 위해 필요한 object들을 리턴하는 @Provides 메소드들을 정의한 NetworkModule이다. AuthenticationInterceptor와 같이 모듈 외부에 존재하되 같은 scope 내에 속한 object 역시 주입받을 수 있다.
AuthenticationInterceptor.java
@Singleton
public class AuthenticationInterceptor implements Interceptor {
private final PreferencesManager preferencesManager;
@Inject**
public AuthenticationInterceptor(PreferencesManager preferencesManager) {
this.preferencesManager = preferencesManager;
}
@NonNull
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Request.Builder requestBuilder = chain.request().newBuilder();
String token = preferencesManager.getAuthToken();
if (token != null) {
requestBuilder.addHeader("Authorization", "Bearer ".concat(token));
}
return chain.proceed(requestBuilder.build());**
}
}
AuthenticationInterceptor는 같은 scope내에 존재하는 object 중 하나인 PreferenceManager을 주입받아 preferenceManager 내부에 token이 존재할 때마다 Header에 토큰 정보를 추가해 요청을 보내주는 인터셉터이다.
UserRepositoryImpl.java
public class UserRepositoryImpl implements UserRepository {
private User cachedUserInfo = null;
private final UserRemoteDataSource userRemoteDataSource;
private final PreferencesManager preferencesManager;
@Inject
UserRepositoryImpl(UserRemoteDataSource userRemoteDataSource, PreferencesManager preferencesManager) {
this.userRemoteDataSource = userRemoteDataSource;
this.preferencesManager = preferencesManager;
}
@Override
public Maybe<UserAuthentication> login(UserLogIn userInput) {
return setUserAuthentication(userRemoteDataSource.login(userInput));
}
@Override
public Maybe<UserAuthentication> signup(RegistrationForm registrationForm) {
return setUserAuthentication(userRemoteDataSource.signup(registrationForm));
}
private Maybe<UserAuthentication> setUserAuthentication(Maybe<UserAuthentication> authentication) {
return authentication
.flatMap((UserAuthentication userAuthentication) -> {
preferencesManager.clearAuthToken();
preferencesManager.saveUserName(userAuthentication.getUserName());
preferencesManager.saveAuthToken(userAuthentication.getToken());
return Maybe.just(userAuthentication);
});
}
@Override
public Maybe<User> loadUserInformation(boolean isFirstLoad) {
if (cachedUserInfo != null && isFirstLoad) {
return Maybe.just(cachedUserInfo);
}
return userRemoteDataSource.getUserInformation()
.flatMap((User userInfo) -> {
refreshCache(userInfo);
return Maybe.just(userInfo);
});
}
@Override
public Completable delete() {
return userRemoteDataSource.delete()
.doOnComplete(this::clearUserInfo);
}
@Override
public Completable logout() {
return Completable.complete()
.doOnComplete(this::clearUserInfo);
}
private void clearUserInfo() {
preferencesManager.clearAuthToken();
cachedUserInfo = null;
}
@Override
public void refreshCache(User userInfo) {
cachedUserInfo = userInfo;
}
@Override
public String getUserName() {
return preferencesManager.getUserName();
}
@Override
public Maybe<Integer> getAccumulatedNumOfUsages(boolean isFirstLoad) {
return loadUserInformation(isFirstLoad).flatMap((User user) -> Maybe.just(user.accumulatedNumOfUsages));
}
@Override
public Maybe<List<MySurvey>> getMySurveyList(boolean isFirstLoad) {
return loadUserInformation(isFirstLoad).flatMap((User user) -> Maybe.just(user.surveyList));
}
@Override
public Maybe<List<MyReservation>> getMyReservationList(boolean isFirstLoad) {
return loadUserInformation(isFirstLoad).flatMap((User user) -> Maybe.just(user.reservationList));
}
@Override
public Maybe<List<MyProduct>> getMyProductList(boolean isFirstLoad) {
return loadUserInformation(isFirstLoad).flatMap((User user) -> Maybe.just(user.productList));
}
}
UserRepositoryImpl은 preferenceManager를 통해 회원가입 혹은 로그인을 통해 받아온 userAuthentication 정보를 각각 sharedPreference에 저장해준다.
PreferencesManager.java
@Singleton
public class PreferencesManager {
private final SharedPreferences sharedPreferences;
@Inject
public PreferencesManager(SharedPreferences sharedPreferences) {
this.sharedPreferences = sharedPreferences;
}
public void saveAuthToken(String token) {
sharedPreferences.edit().putString("token", token).apply();
}
public void saveUserName(String userName) {
sharedPreferences.edit().putString("user_name", userName).apply();
}
public String getAuthToken() {
return sharedPreferences.getString("token", null);
}
public String getUserName() {
return sharedPreferences.getString("user_name", null);
}
public void clearAuthToken() {
sharedPreferences.edit().clear().apply();
}
}
PreferenceManager는 UserRepository와 AuthenticationInterceptor에 모두 주입되는 오브젝트이다. 회원가입 혹은 로그인에 성공하면 UserRepository 내부에서 preferenceManger의 saveAuthToken, saveUserName 메소드를 각각 호출해 토큰과 유저명을 저장한다. 이제 다른 요청을 할 때에 인터셉터에서 토큰이 비어있지 않은 것을 확인하고 헤더에 토큰을 함께 실어 보낸다. 로그아웃이나 회원탈퇴를 하는 경우에는 sharedPreference에 저장된 토큰을 비워준다. 이처럼 JWT 토큰을 사용한 Authentication 로직은 Dagger2 프레임워크에 의해 손쉽게 작성될 수 있다.
Injecting Activities and Fragments
안드로이드에서 Dagger2를 사용할 때에 신경써야 할 부분은 Activity나 Fragment와 같은 안드로이드 UI 컴포넌트들은 Dagger2에 의해 instantiated될 수 없기 때문에 별도의 설정이 필요하다는 것이다. 나의 경우 공식문서에서 소개하는 매뉴얼 중 @ContributesAndroidInjector을 사용해 반환되는 Activity들의 타입들을 반환하는 ActivitiesModule을 따로 만들어주었다.
ActivitiesModule.java
@Module
public abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = {HomeFragmentsModule.class, HomeModule.class})
abstract HomeActivity contributeHomeActivityInjector();
@ContributesAndroidInjector(modules = {BugFragmentsModule.class})
abstract BugActivity contributeBugsActivityInjector();
@ContributesAndroidInjector(modules = {LoginModule.class})
abstract LoginActivity contributeLoginActivityInjector();
@ContributesAndroidInjector(modules = {CompanyFragmentsModule.class})
abstract CompanyActivity contributeCompanyActivityInjector();
@ContributesAndroidInjector(modules = {ProductFragmentsModule.class})
abstract ProductActivity contributeProductActivityInjector();
@ContributesAndroidInjector(modules = {RegisterFragmentsModule.class})
abstract RegisterActivity contributeRegisterActivityInjector();
@ContributesAndroidInjector(modules = {MyPageFragmentsModule.class})
abstract MyPageActivity contributeMyPageActivityInjector();
}
RegisterFragmentsModule.java
@Module
public abstract class RegisterFragmentsModule {
@ContributesAndroidInjector(modules = RegisterModule.class)
abstract RegisterFragment contributeRegisterFragment();
@ContributesAndroidInjector
abstract RegisterCompletedFragment contributeRegisterCompletedFragment();
}
RegisterModule.java
@Module
public abstract class RegisterModule {
@Binds
abstract RegisterContract.View bindRegisterFragment(RegisterFragment registerFragment);
@Provides
static RegisterContract.Presenter provideRegisterPresenter(UserRepository userRepository, RegisterContract.View view, SchedulersFacade scheduler) {
return new RegisterPresenter(userRepository, view, scheduler.ui());
}
}
이렇게 각각의 Activity 서브 컴포넌트들이 필요한 모듈들을 @Component의 modules 옵션을 주는 것과 마찬가지의 방식으로 설치해줄 수 있다. 거의 대부분의 경우 Activity가 FragmentsModule을 include하고 FragmentsModule 내부에서도 마찬가지로 @ContributesAndroidInjector를 통해 Fragment 서브 컴포넌트들을 생성한다. 각각의 Fragment는 뷰와 인터페이스 타입의 오브젝트를 반환하는 api를 정의한 모듈을 포함한다. 이런식으로 인터페이스 타입을 반환하게끔 설정하면 호출될 때마다 인자로 받아온, 인터페이스를 구현한 특정 객체를 반환하게 되어있다. RegisterFragment 객체는 직접 생성할 수 없기 때문에 위와 같이 인자를 통해 객체를 주입받고 @Binds annotation과 함께 abstract method로 정의해주어야한다.
RegisterActivity.class
public class RegisterActivity extends AppCompatActivity implements HasAndroidInjector {
@Inject
DispatchingAndroidInjector<Object> androidInjector;
private ActivityRegisterBinding binding;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
AndroidInjection.inject(this);
super.onCreate(savedInstanceState);
binding = ActivityRegisterBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
initViews();
}
private void initViews() {
setNavController();
}
private void setNavController() {
Toolbar toolBar = binding.toolBar;
NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment_container);
NavController navController = navHostFragment.getNavController();
AppBarConfiguration configuration = new AppBarConfiguration.Builder(R.id.registerFragment, R.id.registerFragmentCompleted)
.setFallbackOnNavigateUpListener(this::onSupportNavigateUp)
.build();
NavigationUI.setupWithNavController(toolBar, navController, configuration);
}
@Override
public AndroidInjector<Object> androidInjector() {
return androidInjector;
}
}
RegisterFragment.java
public class RegisterFragment extends Fragment implements RegisterContract.View {
@Inject
RegisterContract.Presenter presenter;
private FragmentRegisterBinding binding;
@Override
public void onAttach(@NonNull Context context) {
AndroidInjection.inject(this);
super.onAttach(context);
}
...
Activity의 super.onCreate() 메소드 위에 AndroidInjection.inject(this) 코드를 추가해주면 dependency graph에 해당 Activity object를 추가해줄 수 있게 된다. RegisterActivity의 서브컴포넌트에 종속되는 Fragments들 역시 그래프에 injecting 해주려면 Activity 역시 마찬가지로 HasAndroidInjector를 구현하고 주입받은 androidInjector를 반환하는 메소드를 정의해주면 된다. 마무리로 RegisterFragment 내부에서 super.onAttach() 메소드 위에서 AndroidInjection.inject(this)를 작성해주면 된다.
이처럼 어플리케이션->액티비티->프래그먼트의 component hierarchy를 구성한 것은 각각의 스코프에 종속된 객체들의 생명 주기를 구분할 뿐더러 각각의 다른 기능들을 encapsulation하는 효과를 가지고 있다.
추가: Adaptor Refactoring
기존 코드에서는 프래그먼트 내부에서 어댑터 객체를 내부적으로 생성해주었었는데 이 부분 역시 외부에서 주입받는 방식으로 리팩토링해볼 것이다.
CompanyListAdapter.java
public class CompanyListAdapter extends ListAdapter<Company, CompanyListAdapter.ViewHolder> {
private final Function<Integer, Void> itemClickCallback;
private final Function<Integer, Void> itemInterestClickCallback;
protected CompanyListAdapter(@NonNull CompanyDiffCallback diffCallback, @NonNull Function<Integer, Void> itemClickCallback, @NonNull Function<Integer, Void> itemInterestClickCallback) {
super(diffCallback);
this.itemClickCallback = itemClickCallback;
this.itemInterestClickCallback = itemInterestClickCallback;
}
// ...
}
CompanyListFragment가 주입받을 CompanyListAdapter 클래스를 수정해주어야 한다. 여기서 정의한 커스텀 어댑터는 RecyclerView.Adapter를 상속하는 ListAdapter를 상속하고 있기 때문에 DiffUtil.ItemCallback 객체를 인자로 받아야 한다. 또한 업체 아이템을 클릭했을 때 CompanyItemFragment로 navigate 할 수 있도록 해주는 콜백함수, 업체 찜하기 toggle을 했을 때 UI 아이콘이 업데이트될 수 있도록 해주는 콜백함수를 받아야 한다. CompanyDiffCallback 클래스의 생성자는 따로 @Inject annotation을 붙여주었고 콜백함수는 CompanyListFragment 서브컴포넌트에 설치된 모듈에 추가적으로 정의를 해주었다.
CompanyListModule.java
@Module
public abstract class CompanyListModule {
@Binds
abstract CompanyListContract.View bindCompanyListFragment(CompanyListFragment companyListFragment);
@Named("itemClickCallback")
@Provides
static Function<Integer, Void> provideItemClickCallbackFunction(CompanyListContract.View view) {
return (companyId) -> {
view.navigate(companyId);
return null;
};
}
@Named("itemInterestClickCallback")
@Provides
static Function<Integer, Void> companyItemInterestClickCallbackFunction(CompanyListContract.Presenter presenter) {
return (companyId) -> {
presenter.toggleCompanyInterest((Integer) companyId);
return null;
};
}
@Provides
static CompanyListAdapter provideCompanyListAdapter(CompanyDiffCallback companyDiffCallback, @Named("itemClickCallback") Function<Integer, Void> itemClickCallback, @Named("itemInterestClickCallback") Function<Integer, Void> itemInterestClickCallback) {
return new CompanyListAdapter(companyDiffCallback, itemClickCallback, itemInterestClickCallback);
}
@Provides
static CompanyListContract.Presenter provideCompanyListPresenter(CompanyRepository companyRepository, CompanyListContract.View view, SchedulersFacade scheduler) {
return new CompanyListPresenter(companyRepository, view, scheduler.ui());
}
}
위에서 정의한 두 콜백함수의 리턴타입이 모두 같으므로 @Named annotation을 이용해서 구분해주어야한다. 제너릭 인터페이스 Function은 함수형 인터페이스이기때문에 lambda expression을 사용하여 해당 인터페이스를 구현하는 싱글턴 객체를 생성할 수 있다.
CompanyListFragment.java
public class CompanyListFragment extends Fragment implements CompanyListContract.View {
@Inject
CompanyListContract.Presenter presenter;
@Inject
CompanyListAdapter companyListAdapter;
// ...
}
이제 CompanyListFragment 내부에서 직접 커스텀 함수를 정의하여 어댑터 객체를 생성하지 않고도 included된 모듈로부터 주입받을 수 있게 되었다!
Epilogue
러닝커브가 높은 Dagger2를 학습 및 적용하는데 있어 어려움이 많았으나 스스로 공식 문서를 정독하고 이해할 수 있는 범위 내에서 api를 사용하여 프로젝트를 완성했다는 점에서는 굉장히 뿌듯하다. Dependency Injection에 대해서 심층적으로 이해하게 되었고 직접 프로그래밍을 하든 프레임워크를 사용하든 앞으로 적극적으로 활용해야 겠다.