[iOS] iOS-Clean-Architecture and MVVM 학습 - 1

Hyunndy·2023년 1월 30일
1

🐸

최근 Clean Architecture을 공부하고 있습니다.
그중에 많은 분들이 iOS-Clean-Architecture 예제를 통해 이해하고 계시더라구요.

프로젝트 코드를 보니 Clean Architecture 뿐만 아니라

  • 극도의 프로토콜 지향 프로그래밍
  • DI
  • Coordinator 패턴

를 학습하기에 매우 좋아보여서 코드를 직접 Clone 하며 학습해보겠습니다.

포스팅을 나눈 기준은 저의 TIL 기준입니다.
오늘은 DI, Protocol 사용이 주된 내용입니다.


1️⃣ AppDelegate

프로젝트 구조는 Clean Architecture 구조로 되어있습니다.

저는 프로젝트를 직접 Clone 코딩할 계획으로,
AppDelegate가 들어있는 Application 폴더부터 보았습니다.

AppDelegate에서는

  • appDIContainer
  • appFlowCoordinator
    을 선언하고

appDIContainer에서 App 전반적으로 필요한 Dependency를 주입하고,
appFlowCoordinator를 start 시킵니다(?)
(Coordinator 패턴은 다른 포스팅에서 학습하겠습니다. -> Coordinator Pattern)

저는 DI 개념을 코드로 짜는데에 대한 이해가 부족해서, appDIContainer에 집중했습니다.

2️⃣ AppDIContainer

AppDIContainer에서는 다음의 일을 합니다.

  • NetworkService 변수 선언
    • API 데이터 통신
    • 이미지 데이터 통신
  • DIContainers of scenes 생성
    • 화면의 DI Container

여기서 API 데이터 통신 변수를 까보았습니다.

API 데이터 통신 변수를 선언하는 코드입니다.

    lazy var apiDataTransferService: DataTransferService = {
        let config = ApiDataNetworkConfig(baseURL: URL(string: appConfiguration.apiBaseURL)!,
                                          queryParameters: ["api_key": appConfiguration.apiKey,
                                                            "language": NSLocale.preferredLanguages.first ?? "en"])
        
        let apiDataNetwork = DefaultNetworkService(config: config)
        return DefaultDataTransferService(with: apiDataNetwork)
    }()

apiDataTransferService는
통신 Service 확장성(API 데이터, 이미지 데이터 둘 다 커버)을 위해 DataTransferService를 프로토콜을 conform하고 있는 타입으로 lazy 선언 되어있습니다.

실제 return 되는건 DefaultDataTransformService 객체로
DefaultDataTransferService Class는

  • DataTransferService 프로토콜 채택
  • ApiDataNetworkConfig를 갖는 DefaultNetworkService를 변수로 가짐

이렇습니다.

DataTransferService 프로토콜 부터 보겠습니다.

2️⃣-1️⃣ DataTransferService

프로토콜입니다.
(전 개인적으로 프로토콜이면 프로토콜이라 명시하는게 좋습니다.)

public protocol DataTransferService {
    typealias CompletionHandler<T> = (Result<T, DataTransferError>) -> Void
    
    @discardableResult
    func request<T: Decodable, E: ResponseRequestable>(with endpoint: E,
                                                       completion: @escaping CompletionHandler<T>) -> NetworkCancellable? where E.Response == T
    @discardableResult
    func request<E: ResponseRequestable>(with endpoint: E,
                                         completion: @escaping CompletionHandler<Void>) -> NetworkCancellable? where E.Response == Void
}

이름에 맞게 데이터 통신에 필요한 정보가 들어있습니다.

  1. 디코딩 할 Response가 있는 경우 request() 함수
  2. 디코딩 할 Response가 없는 경우 request() 함수
  3. request() 를 통한 API 통신이 끝난 후 CompletionHandler

request 함수의 endPoint로 ResponseRequestable 프로토콜을 채택한 타입이 들어가고 있습니다.
endPoint라고 되어있으니 HTTP 통신을 위한 HTTP 메서드, 쿼리 파라미터 등의 API 통신을 위한 세팅 값이 정의되어 있을 것 같습니다.

