이번에는 RetroFit 통신 라이브러리를 compose에 적용해보겠다.
dependencies {
...
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:3.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.retrofit2:converter-scalars:2.9.0")
...
}
retroFit에 필요한 환경들을 import 해준다.
두번째 줄은 통신할때마다 log를 가져오기 위해서,
셋째 줄과 넷째 줄은 google 로그인과 gson 파싱을 위해 추가해줬다.
RestfulModule
@Module
@InstallIn(SingletonComponent::class)
object RestfulModule {
@Singleton
@Provides
fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor(HttpLoggingInterceptor.Logger.DEFAULT).apply {
level = if (com.google.firebase.BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
@Singleton
@Provides
fun provideOkHttpClient(
httpLoggingInterceptor: HttpLoggingInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(httpLoggingInterceptor)
.addNetworkInterceptor { chain ->
chain.proceed(chain.request().newBuilder().build())
}
.connectTimeout(5000L, TimeUnit.MILLISECONDS)
.readTimeout(5000L, TimeUnit.MILLISECONDS)
.writeTimeout(5000L, TimeUnit.MILLISECONDS)
.build()
}
@Singleton
@Provides
fun provideRetrofit(
okHttpClient: OkHttpClient,
): Retrofit {
val gson = GsonBuilder().setLenient().create()
return Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create(gson))
.addConverterFactory(ScalarsConverterFactory.create())
.client(okHttpClient)
.baseUrl(BuildConfig.BASE_URL)
.build()
}
}
Module은 처음 환경을 구축할 때 다른 개발자 분 벨로그를 많이 참고했었다.
먼저 저번 Room처럼 @Module와 @InstallIn을 통해 Hilt에 선언해준다.
provideHttpLoggingInterceptor은 Request와 Response의 로그를 관리해준다.
개발 과정에서 진짜 진짜 중요하고 덕분에 편하게 개발할 수 있었다..!
관련 라이브러리로 chucker도 있는데, 나중에 포스팅 해보겠다.
provideOkHttpClient는 위에서 생성한 logInterceptor을 추가하고 통신 관련 규약을 작성한 Retrofit에 들어갈 Client이다.
현재는 타임아웃을 5초로 생성해줬다.
마지막으로 provideRetrofit은 Retrofit을 반환하며 위에서 생성한 클라이언트 연결, gson변환과 baseUrl 연동을 해줬다.
AppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
...
@Singleton
@Provides
fun provideDiaryDao(diaryDatabase: DiaryDatabase): DiaryDao = diaryDatabase.diaryDao()
@Singleton
@Provides
fun provideDiaryService(retrofit: Retrofit): DiaryService {
return retrofit.create(DiaryService::class.java)
}
@Singleton
@Provides
fun provideDiaryRepository(
diaryDao: DiaryDao,
diaryService: DiaryService
): DiaryRepository {
return DiaryRepository(diaryDao, diaryService)
...
}
마지막으로 저번에 생성한 AppModule에 Dao와 함께 DiaryService를 Repository에 연결해주면 된다.
DiaryDetailDTO
data class DiaryDetailDTO(
@SerializedName("title")
val title: String,
@SerializedName("content")
val content: String,
@SerializedName("date")
val date: String,
@SerializedName("location")
val location: SeoulLocation,
)
data class 로 통신할 때 사용할 모델을 만들어준다.
Request와 Response 모델이 있고 Response로 사용할 DiaryDeatilDTO를 활용해보겠다.
@SerializedName 은 Gson에서 내부의 문자열을 Json 키값으로 사용해준다.
API문서 그대로 입력해주면 된다.
DiaryService.kt
interface DiaryService {
// 일기 작성
@POST("/diary")
suspend fun diaryWrite(
@Header("token") token: String,
@Body diary: Diary
): Response<Unit>
// 일기 조회
@GET("/diary/{id}")
suspend fun diaryDetail(
@Header("token") token: String,
@Path("id") id: String
): Response<DiaryDetailDTO>
}
interface로 Service를 만들어준다.
이 클래스를 통해 일기와 관련된 API들을 관리하고 호출해준다.
(함수명은 보통 동사가 앞에오지만 정렬을 위해 diary를 의도적으로 앞에 뒀다.)
@POST나 @GET는 RESTFUL의 4가지 형태중 하나로, API가 어떤 역할을 수행할지 나타내준다.
그 뒤에는 API의 BaseURL 뒤의 세부 주소를 입력해준다.
suspend로 선언해주는 이유는 ROOM DAO와 마찬가지로 응답이 올때까지 비동기적으로 함수를 수행해주기 위해서다.
이후에는 API에서 요구하는 대로 파라미터를 넣어주면 된다.
토큰을 이용하는 통신을 가정하고 Header에 토큰을 넣어줬고 일기를 작성하는 API에는 Body 로 Diary 모델을, 조회하는 API에는 일기의 ID값을 넣어줬다.
@GET으로 지정된 API는 결과값을 리턴받게 된다.
일기 조회의 경우에는 앞서 생성한 DiaryDetailDTO를 Response로 받을 수 있다.
DiaryRepository
class DiaryRepository @Inject constructor(
private val diaryDao: DiaryDao,
private val diaryService: DiaryService
) : BaseRepository() {
val diaries: Flow<List<Diary>> = diaryDao.getAll().flowOn(Dispatchers.IO).conflate()
val accessToken = "token"
// DB에 일기 입력하는 함수
suspend fun insertDiaryDao(diary: Diary) = diaryDao.insertDiaries(diary)
// 통신이 성공했을 때만 DB 입력 함수를 호출
suspend fun writeDiary(diary: Diary) {
diaryService.diaryWrite(accessToken, diary).let {
if (it.isSuccessful) insertDiaryDao(diary)
}
}
// 일기 조회 함수
suspend fun readDiary(diaryId: String): Diary? {
diaryService.diaryDetail(accessToken, diaryId).let {
if (it.isSuccessful) {
val response = it.body()!!
return Diary(
title = response.title,
message = response.content,
date = Formatter.longToLocalDateTime(response.date.toLong()),
location = response.location
)
}
}
return null
}
}
지난번에 생성한 DB에 일기를 입력하는 함수를 이번에 만든 함수의 통신 결과가 성공했을 때만 호출되게 변경했다.
항상 최신 DB의 정보를 가져오는 Flow타입의 diaries 변수의 서버와 내부 로컬 DB가 달라질 경우 사용자의 혼란이 생길 수 있기 때문이다.
일기 조회 함수의 경우 방식은 동일하지만 일기 id를 받아서 상세정보를 반환하고 통신이 실패한 경우는 null을 반환해 에러를 알 수 있게 했다.
(통신 에러인지 확인할 수 있게 더 꼼꼼하게 작업해줘야 한다.)
DiaryDetailDTO는 API에서 사용하는 통신모델이기 때문에 더 이상 혼용되지 않도록 Repository에서 내부에서 사용할 Diary 모델로 변경해줬다.
(Formatter는 자체적으로 만든 Util클래스)
이후에는 viewModel에서 함수들을 호출해주면 된다.
// 느낀점
RetroFit을 처음 구축하기까지 정말 고생을 많이 했었다..
공식 문서부터 구글링과 블로그들을 따라해보고 응용해보면서 Hilt가 의존성을 정말 편하게 관리해주고 있다는 것과 Retrofit이 나오기 전까지 HttpConnection과 OkHttp를 거쳐가면서 선배들의 수많은 고충들을 감히 예상해 볼 수 있었다.
Hilt-Compose 초간단 일기 앱 마무리!