GGURUPiOS

Swift Architecture - Clean Architecture 본문

Swift/Architecture

Swift Architecture - Clean Architecture

꾸럽 2023. 4. 27. 20:15

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