던질까말까

  • 개발하다 보면 실패할 가능성이 있는 메소드들이 있다. 네트워킹, 파싱, 파일 열기 등…
  • Swift에서는 이렇게 에러를 던질 수 있는 함수는 throws를 붙여 선언하는데, 이를 호출하는 쪽에서는 try를 붙이고 어떻게든 반드시 에러에 대한 처리를 해야 한다.
// 에러를 던지는 함수
func fetchData() throws -> Data {
    throw NetworkError.timeout
}
 
// try fetchData() - 그냥 호출할 수는 없다.
do {
    let data = try fetchData()
} catch {
    // 에러 처리
}
 
// 에러를 던질 수 있는 throws 함수에서만 가능
func handleData() throws {
    let data = try fetchData() 
}
  • 다른 언어들은 저렇게 함수 시그니처에 에러를 달아놓지는 않아서, 함수를 콜하는 최상단까지 그대로 전파시킬 수 있다. 따라서 Android같은 경우는 최상단의 앱 레벨에서 모든 예외를 한번에 잡고 처리할 수 있다.
  • 하지만 Swift에서는 이런 방식이 불가능하다. 에러가 발생하더라도 UI 프레임워크를 뚫을 수 없기 때문에 호출 지점에서 반드시 처리되어야 한다.

어떻게 해야 할까

  • 에러를 잡는 방법을 정했으니, 어떻게 잡느냐가 관건인데, 이때 빈번히 처리되어야 하는 네트워킹 에러, 데이터 파싱 에러 등은 앱 전반에서 비슷하게 처리되는 경우가 많다.
  • 그래서 보통은 에러핸들러를 만들어서 사용한다. DI로 주입해서 UI단에서 토스트를 띄우든 알럿창을 띄우든 서비스단에서 타임아웃때 리트라이를 하든…싱글톤이 가장 적절할 것이다.

에러 타입은요?

  • Swift의 try-catch는 안전성을 높여주지만 어떤 종류의 에러가 올지 컴파일 타임에 알 수 없다. 이건 에러나 예외를 전파하는 다른 언어들도 마찬가지이지만…
  • 이를 해결하기위해 예전의 라이브러리들은 (Model?, DomainError?) 형태나 enum의 연관값을 통해 돌려 주는 방식이 많았다. 논리상 둘다 nil인 경우가 존재하지만…
  • 보통 네트워킹하는 라이브러리들이 대표적이다. 비동기다 보니 completion으로 (Data?, Error?) -> Void꼴로 넘겨준다.
enum NetworkError: Error {
    case timeout
    case invalidResponse
}
 
// 라이브러리에서 제공하는 API
func requestData(url: String, completion: @escaping (Data?, NetworkError?) -> Void)
 
// 호출
service.requestData(url: "https://api.example.com") { data, error in
    if let error = error {
        switch error {
        case .timeout:
            // 에러 처리
        case .invalidResponse:
            // 에러 처리
        }
    } else if let data = data {
        // 성공 케이스 처리
    } else {
        // 여기는 뭐야!
    }
}

Result 타입의 등장

  • Swift 5에서는 Result<Success, Failure> 타입이 표준 라이브러리에 추가되었다. 성공과 실패를 명확히 구분하면서도, 실패 시에는 구체적인 에러 타입을 지정할 수 있게 되었다.
 
let result: Result<Data, NetworkError>
switch result {
case .success(let data):
    processData(data)
case .failure(let error):
    // error는 NetworkError 타입, 컴파일 타임에 확정!
    switch error {
    case .timeout:
        break
    case .noConnection:
        break
    case .invalidResponse:
        break
    }
}
  • 처음엔 타입을 알 수 있으니 좋았는데, 개인적으로 점점 마음에 들지않았다. 무엇보다도 에러를 처리하지 않을 간단한 정상 케이스에서도 계속 값을 까봐야 하기 때문에 에러 전파가 자연스럽지 못하고, 복잡한 도메인에서 쉽게 쓰기가 애매했기 때문이다. 함수 분리해야 하는데 success부분만 이어서 작성하는 건 뎁스가 깊어지며 함수 구조가 꼬이고, 매개변수로 전부 들고 가자니 함수를 나누면 나눌수록 보일러플레이트가 많아졌다. 이렇게 되면 개발자가 귀찮아서 함수 분리에 점점 소극적이게 된다.
