GGURUPiOS
Swift Architecture - Clean Architecture 본문
Clean Architecture
몇 번 들어보기만 했던 클린 아키텍처.. 최대한 알아듣기 쉽게 정리해보자!
클린 아키텍처란 로버트 C. 마틴이 제안한 아키텍처임 기본적인 구조는 아래의 그림과 같음
그림만 봐도 어질어질 하다 하나씩 뜯어보기 전에 이 아키텍처의 규칙에 대해 알아보자.
기본 전제사항
의존성(Dependency)
- 위의 그림에서 원의 안쪽 으로 들어갈수록 고수준(higher-level)
- 바깥 쪽의 원은 매커니즘이고 안쪽의 원은 정책(policies) 매커니즘: 구체적이고 기술적인 측면에서 구현에 집중되는 부분 (DB접근, 네트워크 통신 등) 정책: 동작이나 결정을 규정하는 지침이나 규칙, 보다 추상적인 개념 (보안 정책, 인증 및 권한 부여)
의존성 규칙(Dependency Rule)
- 소스코드의 의존성은 안으로만 향할 수 있음 내부 원의 어떤 것도 외부 원의 어떤 것을 전혀 알 수 없음 (외부 원에서 선언된 이름은 내부 원의 코드에서 언급되면 안됨 ), (함수, 클래스, 변수 등등 )
- 결국, 외부 원의 어떤것도 내부 원에 영향을 미치면 안됨
용어 정리
예를들어, 영화 검색 앱이있다고 가정
Entity
싱글 앱일 때
- 비즈니스 객체 (데이터)
ex) Movie 구조체
Use Cases
- 애플리케이션 비즈니스 규칙
- DB나 UI에 영향을 받지않음(외부 원에 영향을 받지않음)
ex) 클래스를 만들고 안에 영화목록을 가져오는 비즈니스 로직 작성
Interface Adapters (Controllers, Gateways, Presenters)
- 데이터를 use cases 나 entities의 형식에서 DB나 웹과 같은 일부 외부 기능에 용이한 형식으로 데이터 변환
- 위의 그림에서 같은 계층의 Presenters와 Controllers가 서로 데이터를 주고받음
ex) ViewModel, ViewController 등
FrameWorks & Drivers (Devices, Web, UI, DB, External Interfaces)
- 데이터베이스나 웹 프레임워크 등과 같은 것들
- 내부의 다음 원과 통신하는 코드 외에 많은 코드를 작성하지 않음
네 개의 원만 사용해야 할까?
- 원은 개략적이며 4개만 있어야 한다는 규칙은 없음
- 그러나 의존성 규칙은 항상 적용됨
클린아키텍처의 기본개념은 이렇고 실제 적용 때는 하나 더 알아둬야 할 것이 있음 그것은 SOLID 원칙임
SOLID
역사나 소개는 다른 글들에도 많으니 생략
- S: Single Responsibility Principle
- O: Open-Closed Principle
- L: Liskov Substitution Principle
- I: Interface Segregation Principle
- D: Dependency Inversion Principle
Single Responseibility Principle - 단일 책임 원칙
단일 책임이란 → 객체는 단 하나의 책임만 가져야 한다는 의미 (하나의 기능 으로 생각할 수 있음) 아래의 예제는 너무 많은 기능을 하는 클래스임 (로그인, 네트워킹, 네비게이션)
class StrongLoginPresenter {
// MARK: - Inner
func login {
// Work with data
}
// MARK: - Network
func sendLoginRequest {
// Work with URLRequest formed
}
// MARK: - Navigation
func openMainScreen {
// Work with UINavigationController
}
}
위의 예제를 단일 책임 원칙을 지키는 코드로 바꾸면 아래와 같음
protocol LoginInteractorInput {
func doLogin(login: String, password: String) -> Bool
}
protocol LoginRouterInput {
func openMainScreen()
}
class LoginInteractor: LoginInteractorInput {
func doLogin(login: String, password: String) -> Bool {
// TODO: - Make Network Request
return true
}
}
class LoginRouter: LoginRouterInput {
func openMainScreen() {
// TODO: - Instantiate Main ViewController and show it
}
}
class LoginPresenter {
var interactor: LoginInteractorInput!
var router: LoginRouterInput!
init(interactor: LoginInteractorInput, router: LoginRouterInput) {
self.interactor = interactor
self.router = router
}
// MARK: - Logic
func login() {
let login = "login"
let password = "password"
guard login.isEmpty, password.isEmpty else {
return
}
if interactor.doLogin(login: login, password: password) {
router.openMainScreen()
}
}
}
let router = LoginRouter()
let interactor = LoginInteractor()
let presenter = LoginPresenter(interactor: interactor, router: router)
이전의 예제에서 StrongLoginPresenter 의 Network, Navigation 기능을 분리해서 router 가 Network 책임을, interactor 상호작용을 맡게 했다. Presenter 에서는 이제 router, interactor 의 메소드를 호출할 뿐, Presenter는 상호작용, 네비게이션에 대한 책임이 없음
Open-Closed Principle - 개방형 폐쇄 원칙
Entity는 확장은 열려 있어야(Open) 하지만 수정에는 닫혀(Close) 있어야 한다는 원칙
protocol AnalyticType {
func track(_ event: String, parameters: [String: Any]?)
}
final class FacebookAnalytic: AnalyticType {
func track(_ event: String, parameters: [String : Any]?) {
// TODO: - Send facebook event
}
}
final class GoogleAnalytic: AnalyticType {
func track(_ event: String, parameters: [String : Any]?) {
// TODO: - Send Google event
}
}
final class AnalyticService: AnalyticType {
let interactors: [AnalyticType]
init(interactors: [AnalyticType]) {
self.interactors = interactors
}
func track(_ event: String, parameters: [String : Any]? = nil) {
interactors.forEach { $0.track(event, parameters: parameters) }
}
}
let facebookInteractor = FacebookAnalytic()
let googleInteractor = GoogleAnalytic()
let analyticService = AnalyticService(interactors: [facebookInteractor, googleInteractor])
analyticService.track("Login")
analyticService.track("Post a story", parameters: ["title": "SOLID in swift"])
위의 코드에서 AnalyticService 클래스는 AnalyticType 프로토콜을 구현하면서 ‘FacebookAnalytic’, ‘GoogleAnalytic’ 과 같은 실제 구현체들을 받아 들일 수 있음
→ 확장에는 열려 있지만 수정에는 닫혀있음
예를들어, ‘FirebaseAnalytic’라는 AnalyticType 프로토콜을 준수하는 클래스를 구현 (확장에는 열려있음)하고 사용하려면 AnalyticService의 별다른 코드 수정 없이(수정에는 닫혀있음) 단순히 배열에 추가해주면 됨
Liskov Substitution Principle - 리스코프 치환 원칙
상위 타입의 객체를 하위 타입의 객체로 대체해도 원래의 프로그램의 정확성이 보존되어야 함을 나타내는 원칙 (치환이 가능해야함 정도로 이해)
protocol NameType {
var name: String { get }
}
class UserShort: NameType {
var id: UInt
var name: String
init(id: UInt, name: String) {
self.id = id
self.name = name
}
}
final class UserLong: UserShort {
var status: String
var isOnline: Bool
init(id: UInt, name: String, status: String, isOnline: Bool) {
self.status = status
self.isOnline = isOnline
super.init(id: id, name: name)
}
}
protocol UserProtocol {
func sortedByName() -> [UserShort]
}
final class UserCollection: UserProtocol {
let users: [UserShort]
init(users: [UserShort]) {
self.users = users
}
func sortedByName() -> [UserShort] {
return users.sorted { $0.name > $1.name }
}
}
let follower = UserShort(id: 1, name: "Medium Guest")
let currentUser = UserLong(id: 999, name: "Maxim Vialykh", status: "Work on SOLID Arcticle", isOnline: true)
let userCollection = UserCollection(users: [currentUser, follower])
let sorted = userCollection.sortedByName()
for case let user as UserLong in sorted {
print("UserLong: \\(user.name)")
}
위의 코드에서 UserCollection 클래스의 sortedByName 클래스는 ‘UserShort’ 타입을 리턴함 ’UserShort’는 ‘NameType’ 프로토콜을 준수하고 있기 떄문에 ‘UserShort’의 서브타입인 ‘UserLong’ 객체를 리턴해도 됨 (LSP 준수)
Interface Segregation Principle - 인터페이스 분리 원칙
필요로 하는 간단한 추상화를 만듬 사용하지 않는 인터페이스에 의존하도록 강요되면 안됨 (Swift에서는 Protocol)
protocol ResourceType {
func load()
}
protocol PagginationProtocol {
var offset: UInt { get set }
var limit: UInt! { get set }
var hasMore: Bool { get }
func loadMore()
}
final class ProfileInteractor: ResourceType {
func load() {
// TODO: - Make URLRequest
print("\\(self) load")
}
}
final class UsersListInteractor: ResourceType, PagginationProtocol {
var offset: UInt
var limit: UInt!
var hasMore: Bool
init() {
offset = 0
hasMore = true
}
func load() {
// TODO: - Make URLRequest
print("\\(self) load")
}
func loadMore() {
offset = offset + 10
load()
print("\\(self) loadMore \\(offset)")
}
}
let profileInteractor = ProfileInteractor()
profileInteractor.load()
let usersListInteractor = UsersListInteractor()
usersListInteractor.limit = 30
usersListInteractor.loadMore()
if usersListInteractor.hasMore {
usersListInteractor.loadMore()
}
위의 코드를 이해하는것은 어찌보면 쉬운데, 필요한 인터페이스(프로토콜)만 가져다 쓴 코드임
- ProfileInteractor 클래스는 ResourceType
- UsersListInteractor 클래스는 ResourceType, PagginationProtocol
즉, 해당 인터페이스를 설계할 때 명확한 역할 분리가 필요할 듯
Dependency Inversion Principle - 의존성 역전 원칙
이 부분이 조금 어려웠음
일단 의존성 역전 이라는 말이 애초에 이해가 안갔음 (예제 다음 설명)
찾아보면 ‘고수준 모듈은 저수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다.’ 라고 한다. 결국에는, 특정 클래스나 구조체가 아니라 추상화(swift 에선 protocol) 에 의존해야 한다는 것
의존성 역전이 대체 뭔데 ?
아래 코드는 이해를 돕기위해 막 짠거라 대충 이해해주세용..
// 만약 콜라만 파는 가게가 있다고 가정 Cola, Shop 클래스 선언
// 현재 Shop 은 Cola 에 의존성을 가지고 있는 상태 Shop -> Cola
class Cola {
var price = 1500
}
class Cider {
var price = 1400
}
class Shop {
var product: [Cola]
init(product: [Cola]) {
self.product = product
}
}
// 만약 여기서 콜라가 아니라 사이다도 같이 파는 가게로 바뀌면?
// Shop 은 Cola와 Cider에 의존성을 가져야 하는데 그러면 Shop -> Cola, Cider 가 됨
// Shop 클래스도 수정해야 하므로 OCP 원칙도 위반
// 추상화를 사용해서 수정 가능
protocol Beverage {
var price: Int { get set }
}
class Cola: Beverage {
var price = 1500
}
class Cider: Beverage {
var price = 1400
}
class Shop {
var product: [Beverage]
init(product: [Beverage]) {
self.product = product
}
}
// 이제 의존성 방향은 Shop -> Product <- Cola, Cider 가 됨
// 의존성(화살표 방향)이 역전 됨 (특정 클래스가 아닌 추상화에 의존하는 형태)
DIP 는 OCP 와 LSP 원칙을 지키기 위해 짰던 코드가 자연스럽게 DIP 원칙을 따르게 만듬
즉, 위에서도 알 수 있듯이
간단히 말하자면, 특정 클래스/구조체(구체화)가 아닌 추상화(protocol)에 의존함
protocol UserType {
var id: UInt { get set }
var name: String { get set }
}
struct User: UserType {
var id: UInt
var name: String
init(id: UInt, name: String) {
self.id = id
self.name = name
}
}
protocol Storage {
func add(_ user: UserType)
func delete(_ user: UserType)
}
final class RealmStorage: Storage {
func add(_ user: UserType) {}
func delete(_ user: UserType) {}
}
final class KeychainStorage: Storage {
func add(_ user: UserType) {}
func delete(_ user: UserType) {}
}
protocol UsersProtocol {
func didLoad(_ users: [UserType])
func didRemove(_ user: UserType)
}
final class UsersInteractor: UsersProtocol {
let storage: Storage
init(storage: Storage) {
self.storage = storage
}
func didLoad(_ users: [UserType]) {
users.forEach { storage.add($0) }
}
func didRemove(_ user: UserType) {
storage.delete(user)
}
}
let author = User(id: 999, name: "Maxim Vialykh")
let guest = User(id: 1, name: "Guest")
let users = [author, guest]
let usersInteractor = UsersInteractor(storage: RealmStorage())
usersInteractor.didLoad(users)
let spyInteractor = UsersInteractor(storage: KeychainStorage())
spyInteractor.didRemove(guest)
마찬가지로 예제코드임
참고출처
https://medium.com/@vialyx/ios-best-practices-part-4-s-o-l-i-d-8f878b99d2a7
'Swift > Architecture' 카테고리의 다른 글
[Swift/Coordinator] coordinator 패턴에서의 childCoordinator 할당 해제 (0) | 2024.03.13 |
---|---|
Swift Architecture - Clean Architecture + MVVM (예제) (0) | 2023.04.27 |
Swift Architecture - MVVM (0) | 2023.04.25 |
Swift Architecture - MVC (0) | 2023.04.25 |