✒️ 서론

iOS 프로젝트를 진행할 때, 기본적으로 설정해줘야 하는 다양한 것들이 있다.
그 중에서도 앱의 핵심이라고도 할 수 있는 API와 연결하는 네트워크 관리 객체의 구현은 필수적이다.

UI를 먼저 만들고 네트워크를 구축하는 사람들도 있겠지만, 나는 네트워크 환경을 먼저 구축해두고 UI를 작업하는 것을 좋아한다.
오늘은 내가 사용하는 네트워크 관리 객체를 만드는 기본적인 방법을 간단히 작성해 보고자 한다.


✅ 네트워크 에러 정의

어떤 방식으로 네트워크 환경을 구축하더라도 네트워크 에러에 대한 정의는 해두는 것이 좋다.
그래야 네트워크 에러가 발생했을 때 예측 가능한 에러인지 불가능한 에러인지 판단할 수 있고, 원활한 디버깅이 가능하기 때문이다.

enum NetworkError: Error {
	case invalidURL // 잘못된 URL
    case invalidStatusCode(Int) // 401, 404, 500 등 상태 코드 범위 이탈 시
    case decodingError(Error) // 디코딩 실패
    case unknown(Error) // 알 수 없음
}

에러를 정의할 때 LocalizedError 프로토콜을 채택하여 errorDescription을 만든 뒤 각 에러별로 메세지를 미리 만들어 두어도 좋다.

⭐️ Network Protocol 정의

이제 가장 중요한 Network 관리 객체가 기본적으로 채택해야할 프로토콜을 정의할 것이다.
이 프로토콜은 네트워크 작업이 필요한 레이어(ViewModel 등)에서도 활용되는데, 간단한 기능이라도 굳이 프로토콜로 구현하는 이유는 네트워크 관리 객체를 싱글톤 등으로 인스턴스화 하여 직접 사용하는 것보다 프로토콜을 통해 주입받아 사용하는 것이 의존성 관리 측면에서 더욱 유리하다고 생각하기 때문이다.

protocol NetworkService: AnyObject {
	func request<T: Decodable>(_ url: String) async throws -> T
}

위 프로토콜이 내가 가장 기본적으로 사용하는 방식이다.
만약 프로젝트에 Combine이나 RxSwift를 채택했다면 아래처럼 바꿔볼 수도 있다.

// Combine 채용시
protocol NetworkService: AnyObject {
	func request<T: Decodable>(_ url: String) -> AnyPublisher<T, NetworkError>
}

// RxSwift 채용시
// Observable 대신 Single 등의 다른 Trait을 사용해도 무방
protocol NetworkService: AnyObject {
	func request<T: Decodable>(_ url: String) -> Observable<T>
}

또한 Moya를 채택한 프로젝트라면 매개변수로 URL 대신 TargetType을 받는 것이 좋다.


🔥 네트워크 객체 구현

이제 위에서 구현된 프로토콜을 기반으로 실제 네트워크 통신을 진행할 객체를 구현해주면 된다.

1) URLSession 활용

만약 프로젝트에서 별도의 서드파티 라이브러리를 채택하지 않고 진행 중이라면 사용해볼 수 있는 방법이다.
코드가 다소 복잡해 보이기도 하지만, 가장 기본적인 방법이니 알고 있으면 좋다.

final class NetworkManager: NetworkService {
	
	func request<T: Decodable>(_ url: String) async throws -> T {
    	guard let validURL = URL(string: url) else {
        	throw NetworkError.invalidURL
        }
        
        let session = URLSession(configuration: .default)
        session.dataTask(with: validURL) { (data, response, error) in
        
        	if let error { throw NetworkError.unknown(error) }
        
    	    guard let data else { throw NetworkError.unknown(error) }
        
        	guard let httpResponse = response as? HTTPURLResponse,
        	  	  (200..<300).contains(httpResponse.statusCode) 
        	else {
        		throw NetworkError.invalidStatusCode(httpResponse.statusCode)
        	}
        
        	do {
        		let decodedData = try JSONDecoder().decode(T.self, from: data)
            	return decodedData
        	} catch {
        		throw NetworkError.decodingError(error)
        	}
        }
        .resume()
    }
    
}

2) Alamofire 활용

서드파티 라이브러리인 Alamofire를 사용하면 코드를 비교적 단축시킬 수 있다.
이번엔 Alamofire와 Combine을 결합시킨 방식으로 예시 코드를 짜보았다.

import Alamofire

final class NetworkManager: CombineNetworkService {

	func request<T: Decodable>(_ url: String) -> AnyPublisher<T, NetworkError> {
        return AF.request(url)
            .validate(statusCode: 200..<300) // 유효하지 않은 상태 코드 자동 필터링
            .publishDecodable(type: T.self)
            .value() // Result의 success 값만 추출
            .mapError { error in
                if let responseCode = error.responseCode {
                    return NetworkError.invalidStatusCode(responseCode)
                }
                return NetworkError.unknown(error)
            }
            .eraseToAnyPublisher()
    }
}

3) Moya 활용

Moya는 Alamofire를 기반으로 네트워크 계층을 추상화하여 안전하게 네트워킹을 할 수 있게 돕는 라이브러리이다.
Moya를 사용하는 경우에는 위에서 만든 프로토콜을 수정할 필요가 있지만, 기본 설정만 잘 해두면 편하게 사용할 수 있는 유용한 라이브러리이다.

a) Moya용 프로토콜 정의

우선은 위에서 만든 프로토콜을 아래와 같이 수정해주면 좋다.