func convertData(result: Result<String, NetworkError>) -> Result<Int, NetworkError> {
    switch result {
    case .success(let data)
        if let intData = String(data) {
	        return .success(intData)
        } else {
	        return .failure(TypeConvertingError())
        }
    } catch {
        return .failure(error)
    }
}
  • 그리고 또 하나의 이유는 개발자에게 주어진 선택지가 너무 많아졌다는 점이다. 협업을 하거나 기존의 코드와 붙이다 보면 throws 시그니쳐를 붙였다가 뗐다가, 처리를 위해 스위치문을 열고, case를 분기쳤다가, 적절한 에러를 만들고 던지고…일관적이지 못한 처리가 계속 일어났다.
 
// 보통 기존의 코드를 이렇게 래핑한다.
func fetchResult() -> Result<Data, DomainError> {
    do {
        let data = try fetchData()
        return .success(data)
    } catch {
        return .failure(DomainError()) /// 에러가 난 이유를 위한 특별한 에러
    }
}
 
// 호환성을 위한 반대 변환
func fetchDataThrows() throws -> Data {
    switch fetchResult() {
    case .success(let data):
        return processData(data)
    case .failure(let error):
        throw error // 에러타입을 알아낸 보람이 좀 없어짐
    }
}
  • 안그래도 try?로 에러를 nil로 바꿀 수 있다. 있던거 쓰겠다고 여기저기 붙이다보면 함수 시그니처는 걸레짝이 되어버린다.
  • 사실 에러의 구체적인 이유보다는 성공/실패 여부만 중요한 경우가 많은데, 굳이 Result 타입까지 써야 할까?
/// 8명의 손이 거쳐간 10년된 코드
func Legacy_fetchDataNewV2Wrapper() throws -> Result<Data?, NetworkError>?

SDK 개발자라면 고민해보겠지만

  • 만약 내가 SDK를 개발한다면 정확한 에러 이유를 제공하기 위해 Result 타입을 고민해볼 수도 있다. 하지만 대부분의 경우 에러가 났으면 어차피 원하는 기능을 실행하는 것은 불가능하다. 네트워킹이 실패했든, 파싱이 실패했든 결국 사용자에게는 “일시적인 오류입니다. 잠시 후 다시 시도해주세요” 메시지를 보여주는 게 전부다. 실패한 동작 자체는 함수를 호출한 쪽에서 이미 알고 있고…
  • 정말 구체적인 이유가 필요한 경우에는 오버헤드를 감수하고 타입 캐스팅을 하는 것이 더 나은 선택이라고 생각한다. 다만 어떤 에러를 던지는지는 함수를 만든 사람이 책임지고 설명해야 한다.
do {
	let data = try fetchData()
	processData(data)
} catch let error as NetworkError {
	// 네트워크 에러만 특별 처리
	handleNetworkError(networkError)
} catch {
	// 나머지는 일반 처리
    showGenericErrorMessage()
}
 
/// 차량 명령 전송
/// - Parameters:
///   - command: 서버로 전송할 차량 명령
/// - Throws: ``SendCommandError``
/// - Warning: 네트워킹에 성공했다고 해서 명령이 차량에서 실행됨이 보장되지 않습니다. 최소시간 후 차량 상태를 다시 가져오십시오.
private func sendCommandCar(command: Command) async throws -> CommandResponse {
}
 

Typed throw

  • Swift 6부터는 throws에 구체적인 에러 타입을 명시할 수 있게 되었다.
func fetchData() throws(NetworkError) -> Data {
    // ...
}
do {
    let data = try fetchData()
    processData(data)
} catch {
    // error: NetworkError
    switch error {
    case .timeout:
        showTimeoutMessage()
    case .noConnection:
        showNoConnectionMessage()
    case .invalidResponse:
        showInvalidResponseMessage()
    }
}
 
func processUserData() throws(NetworkError) -> ProcessedData {
    let data = try fetchData()
    return processData(data)
}
  • 컨텍스트를 유지하며 일관된 처리를 할 수 있다. 기존의 문법도 any Error로 그대로 유지된다.
  • 사실 Result타입을 사용하는 것과 크게 차이가 나지는 않는다. 분기가 조금 편해졌다는 점이나 일관성을 가져갈 수 있다는 정도?
  • Swift 6의 장벽이 워낙 높은 탓에 다들 전환하는데 시간이 필요하겠지만, 장기적으로는 Swift 에러 처리의 표준이 될 것 같다.