Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

지속 가능한 iOS 앱 개발을 위한 아키텍처 설계와 모듈화 전략에 대해 설명해주세요. #29

Open
hamfan524 opened this issue Apr 28, 2024 · 2 comments
Assignees

Comments

@hamfan524
Copy link
Member

Clean Architecture, VIPER 등의 아키텍처 패턴과 적용 방법을 소개해주세요.
기능 모듈화, 라이브러리 모듈화 등을 통한 코드 재사용성과 유지보수성 향상 방안을 제시해주세요.
의존성 주입, 인터페이스 분리 등의 설계 원칙을 적용한 모듈 간 느슨한 결합 방법을 설명해주세요.

@hamfan524 hamfan524 self-assigned this Apr 28, 2024
@4T2F 4T2F deleted a comment from Hminchae May 1, 2024
@4T2F 4T2F deleted a comment from ha-nabi May 1, 2024
@4T2F 4T2F deleted a comment from ha-nabi May 1, 2024
@4T2F 4T2F deleted a comment from Hminchae May 1, 2024
@hamfan524
Copy link
Member Author

지속 가능한 iOS 앱 개발을 위한 아키텍처 설계와 모듈화 전략에 대해 설명해주세요.

  • 모듈화는 코드를 독립적인 단위로 나누는 것을 의미합니다. 각 모듈은 특정 기능을 수행하며, 다른 모듈과는 독립적으로 작동할 수 있어야 합니다.
  • 이를 통해 각 모듈을 개별적으로 개발하고 테스트할 수 있으며, 필요에 따라 재사용할 수 있습니다.

모듈화의 장점은 다양합니다.

  1. 코드의 가독성과 관리가 용이해집니다.
  2. 에러의 범위를 모듈 내로 한정할 수 있어 디버깅이 쉬워집니다.
  3. 개발 팀 내에서 작업을 분할하여 효율적으로 협업할 수 있습니다.

위에서 설명한 모듈화를 잘 하기 위해선 앱을 개발하고 유지 보수하는 동안 확장성, 유지 관리성, 재사용성 등을 고려하여 설계해야 하며, 몇가지 중요한 원칙과 전략을 설명하자면..

  1. 의존성 주입: 클래스나 모듈이 직접 생성하는 의존성을 제거하고 외부에서 주입받도록 만드는 디자인 패턴입니다. 이를 통해 유연성을 높이고 테스트 용이성을 개선할 수 있습니다.

  2. 네트워크 계층 분리: 네트워크 요청 및 응답 로직을 별도의 계층으로 분리하여 서버 통신 부분을 단일 책임 원칙에 따라 설계하여 네트워크 코드를 재사용하고 테스트하기 쉽게 만들어줍니다.

  3. 테스트 주도 개발(TDD): 테스트 주도 개발은 코드를 테스트하는 것을 먼저 하고 이에 대한 코드를 작성하는 개발 방법론입니다.
    이를 통해 코드의 품질을 향상시키고 버그를 미리 발견할 수 있습니다.

  4. MVC 대신 MVVM 사용

  5. 모듈화된 아키텍처 사용: 앱을 작은 단위로 분리하여 각 모듈이 특정 기능이나 책임을 가지도록 설계하여 코드를 더 잘 이해하고 유지 보수하기 쉽게 만들어줍니다.

위의 전략을 적용해서 아래에 간단한 MVVM패턴의 코드를 작성해보겠습니다.

// 모델
struct Item {
    let id: UUID
    let name: String
}

// 뷰모델
class ItemListViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func fetchItems() {
        // 네트워크 요청을 통해 데이터 패치
    }
}

// 뷰
struct ItemListView: View {
    @ObservedObject var viewModel: ItemListViewModel
    
    var body: some View {
        List(viewModel.items, id: \.id) { item in
            Text(item.name)
        }
        .onAppear {
            viewModel.fetchItems()
        }
    }
}

// 메인 앱
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ItemListView(viewModel: ItemListViewModel())
        }
    }
}

Clean Architecture, VIPER 등의 아키텍처 패턴과 적용 방법을 소개해주세요.

클린 아키텍처(Clean Architecture)는 시스템의 각 요소들을 명확하게 분리하면서도, 유연하게 연결될 수 있도록 디자인 하는 SW 설계 구조 입니다. 클린아키텍처의 가장 큰 특징들을 나열해보자면..

  1. 구성 요소 분리

클린 아키텍처는 소프트웨어 시스템의 다양한 부분을 독립적인 구성 요소로 분리하는 것을 강조하기에, 시간이 지남에 따라 시스템을 더 쉽게 유지 관리할 수 있습니다.

  1. 모듈화

