직접 문제점을 겪고 보일러 플레이트를 줄이기 위해 asResult 확장 함수를 만들었던 과정을 공유합니다.
(피드백을 받고 Now in Android에서 비슷한 구조의 asResult 함수가 존재한다는 것을 알게 되었습니다)
sealed class ResultType<out T> {
object Uninitialized : ResultType<Nothing>()
object Loading : ResultType<Nothing>()
object Empty : ResultType<Nothing>()
object InputError : ResultType<Nothing>()
data class Success<T>(val data: T) : ResultType<T>()
data class Fail<T>(val data: T) : ResultType<T>()
data class Error(val exception: Throwable) : ResultType<Nothing>() {
val isNetworkError = exception is IOException
}
inline fun onSuccess(
action: (value: T) -> Unit
): ResultType<T> {
if(this is ResultType.Success) action(this.data)
return this
}
inline fun onFailure(
action: (value: T) -> Unit
): ResultType<T> {
if(this is ResultType.Fail) action(this.data)
return this
}
inline fun onError(
action: (value: Throwable) -> Unit
) {
if(this is ResultType.Error) action(this.exception)
return
}
}
이후 설명에서 ResultType class에 대해 나오는데 저희는 ResultType이라는 sealed class로 성공,실패,에러 처리를 관리하고 있습니다.
문제점
문제점 1. emit(ResultType.Success(it))
override fun joinChallenge(challengeSeq: Long, password: String?): Flow<JoinChallengeResponse> = flow<JoinChallengeResponse> {
emit(ResultType.Loading)
challengeRemoteDataSource.joinChallenge(challengeSeq, password).collect {
when (it.code) {
200 -> emit(ResultType.Success(it))
-200 -> emit(ResultType.Fail(it))
}
}
}.catch {
emit(ResultType.Error(it))
}
개발을 진행하면서 RepositoryImpl에서는 위와 같은 구조로 함수를 매번 작성하고 있었습니다.
API 요청에 대한 네트워크, 서버에 대한 오류는 ResultType.Error를 통해 방출하고 있었고 API 요청 로직이 성공하였으면 Success, 실패하였으면 Fail에 대한 코드를 받아 이에 대한 타입을 방출하도록 했습니다.
API 함수를 작성할 때마다 처음으로 느꼈던 문제점이 바로 emit(ResultType.Loading) 등 각 타입에 대한 작성이 항상 불편하다고 생각했습니다.
ide에서 제공하는 자동 완성 기능이 0.1초만에 떴으면 좋겠지만 그러지 못해 빠르게 일일이 작성해야하거나 자동 완성이 뜨기까지 딜레이 시간을 기다려서 작성을 하는 것이 불편했습니다. (컴퓨터가 좋지 못해서 더 오래걸리는 것 같습니다.)
아주 조금이지만 더 편하게 사용하기 위해 Flow에 대한 확장 함수를 처음 작성해보게 되었습니다.
suspend fun <T> FlowCollector<ResultType<T>>.emitResultTypeError(throwable: Throwable) {
this.emit(ResultType.Error(throwable))
}
suspend fun FlowCollector<ResultType.Loading>.emitResultTypeLoading() {
this.emit(ResultType.Loading)
}
suspend fun <T> FlowCollector<ResultType<T>>.emitResultTypeSuccess(data: T) {
this.emit(ResultType.Success(data))
}
suspend fun <T> FlowCollector<ResultType<T>>.emitResultTypeFail(data: T) {
this.emit(ResultType.Fail(data))
}
괄호를 제거하여 더 빠르게 사용하고자 했습니다.
override fun joinChallenge(challengeSeq: Long, password: String?): Flow<JoinChallengeResponse> = flow<JoinChallengeResponse> {
emitResultTypeLoading()
challengeRemoteDataSource.joinChallenge(challengeSeq, password).collect {
when (it.code) {
200 -> emitResultTypeSuccess(it)
-200 -> emitResultTypeFail(it)
}
}
}.catch {
emitResultTypeError(it)
}
단순히 하나의 함수만 봤을때는 시간을 엉청나게 단축해준다고 생각해주지는 않지만 이런 API 함수들을 매번 작성해야 했기 때문에 많은 API 함수를 작성하고 나면 결과적으로 적지 않은 시간을 단축시킬 것이라 생각했습니다.
결과
확장 함수가 Import 되어있지 않다면 자동 완성이 잘 되지 않았고 생산성을 올려주지 못했습니다.
하지만, 이 시도를 통해 불필요한 구조를 제거하기 위한 고민을 할 수 있게 되었습니다.
문제점 2. flow 빌더와 collect, catch
override fun joinChallenge(challengeSeq: Long, password: String?): Flow<JoinChallengeResponse> = flow<JoinChallengeResponse> {
emit(ResultType.Loading)
challengeRemoteDataSource.joinChallenge(challengeSeq, password).collect {
when (it.code) {
200 -> emit(ResultType.Success(it))
-200 -> emit(ResultType.Fail(it))
}
}
}.catch {
emit(ResultType.Error(it))
}
emit을 더 편하게 사용하려던 시도는 실패했습니다. 하지만 이 함수 구조에서 또 다른 문제점이 있었습니다. 매번 flow 빌더를 작성하고, emit(Loading), collect, catch 문에서 emit(Error) 까지. 이런 함수를 최소 20개 이상을 작성해야 하는데 불필요하다고 생각하게 되었습니다.
해결
GPT에게 위 함수의 구조에 대해 설명하고 flow 빌더, emit(Loading) 등을 매번 작성할 필요가 없도록 확장 함수를 만들 수 있을지 물어봤습니다.
GPT가 asResult라는 함수를 알려주었지만 제대로 작동하지 않은 함수를 알려주었습니다. 그래도 transform과 onStart 함수에 대해 알 수 있었고 이를 참고하여 사용하는 ResultType에 맞는 확장 함수를 작성했습니다.
inline fun <T> Flow<T>.asResult(crossinline action: suspend (value: T) -> ResultType<T>): Flow<ResultType<T>> = this.transform {
emit(action(it))
}.onStart {
emitResultTypeLoading()
}.catch {
emitResultTypeError(it)
}
inline fun <T, R> Flow<T>.asResultOtherType(crossinline action: suspend (value: T) -> ResultType<R>): Flow<ResultType<R>> = this.transform {
emit(action(it))
}.onStart {
emitResultTypeLoading()
}.catch {
emitResultTypeError(it)
}
crossinline 키워드는 람다 함수가 다른 실행 컨텍스트로 전달될 때 사용합니다. 다른 실행 컨텍스트를 통해 호출되는 경우 동작하지 않기 때문입니다.
transform 중간 연산자를 통해 datasource 함수를 변환하여 방출하도록 만들었습니다.
asResultOtherType은 mapper를 통해 타입이 변경되는 경우를 위해 만들었습니다.
override fun joinChallenge(challengeSeq: Long, password: String?): Flow<JoinChallengeResponse> =
challengeRemoteDataSource.joinChallenge(challengeSeq, password).asResult {
when (it.code) {
200 -> ResultType.Success(it)
-200 -> ResultType.Fail(it)
}
}
}
asResult를 적용하여 깔끔하게 코드가 변했습니다.
flow 빌더, collect, emit(Loading), catch문까지 작성할 필요가 없어졌습니다.
느낀점
- 가장 불편하게 생각했던 구조를 asResult 확장 함수를 통해 편하게 작성할 수 있었습니다.
- 보일러 플레이트를 제거해 팀의 생산성을 끌어올릴 수 있어 뿌듯했습니다.
- 앞으로도 이런 불편한 구조를 개선해 팀의 생산성을 끌어올려야겠다고 생각했습니다.
'Android 일지 > 리팩토링' 카테고리의 다른 글
WEBP 파일 형식을 사용하여 이미지 파일 크기 문제를 해결하고 빌드 속도 향상, 앱 크기 줄이기 (4) | 2023.12.02 |
---|---|
3. 경로 최적화로 좌표 데이터 약 73% 감소 (2) | 2023.10.17 |
6. SharedPreference에서 DataStore로 변경하여 데이터 일관성 문제 해결하기 (0) | 2023.05.30 |
5. CustomView로 재사용성 향상 (0) | 2023.05.27 |
4. EventFlow 도입 (0) | 2023.05.26 |