Dependency는 의존성이라고 하는데 이 의존성은 하나의 클래스가 다른 하나의 클래스에 의존하는 것을 의미함
A 클래스 객체를 만들기 위해 B를 필요로 한다는 것을 생각해볼 수 있음
여기서 Dependency Injection은 쉽게 생각해보면 이러한 의존관계에 있는 클래스에 대해서 필요한 클래스를 생성자나 setter 메소드를 이용해서 객체 자체를 전달받으면 됨, 따지고 보면 FitIn 진행에 있어서 네트워크 통신 처리, Preferences 처리, 그리고 AAC + Repository를 적용하면서 자연스럽게 서로 DI가 되는 케이스가 됨
그렇다면 이전에 Java로 작성한 부분에 있어서 직접 생성자를 통해서 혹은 new
연산자를 통해서 객체를 주입하는 것은 틀린 것이 아니지만 Library를 사용하는 이유는?
물론 현재의 단계에선 그리고 규모가 크지 않다면 필요가 없을 순 있지만 매번 이 객체를 받아들일 생성자나 setter 메소드를 만들어주는 것 뿐 아니라 계속해서 그에 따라 관리를 해야함
즉, 만약 아무런 고려없이 생성자나 setter를 남발한다면? 나중에 에러가 생기거나 문제가 발생했을 때 손을 대야하는 포인트가 생길 때 서로 의존성이 얽혀서 하나를 고치다가 여러개를 고치거나 꼬이는 상황이 발생할 수 있음
그렇기 때문에 이 Library를 활용하여 확실하게 효율성을 높이는 것 뿐 아니라 올바른 순서로 생성시키고 객체를 받아들일 수 있도록 관리하는 Library를 사용함
그 중 Dagger, Hilt와 같은 라이브러리가 있는데 Hilt의 경우 Dagger를 기반으로 만들었고 Android 전용 DI 라이브러리로 오피셜하게 말하였기 때문에 Hilt를 사용함
Hilt 사용을 위해 @HiltAndroidApp
이 포함된 Application
클래스가 포함되어야 함
이를 통해 의존성 주입의 시작점을 지정하고 컴파일 단계시 DI에 필요한 구성요소를 초기화 할 수 있음
즉 ApplicationContext
등 Dependency를 접근하기 위한 세팅을 미리 한 것
package com.example.fitin_kotlin
import android.app.Application
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
}
}
Hilt에서 Dependency를 주입해줄 수 있는 클래스에는 Activity
, Fragment
, View
등이 있음
이 때 @AndroidEntryPoint
를 통해 의존성 주입을 해줄 수 있음, 그러면 해당 component에 대해서 @Inject
를 통해서 의존성을 주입받을 수 있음
@Inject
어노테이션을 통해서 의존성을 생성받을 수 있음, 생성자에 의존성 인스턴스를 생성하여 생성자의 파라미터로 의존성을 주입받는 것임@HiltViewModel
class DetailViewModel @Inject constructor(
private val newsRepository: NewsRepository,
state: SavedStateHandle
) : ViewModel(){
private val requestNews = MutableLiveData<ResponseNewsList>()
private val _news = MutableLiveData<ResponseNews>()
val news: LiveData<ResponseNews>
get() = _news
init {
requestNews.value = state.getLiveData<ResponseNewsList>("selectedNews").value
getNews()
}
private fun getNews() {
val newsId: Long? = requestNews.value?.id
viewModelScope.launch {
_news.value = newsRepository.getNews(newsId!!)
}
}
}
여기서 앞서 Component 요소가 아닌 ViewModel
에 대해서 의존성 주입을 하기 위한 어노테이션인 @HiltViewModel
을 쓴 것을 알 수 있음
이 부분을 쓰게 된다면 기존의 코드에서 큰 개선사항이 생김 기존 Java 코드를 본다면
final NewsListResponseDto newsList = DetailFragmentArgs.fromBundle(getArguments()).getSelectedNews();
final DetailViewModelFactory viewModelFactory = new DetailViewModelFactory(newsList, requireActivity().getApplication());
viewModel = new ViewModelProvider(this, viewModelFactory).get(DetailViewModel.class);
위의 부분을 보면 Fragment Navigation에서 Safe args를 통해 data를 bundle로 전달하는데 이를 확실히 받기 위해서 ViewModelFactory를 만들고 위와 같이 해당 Bundle 값을 만들어서 생성을 하게 됨
하지만 여기서 @HiltViewModel
을 사용함에 따라 의존성 주입이 생성자 단계에서 아래와 같이 ViewModel에서 받을 수 있게 되고 실제 해당 값 처리 역시 별도의 ViewModelFactory를 만들어서 처리하지 않고 Fragment에서도 아래와 같이 간단하게 처리할 수 있게됨
@HiltViewModel
class DetailViewModel @Inject constructor(
private val newsRepository: NewsRepository,
state: SavedStateHandle
) : ViewModel(){
private val requestNews = MutableLiveData<ResponseNewsList>()
private val _news = MutableLiveData<ResponseNews>()
val news: LiveData<ResponseNews>
get() = _news
init {
requestNews.value = state.getLiveData<ResponseNewsList>("selectedNews").value
getNews()
}
private fun getNews() {
val newsId: Long? = requestNews.value?.id
viewModelScope.launch {
_news.value = newsRepository.getNews(newsId!!)
}
}
}
@AndroidEntryPoint
class DetailFragment : Fragment() {
private val detailViewModel: DetailViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding: FragmentDetailBinding = DataBindingUtil.inflate(inflater, R.layout.fragment_detail, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.detailViewModel = detailViewModel
return binding.root
}
}
앞서 본 내용들은 결국 내부적으로 클래스를 참조하고 의존관계가 형성될 때 쓸 수 있는 부분에 대해서 설명한 것이라면 이제 Retrofit, OkHttpClient 등 기존에 싱글톤으로 만들어서 처리하긴 했지만 외부 라이브러리를 사용하는 경우 Hilt 모듈을 사용하여 의존성을 생성할 수 있음
여기선 의존성 인스턴스를 제공하는 방법을 Hilt에 알려주는 역할을 함 그리고 @InstallIn
어노테이션을 지정하여 어떤 컴포넌트에 install 할지를 정함
현재 시점에 이 install할 지점을 고려하면 결국 Application 단위로 처리해야함, 왜냐하면 Retrofit이 됐든 Repository가 됐든 그리고 SharedPreferences 역시 특정 시점을 넘어서 특정한 상황에서의 ViewModel View 등에서 다 쓰이기 때문에 SingletonComponent
로 설정한 것
그러면 이 부분이 어떻게 개선되냐면 아래와 같이 Java에서의 Repository에선 API와 RetrofitBuilder를 생성자 시점에서 항상 만들고 Preferences 역시 메모리 누수를 감수하더라도 init으로 Application 단위를 직접 설정을 했음
private final UserAPI userApi;
public UserRepository(Application application) {
userApi = RetrofitBuilder.getRetrofit().create(UserAPI.class);
Preferences.init(application);
}
@Inject
를 통한 Service 생성으로만 바로 사용할 수 있음, 그리고 Preferences 역시 API 호출에 따라 계속 활용해야 하는데 아래와 같이 간단하게 @Inject
로 초기화 작업없이 사용가능하게 됨class UserRepository @Inject constructor(private val userService: UserService){
suspend fun postSignUp(requestSignUp: RequestSignUp) = userService.postSignUp(requestSignUp)
suspend fun postSignIn(requestSignIn: RequestSignIn) = userService.postSignIn(requestSignIn)
fun postReIssue(requestTokenReissue: RequestTokenReissue) = userService.getReIssue(requestTokenReissue)
suspend fun getEmail(accessToken: String) = userService.getEmail("Bearer $accessToken")
}
@HiltViewModel
class SignInViewModel @Inject constructor(
private val userRepository: UserRepository,
private val newsRepository: NewsRepository,
private val prefs: EncryptedSharedPreferenceController
) : ViewModel(){
val email: MutableLiveData<String> = MutableLiveData<String>()
val password: MutableLiveData<String> = MutableLiveData<String>()
private val _eventSignIn = MutableLiveData<Boolean>()
val eventSignIn: LiveData<Boolean>
get() = _eventSignIn
fun onSignIn(view: View) {
val request = RequestSignIn(email.value, password.value)
viewModelScope.launch {
val signin = userRepository.postSignIn(request)
when (signin.isSuccessful) {
true -> {
Log.e("token", "성공: " + signin.body()?.accessToken)
prefs.setAccessToken(signin.body()!!.accessToken)
prefs.setRefreshToken(signin.body()!!.refreshToken)
newsRepository.callNews()
}
else -> {
Log.e("실패", "error " + signin.message())
}
}
}
_eventSignIn.value = true
}
@Inject
만으로도 할 수 있는 것이 DI 라이브러리를 활용해서 가능해진 것이고 이를 위한 모듈에서는 어노테이션으로 의존성 생성과 동시에 이 올바른 순서로 생성시켜서 아래와 같이 Network 모듈을 만들 수 있음package com.example.fitin_kotlin.di
import com.example.fitin_kotlin.data.local.EncryptedSharedPreferenceController
import com.example.fitin_kotlin.data.remote.api.NewsService
import com.example.fitin_kotlin.data.remote.api.UserService
import com.example.fitin_kotlin.data.repository.NewsRepository
import com.example.fitin_kotlin.data.repository.UserRepository
//import com.example.fitin_kotlin.network.AuthInterceptor
//import com.example.fitin_kotlin.network.AuthInterceptor
//import com.example.fitin_kotlin.network.AuthInterceptor
import com.example.fitin_kotlin.network.TokenAuthenticator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.Interceptor
import okhttp3.OkHttp
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val BASE_URL = "http://10.0.2.2:8080"
@Singleton
@Provides
fun providesHttpLoggingInterceptor() = HttpLoggingInterceptor()
.apply {
level = HttpLoggingInterceptor.Level.BODY
}
@Singleton
@Provides
fun providesOkHttpClient(httpLoggingInterceptor: HttpLoggingInterceptor, tokenAuthenticator: TokenAuthenticator): OkHttpClient =
OkHttpClient
.Builder()
.authenticator(tokenAuthenticator)
.addInterceptor(httpLoggingInterceptor)
.build()
@Singleton
@Provides
fun provideRetrofit(okHttpClient: OkHttpClient) : Retrofit = Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(BASE_URL)
.client(okHttpClient)
.build()
@Singleton
@Provides
fun provideUserService(retrofit: Retrofit): UserService = retrofit.create(UserService::class.java)
@Singleton
@Provides
fun provideUserRepository(userService: UserService) = UserRepository(userService)
@Singleton
@Provides
fun provideNewsService(retrofit: Retrofit): NewsService = retrofit.create(NewsService::class.java)
@Singleton
@Provides
fun provideNewsRepository(newsService: NewsService) = NewsRepository(newsService)
결국 위의 모듈을 통해서 각각 Retrofit과 API Service를 Repository를 통해 만들고 외부에 주입 받아서 처리하는 것임
이렇게 하면 굳이 생성자 만드는 것과 초기화 로직을 만들 필요없이 위와 같이 필요한 부분에 대해서 @Inject
를 활용하여 주입받은 객체에 대해서 변수처럼 사용할 수 있게 되는 것임
여기서 유의할 점은 DI cycle이 발생할 수 있기 때문에 그 순서와 로직을 잘 설계해서 만들어야함
위와 같이 모듈로 관리함에 따라 Network 처리와 추가로 Preferences 역시 @Inject
를 통해서 쉽게 사용이 가능하고 보일러 플레이트 코드가 발생할 일도 줄어듬
기존에 로직에서는 Interceptor를 통해서 401 에러일 경우 체크를 해서 수정하게끔 처리함, 하지만 이 상황의 경우 모듈에서 의존성 주입을 추가할 경우 Cycler 에러가 발생하고 또 네트워크 비동기 처리 방식 자체도 코루틴을 활용함에 따라 원활하게 기존 로직이 처리가 되지 못하였음
그 진행 과정 중 사실 Interceptor로 직접 수동적으로 401 에러를 잡는 사항이 문제가 발생할 수 있는 요소들도 있고 어느정도 자동으로 해당 에러를 캐치하는 부분을 찾았는데 이 때 Authenticator를 활용하면 자동으로 해당 에러를 캐치하여 핸들링 하는 것을 알아냄
그래서 이 부분에 대해서 Authenticator를 적용함 물론 이전 Interceptor에서 처리하는 메인 로직처리 방식은 비슷해 보이지만 자동으로 에러를 캐치하여 다시 재발급 처리를 진행하기 때문에 이를 횟수 제한과 바로 통신 결과 처리를 하여 더 빠르게 401 에러를 핸들링 할 수 있었음
package com.example.fitin_kotlin.network
import android.util.Log
import com.example.fitin_kotlin.data.local.EncryptedSharedPreferenceController
import com.example.fitin_kotlin.data.model.network.request.RequestTokenReissue
import com.example.fitin_kotlin.data.model.network.response.ResponseToken
import com.example.fitin_kotlin.data.repository.UserRepository
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenAuthenticator @Inject constructor(
private val prefs: EncryptedSharedPreferenceController,
private val repository: Lazy<UserRepository>
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.responseCount >= 2) {
return null
}
val requestTokenReissue =
RequestTokenReissue(
prefs.getAccessToken().toString(),
prefs.getRefreshToken().toString()
)
val token: retrofit2.Response<ResponseToken> =
repository.get().postReIssue(requestTokenReissue).execute()
if (token.isSuccessful) {
prefs.setAccessToken(token.body()!!.accessToken)
prefs.setRefreshToken(token.body()!!.refreshToken)
Log.e("Token", "token ${prefs.getAccessToken().toString()}")
return response.request.newBuilder()
.header("Authorization", "Bearer ${token.body()?.accessToken}")
.build()
} else {
return null
}
return null
private val Response.responseCount: Int
get() = generateSequence(this) { it.priorResponse }.count()
}
Interceptor에서 진행한 바와 같이 새롭게 요청을 해서 처리하는 부분은 유사함
하지만 이를 떠나서 로그인 & 재발급 부분은 백엔드쪽과 좀 더 상의해서 수정을 해봐야 할 것 같음