아티클 목록으로 가기

멀티 레이어 아키텍처에서 Sandwich를 활용한 확장 가능한 API 응답 처리

skydovesJaewoong Eum (skydoves)||23분 소요

멀티 레이어 아키텍처에서 Sandwich를 활용한 확장 가능한 API 응답 처리

최근 안드로이드 애플리케이션은 MVVM이나 MVI 같은 멀티 레이어 아키텍처를 널리 채택하고 있습니다. 이러한 아키텍처에서는 데이터가 데이터 소스, 리포지토리, ViewModel(또는 프레젠테이션 레이어)과 같은 여러 계층을 거쳐 흐르게 됩니다. 각 계층은 고유한 책임을 지니고 있으며, 네트워크 응답은 UI에 도달하기까지 모든 계층을 통과해야 합니다. 이러한 계층 분리는 깔끔하고 테스트하기 쉬운 코드를 만들어 주지만, 한 가지 현실적인 과제가 생깁니다. 바로 API 응답(에러와 예외 포함)이 각 계층 경계를 넘을 때 어떻게 처리할 것인가 하는 문제입니다.

대부분의 개발자는 API 호출을 try-catch 블록으로 감싸고 폴백(fallback) 값을 반환하는 방식으로 이 문제를 해결합니다. 소규모 프로젝트에서는 이 방식이 잘 동작하지만, API 호출이 늘어날수록 결과가 모호해지고, 보일러플레이트 코드가 여기저기 흩어지며, 하위 계층에서 필요로 하는 맥락 정보를 잃어버리게 됩니다. 결국 ViewModel에서는 빈 리스트가 "데이터가 없음"을 뜻하는 것인지 "네트워크 오류"를 뜻하는 것인지 구분할 수 없게 되고, 리포지토리는 중요한 에러 정보를 삼켜 버리며, 데이터 소스는 동일한 에러 처리 패턴을 수십 번 반복하게 됩니다.

이번 글에서는 Retrofit API 호출을 레이어 아키텍처에서 처리할 때 발생하는 문제점, 기존 방식이 규모가 커질수록 한계에 부딪히는 이유, 그리고 Sandwich가 네트워크 레이어부터 UI까지 타입 안전하고 조합 가능한 응답 처리 솔루션을 어떻게 제공하는지 살펴보겠습니다. 아울러 기본적인 응답 처리부터 순차 합성(sequential composition), 응답 병합(response merging), 전역 에러 매핑(global error mapping), Flow 통합에 이르기까지 Sandwich API 전체를 실전 사용 사례와 함께 알아보겠습니다.

Retrofit API 호출과 코루틴

대부분의 안드로이드 프로젝트에서는 Retrofit코틀린 코루틴을 조합하여 네트워크 통신을 수행합니다. 일반적인 서비스 인터페이스는 다음과 같이 작성합니다.

interface PosterService {

  @GET("DisneyPosters.json")
  suspend fun fetchPosterList(): List<Poster>
}

위 서비스는 List<Poster>를 직접 반환합니다. Retrofit이 JSON 응답 본문을 역직렬화하여 데이터를 전달해 주기 때문입니다. 요청이 성공할 때는 아무 문제 없이 동작하지만, 실패를 구조적으로 처리할 방법이 없다는 한계가 있습니다. Retrofit은 2xx가 아닌 상태 코드에 대해 HttpException을 던지고, 네트워크 문제에 대해서는 다양한 IO 예외를 던집니다. 이러한 예외를 잡아 처리하는 책임은 온전히 호출 측에 떨어지게 됩니다.

이 서비스를 데이터 소스에서 사용할 때, 기존 방식은 다음과 같습니다.

class PosterRemoteDataSource(
  private val posterService: PosterService,
) {
  suspend fun fetchPosterList(): List<Poster> {
    return try {
      posterService.fetchPosterList()
    } catch (e: HttpException) {
      emptyList()
    } catch (e: Throwable) {
      emptyList()
    }
  }
}

데이터 소스가 발생 가능한 모든 예외를 잡아서 폴백 값으로 emptyList()를 반환하고 있습니다. 호출 측 입장에서 보면 이 함수는 언제나 성공하며, 항상 List<Poster>를 반환합니다. 위 코드로부터 Flow를 생성하면 다음과 같은 흐름이 만들어집니다.

겉으로는 단순해 보이지만, 여기에는 심각한 문제가 숨어 있습니다. 이 코드는 컴파일되고 실행됩니다. 하지만 데이터 소스가 리포지토리를, 리포지토리가 ViewModel을, ViewModel이 UI를 구동하는 전체 아키텍처를 따라 데이터 흐름을 추적해 보면 문제가 명확히 드러납니다.

기존 응답 처리 방식의 문제점

위의 코드에는 프로젝트 규모가 커지고 API 엔드포인트 수가 늘어날수록 복합적으로 심화되는 세 가지 핵심 문제가 있습니다.

모호한 결과

데이터 소스가 HTTP 에러와 네트워크 예외 모두에 대해 emptyList()를 반환합니다. 하위 계층(리포지토리, ViewModel)은 List<Poster>만 전달받으므로, 완전히 다른 세 가지 시나리오를 구분할 방법이 없습니다.

  1. 요청이 성공했고, 서버가 실제로 빈 리스트를 반환한 경우
  2. 401 Unauthorized 에러로 요청이 실패한 경우
  3. 디바이스에 네트워크 연결이 없는 경우

세 가지 시나리오 모두 동일한 결과, 즉 빈 리스트를 생성합니다. 리포지토리는 에러 메시지를 표시해야 하는지, 로그인 화면으로 리다이렉트해야 하는지, "데이터 없음" 화면을 보여줘야 하는지 판단할 수 없습니다. ViewModel은 "다시 로그인해 주세요" 다이얼로그를 띄워야 하는 상황에서 빈 상태 화면을 보여줄 수도 있습니다. 응답의 맥락이 소실되면, 하위 계층에서 아무리 정교한 로직을 추가해도 그 맥락을 되살릴 수 없습니다.

