Swift

Modeling errors

Phililip
728x90

안녕하세요.

 

이번에는 Swift에서 throw-catch 키워드를 사용한 Error 처리 방식을 개선할 수 있는 3가지 관점에 대해서 소개해보려고 합니다.

 


우선 아래 코드를 볼까요?

actor InMemoryCache<Key: Hashable & Codable, Value: Codable> {
    enum ErrorKind: Error {
        case noValue(Key)
        case outOfMemory(availableBytes: Int)
    }

    private var memoryLimit: Int
    init(memoryLimit: Int) {
        self.memoryLimit = memoryLimit
    }

    func get(for key: Key) throws -> Value {
        guard contains(key: key) else {
            throw ErrorKind.noValue(key)
        }

        // fetch and return value here
    }

    func set(value: Value, for key: Key) throws -> Void {
        let size = calculateSize(for: value)
        let availableSpace = calculateAvailableSpace()

        guard size <= availableSpace else {
            throw ErrorKind.outOfMemory(availableBytes: availableSpace)
        }

        memoryLimit -= size
        // store value here
    }

    func deleteValue(for key: Key) throws -> Void {
        guard contains(key: key) else {
            throw ErrorKind.noValue(key)
        }

        let value = try get(for: key)
        let size = calculateSize(for: value)
        memoryLimit += size
        // delete value here
    }

    // more code here
}

 

위 코드는 메모리 내 캐시를 관리하는 코드입니다. 

 

key에 대응되는 값이 없을 경우 Error를 던지고, 값을 저장할 때 메모리 용량을 초과하면 Error를 던집니다.

 

 

 

이렇게 만든 메모리 내 캐시 관리하는 모듈을 사용하게 되면 아래와 같은 구조가 될 것입니다.

final class APIClient {
    typealias Cache = InMemoryCache<URL, User>
    
    private static let logger = Logger(
        subsystem: Bundle.main.bundleIdentifier!,
        category: String(describing: APIClient.self)
    )
    
    private let cache = Cache(memoryLimit: 1_000)

    func fetchUser(from url: URL) async throws -> User {
        do {
            let user = try await cache.get(for: url)
            return user
        } catch Cache.ErrorKind.noValue {
            do {
                let user = try await fetchRemoteUser(from: url)
                try await cache.set(value: user, for: url)
            } catch Cache.ErrorKind.outOfMemory {
                Self.logger.warning("Cache is full")
            } catch {
                // Catch the errors occuring while
                // fetching user from the remote server
            }
        }
    }

    private func fetchRemoteUser(from url: URL) async throws -> User {
        // ...
    }
}

 

fetchUser method를 보면, do-catch가 중첩되어 있어서 보기 안 좋고 코드 자체도 좀 복잡한 감이 없지 않아 있습니다...

 

 

이건, InMemoryCache 클래스가 사용자를 전혀 고려하지 않았기 때문이에요...ㅠㅠ

 

InMemoryCahce 클래스를 사용자 친화적으로 리팩토링하기 위한 3가지 관점을 소개할게요.

 

 

 

## 1. 꼭 필요한 에러만 정의하기

굳이 Error를 던질 필요는 없는데 Error로 정의되어 있는지 확인해보세요.

 

지금 예시로 따지자면 noValue 에러가 해당되겠네요.

 

key에 대응되는 값을 찾아서 없으면 noValue 에러 대신 nil을 반환해주면 throw를 굳이 할 필요는 없겠죠??

func get(for key: Key) -> Value? {
    guard contains(key: key) else {
        return nil
    }

    // fetch value here
}

 

가독성을 해치지 않으면서도, 사용자가 guard-let으로 쉽게 처리가 가능해요.

 

 

 

## 2. 모든 Error를 상위로 전달할 필요는 없다

Error를 받았다고 해서, 모든 Error를 전파할 필요는 없어요.

 

모든 Error를 상위로 전파할 경우, 사용자는 모든 에러를 고려해야 하고 처리하는데 어려움을 겪습니다.

 

 

그러니, 가능한 경우라면 특정 에러가 발생했을 경우에는 Error를 던지지 않고 별도의 로직을 수행하도록 구현하는 것이 사용자 입장에서 더 좋을 수가 있어요.

import CloudKit

final class CloudService {
    private static let logger = Logger(
        subsystem: Bundle.main.bundleIdentifier!,
        category: String(describing: CloudService.self)
    )

    private let container = CKContainer.default()
    private var pending: Set<CKRecord> = []

    func save(_ user: CKRecord) async throws {
        pending.insert(user)

        while let user = pending.popFirst() {
            do {
                try await container.privateCloudDatabase.save(user)
            } catch CKError.networkUnavailable {
                pending.insert(user)
                break
            }
        }
    }
}

 

 

 

## 3. Error 일반화

Error에 대한 모든 경우를 정의할 필요는 없습니다.

 

필요하다면 Error를 정의해야 하지만, 어쩔 때는 메시지를 설정할 수 있는 일반화된 단일 Error를 사용하는 것이 좋을 수 있어요.

actor InMemoryCache<Key: Hashable & Codable, Value: Codable> {
    enum ErrorKind: Error {
        case general(String)
        case outOfMemory
    }

    private var storage: [Key: Value] = [:]
    private var memoryLimit: Int
    init(memoryLimit: Int) {
        self.memoryLimit = memoryLimit
    }

    func swapToDisk() async throws -> Void {
        let encoder = JSONEncoder()
        do {
            try encoder.encode(storage)
        } catch {
            throw ErrorKind.general(error.localizedDescription)
        }
        // ...
    }

    func loadFromDisk() async throws -> Void {
        let data: Data = // ...
        let decoder = JSONDecoder()
        do {
            storage = try decoder.decode([Key: Value].self, from: data)
        } catch {
            throw ErrorKind.general(error.localizedDescription)
        }
    }
    
    // more code here
}

 

 

 

## 참고

- https://swiftwithmajid.com/2022/05/11/modeling-errors-in-swift/

 

Modeling errors in Swift

The new Swift Concurrency feature doesn’t only bring new opportunities for writing safer and more maintainable async code but also changes the way we handle errors. I didn’t use throw-catch keywords too much in my legacy code because usually, I had a c

swiftwithmajid.com

 

 


 

이번 글은 여기서 마무리.

 

 

 

반응형