클린 아키텍처는 모듈식 설계를 권장합니다. 이는 시스템의 개별 구성 요소를 분리 해 주고, 테스트와 유지보수를 쉽게 만들어 줍니다.

  1. 확장성

클린 아키텍처는 시스템 구축에 사용되는 기본 기술과 요구사항의 변화를 수용할 수 있는 확장 가능한 설계를 제공합니다.

  1. 재사용성

클린 아키텍처는 여러 시스템에서 재사용 가능한 컴포넌트를 만드는 것을 장려합니다.

이이 따라, 개발자가 다른 SW혹은 기능을 개발 할 때 구축하는 데 필요한 시간과 노력이 줄어들게 됩니다.

  1. 개념의 단순함

클린 아키텍쳐의 개념은 단순하고, 이해 하기도 굉장히 간단합니다.

워낙 개념이 이해하기 쉽기에 여러 개발자들과 IT 스타트업들이 도입을 시도했고, 하나의 큰 트렌드로 이어지게 되었습니다.

image

image-1

위 사진은 클린 아키텍처에 대한 그래프입니다.

간단히 설명하면,

  • Clean Architecture 그래프에서 볼 수 있듯이 애플리케이션에는 서로 다른 계층이 있다!

  • 가장 주요 규칙은 내부 레이어에서 외부 레이어로의 종속성(dependency)을 갖지 않는 것 (내부 -> 외부 ❌)

  • 외부 계층에서 안쪽으로만 종속성이 있을 수 있다. (외부 -> 내부)

Clean Architecture의 다른 패턴과의 차이점에 대해선 제가 이전에 정리해둔 글이 있어 자세히 보고 싶으면 여기 글에 가서 보시면 더 편하게 볼 수 있습니다.

Viper패턴은 위 클린 아키텍처 구조를 iOS에 맞게 변형하여 대중화되어 많이 사용되고 있는 패턴입니다.

image

View, Interactor, Presenter, Entity, Router의 약자를 따와서 VIPER라는 이름이 명명된 단일책임원칙 기반의 클린 아키텍쳐입니다.

  • View

    • ViewController를 의미, UI 관련 부분만 담당합니다.

    • Presenter를 소유하고 있으며(의존적) 이에 따라 담당하는 UI를 업데이트 합니다.

  • Interactor

    • 모델 로직을 담당하며 Network 통신이나 entity에 대한 처리를 하고 Presenter에게 알립니다.
  • Presenter

    • View, Router. Interactor 에 의존적이며 View에서 받은 사용자 이벤트를 통해 interactor에게 업데이트 할 것인지 물어봅니다.
    • 또는 Router를 통해 화면 이동을 처리합니다.
  • Entity

    • 가공되기 전의 data model입니다.
  • Router(WireFrame)

    • 화면 전환과 의존성 주입을 담당합니다.

패턴에 대한 설명은 위 설명이 전부이고, 아래에 예시 코드들을 보겠습니다.

View

import Foundation
import UIKit

// ViewController
// protocol
// reference presenter

protocol AnyView {
    var presenter: AnyPresenter? { get set }
    
    func update(with users: [User])
    func update(with error: String)
}

class UserViewController: UIViewController, AnyView {
    var presenter: AnyPresenter?
    
    private let tableView: UITableView = {
       let table = UITableView()
        
        table.register(UITableViewCell.self,
                       forCellReuseIdentifier: "cell")
        table.isHidden = true
        return table
    }()
    
    private let label: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.isHidden = true
        return label
    }()
    
    
    var users: [User] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBlue
        view.addSubview(label)
        view.addSubview(tableView)
        tableView.delegate = self
        tableView.dataSource = self
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.frame = view.bounds
        label.frame = CGRect(x: 0, y: 0, width: 200, height: 50)
        label.center = view.center
    }
    
    func update(with users: [User]) {
        print("got users")
        DispatchQueue.main.async {
            self.users = users
            self.tableView.reloadData()
            self.tableView.isHidden = false
        }
    }
    
    func update(with error: String) {
        print(error)
        DispatchQueue.main.async {
            self.users = []
            self.label.text = error
            self.tableView.isHidden = true
            self.label.isHidden = false
        }
    }
}

extension UserViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return users.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = users[indexPath.row].name
        return cell
    }
}

Interactor

import Foundation

// object
// protocol
// ref to presenter

// https://jsonplaceholder.typicode.com/users


protocol AnyInteractor {
    var presenter: AnyPresenter? { get set }
    
    func getUsers()
}

class UserInteracotr: AnyInteractor {
    var presenter: AnyPresenter?
    