실패 시 emptyList() 대신 null을 반환하는 방식을 시도해 볼 수도 있습니다. 하지만 이 역시 새로운 모호함을 만들어 냅니다. null이 "에러"를 뜻하는 것인지 "데이터 없음"을 뜻하는 것인지 알 수 없기 때문입니다. 결국 래퍼 타입이 필요해지게 되며, 이는 다음 문제로 이어집니다. 암묵적 규칙 하나가 더 쌓이는 셈입니다.

보일러플레이트 에러 처리

모든 API 호출마다 개별적인 try-catch 블록이 필요합니다. 서비스 메서드가 20개라면, 거의 동일한 try-catch 블록을 20번 작성하게 됩니다. 각 블록은 HttpException을 잡고, Throwable을 잡고, 폴백 값을 반환합니다. 이런 반복은 유지보수 부담을 높이고, 20곳의 호출 지점 중 한 곳에서 특정 예외 타입을 처리하지 않는 실수가 발생할 가능성을 키웁니다.

여러 메서드를 가진 데이터 소스를 살펴보겠습니다.

class UserRemoteDataSource(private val userService: UserService) {

  suspend fun fetchUser(id: String): User? {
    return try {
      userService.fetchUser(id)
    } catch (e: HttpException) { null }
      catch (e: Throwable) { null }
  }

  suspend fun fetchFollowers(id: String): List<User> {
    return try {
      userService.fetchFollowers(id)
    } catch (e: HttpException) { emptyList() }
      catch (e: Throwable) { emptyList() }
  }

  suspend fun updateProfile(profile: Profile): Boolean {
    return try {
      userService.updateProfile(profile)
      true
    } catch (e: HttpException) { false }
      catch (e: Throwable) { false }
  }
}

패턴은 매번 동일합니다. 호출을 시도하고, HttpException을 잡고, Throwable을 잡고, 폴백 값을 반환합니다. 달라지는 것은 폴백 값(null, emptyList(), false)뿐입니다. 모든 데이터 소스 클래스에 반복적으로 존재해서는 안 되는, 전형적인 보일러플레이트입니다.

1차원적 응답 처리

리포지토리와 프레젠테이션 계층은 원시 데이터 타입(List<Poster>, User?, Boolean)만 전달받습니다. HTTP 상태 코드, 에러 바디, 예외 상세 정보에 접근할 방법이 없습니다. 가령 ViewModel에서 HTTP 상태 코드에 따라 특정 에러 메시지를 표시해야 하는 경우(401이면 "세션이 만료되었습니다", 503이면 "서버 점검 중입니다" 등), 데이터 소스가 반환 타입에 해당 정보를 어떻게든 담아야 합니다.

예외를 상위로 전파하거나(이 경우 예외를 잡는 의미가 사라집니다), 모든 API 호출에 대해 별도의 sealed class를 만들거나(보일러플레이트가 더 늘어납니다), 아니면 정보를 완전히 잃어버리게 됩니다.

실제로 필요한 것은 API 호출의 전체 결과(성공 데이터, 에러 상세 정보, 예외 정보)를 캡슐화하면서, 어떤 계층을 거치더라도 맥락을 잃지 않고 흘러갈 수 있는 단일 타입입니다. 바로 이것이 Sandwich가 제공하는 핵심 기능입니다.

Hello Sandwich

Sandwich는 sealed 타입을 활용하여 API 및 I/O 호출에 대한 표준화된 응답 타입을 구성하는 코틀린 멀티플랫폼(Kotlin Multiplatform) 라이브러리입니다. 경량의 ApiResponse 타입을 제공하여 성공, 에러, 예외 케이스를 각각 구분된 서브타입으로 모델링합니다. 또한 아키텍처 전반에 걸쳐 응답을 처리, 변환, 복구, 검증, 필터링, 조합할 수 있는 풍부한 조합형 확장 함수 세트를 제공합니다.

Sandwich는 Retrofit, Ktor, Ktorfit과 함께 사용할 수 있습니다. 어노테이션 프로세싱, 코드 생성, 커스텀 Gradle 플러그인이 필요 없습니다. 설정은 단 한 줄이면 됩니다.

설정

모듈의 build.gradle.kts에 다음 의존성을 추가합니다.

dependencies {
  // 사용하는 HTTP 클라이언트에 맞는 모듈을 선택합니다
  implementation("com.github.skydoves:sandwich-retrofit:$version") // Retrofit용
  implementation("com.github.skydoves:sandwich-ktor:$version")     // Ktor용
  implementation("com.github.skydoves:sandwich-ktorfit:$version")  // Ktorfit용
}

Retrofit을 사용하는 경우, Retrofit 빌더에 ApiResponseCallAdapterFactory를 추가합니다.

val retrofit = Retrofit.Builder()
  .baseUrl(BASE_URL)
  .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
  .addConverterFactory(MoshiConverterFactory.create())
  .build()

ApiResponseCallAdapterFactory는 모든 Retrofit 호출을 가로채서 결과를 ApiResponse로 래핑합니다. 성공 응답, HTTP 에러, 네트워크 예외를 자동으로 처리하므로 별도의 try-catch 블록이나 커스텀 인터셉터를 추가할 필요가 없습니다.

이제 서비스의 반환 타입을 ApiResponse<T>로 변경합니다.

interface PosterService {

  @GET("DisneyPosters.json")
  suspend fun fetchPosterList(): ApiResponse<List<Poster>>
}

설정은 이것이 전부입니다. 서비스 메서드가 List<Poster> 대신 ApiResponse<List<Poster>>를 반환하게 되었습니다. 성공, HTTP 에러, 네트워크 예외 등 모든 가능한 결과가 이 단일 반환 타입에 담기게 됩니다.

Ktor의 경우, apiResponseOf 확장 함수를 사용하여 Ktor의 HttpResponse를 래핑할 수 있습니다.

val apiResponse = apiResponseOf<List<Poster>> {
  client.get("DisneyPosters.json")
}

Ktorfit의 경우, ApiResponseConverterFactory를 추가합니다.

val ktorfit = Ktorfit.Builder()
  .baseUrl(BASE_URL)
  .converterFactories(ApiResponseConverterFactory.create())
  .build()

세 가지 통합 방식 모두 동일한 ApiResponse<T> 타입을 생성하므로, 나머지 아키텍처(리포지토리, ViewModel, UI)는 어떤 HTTP 클라이언트를 사용하든 동일하게 동작합니다.

