[Swift] Result 타입의 이해

frogKing·2023년 7월 8일
0
post-thumbnail

서론

API의 Response를 Result를 감싼다는 것만 알고 있었고, 회사에서 success handler, failure handler를 인자로 받아 Result의 switch case에 각 handler를 대응하는 식의 함수를 만들어 사용하고 있었다.

그렇게 누가 만든 함수를 사용하면서 불편함이 없어서일까.. Result를 따로 공부해야겠다는 생각을 안하고 있었다. 그러던 중 새로운 프로젝트를 진행하면서 위 함수를 withCheckedContination을 이용하면 클로저를 사용하지 않고도 분기 처리를 할 수 있지 않을까라는 생각에 success value를 옵셔널로 반환하는 함수로 만들었다.

하지만 나중에서야 해당 코드를 발견한 상급자 분께서 Result에 어떤 프로퍼티, 함수가 있는지 하나도 모르고 짠 것 같다, Document부터 보라는 피드백을 들어 부랴부랴 공부하게 되었다. (모르면 미루지 말고 공부하자)

Result

A value that represents either a success or a failure, including an associated value in each case.

API에 대한 Response를 받게 되면 성공 혹은 실패 두 가지 경우로 나뉘게 되는데 Result는 성공 혹은 실패에 대응하는 associated value(연관값)를 들고 있는 값이라고 보면 된다.

필요성

기존에 throws를 통해 Error handling을 해줄 수 있는데도 왜 Result가 만들어진걸까? Writing Failable Asynchronous APIs 라는 Document를 보면 알 수 있다.

API를 작성할 때 throws 키워드를 사용하여 API 호출이 Error를 throw할 수 있지만, throws 키워드를 사용하여 비동기적으로 Error를 반환하는 함수를 만들 수는 없다고 한다.

func fetch(success: (Data) -> Void, failure: (Error) -> Void) {
	... 
}

이 document가 나올 당시에만 해도 throws 키워드는 동기적으로 Error를 처리하는 상황에만 쓰였기 때문에 위와 같이 API의 성공, 실패를 클로저로 처리를 해왔다.

func getSerializedObject(data: Data) throws -> Any {
    return try JSONSerialization.jsonObject(with: data)
}

보통 위와 같은 상황에서 throws를 사용해왔다.

func fetch() async throws -> Data? {
    ...
}

하지만 async-await가 나오면서 throws는 비동기 코드에서도 함께 사용할 수 있게 되었다. 이 부분은 주제와 다소 벗어나기 때문에 궁금하면 async, await 키워드로 구글링 ㄱㄱ

어떻게 쓰는 걸까?

일반적인 사용법

위에서 success, failure handler를 통해 결과를 처리하는 fetch 함수가 있었다. 우선 이 함수를 Result를 이용하여 수정한 함수는 다음과 같다.

func fetch(completion: (Result<Data, Error>) -> Void) {
    ...
}

내부 로직까지 보여주려면 코드가 장황해지기 때문에 인자만 간단히 살펴보시길..

회사 코드에서 AFNetworking이라는 deprecated library를 사용한 코드가 있는데(Alamofire 같은 느낌) 함수 인자로 success와 failure를 받고 있길래 '왜 completion만 받지 않고 저렇게 나눠서 받지?'라는 생각을 했었는데 이제야 그 이유를 알게 된 것 같다.

enum AnyError: Error {
    case notFive
}

func fetch(completion: (Result<Int, AnyError>) -> Void) {
    let value = fetchValue()
    
    var result: Result<Data, Error>
    if value == 5 {
        return completion(.success(value))
    } else {
        return completion(.failure(.notFive))
    }
}

다음은 Result로 감싸는 부분이다. fetchValue()라는 비동기 함수를 통해 가져온 값이 5이면 Success과 함께 success value로 value를 반환, 5가 아니면 Failure와 함께 Error 값을 함께 반환하게 된다. (이런 기능을 하는 함수가 있을까?)

fetch 함수를 사용하는 곳에서는 다음과 같이 success와 failure를 분기 처리하면 된다.

fetch { result in
    switch result {
    case .success(let value):
        print(value)
    case .failure(let error):
        print(error?.localizedDescription)
    }
}

throwing Expression to Result

throws 키워드를 사용한 기존의 표현식을 init(catching:)을 이용하여 Result로 변환할 수 있다. 아무래도 throws 키워드를 이용했기 때문에 동기 코드일텐데 Result는 비동기에서의 Error handling 만이 목적이 아니라 do-catch 구문을 대체하여 좀 더 간단하게 코드를 짤 수 있다는 것이 장점이라고 할 수 있다.
(아래 예시는 Preserving the Results of a Throwing Expression Document를 참고하였다.)

enum EntropyError: Error {
    case entropyDepleted
}


struct UnreliableRandomGenerator {
    func random() throws -> Int {
        if Bool.random() {
            return Int.random(in: 1...100)
        } else {
            throw EntropyError.entropyDepleted
        }
    }
}

위와 같이 성공하면 Int, 실패하면 Error를 던지는 함수가 있다.

struct RandomnessMonitor {
    let randomnessSource: UnreliableRandomGenerator
    var results: [Result<Int, Error>] = []


    init(generator: UnreliableRandomGenerator) {
        randomnessSource = generator
    }


    mutating func sample() {
        let sample = Result { try randomnessSource.random() }
        results.append(sample)
    }


    func summary() -> (Double, Double) {
        let totals = results.reduce((sum: 0, count: 0)) { total, sample in
            switch sample {
            case .success(let number):
                return (total.sum + number, total.count)
            case .failure:
                return (total.sum, total.count + 1)
            }
        }


        return (
            average: Double(totals.sum) / Double(results.count - totals.count),
            failureRate: Double(totals.count) / Double(results.count)
        )
    }
}

이렇게 throws 표현식을 Result(catching: () throws -> Success)을 이용하여 깔끔하게 짤 수도 있다.

Result to Throwing Expression

Result를 throws 키워드를 사용한 표현식으로 바꿀 수도 있다.
(이걸 모르고 함수를 새로 만들었다)

let integerResult: Result<Int, Error> = .success(5)
do {
    let value = try integerResult.get()
    print("The value is \(value).")
} catch {
    print("Error retrieving the value: \(error)")
}

Result로 래핑된 success value를 반환한다. do-catch 구문으로 굳이 쓸까 싶지만은..

let integerResult: Result<Int, Error> = .success(5)
guard let value = try? integerResult.get() else { return }

나는 이런 식으로 성공한 케이스에서만 코드를 짜려고 저 get() 함수와 똑같은 기능을 하는 함수를 만들었었다 ㅎㅎ.. 앞으로는 기존에 만들어놓은 것들을 잘 알아보고 함수를 만들자..

Transforming Result

Result는 map, mapError, flatMap, flatMapError 같은 함수도 제공한다.

struct AnyError: Error {
    init(_ error: Error) { ... }
}

let result: Result<Int, Error> = .success(5)

let mappedSuccessValueResult = result.map { String($0) } // Result<String, Error>
let mappedErrorResult = result.mapError { AnyError($0) } // Result<Int, AnyError>

map은 success value를 mapping하는 함수, mapError는 error를 mapping하는 함수라고 보면 된다.

flatMap, flatMapError도 같은 맥락으로 이해하면 될 듯 하다.

번외

그 외에 == 같은 비교연산자, combine과 함께 쓸 수 있는 publisher 등을 제공한다. 궁금하면 Document를 찾아보자. (이젠 지쳤다)

profile
내가 이걸 알고 있다고 말할 수 있을까

0개의 댓글