    func getUsers() {
        print("Start fetching")
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else { return }
        let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
            guard let data = data, error == nil else {
                self?.presenter?.interactorDidFetchUsers(with: .failure(FetchError.failed))
                return
            }
            
            do {
                let entities = try JSONDecoder().decode([User].self, from: data)
                
                self?.presenter?.interactorDidFetchUsers(with: .success(entities))

            } catch {
                self?.presenter?.interactorDidFetchUsers(with: .failure(error))
            }
        }
        
        task.resume()
    }
}

Presenter

import Foundation

// Object
// protocol
// ref to interactor, router, view

enum FetchError: Error {
    case failed
}

protocol AnyPresenter {
    var router: AnyRouter? { get set }
    var interactor: AnyInteractor? { get set }
    var view: AnyView? { get set }
    
    func interactorDidFetchUsers(with result: Result<[User], Error>)
}

class UserPresenter: AnyPresenter {
    var router: AnyRouter?
    
    var interactor: AnyInteractor? {
        didSet {
            interactor?.getUsers()
        }
    }
    
    var view: AnyView?
    
    func interactorDidFetchUsers(with result: Result<[User], Error>) {
        switch result {
        case .success(let users):
            view?.update(with: users)
        case .failure:
            view?.update(with: "Something went wrong")
        }
    }
}

Entity

import Foundation

// Model

struct User: Codable {
    let name: String
    
}

Router

import Foundation

// Object
// Entry point

import UIKit

typealias EntryPoint = AnyView & UIViewController

protocol AnyRouter {
    var entry: EntryPoint? { get }
    
//    func stop()
//    func route(to destination)
    
    static func start() -> AnyRouter
}

class UserRouter: AnyRouter {
    var entry: EntryPoint?
    
    static func start() -> AnyRouter {
        let router = UserRouter()
        
        // Assign VIP
        var view: AnyView = UserViewController()
        var presenter: AnyPresenter = UserPresenter()
        var interactor: AnyInteractor = UserInteracotr()
        
        view.presenter = presenter
        
        interactor.presenter = presenter
        
        presenter.router = router
        presenter.view = view
        presenter.interactor = interactor
        
        router.entry = view as? EntryPoint
        
        return router
    }
}

Viper패턴을 적용했을 때의 장점

  • 책임 분리의 원칙(SRP)에 따라 역할에 따라 분리가 되어, 재사용성이 높아지고 테스트가 용이합니다.

  • 새로운 기능을 추가하는 것이 더 용이합니다.

Viper패턴의 단점

  • 역할 단위의 구분이 명확한 것이 장점이자 단점입니다.
    매우 작은 역할을 가지는 클래스들을 위해 엄청나게 많은 인터페이스를 작성해야 하기 때문에 많은 유지보수 비용이 든다는 것이 단점입니다.

  • View 트리와 business 트리가 밀접하게 결합되어 있어, View로직만 포함하거나 business 로직만 포함하는 노드를 구현하기 힘듭니다.

위 Viper패턴의 단점들을 해결하기 위해 나온 패턴인 Ribs패턴에 대해서는 다음에 알아보도록 하겠습니다.

기능 모듈화, 라이브러리 모듈화 등을 통한 코드 재사용성과 유지보수성 향상 방안을 제시해주세요.

  1. 기능 모듈화 (Feature Modularization)

기능 모듈화는 앱의 기능을 별도의 모듈로 분리하여 개발하는 접근 방식입니다. 각 모듈은 특정 기능 또는 부분을 담당하고, 이를 독립적으로 테스트하고 재사용할 수 있습니다. 예를 들어, 게시판이나 채팅 기능을 담당하는 모듈을 만들 수 있습니다.

  1. 라이브러리 모듈화 (Library Modularization)

라이브러리 모듈화는 공통 기능이나 유틸리티를 담당하는 모듈을 만드는 것입니다. 이러한 모듈은 다른 앱이나 프로젝트에서도 재사용될 수 있습니다. 예를 들어, 네트워킹 기능을 담당하는 라이브러리를 만들 수 있습니다.

라이브러리 모듈화 예시 : Hsungjin/SnipImage

@4T2F 4T2F deleted a comment from Hminchae May 2, 2024
@hamfan524
Copy link
Member Author

hamfan524 commented May 2, 2024

의존성 주입, 인터페이스 분리 등의 설계 원칙을 적용한 모듈 간 느슨한 결합 방법을 설명해주세요.

의존성 주입

의존성 주입은 위에서 설명하였듯이 클래스나 모듈이 직접 생성하는 의존성을 제거하고 외부에서 주입받도록 만드는 디자인 패턴입니다. 모듈이 필요로 하는 의존성을 외부에서 주입받도록 하기에 모듈은 직접 의존성을 생성하거나 초기화하지 않고 외부에서 주입받아 사용할 수 있습니다.

import Foundation

protocol DataFetcher {
    func fetchData(url: URL) async throws -> Data
}