ApiResponse

ApiResponse는 API 호출의 모든 가능한 결과를 모델링하는 세 개의 서브타입을 가진 sealed 인터페이스입니다. 이 세 가지 타입의 차이를 이해하는 것이 Sandwich를 효과적으로 활용하는 데 있어 핵심이 됩니다.

기본적으로 ApiResponseApiResponse.Success, ApiResponse.Failure.Error, ApiResponse.Failure.Exception이라는 세 가지 서브클래스로 구성됩니다.

ApiResponse.Success

서버가 2xx 상태 코드를 반환하고, 응답 본문이 에러 없이 역직렬화된 성공 응답을 나타냅니다. 이것이 정상적인 동작 경로이며, data 프로퍼티에 역직렬화된 응답 본문이 담겨 있습니다.

val apiResponse = ApiResponse.Success(data = posterList)
val data: List<Poster> = apiResponse.data

Retrofit과 연동하는 경우, ApiResponse.Success는 원본 응답 메타데이터도 함께 담고 있어 플랫폼별 확장을 통해 접근할 수 있습니다.

val statusCode: StatusCode = apiResponse.statusCode
val headers: Headers = apiResponse.headers

성공 응답에서 페이지네이션 헤더, 캐시 제어 지시어, 요청 제한(rate limit) 정보 등을 읽어야 할 때 유용합니다.

ApiResponse.Failure.Error

HTTP 에러 응답을 나타냅니다. 서버가 요청을 수신하고 응답을 보냈지만, 상태 코드가 2xx가 아닌 경우입니다. 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error 등 모든 HTTP 에러 코드가 여기에 해당합니다. 에러 페이로드(응답 본문)를 파싱할 수 있도록 제공됩니다.

val apiResponse = ApiResponse.Failure.Error(payload = errorBody)
val payload = apiResponse.payload

Retrofit에서 payload에는 원본 okhttp3.Response가 담겨 있어 에러 바디, 상태 코드, 헤더를 읽을 수 있습니다. Ktor에서는 HttpResponse가 담깁니다. 어느 경우든 전체 에러 맥락이 보존됩니다.

ApiResponse.Failure.Exception

서버로부터 HTTP 응답을 수신하기 전에 클라이언트 측에서 발생한 예외를 나타냅니다. 이는 Failure.Error와 본질적으로 다릅니다. Error는 네트워크 요청이 완료되었고 서버가 에러 응답을 보낸 것이고, Exception은 요청 자체가 완료되지 못한 것입니다. 주요 원인은 다음과 같습니다.

  • 네트워크 연결 실패 (비행기 모드, Wi-Fi 미연결)
  • DNS 해석 오류 (서버 호스트 이름을 확인할 수 없음)
  • 연결 타임아웃 (서버가 제시간에 응답하지 않음)
  • SSL/TLS 핸드셰이크 실패 (인증서 문제)
  • JSON 파싱 에러 (응답 본문을 역직렬화할 수 없음)
val apiResponse = ApiResponse.Failure.Exception(throwable = exception)
val throwable: Throwable = apiResponse.throwable
val message: String? = apiResponse.message

이러한 구분은 UI에서 매우 중요합니다. 서버가 401 Unauthorized를 반환한 경우(Failure.Error)에는 "다시 로그인해 주세요"를 표시하고, 디바이스에 네트워크가 없는 경우(Failure.Exception)에는 "인터넷 연결을 확인해 주세요"를 표시할 수 있기 때문입니다. 이 구분이 없으면 사용자에게 적절한 안내를 제공할 수 없습니다.

ApiResponse 처리하기

ApiResponse가 모든 결과를 담고 있으므로, 이제 각 케이스를 깔끔하게 처리할 방법이 필요합니다. Sandwich는 응답 타입에 따라 스코프 람다를 실행하는 체이닝 가능한 확장 함수를 제공합니다.

val response = posterService.fetchPosterList()
response.onSuccess {
  // this: ApiResponse.Success<List<Poster>>
  // `data`에 직접 접근 가능
  val posters: List<Poster> = data
}.onError {
  // this: ApiResponse.Failure.Error
  // `payload`, `message()`, `statusCode` 등에 접근 가능
  val message = message()
}.onException {
  // this: ApiResponse.Failure.Exception
  // `throwable`, `message` 등에 접근 가능
  val cause = throwable
}

각 람다는 응답이 해당 타입과 일치할 때만 실행됩니다. onSuccess 블록은 성공 시에만, onError는 HTTP 에러 시에만, onException은 클라이언트 측 예외 발생 시에만 실행됩니다. 여기서 핵심적인 설계 포인트는 각 확장 함수가 원본 ApiResponse를 반환하므로, 세 가지 핸들러 모두 하나의 표현식으로 체이닝할 수 있다는 점입니다. 일치하는 핸들러만 실행되고, 나머지는 건너뛰게 됩니다.

각 람다 내부에서 this는 해당하는 ApiResponse 서브타입으로 설정됩니다. onSuccess 내부에서는 datatag에 직접 접근할 수 있고, onError 내부에서는 payload에 접근하고 message()를 호출할 수 있으며, onException 내부에서는 throwablemessage에 접근할 수 있습니다. 별도의 수동 캐스팅이 필요 없습니다.

에러와 예외를 구분할 필요가 없다면, onFailure를 사용하여 두 가지 실패 타입을 하나의 블록에서 처리할 수도 있습니다.

response.onSuccess {
  _posters.value = data
}.onFailure {
  // this: ApiResponse.Failure
  // Error와 Exception 모두 처리
  _error.value = message()
}

서버 에러든 네트워크 예외든 동일한 에러 상태를 UI에 표시하는 경우에 편리합니다.

when 표현식을 활용한 완전 매칭

코틀린의 when 표현식을 사용하여 모든 케이스를 빠짐없이 처리할 수도 있습니다.

when (response) {
  is ApiResponse.Success -> {
    val posters = response.data
  }
  is ApiResponse.Failure.Error -> {
    val message = response.message()
  }
  is ApiResponse.Failure.Exception -> {
    val throwable = response.throwable
  }
}

