장고 관련 포스팅은 주구장창 써놨으니 여기서는 API 관련 포스팅은 작성하지 않을 것이다.
여기저기 참조를 해도 기능을 구현하는 데는 아무런 문제가 없지만, 포스팅을 작성하는 데까지는 한참이 걸렸다.
첫 번째 이유는 MVVM 아키텍처를 이해하는데 시간이 오래 걸려서 도무지 글을 쓸 수 있는 상황이 아니었고,
두 번째 이유는 계속 보다보니 이해는 되는데 '왜 굳이 이렇게 구현하지?'라고 드는 부분들과 여러 커스텀 기능들을 추가해보다가 이제서야 포스팅을 시도할 수 있게 되었다.
솔직히 개발 공부는 이론부터 시작하는 게 아니라는 신념 하나로 머리 깨지면서 코드 작성하고 분석 중인 거라 틀린 내용이 많을 것이다.
그래도 열심히 분석해놔서 정리라도 해놓고 싶어서 포스팅을 작성하게 됐다.
참고로 참고한 코드를 다 뜯어서 분석해봤는데 플로우는 대강 요렇게 생겨먹었다.
아직 늦지 않았습니다. 도망치세요.
내가 해보면서 느낀 건 이 내용이 어렵다고 느껴진다면 MVVM에 대한 이해가 부족하기 때문이라고 생각한다.
나도 하나도 모르겠는데, 포스팅 정리하면서 온갖 블로그 죄다 참조했더니 이제서야 조금 알 것 같은 기분이 든다.
굉장히 많은 내용들을 적어놓긴 했지만 쓰면서 중요하다 싶은 키워드는 이후 Concept part로 제대로 다룰 예정이다.
그리고 글을 쓰다보니 처음 구현한 방법과 다르게 커스텀한 내용도 있기 때문에 위의 플로우 차트는 참고용으로만 보는 게 좋다.
시작하기 전에 검토해보는 내 코드의 문제점.
MVVM + 클린 아키텍처를 따르는 디렉토리 구조와는 상당히 멀다. (일단 개발 먼저 해보는 중)
compose라는 기능을 이용해보고 싶은데, 지금은 일단 xml을 사용하여 view를 뿌릴 것이다.
불필요한 코드가 여전히 많이 존재한다고 생각된다.
싱글톤 패턴에서 interface의 Impl을 만들지 않고도 한 번에 처리하는 방법이 있는데, 아직 익숙칠 않아서 적용해보지 못했다.
refresh token을 재발급 받기 위해 다섯 가지 방법 정도를 사용해보았지만 runBlocking을 거는 방법 외에 더 나은 방법을 여전히 찾지 못 했다. (그래서 해당 스니펫은 일단 포함시키지 않았다.)
목차 1. Network 환경 구축하기 2. ngrok로 외부에서 서버 접근 가능하게 만들기 3. 디렉토리 구성 4. Base Class 5. Util Class 6. Request & Response 7. DI Module 8. Repository & UseCase 9. ViewModel 10. Activity User Interface
Django는 서버에 접근하려는 호스트를 제한하는 기능을 지원하는데, 여기서 모든 호스트를 허용하게 만들었다.
2️⃣ Android Network
우선 gradle에 종속성을 추가해줘야 하는데, 뭐가 엄청 많다.
참고로 개인적인 개발을 이미 하던 중이라 이번 작업과는 관련없는 디펜던시도 있습니당..
// 프로젝트 레벨의 build.gradle// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
dependencies {
classpath "com.google.dagger:hilt-android-gradle-plugin:2.44.2"
}
}
plugins {
id 'com.android.application' version '7.2.0' apply false
id 'com.android.library' version '7.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}
만약 API를 실행하는 PC와 에뮬레이터를 실행하는 장치가 서로 다를 때는 localhost 주소의 개념으로 접근이 불가능하다. (또한 실제 모바일 기기로 테스트 해보는 경우 또한 마찬가지) 만약, 하나의 PC에서 API 실행과 에뮬레이터를 모두 돌린다면 URL를 다음과 같이 지정해주면 된다. http://10.0.2.2:8000
1️⃣ Ngrok
API 개발 시, 상황에 따라서는 외부 서비스 연동 혹은 접근을 허용해주어야 한다.
다만 Production이 아닌 단순 개발 단계에서 이러한 작업을 해주는 일은 다소 번거로워 지는데,
Ngrok은 아주 간단하게 외부에서 로컬로 접속할 수 있도록 도와주는 터널링 프로그램이다.
Utility Class라 하면 전역에서 자주 사용되는 정적인 메소드로만 구성된 클래스다.
내가 참고하고 있는 블로그에선 총 3개의 소스 파일을 작성해놨다.
그런데 나는 showToast는 baseActivity에서 구현해놨으므로 굳이 만들지 않았다.
📌 showToast
✒️ Util or BaseActivity
showToast를 Util로 분리해두지 않은 이유가 성능 측면과 관련이 있기 때문은 아니다. 그저 BaseActivity에 선언해놓는다고 하더라도 복잡한 비즈니스 로직을 처리하는 함수는 아니기 때문에 굳이 추가해도 문제가 되지 않는다고 판단하여 따로 분리하지 않은 것뿐이다. 그런데 혹시나 본인이 판단했을 때, showToast를 Util로 분리하는 것이 더 좋다고 판단된다면 BaseActivity에서 showToast 함수를 제거하고 아래 코드를 선언한 뒤 호출하면 된다.
서버에서 API를 통해 JSON 값을 받으면, 이를 파싱해서 알맞게 값을 넣어주어야 하는 수작업이 필요하다.
하지만 이 과정을 한 번이라도 해본 사람은 알겠지만, 진짜 너무 귀찮다.
그런 귀찮음을 호소하는 개발자가 한 둘이 아니었을 테니, 자바 객체를 JSON으로(혹은 역으로) 변환하는 라이브러리가 당연히 존재한다.
그게 바로 Gson이다.
2️⃣ Retrofit
@Singleton@ProvidesfunprovideRetrofit( // 원격 API에 네트워크 클래스 생성
gson: Gson,
okHttpClient: OkHttpClient
): Retrofit.Builder {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.client(okHttpClient)
}
해당 함수는 Retrofit.Builder 인스턴스를 반환하는 함수를 Singleton으로 등록한다.
따라서 애플리케이션 전체에서 한 번만 Retrofit.Builder 인스턴스를 생성하고 재사용할 수 있게 된다.
HttpLoggingInterceptor를 추가하면 요청을 실패하게 만든 오류의 원인을 디버깅하고 확인하는데 필수적이다.
ErrorInterceptor는 처음에 provideInterceptor 내에서 람다 함수를 이용해 만들었었는데 몇 가지 문제가 있었다.
📌 Interceptor 객체 상속 VS lambda 함수로 Interceptor 처리
1️⃣ 실행 순서
Interceptor 객체를 상속받아 처리하는 방법은 Interceptor 객체가 생성되고 등록된 이후에 Retrofit 라이브러리에서 요청을 보낼 때마다 실행된다. 반면, provideInterceptor에서 처리하는 방법은 Retrofit 라이브러리에서 OkHttpClient 객체를 생성할 때 실행된다. 따라서, Interceptro 객체를 상속받는 방법은 요청에 대한 처리를 보다 자세하게 할 수 있지만, provideInterceptor에서 처리하는 방법은 초기 설정 등의 처리를 할 수 있다.
2️⃣ 유연성
Interceptor 객체를 상속받는 방법은 각각의 Interceptor 객체가 독립적으로 동작하기 때문에 서로 다른 Interceptor 객체를 생성해서 요청에 대한 처리를 다르게 할 수 있다. provideInterceptor에서 처리하는 방법은 모든 요청에 대해 동일한 처리를 할 수밖에 없기 때문에 유연성이 떨어질 수 있다. 따라서 요청에 대한 처리를 자세하게 하고 싶다면 Interceptor 객체를 상속받는 방법을 사용하고, 초기 설정 등의 처리를 하고 싶다면 provideInterceptor에서 처리하는 방법을 사용하는 것이 좋다.
나는 Interceptor를 이용해서 간단한 에러 처리와 access token 만료 시, refresh token을 요청하는 과정을 거치려 했기 때문에 Interceptor 객체를 상속받는 방식을 택했다.
AuthRepository 클래스는 인증 데이터를 Network Request로부터 가져와 ViewModel에 전달한다.
(정확히는 viewModel에서 AuthRepository를 호출해서 결과를 건네 받는다.)
Local DB에서 데이터를 가져올 경우 Local Data를 가져와서 ViewModel에 전달하는 데도 계속 사용한다.
중요 목표는 source를 알지 못 하는 viewModel없이 source에서 viewModel로 데이터를 전달한다는 목표다.
📌 UseCase
✒️ What is UseCase?
처음에 대체 UseCase라는 클래스가 왜 필요한가 한참을 고민했었다. 이는 Clean Architecture를 지키기 위해 domain layer에서 사용하는 클래스로서 서비스를 사용하고 있는 사용자(User)가 해당 서비스를 통해 하고자 하는 것을 의미한다. 즉, ViewModel이 어떤 것을 하고자 하는지 직관적으로 파악할 수 있으며, Repository를 직접 전달받지 않고 UseCase를 사용함으로써 의존성을 낮출 수 있는 효과까지 얻을 수 있다.
// Kotlin invoke 연산자 : 이름 없이 호출된다. == 메서드 이름없이 호출할 수 있다.// 람다는 invoke 함수를 가진 객체다.// Kotlin Flow는 suspend function을 보완하기 위한 객체.// suspend fun이 하나의 결과물을 던진다면, flow로 여러 개의 결과를 원하는 형식으로 던질 수 있다.// suspend fun과 동일하게 비동기로 동작하며 cold stream을 지원한다.
몇 년 전까지만 해도 MutableLiveData를 많이 사용했지만, 나는 MutableStateFlow를 사용했다.
이유는 .xml로 view를 다루지 않고, compose로 대체하기 위한 밑작업이다.
MutableStateFlow로 넘어오는 값을 _registerState가 관찰하고 있고, registerState로 원하는 view에 전달하는 최종값을 알려준다.
✒️ MutableLiveData vs MutableStateFlow
1. 데이터 흐름 방식 • MutableLiveData: 데이터를 일회성 이벤트로 제공하며, Observer 패턴을 사용하여 데이터 변경을 알린다. 관찰자가 데이터를 수독으로 구독하고 업데이트를 처리해야 하므로, 데이터 변화를 즉시 반영하기 위해 Observer 객체가 추가적으로 필요하다. • MutableStateFlow: 데이터의 최신 상태를 연속적으로 제공하며, 코루틴 플로우 형태를 가지면서 데이터 변경을 자동으로 알린다. 이런 흐름 방식은 Compose와 같은 선언형 UI Framework와 잘 맞으며, 데이터가 변경될 때마다 UI가 자동으로 업데이트된다.
2. 데이터 타입 • MutableLiveData: Android Jetpack에서 제공되는 클래스로써 Android 앱에서 UI 업데이트를 위해 사용된다. LiveData는 Nullable일 수 있으며, LiveData 자체의 생명주기를 가지므로 메모리 누수를 방지할 수 있다. • MutableStateFlow: StateFlow는 Kotlin 표준 라이브러리 중 하나로써 코루틴 플로우와 함께 사용된다. StateFlow의 Data는 Nullable일 수 없으며, 상태 값이 항상 존재한다.
3. 구독 방식 • MutableLiveData: Observer 패턴을 사용하여 데이터 변경을 알리며, Observer 객체가 LiveData에 등록되어야 한다. 일반적으로 Activity나 Fragment와 같은 Android 컴포넌트의 LifeCycle을 따른다. • MutableStateFlow: 코루틴 플로우를 기반으로 하며, 코루틴을 사용하여 비동기적으로 데이터를 처리하고 업데이트를 수신할 수 있다. StateFlow를 구독하면 코루틴이 생성되어 데이터 변경 사항을 수진하고, 구독을 취소하면 코루틴도 취소된다.
✒️ 왜 compose를 쓸 때는 MutableStateFlow를 써야 하는가?
Compose 사용은 기존 xml을 사용하던 것과 달리 Android의 완전한 선언형 프로그래밍으로써의 탈피를 의미한다. UI 상태의 변경이 외부에서 일어날 경우 자동으로 재구성되는데, 이런 특징으로 인해 Compose에서 상태를 관리하려면 변경 가능한(Mutable) 상태를 지원해야 한다.
LiveData를 사용해야 하는 경우, 데이터 변경을 알리기 위해 Observer 패턴을 사용해야 하나, 이는 Compose의 LifeCycle과는 맞지 않는다. 따라서 Compose에서는 Mutable State를 사용하여 상태 변화를 알리며, 최신 값을 수신하는 Flow를 사용하는 것이 권장된다.
특히, Compose는 MutableStateFlow에 대한 구독을 자동으로 관리해주기 때문에 개발자가 직접 상태 변경 알림을 처리할 필요조차 없다.
funregister(registerRequest: RegisterRequest) {
registerUserUseCase(registerRequest)
.onEach { result ->
when(result) {
is Resource.Success -> {
_registerState.value = RegisterState(data = result.data)
}
is Resource.Loading -> {}
is Resource.Error -> {
Log.e("RegisterInterceptor", "failed ViewModel ${result.message}")
_registerState.value = RegisterState(error = result.message)
}
}
}.launchIn(viewModelScope)
}
funlogin(loginRequest: LoginRequest) {
loginUserUseCase(loginRequest)
.onEach { result ->
when(result) {
is Resource.Success -> {
val access = result.data?.access
val refresh = result.data?.refresh
if (access != null && refresh != null) {
AuthApplication.prefs.setAccessToken("access", access)
AuthApplication.prefs.setRefreshToken("refresh", refresh)
}
_loginState.value = LoginState(data = result.data)
}
is Resource.Loading -> {}
is Resource.Error -> {
Log.e("LoginInterceptor", "failed ViewModel ${result.message}")
_loginState.value = LoginState(error = result.message)
}
}
}.launchIn(viewModelScope)
}
📌 RegisterState & LoginState
그리고 현재 응답 상태를 RegisterState와 LoginState를 이용해서 데이터 상태를 표시하도록 정의하고 있다.