Open hamfan524 opened 7 months ago
모듈화의 장점은 다양합니다.
위에서 설명한 모듈화를 잘 하기 위해선 앱을 개발하고 유지 보수하는 동안 확장성, 유지 관리성, 재사용성 등을 고려하여 설계해야 하며, 몇가지 중요한 원칙과 전략을 설명하자면..
의존성 주입: 클래스나 모듈이 직접 생성하는 의존성을 제거하고 외부에서 주입받도록 만드는 디자인 패턴입니다. 이를 통해 유연성을 높이고 테스트 용이성을 개선할 수 있습니다.
네트워크 계층 분리: 네트워크 요청 및 응답 로직을 별도의 계층으로 분리하여 서버 통신 부분을 단일 책임 원칙에 따라 설계하여 네트워크 코드를 재사용하고 테스트하기 쉽게 만들어줍니다.
테스트 주도 개발(TDD): 테스트 주도 개발은 코드를 테스트하는 것을 먼저 하고 이에 대한 코드를 작성하는 개발 방법론입니다.
이를 통해 코드의 품질을 향상시키고 버그를 미리 발견할 수 있습니다.
MVC 대신 MVVM 사용
모듈화된 아키텍처 사용: 앱을 작은 단위로 분리하여 각 모듈이 특정 기능이나 책임을 가지도록 설계하여 코드를 더 잘 이해하고 유지 보수하기 쉽게 만들어줍니다.
위의 전략을 적용해서 아래에 간단한 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)는 시스템의 각 요소들을 명확하게 분리하면서도, 유연하게 연결될 수 있도록 디자인 하는 SW 설계 구조 입니다. 클린아키텍처의 가장 큰 특징들을 나열해보자면..
클린 아키텍처는 소프트웨어 시스템의 다양한 부분을 독립적인 구성 요소로 분리하는 것을 강조하기에, 시간이 지남에 따라 시스템을 더 쉽게 유지 관리할 수 있습니다.
클린 아키텍처는 모듈식 설계를 권장합니다. 이는 시스템의 개별 구성 요소를 분리 해 주고, 테스트와 유지보수를 쉽게 만들어 줍니다.
클린 아키텍처는 시스템 구축에 사용되는 기본 기술과 요구사항의 변화를 수용할 수 있는 확장 가능한 설계를 제공합니다.
클린 아키텍처는 여러 시스템에서 재사용 가능한 컴포넌트를 만드는 것을 장려합니다.
이이 따라, 개발자가 다른 SW혹은 기능을 개발 할 때 구축하는 데 필요한 시간과 노력이 줄어들게 됩니다.
클린 아키텍쳐의 개념은 단순하고, 이해 하기도 굉장히 간단합니다.
워낙 개념이 이해하기 쉽기에 여러 개발자들과 IT 스타트업들이 도입을 시도했고, 하나의 큰 트렌드로 이어지게 되었습니다.
위 사진은 클린 아키텍처에 대한 그래프입니다.
간단히 설명하면,
Clean Architecture 그래프에서 볼 수 있듯이 애플리케이션에는 서로 다른 계층이 있다!
가장 주요 규칙은 내부 레이어에서 외부 레이어로의 종속성(dependency)을 갖지 않는 것 (내부 -> 외부 ❌)
외부 계층에서 안쪽으로만 종속성이 있을 수 있다. (외부 -> 내부)
Clean Architecture의 다른 패턴과의 차이점에 대해선 제가 이전에 정리해둔 글이 있어 자세히 보고 싶으면 여기 글에 가서 보시면 더 편하게 볼 수 있습니다.
Viper패턴은 위 클린 아키텍처 구조를 iOS에 맞게 변형하여 대중화되어 많이 사용되고 있는 패턴입니다.
View, Interactor, Presenter, Entity, Router의 약자를 따와서 VIPER라는 이름이 명명된 단일책임원칙 기반의 클린 아키텍쳐입니다.
View
ViewController를 의미, UI 관련 부분만 담당합니다.
Presenter를 소유하고 있으며(의존적) 이에 따라 담당하는 UI를 업데이트 합니다.
Interactor
Presenter
Entity
Router(WireFrame)
패턴에 대한 설명은 위 설명이 전부이고, 아래에 예시 코드들을 보겠습니다.
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
}
}
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()
}
}
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")
}
}
}
import Foundation
// Model
struct User: Codable {
let name: String
}
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
}
}
책임 분리의 원칙(SRP)에 따라 역할에 따라 분리가 되어, 재사용성이 높아지고 테스트가 용이합니다.
새로운 기능을 추가하는 것이 더 용이합니다.
역할 단위의 구분이 명확한 것이 장점이자 단점입니다.
매우 작은 역할을 가지는 클래스들을 위해 엄청나게 많은 인터페이스를 작성해야 하기 때문에 많은 유지보수 비용이 든다는 것이 단점입니다.
View 트리와 business 트리가 밀접하게 결합되어 있어, View로직만 포함하거나 business 로직만 포함하는 노드를 구현하기 힘듭니다.
위 Viper패턴의 단점들을 해결하기 위해 나온 패턴인 Ribs패턴에 대해서는 다음에 알아보도록 하겠습니다.
기능 모듈화는 앱의 기능을 별도의 모듈로 분리하여 개발하는 접근 방식입니다. 각 모듈은 특정 기능 또는 부분을 담당하고, 이를 독립적으로 테스트하고 재사용할 수 있습니다. 예를 들어, 게시판이나 채팅 기능을 담당하는 모듈을 만들 수 있습니다.
라이브러리 모듈화는 공통 기능이나 유틸리티를 담당하는 모듈을 만드는 것입니다. 이러한 모듈은 다른 앱이나 프로젝트에서도 재사용될 수 있습니다. 예를 들어, 네트워킹 기능을 담당하는 라이브러리를 만들 수 있습니다.
의존성 주입은 위에서 설명하였듯이 클래스나 모듈이 직접 생성하는 의존성을 제거하고 외부에서 주입받도록 만드는 디자인 패턴입니다. 모듈이 필요로 하는 의존성을 외부에서 주입받도록 하기에 모듈은 직접 의존성을 생성하거나 초기화하지 않고 외부에서 주입받아 사용할 수 있습니다.
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
프로토콜을 준수하고 있으니, DataRepository
의 dataFetcher
프로퍼티에 NetworkManager
인스턴스가 할당되었다면, DataRepository
에서 fetchData
함수를 호출할 때 NetworkManager
의 fetchData
함수가 실행되게 됩니다.
`
현재 DataRepository
에 NetworkManager
인스턴스가 할당되어 있지 않으니 할당해주고 호출하는 코드도 작성해보겠습니다.
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
함수를 호출할 때 NetworkManager
의 fetchData
함수가 실행되게 됩니다.인터페이스 분리는 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 분리하는 것입니다.
// 데이터 로드 프로토콜
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]) {
// 데이터를 네트워크에 저장하는 로직
}
}
위에서 학습한 의존성 주입을 통한 모듈 구성
// 데이터 처리 모듈
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)
}
}
사용
// 파일에서 데이터를 로드하고 파일에 저장하는 경우
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` 클래스는 이 두 프로토콜을 의존성으로 주입받아 데이터를 로드하고 저장하는 메서드를 수행합니다.
이렇게 함으로써 각 모듈은 인터페이스에만 의존하고, 구현체에는 의존하지 않는 느슨한 결합이 된 코드를 작성 가능합니다.
Clean Architecture, VIPER 등의 아키텍처 패턴과 적용 방법을 소개해주세요. 기능 모듈화, 라이브러리 모듈화 등을 통한 코드 재사용성과 유지보수성 향상 방안을 제시해주세요. 의존성 주입, 인터페이스 분리 등의 설계 원칙을 적용한 모듈 간 느슨한 결합 방법을 설명해주세요.