ApiResponse가 sealed 인터페이스이기 때문에, 컴파일러가 모든 케이스가 처리되었는지 검증합니다. 새로운 서브타입이 추가된다면, else 분기가 없는 모든 when 표현식에서 업데이트할 때까지 컴파일 타임 에러가 발생합니다. 에러 처리가 완전한지 컴파일 타임에 보장받을 수 있는 것입니다.

멀티 레이어 아키텍처에서의 ApiResponse 활용

ApiResponse를 도입하면 전체 아키텍처가 달라집니다. 데이터 소스는 더 이상 try-catch 블록, 폴백 값, 별도의 에러 처리가 필요 없으므로 매우 간결해집니다.

class PosterRemoteDataSource(
  private val posterService: PosterService,
) {
  suspend fun fetchPosterList(): ApiResponse<List<Poster>> {
    return posterService.fetchPosterList()
  }
}

중첩된 try-catch 블록으로 emptyList()를 반환하던 기존 버전과 비교해 보면, 데이터 소스가 이제는 단순한 패스 스루(pass-through)가 되었음을 알 수 있습니다. 모든 에러 맥락은 ApiResponse에 그대로 보존됩니다.

리포지토리는 ApiResponse 래퍼를 유지한 채 비즈니스 로직을 적용할 수 있습니다. 가령 성공한 결과를 캐싱하거나, 응답에 로컬 데이터를 추가하는 식으로 활용할 수 있습니다.

class PosterRepository(
  private val remoteDataSource: PosterRemoteDataSource,
  private val posterDao: PosterDao,
) {
  suspend fun fetchPosterList(): ApiResponse<List<Poster>> {
    val response = remoteDataSource.fetchPosterList()
    response.onSuccess {
      posterDao.insertPosterList(data) // 성공한 결과를 캐싱
    }
    return response
  }
}

ViewModel은 전체 맥락을 활용하여 각 시나리오에 적합한 UI 결정을 내릴 수 있습니다.

class PosterViewModel(
  private val repository: PosterRepository,
) : ViewModel() {

  fun fetchPosters() {
    viewModelScope.launch {
      val response = repository.fetchPosterList()
      response.onSuccess {
        _posters.value = data
      }.onError {
        // 서버가 에러로 응답한 경우, 적절한 메시지를 표시
        _error.value = message()
      }.onException {
        // 네트워크 또는 클라이언트 에러, 연결 상태 확인 안내
        _error.value = "Please check your internet connection."
      }
    }
  }
}

응답은 모든 계층을 통과하면서 전체 맥락을 온전히 유지합니다. ViewModel에서는 빈 데이터(빈 리스트를 가진 성공 응답), 서버 에러(401, 500 등), 네트워크 장애(연결 없음, 타임아웃)를 명확히 구분할 수 있으며, 데이터 소스가 어떤 정보도 소실시키지 않습니다. 각 계층은 다른 계층에서 필요로 하는 정보를 제거하지 않으면서 자체적인 로직을 추가합니다.

데이터 직접 추출하기

체이닝 방식의 전체 처리가 필요 없고, 응답에서 데이터만 꺼내고 싶은 경우가 있습니다. Sandwich는 이를 위한 세 가지 추출 함수를 제공합니다.

// 성공 시 데이터를 반환하고, 그 외에는 null을 반환
val posters: List<Poster>? = response.getOrNull()

// 성공 시 데이터를 반환하고, 그 외에는 기본값을 반환
val posters: List<Poster> = response.getOrElse(emptyList())

// 성공 시 데이터를 반환하고, 그 외에는 예외를 던짐
val posters: List<Poster> = response.getOrThrow()

getOrNull()은 데이터만 필요하고 null을 하위에서 처리할 수 있는 맥락에서 유용합니다. let 블록 내부나 다른 nullable 연산과 조합할 때 활용하면 됩니다. getOrElse()는 컬렉션 엔드포인트의 빈 리스트처럼 합리적인 기본값이 있을 때 적합합니다.

getOrThrow()는 실패를 예외로 전파하고 싶을 때 사용하며, Sandwich 코드와 예외를 기대하는 레거시 코드 사이의 경계에서 유용합니다.

getOrElse에 람다를 전달하면 지연 평가도 가능합니다.

val posters: List<Poster> = response.getOrElse {
  posterDao.getCachedPosters() // 실패 시에만 호출됨
}

코루틴 및 Flow와 함께 사용하는 ApiResponse

코틀린 Flow를 활용한 리액티브 아키텍처에서는 핸들러 스코프 내에서 suspend 함수를 호출할 수 있어야 합니다. 기본 onSuccess, onError, onException은 suspend가 아닌 람다를 받기 때문에, 핸들러 내부에서 데이터베이스 작업, 추가 네트워크 요청 등의 suspend 함수를 호출할 수 없습니다.

이 문제를 해결해 주는 것이 suspend 변형인 suspendOnSuccess, suspendOnError, suspendOnException입니다.

fun fetchPosterList() = flow {
  val response = posterService.fetchPosterList()
  response.suspendOnSuccess {
    posterDao.insertPosterList(data) // suspend 함수: 데이터베이스에 저장
    emit(data)                       // suspend 함수: Flow로 방출
  }.suspendOnError {
    val cached = posterDao.getCachedPosterList() // suspend 함수: 캐시로 폴백
    emit(cached)
  }.suspendOnException {
    emit(emptyList())
  }
}.flowOn(Dispatchers.IO)

이 패턴이 유용한 이유는 각 핸들러 스코프가 suspend 람다이기 때문입니다. 코루틴 빌더를 중첩시키지 않고도 핸들러 내부에서 직접 데이터베이스 삽입, 추가 API 호출, 파일 I/O 등 모든 suspend 작업을 수행할 수 있습니다. Flow는 방출된 데이터를 수집하여 UI 레이어 쪽으로 전달합니다.

Flow로 직접 변환하기

성공 데이터만 Flow로 가져오고 실패는 별도로 처리하고 싶은 경우, Sandwich는 toFlow() 확장 함수를 제공합니다.

val flow: Flow<List<Poster>> = posterService.fetchPosterList()
  .onError {
    logger.error("API error: ${message()}")
  }.onException {
    logger.error("Network error: $message")
  }.toFlow()