class NetworkManager: DataFetcher {
    static let shared = NetworkManager()
    
    func fetchData(url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

struct DataRepository {
    let dataFetcher: DataFetcher
    
    func fetchData(url: URL) async throws -> Data {
        return try await dataFetcher.fetchData(from: url)
    }
}
  • 위 코드에서 DataRepository는 외부에서 DataFetcher 프로토콜을 준수하는 객체를 주입받기에, DataRepository는 실제 데이터를 가져오는 구현체에 의존하지 않고 인터페이스에만 의존하게 됩니다.

  • NetworkManager 클래스가 DataFetcher 프로토콜을 준수하고 있으니, DataRepositorydataFetcher 프로퍼티에 NetworkManager 인스턴스가 할당되었다면, DataRepository에서 fetchData 함수를 호출할 때 NetworkManagerfetchData 함수가 실행되게 됩니다.
    `

  • 현재 DataRepositoryNetworkManager 인스턴스가 할당되어 있지 않으니 할당해주고 호출하는 코드도 작성해보겠습니다.

let networkManager = NetworkManager.shared
let dataRepository = DataRepository(dataFetcher: networkManager)

do {
    let url = URL(string: "https://github.com/hamfan524")!
    let data = try await dataRepository.fetchData(from: url)
    // 데이터 사용
} catch {
    print("에러처리하시면 됩니다.")
}
  • 위 코드 처럼 DataRepository를 사용할 때에는 dataFetcher 프로퍼티에 NetworkManager 인스턴스나 다른 DataFetcher 프로토콜을 준수하는 객체를 할당해주면 DataRepository에서 fetchData 함수를 호출할 때 NetworkManagerfetchData 함수가 실행되게 됩니다.

인터페이스 분리

인터페이스 분리는 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 분리하는 것입니다.

  1. 프로토콜 정의
// 데이터 로드 프로토콜
protocol DataLoader {
    func loadData() -> [String]
}

// 데이터 저장 프로토콜
protocol DataSaver {
    func saveData(data: [String])
}
  1. 클래스 구현
// 파일로부터 데이터를 로드하는 클래스
class FileDataLoader: DataLoader {
    func loadData() -> [String] {
        // 파일에서 데이터를 로드하는 로직
        return ["Data1", "Data2", "Data3"]
    }
}

// 데이터를 파일에 저장하는 클래스
class FileDataSaver: DataSaver {
    func saveData(data: [String]) {
        // 데이터를 파일에 저장하는 로직
    }
}

// 네트워크 통신으로 데이터 받아오는 클래스
class NetworkDataLoader: DataLoader {
    func loadData() -> [String] {
        // 네트워크 통신으로 데이터 받아오는 로직
        return ["DataA", "DataB", "DataC"]
    }
}

// 데이터를 네트워크에 저장하는 클래스
class NetworkDataSaver: DataSaver {
    func saveData(data: [String]) {
        // 데이터를 네트워크에 저장하는 로직
    }
}
  1. 위에서 학습한 의존성 주입을 통한 모듈 구성
// 데이터 처리 모듈
class DataManager {
    let dataLoader: DataLoader
    let dataSaver: DataSaver
    
    init(dataLoader: DataLoader, dataSaver: DataSaver) {
        self.dataLoader = dataLoader
        self.dataSaver = dataSaver
    }
    
    func processData() {
        let data = dataLoader.loadData()
        // 데이터 처리 로직
        dataSaver.saveData(data: data)
    }
}
  1. 사용
// 파일에서 데이터를 로드하고 파일에 저장하는 경우
let fileDataLoader = FileDataLoader()
let fileDataSaver = FileDataSaver()
let fileDataManager = DataManager(dataLoader: fileDataLoader, dataSaver: fileDataSaver)
fileDataManager.processData()

// 네트워크에서 데이터를 로드하고 네트워크에 저장하는 경우
let networkDataLoader = NetworkDataLoader()
let networkDataSaver = NetworkDataSaver()
let networkDataManager = DataManager(dataLoader: networkDataLoader, dataSaver: networkDataSaver)
networkDataManager.processData()

위 코드에서는 DataLoaderDataSaver라는 두 개의 프로토콜을 정의하고, 각 프로토콜을 따르는 FileDataLoader, FileDataSaver, NetworkDataLoader, NetworkDataSaver 클래스를 구현했습니다.

그리고 DataManager 클래스는 이 두 프로토콜을 의존성으로 주입받아 데이터를 로드하고 저장하는 메서드를 수행합니다.

이렇게 함으로써 각 모듈은 인터페이스에만 의존하고, 구현체에는 의존하지 않는 느슨한 결합이 된 코드를 작성 가능합니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant