4T2F / ThinkBig2

🌟씽크빅 2팀 스터디 🌟
2 stars 0 forks source link

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

Open hamfan524 opened 1 month ago

hamfan524 commented 1 month ago

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

hamfan524 commented 1 month ago

지속 가능한 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의 다른 패턴과의 차이점에 대해선 제가 이전에 정리해둔 글이 있어 자세히 보고 싶으면 여기 글에 가서 보시면 더 편하게 볼 수 있습니다.

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

image

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

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

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패턴을 적용했을 때의 장점

Viper패턴의 단점

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

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

  1. 기능 모듈화 (Feature Modularization)

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

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

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

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

hamfan524 commented 1 month ago

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

의존성 주입

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

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)
    }
}
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("에러처리하시면 됩니다.")
}

인터페이스 분리

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

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

// 데이터 저장 프로토콜 protocol DataSaver { func saveData(data: [String]) }


2. 클래스 구현
```Swift
// 파일로부터 데이터를 로드하는 클래스
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)
    }
    }
  2. 사용

    
    // 파일에서 데이터를 로드하고 파일에 저장하는 경우
    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()



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

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

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