toFlow() 확장 함수는 성공 데이터를 방출하는 Flow를 생성합니다. 응답이 실패인 경우 Flow는 아무것도 방출하지 않습니다(emptyFlow()를 반환). 실패를 사이드 이펙트(로깅, 토스트 표시 등)로 처리하고 성공 데이터만 상태에 흘려보내고 싶을 때 유용합니다.

데이터를 변환하면서 Flow로 전환하는 것도 가능합니다. 리포지토리 계층에서 API 응답을 캐싱한 다음 로컬 데이터베이스에서 데이터를 반환하고자 할 때 자주 사용되는 패턴입니다.

val flow = posterService.fetchPosterList()
  .toFlow { posters ->
    posters.forEach { it.page = page }
    posterDao.insertPosterList(posters)
    posterDao.getAllPosterList(page) // 캐싱된 데이터를 반환
  }.flowOn(Dispatchers.IO)

람다는 성공 데이터를 받아 변환된 결과를 반환합니다. 데이터베이스 작업은 Flow의 코루틴 컨텍스트 내에서 실행되므로 모든 것이 suspend 안전합니다. 이 방식을 활용하면 아래 이미지처럼 서로 다른 멀티 레이어 전반에 걸쳐 매우 통일된 응답 타입을 가질 수 있습니다.

응답 매핑 및 변환

실제 API가 UI에서 필요한 형태 그대로 데이터를 반환하는 경우는 드뭅니다. 서버가 토큰과 메타데이터를 포함한 UserAuthResponse를 반환하더라도, ViewModel에서 필요한 것은 사용자 객체와 토큰 문자열만 담은 LoginInfo일 수 있습니다. Sandwich는 ApiResponse 내부의 데이터를 응답 체인을 끊거나 실패 맥락을 잃지 않으면서 변환하는 매핑 확장 함수를 제공합니다.

mapSuccess

성공 데이터를 타입 T에서 타입 V로 변환합니다. 응답이 실패인 경우, mapSuccess는 변경 없이 그대로 통과시킵니다.

val response: ApiResponse<LoginInfo> = authService.requestToken(
  UserRequest(authProvider = provider, authIdentifier = id, email = email),
).mapSuccess {
  // `this`는 UserAuthResponse
  LoginInfo(user = user, token = token)
}

API 모델을 도메인 모델로 변환하고자 하는 리포지토리 계층에서 특히 유용합니다. 리포지토리는 ViewModel에 ApiResponse<LoginInfo>를 노출하고, 서비스는 ApiResponse<UserAuthResponse>를 반환합니다. 매핑은 한 곳에서만 이루어지며, 실패 응답은 수정 없이 그대로 전달됩니다.

리스트 응답에서 단일 항목을 추출하는 것도 자주 사용되는 패턴입니다.

val response: ApiResponse<Poster?> = posterService.fetchPosterList()
  .mapSuccess { firstOrNull() }

mapFailure

실패 페이로드를 변환합니다. 서로 다른 엔드포인트의 에러 바디를 표준화하고자 할 때 유용합니다.

val response = apiResponse.mapFailure { responseBody ->
  "error body: ${responseBody?.string()}".toResponseBody()
}

flatMap

ApiResponse를 완전히 다른 ApiResponse로 변환합니다. 성공 데이터만 변환하는 mapSuccess와 달리, flatMap은 전체 응답에 접근하여 어떤 ApiResponse 타입이든 반환할 수 있습니다. 서버 에러 바디를 커스텀 에러 타입으로 매핑할 때 강력하게 활용됩니다.

val response = service.fetchMovieList()
  .flatMap {
    if (this is ApiResponse.Failure.Error) {
      val errorBody = (payload as? Response)?.body?.string()
      if (errorBody != null) {
        val error: ErrorMessage = Json.decodeFromString(errorBody)
        when (error.code) {
          10000 -> LimitedRequest
          10001 -> WrongArgument
          else -> this
        }
      } else this
    } else this
  }

flatMap 이후, 응답은 원본 ApiResponse.Success(변경 없음)이거나 커스텀 에러 타입(LimitedRequest, WrongArgument) 중 하나가 됩니다. 하위 계층에서는 에러 바디를 다시 파싱할 필요 없이 커스텀 타입에 대해 패턴 매칭을 수행할 수 있습니다.

순차적 의존 요청

실제 워크플로우에서는 이전 호출의 결과에 다음 호출이 의존하는 여러 API 호출을 체이닝해야 하는 경우가 많습니다. 가령 먼저 인증 토큰을 가져오고, 그 토큰으로 사용자 상세 정보를 조회하고, 사용자 이름으로 포스터를 쿼리하는 경우가 이에 해당합니다. 어떤 단계에서든 실패하면 전체 체인이 해당 단계의 에러와 함께 실패해야 합니다.

Sandwich는 이 패턴을 위해 thensuspendThen 중위 함수를 제공합니다.

val response = service.getUserToken(userId) suspendThen { tokenResponse ->
  service.getUserDetails(tokenResponse.token)
} suspendThen { userResponse ->
  service.queryPosters(userResponse.user.name)
}

response.onSuccess {
  _posters.value = data
}.onFailure {
  _error.value = message()
}

getUserToken이 실패하면 getUserDetailsqueryPosters는 호출되지 않습니다. 첫 번째 호출의 실패가 최종 onFailure 핸들러로 직접 전파됩니다. getUserToken이 성공하고 getUserDetails가 실패하면 queryPosters는 건너뛰고, getUserDetails의 에러가 전파됩니다. 깊이 중첩된 콜백이나 연쇄적인 if 검사를 제거할 수 있습니다.

suspendThenmapSuccess와 조합하여 최종 결과를 변환하는 것도 가능합니다.

service.getUserToken(userId) suspendThen { tokenResponse ->
  service.getUserDetails(tokenResponse.token)
} suspendThen { userResponse ->
  service.queryPosters(userResponse.user.name)
}.mapSuccess { posterResponse ->
  posterResponse.posters
}.onSuccess {
  posterStateFlow.value = data
}.onFailure {
  Log.e("API", message())
}

복구 및 폴백

네트워크 요청은 실패하게 마련입니다. 서버가 다운되고, 디바이스가 연결을 잃고, API가 예상치 못한 에러를 반환합니다. Sandwich는 요청이 실패했을 때 응답 체인의 조합 가능성을 유지하면서 폴백 동작을 정의할 수 있는 복구 확장 함수를 제공합니다.