2️⃣-2️⃣ ResponseRequestable

프로토콜 입니다.
(네이밍이 마음에 들진 않습니다. ㅠ ~able말고 다른 대체 단어가 뭐가있을까요)

public protocol ResponseRequestable: Requestable {
    associatedtype Response
    
    var responseDecoder: ResponseDecoder { get }
}
  • Requestable 프로토콜을 채택하며
  • 사용처에서 Decodable을 채택한 타입이 들어갈 것 같은 associatetype Response
  • Decodable 객체를 디코딩해 줄 responseDecoder
    이 정의되어 있습니다.

아마 HTTP 통신에 필요한 세팅 값은 Requestable 프로토콜에 정의되어 있고,
Response + Requestable 답게 API 통신으로 받은 Response를 디코딩하는데 필요한 정보가 정의되어 있습니다.

잠깐 responseDecoder을 보자면.. ResponseDecoder 프로토콜을 채택하고 있습니다.
아마 JSONDecoder.decode(T.self, data: Data) 요런식의 제네릭 함수가 있을 것 같습니다.

public protocol ResponseDecoder {
    func decode<T: Decodable>(_ data: Data) throws -> T
}

// MARK: - Response Decoders
public class JSONResponseDecoder: ResponseDecoder {
    private let jsonDecoder = JSONDecoder()
    public init() { }
    public func decode<T: Decodable>(_ data: Data) throws -> T {
        return try jsonDecoder.decode(T.self, from: data)
    }
}

ㅎㅎ 맞네요.

2️⃣-3️⃣ Requestable

프로토콜 입니다.

public protocol Requestable {
    var path: String { get }
    var isFullPath: Bool { get }
    var method: HTTPMethodType { get }
    var headerParameters: [String: String] { get }
    var queryParametersEncodable: Encodable? { get }
    var queryParameters: [String: Any] { get }
    var bodyParametersEncodable: Encodable? { get }
    var bodyParameters: [String: Any] { get }
    var bodyEncoding: BodyEncoding { get }
    
    func urlRequest(with networkConfig: NetworkConfigurable) throws -> URLRequest
}
  • API 통신에 필요한 세팅 값들
  • networkConfig를 사용한 URLRequest 객체 생성하는 함수
    가 정의 되어 있습니다.

NetworkConfigurable을 잠깐 보자면...

public protocol NetworkConfigurable {
    var baseURL: URL { get }
    var headers: [String: String] { get }
    var queryParameters: [String: String] { get }
}

public struct ApiDataNetworkConfig: NetworkConfigurable {
    public let baseURL: URL
    public let headers: [String: String]
    public let queryParameters: [String: String]
    
     public init(baseURL: URL,
                 headers: [String: String] = [:],
                 queryParameters: [String: String] = [:]) {
        self.baseURL = baseURL
        self.headers = headers
        self.queryParameters = queryParameters
    }
}

baseURL, headers, queryParam가 정의되어 있는데, 실제 사용처를 보면

let config = ApiDataNetworkConfig(baseURL: URL(string: appConfiguration.apiBaseURL)!,
                                          queryParameters: ["api_key": appConfiguration.apiKey,
                                                            "language": NSLocale.preferredLanguages.first ?? "en"])

모든 API의 기본 설정 값들이 들어가고 있습니다.

정리하자면..
NetworkConfigurable(ex. apiDataNetworkConfig): 모든 API의 기본 설정 값
Requestable(ex. MovieAPI, MovieSearchQueryAPI): 각 API의 세부 설정 값(디코딩 param, 인코딩 param 등)

여기까지 AppDIContainer에서 주입되는 apiDataTransferService가 채택하고 있는 DataTransferService 프로토콜에 정의되어있던 여러 프로토콜에 대해 정리했습니다.


