이전에도 MVI 구조를 정리했듯, "왜 이렇게 짰지?"를 다시 돌아보는 게 진짜 성장이라고 생각한다. 이번엔 프로젝트를 진행하면서 팀원들과 함께 만든 네트워크 매퍼/처리 유틸리티들을 코드와 이유를 함께 하나씩 해부한다.
1. 네트워크 결과를 Resource로 바꾸는 이유와 실제 코드
문제: API 호출 결과가 성공/실패/에러 등 다양한 케이스가 있는데, 뷰 단에서 그걸 하나하나 분기 처리하면 코드가 지저분해진다.
해결: NetworkResult → Resource로 변환해서 UI 레이어엔 언제나 Loading/Success/Failure만 보내줌으로써 단일 상태로 관리.
fun <T : Any, R : Any> apiResponseToResourceFlow(
mapper: (T) -> R,
call: suspend () -> NetworkResult<ApiResponse<T>>
): Flow<Resource<R>> = resourceFlowFromNetworkResult(call) { data ->
if (data == null) {
Resource.Failure(DEFAULT_ERROR_MESSAGE, HttpStatus.NO_CONTENT)
} else {
Resource.Success(mapper(data))
}
}
- 이유: API DTO를 도메인 모델로 변환하는 mapper도 받을 수 있어서, 관심사 분리/테스트 용이성/확장성 모두 챙겼다.
2. Flow로 감싼 이유, 코루틴 비동기 처리와 연동
문제: 코루틴 기반 API는 suspend로 동작하지만, UI는 항상 비동기 흐름이 필요한 경우가 많다. 특히 상태관리(MVI 등)에서는 Flow 타입이 장점이 명확함.
해결: resourceFlowFromNetworkResult 에서 flow 빌더로 감싸주고, IO Dispatcher에서 동작시키는 구조.
private fun <T : Any, R : Any> resourceFlowFromNetworkResult(
call: suspend () -> NetworkResult<ApiResponse<T>>,
onSuccess: suspend (T?) -> Resource<R>
): Flow<Resource<R>> = flow {
emit(Resource.Loading)
val result = withTimeoutOrNull(3_000L) {
call()
} ?: return@flow emit(Resource.Failure("요청 시간이 초과되었습니다.", HttpStatus.REQUEST_TIMEOUT))
when (result) {
is NetworkResult.Success -> emit(onSuccess(result.data.data))
is NetworkResult.Error -> emit(Resource.Failure(result.message ?: DEFAULT_ERROR_MESSAGE, result.code))
is NetworkResult.Exception -> emit(handleNetworkException(result.e))
}
}.flowOn(Dispatchers.IO)
- 이유: (1) 타임아웃 체크가 일관적으로 들어가고 (2) Exception/Error 각각 제대로 구분된다.
- 실전 인사이트: Flow로 래핑하면 Compose, Orbit 등 MVI 프레임워크에서 collect만 하면 테스트/구독/취소 등 전부 깔끔하게 된다.
3. Exception, Error 케이스 분리의 절실함
문제: 서버 에러(5xx, 4xx)와 네트워크 예외(Timeout, Disconnect 등)가 다르게 처리돼야 하는데, 흔히 섞어서 관리하는 경우가 많다.
해결: NetworkResult를 Error/Exception으로 구분 + handleNetworkException으로 구체적인 상황별 메시지와 코드 제공
internal fun handleNetworkException(e: Throwable): Resource.Failure {
return when (e) {
is ConnectException -> Resource.Failure(
errorMessage = "네트워크 연결을 확인해주세요.",
code = HttpStatus.NETWORK_DISCONNECTED
)
is SocketTimeoutException -> Resource.Failure(
errorMessage = "서버 응답이 지연되었습니다.",
code = HttpStatus.REQUEST_TIMEOUT
)
else -> Resource.Failure(
errorMessage = e.message ?: DEFAULT_ERROR_MESSAGE,
code = HttpStatus.UNKNOWN
)
}
}
- 이유: 실제 운영에서 서버서 502 뜨는 것과, 단순히 4G 끊겨서 발생하는 것의 사용자 안내가 달라야 함.
- 실전 경험: 운영하면서 문의가 쏟아질 때, 메시지 및 코드별로 제대로 안내하는 게 장애 파악에 핵심임.
4. 매퍼와 리스트 매퍼, 그리고 빈 응답 처리
문제: 정상적으로 응답 성공인데 바디는 빈 값, 또는 Unit 등 다양할 때. 예전엔 null이 바로 내려오면서 NPE가 생기곤 했다.
해결: 빈 바디는 항상 Unit 반환 & 리스트 때도 null-safe하게 처리
fun <T : Any> emptyApiResponseToResourceFlow(
call: suspend () -> NetworkResult<ApiResponse<T>>
): Flow<Resource<Unit>> = resourceFlowFromNetworkResult(call) {
Resource.Success(Unit)
}
fun <T : Any, R : Any> apiResponseListToResourceFlow(
mapper: (T) -> R,
call: suspend () -> NetworkResult<ApiResponse<List<T>>>
): Flow<Resource<List<R>>> = resourceFlowFromNetworkResult(call) { data ->
if (data == null) {
Resource.Failure(DEFAULT_ERROR_MESSAGE, HttpStatus.NO_CONTENT)
} else {
Resource.Success(data.map(mapper))
}
}
- 이유: PUT/DELETE 등에서 빈 바디 오는 경우에도 안정적으로 Success(Unit)으로 반환하여 코드 단순화.
- 실전 경험: 실제로 SaaS API 연동할 때 빈 리스트/Unit 응답은 흔하므로, 여기서 미리 처리해두면 화면/비즈니스 로직 방어 코드가 확 줄어든다.
5. Suspend 버전과 Flow 버전의 이중 구조
문제: 때로는 일회성 API 호출만 필요한 경우와 상태 관찰이 필요한 경우가 섞여있다.
해결: 동일한 로직을 suspend 함수와 Flow 함수로 각각 제공하는 이중 구조
// Suspend 버전
suspend fun <T : Any, R : Any> apiResponseToResource(
mapper: (T) -> R,
call: suspend () -> NetworkResult<ApiResponse<T>>
): Resource<R> = resourceFromNetworkResult(call) { data ->
if (data == null) {
Resource.Failure(
errorMessage = DEFAULT_ERROR_MESSAGE,
code = HttpStatus.NO_CONTENT
)
} else {
Resource.Success(mapper(data))
}
}
// Flow 버전은 위에서 본 apiResponseToResourceFlow
private suspend fun <T : Any, R : Any> resourceFromNetworkResult(
call: suspend () -> NetworkResult<ApiResponse<T>>,
onSuccess: (T?) -> Resource<R>
): Resource<R> {
return withTimeoutOrNull(3_000L) {
when (val result = call()) {
is NetworkResult.Success -> onSuccess(result.data.data)
is NetworkResult.Error -> Resource.Failure(
errorMessage = result.message ?: DEFAULT_ERROR_MESSAGE,
code = result.code
)
is NetworkResult.Exception -> handleNetworkException(result.e)
}
} ?: Resource.Failure(
errorMessage = "요청 시간이 초과되었습니다.",
code = HttpStatus.REQUEST_TIMEOUT
)
}
- 이유: UseCase에서는 suspend 버전이 간단하고, ViewModel에서는 Flow 버전이 상태 관리에 유리함.
- 실전 경험: Repository 레이어에서 선택의 여지를 주면 각 상황에 맞는 최적의 코드를 짤 수 있다.
6. 확장 함수로 더 간단한 변환 패턴
문제: ApiResponse 래핑이 필요 없는 단순한 NetworkResult → Resource 변환도 많이 필요함.
해결: 확장 함수로 더 직관적인 변환 패턴 제공
suspend fun <T : Any> (suspend () -> NetworkResult<T>).toResource(): Resource<T> {
return withTimeoutOrNull(3_000L) {
when (val result = this@toResource()) {
is NetworkResult.Success -> Resource.Success(result.data)
is NetworkResult.Error -> Resource.Failure(errorMessage = result.message ?: DEFAULT_ERROR_MESSAGE, code = result.code)
is NetworkResult.Exception -> {
when (result.e) {
is ConnectException -> Resource.Failure(errorMessage = result.e.message ?: DEFAULT_ERROR_MESSAGE, code = HttpStatus.NETWORK_DISCONNECTED)
else -> Resource.Failure(errorMessage = result.e.message ?: DEFAULT_ERROR_MESSAGE, code = HttpStatus.UNKNOWN)
}
}
}
} ?: Resource.Failure(errorMessage = "time out", code = HttpStatus.REQUEST_TIMEOUT)
}
fun <T : Any> (suspend () -> NetworkResult<T>).toResourceFlow(): Flow<Resource<T>> = flow {
emit(Resource.Loading)
withTimeoutOrNull(3_000L) {
this@toResourceFlow()
.onSuccess { emit(Resource.Success(it)) }
.onError { code, message ->
emit(Resource.Failure(errorMessage = message ?: DEFAULT_ERROR_MESSAGE, code = code))
}
.onException { e ->
emit(
when (e) {
is ConnectException -> Resource.Failure(errorMessage = e.message ?: DEFAULT_ERROR_MESSAGE, code = HttpStatus.NETWORK_DISCONNECTED)
else -> Resource.Failure(errorMessage = e.message ?: DEFAULT_ERROR_MESSAGE, code = HttpStatus.UNKNOWN)
}
)
}
} ?: Resource.Failure(errorMessage = "time out", code = HttpStatus.REQUEST_TIMEOUT)
}.flowOn(Dispatchers.IO)
- 이유: 함수형 스타일로 더 직관적이고, 체이닝 패턴을 활용할 수 있어서 가독성이 좋아진다.
- 실전 경험: Repository에서
{ apiService.getUser() }.toResourceFlow()처럼 간단하게 쓸 수 있어서 코드량이 대폭 줄어든다.
7. NetworkResult의 체이닝 패턴과 로깅
문제: NetworkResult 처리에서 각 케이스별로 다른 작업을 해야 하는데, when절로 처리하면 코드가 길어진다.
해결: 확장 함수로 체이닝 패턴 구현 + 로깅 유틸리티 제공
suspend fun <T : Any> NetworkResult<T>.onSuccess(
executable: suspend (T) -> Unit
): NetworkResult<T> = apply {
if (this is NetworkResult.Success<T>) {
executable(data)
}
}
suspend fun <T : Any> NetworkResult<T>.onError(
executable: suspend (code: Int, message: String?) -> Unit
): NetworkResult<T> = apply {
if (this is NetworkResult.Error<T>) {
executable(code, message)
}
}
suspend fun <T : Any> NetworkResult<T>.onException(
executable: suspend (e: Throwable) -> Unit
): NetworkResult<T> = apply {
if (this is NetworkResult.Exception<T>) {
executable(e)
}
}
fun <T : Any> NetworkResult<T>.log(tag: String = "NetworkResult"): String {
return when (this) {
is NetworkResult.Success -> ("[$tag] Success: data=$data")
is NetworkResult.Error -> ("[$tag] Error: code=$code, message=$message")
is NetworkResult.Exception -> ("[$tag] Exception: ${e::class.simpleName}: ${e.message}")
}
}
- 이유: 함수형 스타일로 가독성을 높이고, 로깅도 일관되게 처리할 수 있다.
- 실전 경험: 디버깅할 때
.log()만 체이닝해주면 바로 상황을 파악할 수 있어서 개발 속도가 빨라진다.
8. Custom CallAdapter, Retrofit과 단단하게 엮는 이유
문제: 기본 Retrofit 반환은 Response인데, 각 상황별로 직접 파싱하고 처리하려면 CallAdapter를 커스텀 해야 한다.
해결: 서비스 메서드마다 NetworkResult로 통일해서 처리가능하게 한다.
class NetworkResultCallAdapterFactory private constructor() : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java) return null
val callType = getParameterUpperBound(0, returnType as ParameterizedType)
if (getRawType(callType) != NetworkResult::class.java) return null
val resultType = getParameterUpperBound(0, callType as ParameterizedType)
return NetworkResultCallAdapter(resultType)
}
companion object {
fun create(): NetworkResultCallAdapterFactory = NetworkResultCallAdapterFactory()
}
}
class NetworkResultCall<T : Any>(
private val call: Call<T>,
private val resultType: Type
) : Call<NetworkResult<T>> {
override fun enqueue(callback: Callback<NetworkResult<T>>) {
call.enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val apiResult = handleApi(resultType) { response }
callback.onResponse(this@NetworkResultCall, Response.success(apiResult))
}
override fun onFailure(call: Call<T>, t: Throwable) {
val networkResult = NetworkResult.Exception<T>(t)
callback.onResponse(this@NetworkResultCall, Response.success(networkResult))
}
})
}
}
- 이유: 모든 네트워크 처리 패턴을 한 타입으로, 테스트/코드 유연성이 극대화됨.
- 실전 경험: 다양한 API(서드파티, 사내 등)를 연결할 때, 매번 Response parsing 하면서 실수할 여지를 없앤다.
9. handleApi의 세밀한 응답 처리
문제: Retrofit Response를 NetworkResult로 변환할 때 다양한 엣지 케이스들을 처리해야 함.
해결: 모든 응답 상황을 체계적으로 분류해서 처리
fun <T : Any> handleApi(
resultType: Type,
execute: () -> Response<T>
): NetworkResult<T> {
return try {
val response = execute()
val body = response.body()
if (response.isSuccessful && body != null) {
NetworkResult.Success(body)
} else if (response.isSuccessful && body == null) {
if (resultType == Unit::class.java) {
@Suppress("UNCHECKED_CAST")
NetworkResult.Success(Unit as T)
} else {
NetworkResult.Error(
code = HttpStatus.INTERNAL_SERVER_ERROR,
message = "Server returned invalid null body"
)
}
} else {
handleErrorResponse(response)
}
} catch (e: HttpException) {
NetworkResult.Error(code = e.code(), message = e.message())
} catch (e: Throwable) {
NetworkResult.Exception(e)
}
}
private fun <T : Any> handleErrorResponse(response: Response<T>): NetworkResult.Error<T> {
val errorBody = response.errorBody()?.string() ?: throw IllegalStateException("Error body is null code : ${response.code()}")
val errorMessage = extractErrorMessage(errorBody)
return NetworkResult.Error(
code = response.code(),
message = errorMessage.ifBlank { "errorMessage is empty (code: ${response.code()})" }
)
}
private fun extractErrorMessage(errorBody: String): String {
val json = JSONObject(errorBody)
if (!json.has("message")) {
throw IllegalStateException("Error body does not contain 'message' field: $errorBody")
}
return json.getString("message")
}
- 이유: 성공+바디있음/성공+바디없음/실패+에러바디/예외 등 모든 케이스를 명확히 구분해서 처리.
- 실전 경험: Unit 타입 특별 처리로 DELETE/PUT API가 안전하게 동작하고, 에러 메시지 추출도 일관되게 된다.
10. noContentResponseToResourceFlow - 특수 케이스 처리
문제: ApiResponse 래핑 없이 바로 Unit을 반환하는 API들도 있어서, 별도 처리가 필요함.
해결: ApiResponse 래핑이 없는 경우를 위한 전용 함수
fun noContentResponseToResourceFlow(
call: suspend () -> NetworkResult<Unit>
): Flow<Resource<Unit>> = flow {
emit(Resource.Loading)
val result = withTimeoutOrNull(3_000L) {
call()
} ?: return@flow emit(Resource.Failure("요청 시간이 초과되었습니다.", HttpStatus.REQUEST_TIMEOUT))
when (result) {
is NetworkResult.Success -> emit(Resource.Success(Unit))
is NetworkResult.Error -> emit(Resource.Failure(result.message ?: DEFAULT_ERROR_MESSAGE, result.code))
is NetworkResult.Exception -> emit(handleNetworkException(result.e))
}
}.flowOn(Dispatchers.IO)
- 이유: API 설계가 다른 서버들과 연동할 때, ApiResponse 패턴이 아닌 경우도 동일하게 처리할 수 있다.
- 실전 경험: 마이크로서비스나 외부 API 연동에서 응답 형식이 제각각일 때 유연하게 대응 가능.
11. NullOnEmptyConverterFactory - 진짜 운영에서 편리함
문제: 서버 쪽 응답 바디가 '빈 문자열'로 오는 경우 JSON 파싱 예외 발생.
해결: contentLength == 0L이면 null 반환해 JSON 파싱에서 안전하게 처리.
val NullOnEmptyConverterFactory = object : Converter.Factory() {
fun converterFactory() = this
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
) = object : Converter<ResponseBody, Any?> {
val nextResponseBodyConverter = retrofit.nextResponseBodyConverter<Any?>(converterFactory(), type, annotations)
override fun convert(value: ResponseBody) = if (value.contentLength() != 0L) nextResponseBodyConverter.convert(value) else null
}
}
- 이유: 의외로 타사 API, 해외 서비스 연동에서 빈 응답 엄청 자주 발생한다. 운영중 파싱 에러 하나를 방지해버림.
12. ApiResponse 데이터 구조의 표준화
문제: 서버 응답 형식이 일정하지 않으면 매번 다르게 처리해야 해서 복잡해진다.
해결: 표준 응답 구조를 정의해서 일관성 확보
@Serializable
data class ApiResponse<T>(
val status: String,
val code: Int,
val message: String,
val data: T? = null
)
- 이유: 백엔드팀과 협의해서 표준 응답 형식을 만들면, 프론트엔드에서 처리 로직이 훨씬 단순해진다.
- 실전 경험: 이 구조로 통일하니까 에러 처리, 성공 처리, 로딩 상태 등이 모두 예측 가능해졌다.
결론
API와 네트워크 장애, 사용자 경험, 그리고 테스트 및 협업까지 고려해 점진적으로 개선해온 아키텍처다. 코드 복잡도의 대부분은 처음엔 낯설었지만, 실전에서 운영하다 보면 하나하나 필요한 이유가 생긴다.
핵심 설계 원칙들:
- 타입 안전성: 컴파일 타임에 최대한 많은 오류를 잡자
- 일관성: 모든 네트워크 호출이 동일한 패턴으로 처리되어야 한다
- 확장성: 새로운 API나 요구사항이 와도 기존 코드 수정 최소화
- 테스트 용이성: 각 레이어를 독립적으로 테스트할 수 있어야 한다
- 운영 친화적: 장애 상황에서 빠른 파악과 대응이 가능해야 한다
'Android > Study' 카테고리의 다른 글
| [Android] 안드로이드 프로젝트에서 토큰 관리는 어떻게 했나요? feat AuthManagingSystem (0) | 2025.08.26 |
|---|---|
| [Android] 멀티모듈, Feature-Driven Modularization에 대해서 공부해보자. (0) | 2025.08.26 |
| [Android] DataStore를 왜 쓸까? feat. 내부 데이터 저장하기 (0) | 2025.08.24 |
| [Android] `UiState–Intent–SideEffect` MVI 구조를 자체 설계·적용해보자. (0) | 2025.08.24 |
| [Android] Compose Navigation은 어떻게 하면 좋을까? (0) | 2025.08.19 |