recover

응답이 실패인 경우 폴백 값으로 ApiResponse.Success를 반환합니다. 응답이 이미 성공이면 변경 없이 그대로 통과합니다.

val response = posterService.fetchPosterList()
  .recover(emptyList())

catch 블록에서 emptyList()를 반환하는 것과 비슷해 보이지만, 중요한 차이점이 있습니다. 복구가 모든 응답 맥락이 캡처된 이후에 수행된다는 것입니다. recover를 다른 확장 함수와 체이닝할 수 있습니다.

val response = posterService.fetchPosterList()
  .peekError { logger.error("API error: ${message()}") }
  .peekException { crashReporter.record(throwable) }
  .recover(emptyList())

에러가 로깅되고 크래시 리포터에 기록된 후에 복구가 폴백 값을 생성합니다. 로깅과 크래시 리포팅은 원본 실패 정보를 그대로 볼 수 있습니다. try-catch 방식에서는 예외를 잡고, 로깅하고, 폴백을 반환하는 것을 모두 같은 블록에서 처리해야 합니다.

지연 평가를 위해 람다 변형도 사용할 수 있습니다.

val response = posterService.fetchPosterList()
  .recover { posterDao.getCachedPosterList() }

람다는 응답이 실패인 경우에만 호출되므로, 성공 요청에서는 데이터베이스 쿼리가 실행되지 않습니다.

recoverWith

실패를 또 다른 ApiResponse를 반환하는 대체 작업으로 복구합니다. 폴백이 다른 API 호출이거나 역시 실패할 수 있는 데이터베이스 쿼리인 경우에 유용합니다.

val response = primaryService.fetchPosterList()
  .recoverWith { failure ->
    // 백업 서비스를 시도, 이것도 ApiResponse를 반환
    backupService.fetchPosterList()
  }

프라이머리 서비스가 실패하면 백업 서비스를 호출합니다. 백업마저 실패하면 그 실패가 최종 결과가 됩니다. suspend 폴백이 필요한 경우 suspendRecoverWith를 사용합니다.

val response = primaryService.fetchPosterList()
  .suspendRecoverWith { failure ->
    backupService.fetchPosterList() // suspend 함수
  }

검증(Validation)

성공한 API 응답이라도 애플리케이션의 요구 사항을 충족하지 못하는 데이터를 포함할 수 있습니다. 서버가 200 OK를 반환했지만, 데이터 자체가 유효하지 않거나, 비어 있거나, 필수 필드가 누락된 경우입니다. Sandwich는 데이터가 기준을 통과하지 못하면 성공 응답을 실패로 전환하는 검증 확장 함수를 제공합니다.

validate

술어(predicate)로 성공 데이터를 검증합니다. 술어가 false를 반환하면, 지정한 에러 메시지와 함께 응답이 ApiResponse.Failure.Error로 전환됩니다.

val response = posterService.fetchPosterList()
  .validate(
    predicate = { it.isNotEmpty() },
    errorMessage = { "Poster list cannot be empty" },
  )

이제 HTTP 요청이 기술적으로 성공했더라도 리스트가 비어 있으면 하위 핸들러는 Failure.Error를 보게 됩니다. 서버 측에서 문제가 발생했을 때 적절한 에러 코드 대신 빈 배열을 반환하는 API를 처리할 때 유용합니다.

requireNotNull

성공 데이터 내 특정 필드가 null이 아닌지 검증합니다. 선택된 값이 null이면 응답이 실패로 전환됩니다.

val response = userService.fetchUser()
  .requireNotNull(
    selector = { it.profileImage },
    errorMessage = { "Profile image is required" },
  )

이는 ApiResponse<User>ApiResponse<String>(프로필 이미지 URL)으로 변환하면서, 동시에 null이 아닌지 검증합니다. 추출과 검증을 하나의 연산으로 결합하는 것입니다.

suspend 검증 로직(데이터베이스 확인이나 검증 서비스 호출 등)이 필요한 경우에는 suspend 변형을 사용합니다.

val response = userService.fetchUser()
  .suspendValidate { user ->
    userValidator.isValid(user) // suspend 함수
  }

리스트 응답 필터링

리스트를 반환하는 API 응답에 대해, Sandwich는 술어에 기반하여 성공 데이터에서 항목을 제거하는 필터링 확장 함수를 제공합니다.

val response = posterService.fetchPosterList()
  .filter { poster -> poster.isActive }

활성 포스터만 포함하는 ApiResponse<List<Poster>>를 반환합니다. 실패 응답에 대해서는 필터가 아무런 영향을 주지 않으며, 실패가 그대로 통과합니다.

반대 동작도 사용할 수 있습니다.

val response = posterService.fetchPosterList()
  .filterNot { poster -> poster.isDeprecated }

서버가 필터링 쿼리를 지원하지 않아서 클라이언트 측에서 필터링해야 할 때 특히 유용합니다. 필터를 다른 확장 함수와 체이닝하여 완전한 파이프라인을 구성할 수 있습니다.

val response = posterService.fetchPosterList()
  .validate(predicate = { it.isNotEmpty() }) { "No posters found" }
  .filter { it.isActive }
  .mapSuccess { sortedByDescending { it.createdAt } }
  .recover(emptyList())

리스트가 비어 있지 않은지 검증하고, 활성 항목으로 필터링하고, 생성 날짜 기준으로 정렬하고, 어떤 단계에서든 실패하면 빈 리스트로 폴백합니다. 모든 단계가 ApiResponse<List<Poster>>를 대상으로 동작하므로 깔끔하게 조합됩니다.

여러 응답 합성하기

많은 화면이 여러 API 엔드포인트의 데이터를 동시에 필요로 합니다. 홈 화면에서는 사용자 프로필 데이터와 추천 포스터 목록이 필요할 수 있고, 대시보드에서는 설정, 알림, 활동 데이터가 필요할 수 있습니다. Sandwich는 여러 ApiResponse 인스턴스를 하나의 응답으로 합성하는 zip 확장 함수를 제공합니다.

zip

두 개의 ApiResponse 인스턴스를 합성합니다. 두 응답 모두 성공이면 변환 함수가 실행되고, 하나라도 실패이면 첫 번째 실패가 반환됩니다.