다시 AppDIContainer의 apiDataTransferService의 선언부를 보면

    // MARK: - Network
    lazy var apiDataTransferService: DataTransferService = {
        let config = ApiDataNetworkConfig(baseURL: URL(string: appConfiguration.apiBaseURL)!,
                                          queryParameters: ["api_key": appConfiguration.apiKey,
                                                            "language": NSLocale.preferredLanguages.first ?? "en"])
        
        let apiDataNetwork = DefaultNetworkService(config: config)
        return DefaultDataTransferService(with: apiDataNetwork)
    }()

위에 살펴본 ApiDataNetworkConfig을 변수로 갖는 DefaultNetworkService 객체를 까보겠습니다.

3️⃣ DefaultNetworkService

  • NetworkService 프로토콜을 채택합니다.
  • private let config: NetworkConfigurable -> API 기본 설정 값. apiDataNetworkConfig
  • private let sessionManager: NetworkSessionManager

DI에서 주입하려는 apiDataTransferService 객체에서 실제 네트워크와의 통신을 담당하는 부분 입니다.

config는 위에서 봤고,
sessionManager은 직접적인 URL 통신이 정의되어 있습니다.

public protocol NetworkSessionManager {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
    
    func request(_ request: URLRequest,
                 completion: @escaping CompletionHandler) -> NetworkCancellable
}

public class DefaultNetworkSessionManager: NetworkSessionManager {
    public init() {}
    public func request(_ request: URLRequest,
                        completion: @escaping CompletionHandler) -> NetworkCancellable {
        let task = URLSession.shared.dataTask(with: request, completionHandler: completion)
        task.resume()
        return task
    }
}

NetworkService 살펴보겠습니다.

3️⃣-1️⃣ NetworkService

프로토콜 입니다.

public protocol NetworkService {
    typealias CompletionHandler = (Result<Data?, NetworkError>) -> Void
    
    func request(endpoint: Requestable, completion: @escaping CompletionHandler) -> NetworkCancellable?
}

위에서 보았던 DataTransferService안의 endPoint. Requestable을 endPoint로 받는 함수가 정의도어 있습니다.

실제 구현부는

    public func request(endpoint: Requestable, completion: @escaping CompletionHandler) -> NetworkCancellable? {
        do {
            let urlRequest = try endpoint.urlRequest(with: config)
            return request(request: urlRequest, completion: completion)
        } catch {
            completion(.failure(.urlGeneration))
            return nil
        }
    }

Requestable을 채택한 실제 사용 API(ex. movieAPI, movieSearchQueryAPI)등에서 만든 URLRequest 객체를 통해 SessionManager의 request를 호출해서 실제 API 통신을 합니다.

여기까지

  • API 통신을 위한 여러 세팅 + API 통신으로 받은 Response에 대한 동작 정의: DataTransferService 프로토콜
  • URLSession으로 실제 통신 담당 - NetworkService 프로토콜
    까지 봤습니다.

마지막으로 실제 DI되는 객체인 DefaultDataTransferService 클래스를 보겠습니다.
(근데 DI되는 객체가 맞는 표현인지 모르겠습니다)


4️⃣ DefaultDataTransferService

  • DataTransferService 채택
  • networkService 객체, error에 대한 처리 객체 보유

여기서 갖고있는 정보는 위에서 다 살펴봤으므로 실제 DataTransferService 구현부가 어떤지 보겠습니다.

    public func request<T: Decodable, E: ResponseRequestable>(with endpoint: E,
                                                              completion: @escaping CompletionHandler<T>) -> NetworkCancellable? where E.Response == T {

        return self.networkService.request(endpoint: endpoint) { result in
            switch result {
            case .success(let data):
                let result: Result<T, DataTransferError> = self.decode(data: data, decoder: endpoint.responseDecoder)
                DispatchQueue.main.async { return completion(result) }
            case .failure(let error):
                self.errorLogger.log(error: error)
                let error = self.resolve(networkError: error)
                DispatchQueue.main.async { return completion(.failure(error)) }
            }
        }
    }