import Moya

protocol NetworkService: AnyObject {
	associatedtype API: TargetType
    var provider: MoyaProvider<API> { get }
    
    // Combine 사용시
    func request<T: Decodable>(_ target: API) -> AnyPublisher<T, NetworkError>
    
    // RxSwift 사용시
    func request<T: Decodable>(_ target: API) -> Single<T>
}

주의할 점은 Moya를 사용하기 위해서 반드시 TargetType을 채택한 객체를 구현해야 한다는 점이다.
TargetType은 API 통신을 진행할 URL을 정의하거나 해당 API의 호출 방식(get, post 등)을 설정할 수 있다.
만약 모든 API의 기본적인 URL이 같다면 아래와 같이 확장해주어도 좋다.

extension TargetType {
	var baseURL: URL { URL(string: "여기에 BaseURL을 입력")! }
}

b) 네트워크 객체 구현

이제 위에서 구현한 프로토콜을 활용하여 아래와 같이 네트워크 객체를 구현해주면 된다.

final class NetworkManager<API: TargetType>: NetworkService {
	private let provider: MoyaProvider<API>()
 
 	// Combine
    func request<T: Decodable>(_ target: API) -> AnyPublisher<T, NetworkError> {
    	return provider.requestPublisher(target)
        	.filterSuccessfulStatusCodes()
            .map(T.self)
            .mapError { error in
            	guard let moyaError = error as? MoyaError else { return NetworkError.unknown(error) }
                switch moyaError {
                case .statusCode(let response):
                	return NetworkError.invalidStatusCode(response.statusCode)
                default:
                	return NetworkError.unknown(moyaError)
                }
            }
            .eraseToAnyPublisher()
    }
    
    // RxSwift
    func request<T: Decodable>(_ target: API) -> Single<T> {
    	return provider.rx.request(target)
        	.filterSuccessfulStatusCodes()
            .map(T.self)
            .catch { error in
                guard let moyaError = error as? MoyaError else { return NetworkError.unknown(error) }
                switch moyaError {
                case .statusCode(let response):
                	return NetworkError.invalidStatusCode(response.statusCode)
                default:
                	return NetworkError.unknown(moyaError)
                }
            }
    }
}

위 예시의 경우 TargeType이 달라질 때마다 새로운 네트워크 객체를 만들어야 하기 때문에, 만약 프로젝트에서 다양한 도메인(TargetType)을 지원한다면 부적절한 방식이다.

그렇다면 도메인이 여러가지 있다면 어떻게 하면 될까?
그것은 Moya가 기본적으로 제공하는 MultiTarget 타입을 활용하면 된다.

final class NetworkManager: NetworkService {
    // 만약 도메인이 여러가지인 경우 MultiTarget을 채택
    private let provider = MoyaProvider<MultiTarget>()
    
     // Combine
    func request<T: Decodable, Target: TargetType>(_ target: Target) -> AnyPublisher<T, NetworkError> {
    	// 전달받은 Target을 MultiTarget으로 래핑
    	let multiTarget = MultiTarget(target)
    
    	return provider.requestPublisher(multiTarget)
        	.filterSuccessfulStatusCodes()
            .map(T.self)
            .mapError { error in
            	guard let moyaError = error as? MoyaError else { return NetworkError.unknown(error) }
                switch moyaError {
                case .statusCode(let response):
                	return NetworkError.invalidStatusCode(response.statusCode)
                default:
                	return NetworkError.unknown(moyaError)
                }
            }
            .eraseToAnyPublisher()
    }
    
    // RxSwift
    func request<T: Decodable, Target: TargetType>(_ target: Target) -> Single<T> {
    	// 전달받은 Target을 MultiTarget으로 래핑
    	let multiTarget = MultiTarget(target)
    
    	return provider.rx.request(multiTarget)
        	.filterSuccessfulStatusCodes()
            .map(T.self)
            .catch { error in
                guard let moyaError = error as? MoyaError else { return NetworkError.unknown(error) }
                switch moyaError {
                case .statusCode(let response):
                	return NetworkError.invalidStatusCode(response.statusCode)
                default:
                	return NetworkError.unknown(moyaError)
                }
            }
    }
}

MultiTarget이란, 서로 다른 TargetType들을 하나의 단일 TargetType으로 통합하여 하나의 MoyaProvider 인스턴스로 모든 종류의 API 요청을 처리할 수 있도록 도와주는 역할을 한다.

때문에 새로운 도메인이 추가되더라도 NetworkManager 내부의 속성을 수정할 필요가 없으므로 유지보수성이 용이해진다.


🏁 마무리

오늘은 다양한 방식으로 유연한 네트워크 객체를 구현하는 방법에 대해 알아보았다.
여기까지가 네트워크 관리 객체를 만들기 위한 기본적인 단계일 뿐이고, 실제로 프로젝트를 진행할 때는 보다 세세한 조정과 더 많은 작업을 거쳐야할 것이다.

여기서 더 유연성을 높이고 싶다면 Dependency Injection을 활용하여 NetworkManager를 직접 생성하지 말고, ViewModel 등에서 프로토콜 타입으로 주입을 받으면 좋다.
이렇게 구현하면 테스트 용이성도 향상되기 때문에 훨씬 유연한 코드를 만들 수 있다.

유연성도 좋지만, 프로젝트의 규모에 따라 적절한 방식을 선택하여 적용하는 것이 가장 중요하다.

profile
이유있는 코드를 쓰자!!

0개의 댓글