val usersResponse = userService.fetchUsers()
val postersResponse = posterService.fetchPosters()

val combined = usersResponse.zip(postersResponse) { users, posters ->
  HomeScreenData(users = users, posters = posters)
}

combined.onSuccess {
  _homeData.value = data
}.onFailure {
  _error.value = message()
}

두 개의 onSuccess 핸들러를 중첩하거나 async/await을 사용하면서 수동으로 에러를 확인하는 것보다 훨씬 깔끔합니다. 사용자 호출은 성공했지만 포스터 호출이 실패한 경우, 포스터 실패가 합성된 결과로 반환됩니다. 불완전한 데이터도, 일관성 없는 상태도 없습니다.

변환 없이 단순히 Pair로 합성하려면 다음과 같이 합니다.

val paired = usersResponse.zip(postersResponse)
// ApiResponse<Pair<List<User>, List<Poster>>>를 반환

페이지네이션 응답 병합

동일한 엔드포인트의 여러 페이지를 호출하고 결과를 하나의 응답으로 합쳐야 할 때, Sandwich는 merge 확장 함수를 제공합니다.

val response = posterService.fetchPosterList(page = 0).merge(
  posterService.fetchPosterList(page = 1),
  posterService.fetchPosterList(page = 2),
  mergePolicy = ApiResponseMergePolicy.PREFERRED_FAILURE,
)

response.onSuccess {
  // `data`에 세 페이지의 합산 리스트가 담겨 있음
  _posters.value = data
}.onError {
  // 하나 이상의 페이지가 에러를 반환함
  _error.value = message()
}

mergePolicy 파라미터는 실패 처리 방식을 제어합니다. PREFERRED_FAILURE는 어떤 페이지라도 실패하면 실패를 반환합니다. IGNORE_FAILURE는 성공한 페이지를 모아 실패를 무시하며, 부분적인 결과가 필요할 때 유용합니다.

peek으로 응답 관찰하기

peek 확장 함수를 사용하면 응답을 수정하지 않고 사이드 이펙트를 위해 관찰할 수 있습니다. 로깅, 애널리틱스, 캐싱, 크래시 리포팅처럼 응답의 사이드 이펙트로 수행되어야 하지만 응답 자체를 변경해서는 안 되는 작업에 적합합니다.

val response = posterService.fetchPosterList()
  .peekSuccess { posters ->
    analytics.trackPostersLoaded(posters.size)
    logger.info("Loaded ${posters.size} posters")
  }
  .peekError { error ->
    errorTracker.trackApiError(error.statusCode)
    logger.warn("API error: ${error.message()}")
  }
  .peekException { exception ->
    crashReporter.recordException(exception.throwable)
    logger.error("Network exception: ${exception.message}")
  }

peek 확장 함수는 원본 ApiResponse를 변경 없이 반환합니다. 다른 확장 함수 전후로 자유롭게 peek 호출을 체이닝할 수 있습니다. 처리 로직과 독립적으로 실행되는 관찰 파이프라인을 구축하는 데 유용합니다.

val response = posterService.fetchPosterList()
  .peekSuccess { analytics.trackSuccess() }
  .peekFailure { analytics.trackFailure() }
  .recover(emptyList()) // peek이 복구 전에 실행되므로, 원본 실패 정보를 볼 수 있음

모든 응답 타입에 대해 실행되는 범용 peek도 제공됩니다.

val response = posterService.fetchPosterList()
  .peek { logger.info("Response received: $it") }

suspend 사이드 이펙트(캐시 또는 데이터베이스 쓰기 등)가 필요한 경우에는 suspend 변형인 suspendPeekSuccess, suspendPeekError, suspendPeekException, suspendPeekFailure, suspendPeek을 사용합니다.

커스텀 에러 타입

실제 API는 에러 코드와 메시지가 포함된 구조화된 에러 바디를 반환합니다. 이를 raw 문자열이나 JSON이 아닌 타입 있는 객체로 처리하고 싶을 것입니다. Sandwich에서는 ApiResponse.Failure.Error 또는 ApiResponse.Failure.Exception을 확장하여 커스텀 에러 및 예외 타입을 정의할 수 있습니다.

data object LimitedRequest : ApiResponse.Failure.Error(
  payload = "your request is limited",
)

data object WrongArgument : ApiResponse.Failure.Error(
  payload = "wrong argument",
)

data object UnKnownError : ApiResponse.Failure.Exception(
  throwable = RuntimeException("unknown error"),
)

data object HttpException : ApiResponse.Failure.Exception(
  throwable = RuntimeException("http exception"),
)

이 커스텀 타입은 어떤 T에 대해서든 ApiResponse<T>와 함께 동작합니다. Failure.ErrorFailure.ExceptionFailure<Nothing>을 확장하며, 코틀린의 공변성(covariance)을 통해 모든 제네릭 파라미터와 호환되기 때문입니다.

하위 계층에서는 커스텀 타입에 대해 직접 패턴 매칭을 수행할 수 있습니다.

response.onError {
  when (this) {
    LimitedRequest -> showRateLimitDialog()
    WrongArgument -> showValidationError()
    else -> showGenericError()
  }
}.onException {
  when (this) {
    HttpException -> showHttpErrorUI()
    UnKnownError -> showGenericErrorUI()
    else -> showNetworkError()
  }
}

전역 실패 매퍼(Global Failure Mappers)

커스텀 에러 타입을 정의하는 것은 유용하지만, 에러 바디를 파싱하고 적절한 커스텀 타입을 생성하는 작업이 여전히 남아 있습니다. 모든 API 호출에서 flatMap으로 이 작업을 수행하면 보일러플레이트 문제가 다시 발생합니다. Sandwich는 애플리케이션 초기화 시 한 번만 등록하면 되는 전역 실패 매퍼를 통해 이 문제를 해결합니다.

// Application.onCreate() 또는 DI 모듈에서
SandwichInitializer.sandwichFailureMappers += ApiResponseFailureMapper { failure ->
  if (failure is ApiResponse.Failure.Error) {
    val errorBody = (failure.payload as? Response)?.body?.string()
    if (errorBody != null) {
      val error: ErrorMessage = Json.decodeFromString(errorBody)
      return@ApiResponseFailureMapper when (error.code) {
        10000 -> LimitedRequest
        10001 -> WrongArgument
        10002 -> HttpException
        else -> UnKnownError
      }
    }
  }
  failure
}