networkService의 request에서 실제 URLSession.dataTask가 일어나고,
여기선 Response를 받아서 디코딩 후 completion 시킵니다.
구현부는 다른 API 통신과 다를게 없네요.

실제 사용부는 이렇습니다.
Poster정보를 받아오는 fetchImage() 함수 입니다.

    func fetchImage(with imagePath: String, width: Int, completion: @escaping (Result<Data, Error>) -> Void) -> Cancellable? {
        
        let endpoint = APIEndpoints.getMoviePoster(path: imagePath, width: width)
        let task = RepositoryTask()
        task.networkTask = dataTransferService.request(with: endpoint) { (result: Result<Data, DataTransferError>) in

            let result = result.mapError { $0 as Error }
            DispatchQueue.main.async { completion(result) }
        }
        return task
    }

APIEndPoint객체에서 실제 사용하는 API를 리턴하고, request 함수를 호출해 데이터를 받습니다.

여기까지 AppDIContainer가 주입하는 Network 관련 Dependency 였습니다. 👐


1️⃣ MoviesSceneDIContainer

AppDIContainer에서는 DIContainers of scenes(화면들의 DI객체)를 만들어서 반환합니다.

    // MARK: - DIContainers of scenes
    func makeMoviesSceneDIContainer() -> MoviesSceneDIContainer {
        let dependencies = MoviesSceneDIContainer.Dependencies(apiDataTransferService: apiDataTransferService,
                                                               imageDataTransferService: imageDataTransferService)
        return MoviesSceneDIContainer(dependencies: dependencies)
    }

위에서 만든 네트워크 모듈들을 dependency로 주입하고, MovieSceneDICOntainer를 리턴합니다.

이 앱의 유일한 Scene인 Movie화면에 대한 DIContainer를 보겠습니다.
여기서부터는 Coordinator 패턴을 알아야 코드 분석이 가능할 것 같습니다.

흥미로운것은 Clean Architecture의 구성요소인

  • Use Case
  • Presenter(Views + ViewModels)
  • Persistent Storage
  • Repositories
    들을 생성하는 코드가 모두 여기 선언되어있습니다.

Coordinator 패턴을 아직 몰라서 오늘의 TIL은 여기서 마치겠습니다! 🙇‍♀️


느낀점

저 스스로 취약하다고 생각하는 부분이 프로토콜 지향 프로그래밍 입니다.

게임 학부 시절 + 스마일게이트 기간 동안 오랜 시간 객체 지향 프로그래밍에 세뇌되어 있었고, 현재 다니고있는 회사의 프로젝트도 Object-C를 쓰다가 여기서 Swift로 컨버팅 된 프로젝트라 Swift가 프로토콜 지향 프로그램 언어인건 알고 있지만 업무를 하면서도 프로토콜을 딥하게 사용하지 않았는데요.

그러다보니 애매한 경력이 생긴 지금 스스로도 느낄만큼 프로토콜 지향 프로그래밍에 부족함을 느낍니다.

이 앱의 결과물은 사실 검색창에 영화 이름을 검색하면 API 통신을 통해 받아온 영화 리스트를 뿌려주고, 그 셀을 클릭하면 영화 상세화면이 나오는 간단한 앱입니다.
아무 생각 없이 구현하라고 하면 정말 1시간 안에 뚝딱 할 수 있을 정도의 기능을 갖고있다고 볼 수 있습니다.

하나하나 코드를 까볼수록 처음엔 이렇게까지..?라는 생각이 들었는데
네트워크모듈쪽만 학습한 지금 촘촘히 프로토콜을 설계함으로써 1시간 개발하고서는 절대 할 수 없는 확장성을 가진다고 생각했습니다.
UI만 바꿔주고 DIContainer에 Presentation에 넣어버리면 될 것 같습니다.

오늘도 프로토콜에 약한 저 자신을 반성하며... 이 프로젝트 까보기가 끝나면 자존감이 조금이라도 회복되었으면 좋겠습니다.

profile
https://hyunndyblog.tistory.com/163 티스토리에서 이사 중

0개의 댓글