한 번 등록하면 이 매퍼가 애플리케이션 전체의 모든 ApiResponse.Failure를 자동으로 변환합니다. posterService.fetchPosterList(), userService.fetchUser(), authService.login() 등 어떤 API 호출이든 매퍼를 거치게 됩니다. ViewModel과 리포지토리에서는 커스텀 타입만 처리하면 됩니다.

val response = service.fetchMovieList()
response.onSuccess {
  _movies.value = data
}.onException {
  when (this) {
    LimitedRequest -> showRateLimitUI()
    WrongArgument -> showValidationUI()
    HttpException -> showHttpErrorUI()
    UnKnownError -> showGenericErrorUI()
    else -> showDefaultErrorUI()
  }
}

에러 바디 읽기에 suspend 함수가 필요한 Ktor 또는 Ktorfit 환경(HttpResponse.bodyAsText() 등)에서는 ApiResponseFailureSuspendMapper를 사용합니다.

SandwichInitializer.sandwichFailureMappers += object : ApiResponseFailureSuspendMapper {
  override suspend fun map(apiResponse: ApiResponse.Failure<*>): ApiResponse.Failure<*> {
    if (apiResponse is ApiResponse.Failure.Error) {
      val errorBody = (apiResponse.payload as? HttpResponse)?.bodyAsText()
      if (errorBody != null) {
        val error: ErrorMessage = Json.decodeFromString(errorBody)
        return when (error.code) {
          10000 -> LimitedRequest
          10001 -> WrongArgument
          else -> UnKnownError
        }
      }
    }
    return apiResponse
  }
}

suspend 매퍼는 suspend 컨텍스트에서 적절히 대기(await)되므로, 매핑된 응답이 호출자에게 올바르게 반환됩니다. 동기 매퍼와 suspend 매퍼를 같은 리스트에 등록할 수 있으며, 등록된 순서대로 적용됩니다.

전역 오퍼레이터(Global Operators)

전역 실패 매퍼가 실패 응답을 변환하는 반면, 전역 오퍼레이터는 성공을 포함한 모든 응답을 횡단적 관심사(cross-cutting concerns)를 위해 관찰합니다. 중앙 집중식 로깅, 애널리틱스, 인증 토큰 갱신, 글로벌 에러 표시기 등에 적합합니다.

SandwichInitializer.sandwichOperators += object : ApiResponseOperator<Any>() {

  override fun onSuccess(apiResponse: ApiResponse.Success<Any>) {
    logger.info("API success: ${apiResponse.data}")
  }

  override fun onError(apiResponse: ApiResponse.Failure.Error) {
    logger.error("API error: ${apiResponse.message()}")
    if (apiResponse.statusCode == StatusCode.Unauthorized) {
      // 글로벌 로그아웃 또는 토큰 갱신 트리거
      authManager.onUnauthorized()
    }
  }

  override fun onException(apiResponse: ApiResponse.Failure.Exception) {
    logger.error("API exception: ${apiResponse.message}")
    crashReporter.recordException(apiResponse.throwable)
  }
}

이 오퍼레이터는 ApiResponse.of 또는 ApiResponse.suspendOf를 통해 생성된 모든 ApiResponse에 대해 실행됩니다(Retrofit, Ktor, Ktorfit 응답 모두 포함). 위 예시의 401 처리는 전역으로 적용되어, 어떤 API 호출이든 401을 반환하면 각 호출 지점에서 별도로 확인하지 않아도 인증 매니저가 트리거됩니다.

suspend 오퍼레이터가 필요한 경우에는 ApiResponseSuspendOperator를 사용하여 suspend 컨텍스트에서 적절히 대기합니다.

SandwichInitializer.sandwichOperators += object : ApiResponseSuspendOperator<Any>() {

  override suspend fun onSuccess(apiResponse: ApiResponse.Success<Any>) {
    analyticsService.trackSuccess() // suspend 함수
  }

  override suspend fun onError(apiResponse: ApiResponse.Failure.Error) {
    analyticsService.trackError(apiResponse.message()) // suspend 함수
  }

  override suspend fun onException(apiResponse: ApiResponse.Failure.Exception) {
    analyticsService.trackException(apiResponse.throwable) // suspend 함수
  }
}

결론

try-catch 블록을 사용한 기존의 API 응답 처리 방식은 레이어 아키텍처를 통해 데이터가 흐르는 과정에서 모호한 결과, 반복적인 보일러플레이트, 소실된 맥락 정보라는 문제를 야기합니다. 예외를 잡아서 다시 래핑하는 각 계층이 하위 계층에서 올바른 UI 결정을 내리기 위해 필요한 정보를 벗겨 내기 때문입니다. emptyList()를 전달받은 ViewModel은 서버가 데이터를 반환하지 않았는지, 사용자 토큰이 만료되었는지, 디바이스의 네트워크 연결이 끊어졌는지 구분할 수 없습니다.

SandwichApiResponse라는 sealed 타입을 도입하여 이 문제를 해결합니다. 성공 데이터, HTTP 에러 상세 정보, 예외 정보를 하나의 값에 담아 모든 계층을 투명하게 흘러갈 수 있도록 합니다. ApiResponseCallAdapterFactory는 한 줄로 Retrofit에 통합되고, apiResponseOf 확장 함수는 Ktor에서, ApiResponseConverterFactory는 Ktorfit에서 동작합니다. 기본적인 응답 처리를 넘어, 체이닝 핸들러, 매핑, 순차 합성, 복구, 검증, 필터링, zip, 병합, peek, 전역 인터셉션에 이르는 전체 확장 API가 모든 프로덕션 애플리케이션에서 마주치는 실전 패턴을 깔끔하게 조합하여 해결합니다.

Sandwich에 대해 더 자세히 알아보고 싶으시다면, 공식 문서, GitHub 리포지토리, 그리고 Sandwich를 실전에서 활용하는 Pokedex 같은 샘플 프로젝트를 살펴보시길 권장합니다.

아티클 목록으로 가기