[iOS]SOLID 원칙

SOLID 🤷🏻‍♂️

  • 아래의 5가지 원칙의 앞 글자를 따서 만들어진 SOLID 원칙 ▾
    • S ( Single Responsibility Principle ) : 단일 책임 원칙
    • O ( Open / Close Principle ) : 개방/폐쇄 원칙
    • L ( Liscov Substitution Principle ) : 리스코브 치환 원칙
    • I ( Interface Segregation Principle ) : 인터페이스 분리 원칙
    • D ( Denpendency Inversion Principle ) : 의존성 역전 원칙

Why used it ❓

  1. 쉽게 변경이 가능한 유연한 코드를 가지게 됩니다. 이것은 재사용과 유지 관리가 가능합니다.
  2. 그렇게 개발된 소프트웨어는 안정되고, 탄탄하며 확장성이 뛰어날 것입니다. ( 새로운 기능도 쉽게 추가할 수 있습니다. )
  3. 디자인 패턴의 사용과 함께 응집력이 높고(시스템의 요소가 밀접하게 연관되어 있는), 결합도가 낮은(요소들 간의 의존도가 낮은) 소프트웨어를 만들 수 있습니다.

 

Single Responsibility Principle 단일 책임 원칙 ▼

정의

  • 이 원칙에 따르면, 클래스는 하나의 이유로만 변화해야 합니다.
  • 클래스는 한 가지의 책임만 가지고 있어야 합니다.
class LoginUser {
	func login() {
    	let data = authenticareuserViaAPI()
        let user = decodeUser(data: data)
        saveToDB(array: array)
    } 
     
    private func authenticareUserViaAPI() -> Data {
        // Call server to authenticate and return user's info
    }
    
    private func decodeUser(data: Data) -> User {
        // Decode data (Codable protocol) into User object
    }
    
    private func saveUserInfoOnDatabase(user: User) {
        // Save User info onto database
    }
    
}
  • 위 코드는 인증 & 디코딩 & 저장의 3가지 책임을 가지고 있습니다.
  • 단일 책임 원칙을 충족하기 위해서는 이 책임을 각각의 작은 클래스로 분리해야 합니다.
class LoginUser {

    let oAuthHandler: OAuthHandler
    let decodeHandler: DecodeHandler
    let databaseHandler: DataBaseHandler
    
    init(oAuthHandler: OAuthHandler, decodeHandler: DecodeHandler, databaseHandler: DataBaseHandler) {
        self.oAuthHandler = oAuthHandler
        self.decodeHandler = decodeHandler
        self.databaseHandler = databaseHandler
    }
    
    func login() {
        let data = oAuthHandler.authenticareUserViaAPI()
        let user = decodeHandler.decodeUser(data: data)
        databaseHandler.saveUserInfoOnDatabase(user: user)
    }
    
}

class OAuthHandler {

    func authenticareUserViaAPI() -> Data {
        // Call server to authenticate and return user's info
    }
}

class DecodeHandler {
    func decodeUser(data: Data) -> User {
        // Decode data (Codable protocol) into User object
    }
}

class DataBaseHandler {
    func saveUserInfoOnDatabase(user: User) {
        // Save User info onto database
    }
}
같은 이유로 변하는 것을 모읍시다 ! 그리고 다른 이유로 변경되는 것을 분리합시다 !

Open/Close Principle 개방/폐쇄 원칙 ▼

정의

  • 이 원칙에 따르면, 확장은 클래스의 행동의 변화 없이 가능해야 합니다.
  • 확장에는 열려 있고, 변경에는 닫혀 있어야 합니다. → 이것은 추상화에 의해 가능해집니다.

예제 코드

class Scrapper {
    func scrapVehicles() {
        let cars = [
            Car(brand: "Ford"),
            Car(brand: "Peugeot"),
            Car(brand: "Toyota"),
        ]

        cars.forEach { car in
            print(car.getScrappingAddress())
        }

        let trucks = [
            Truck(brand: "Volvo"),
            Truck(brand: "Nissan"),
        ]

        trucks.forEach { truck in
            print(truck.getScrappingAddress())
        }
    }
}

class Car {
    let brand: String
    
    init(brand: String) {
        self.brand = brand
    }

    func getScrappingAddress() -> String {
        return "Cars scrapping address"
    }
}

class Truck {
    let brand: String

    init(brand: String) {
        self.brand = brand
    }

    func getScrappingAddress() -> String {
        return "Trucks scrapping address"
    }
}
  • 각각의 새로운 타입의 vehicle에 대해 getScrapingAddress()는 다시 구현되어야만 합니다.
  • 이는 개방/폐쇄 원칙을 어기게 됩니다.
  • 이것을 해결하기 위해 getScrapingAddress() 메소드를 포함한 Scrappable 프로토콜을 만듭니다.
protocol Scrappable {
    func getScrapingAddress() -> String
}

class Scrapper {
    func getScrapingAddress() {
        let vehicles: [Scrappable] = [
            Car(brand: "Ford"),
            Car(brand: "Peugeot"),
            Car(brand: "Toyota"),
            Truck(brand: "Volvo"),
            Truck(brand: "Nissan"),
        ]

        vehicles.forEach { vehicle in
            print(vehicle.getScrapingAddress())
        }
    }
}

class Car: Scrappable {
    let brand: String

    init(brand: String) {
        self.brand = brand
    }

    func getScrapingAddress() -> String {
        return "Cars scrapping address"
    }
}

class Truck: Scrappable {
    let brand: String

    init(brand: String) {
        self.brand = brand
    }

    func getScrapingAddress() -> String {
        return "Trucks scrapping address"
    }
}
커맨드 패턴 공부하면서 프로토콜을 왜 사용하는지 궁금했는데 개방/폐쇄 원칙을 위해서 사용한다는 것을 알게 되었습니다.

Liscov Substitution Principle 리스코브 치환 원칙 ▼

정의

  • 프로그램에서 어떤 클래스도 그 기능에 영향을 주지 않고, 해당 클래스의 서브클래스로 대체 가능해야 합니다.

예제 코드

  • 사용자와 연락하는(이메일 보내기와 같은) 책임을 가진 UserService 라는 클래스가 하나 있다고 가정합니다.
  • 17세 이상의 사용자에게 이메일을 보내도록 비즈니스 로직을 변경해야 한다면, 새로운 비즈니스 로직을 가지는 서브 클래스를 만들 수 있습니다.
class UserService {
	func contact(user: User) {
    	/// Retrieve user from database
    }
}

class ValidUserService: UserService {
	override func contact(user: User) {
    	guard user.age > 17 else { return }
        
        super.contact(user: User)
    }
}
  • 이런 경우에 사용자의 나이가 17세 이상이라는 조건을 서브클래스가 추가했기 때문에 리스코브 치환 원칙은 충족되지 않습니다.
  • 서브클래스를 추가하지 않고 UserService 에 (기본값을 포함한) 전제 조건을 추가하는 것으로 이러한 문제를 해결할 수 있습니다.
class UserService {
	func contact(user: User, minAge: Int = 0) {
    	guard user.age > minAge else { return }
        /// Retrieve user from database
    }
}
진짜 조사하면서 효율적인 방법이라는 생각이 들었습니다. 앞으로 애용해보려고 합니다.

Interface Segregation Principle 인터페이스 분리 원칙 ▼

정의

  • 일반적인 인터페이스를 가지는 것보다 구체적인 각각의 다른 인터페이스를 가지는 것이 낫다고 명시되어 있습니다.
  • 사용하지 않는 메소드는 구현할 필요가 없다는 것을 명시합니다.

예제 코드

  • 예를 들어, 동물이 움직이는 메소드를 포함한 AnimalProtocol을 구현해보겠습니다.
protocol AnimalProtocol {
	func walk()
    func swim()
    func fly()
}

struct Animal: AnimalProtocol {
	func walk() {}
    func swim() {}
    func fly() {}
}

struct Whale: AnimalProtocol {
	func swim() {
    	/// Whale은 이 메소드만 구현하면 됩니다
        /// 다른 메소드는 상관이 없습니다
    }
    func walk() {}
    func fly() {}
}
  • Whale이 프로토콜을 채택했음에도 메소드 두 개는 구현하지 않게 됩니다.
  • 해결 방법은 3개의 인터페이스(프로토콜)를 만들어 각각 필요한 프로토콜만 채택하는 것입니다.
protocol WalkProtocol {
    func walk()
}

protocol SwimProtocol {
    func swim()
}

protocol FlyProtocol {
    func fly()
}

struct Whale: SwimProtocol {
    func swim() {}
}

struct Crocodile: WalkProtocol, SwimProtocol {
    func walk()
    func swim() {}
}
필요한 기능만 구현하니까 더 직관적이고 효율적으로 코딩할 수 있을 것 같습니다.

Dependency Inversion Principle 의존성 역전 원칙 ▼

정의

  • 이 원칙을 따르면모듈 간의 의존성을 낮춰 클래스 간의 결합도를 낮추도록 합니다.
    • 상위 클래스가 하위 클래스에 의존하지 않게 됩니다.
    • 둘 다 추상 타입에 의존하게 됩니다.
    • 추상 타입은 구체 타입에 의존하지 않게 됩니다.
    • 구체 타입이 추상 타입에 의존하게 됩니다.

예제 코드

class User {
	var name: String
    
    init(name: String) {
    	self.name = name
    }
}

class CoreData {
	func save(user: User) {
    	/// Save user on database
    }
}

class UserService {
	func save(user: User) {
    	let database = CoreData()
        database.save(user: user)
    }
}
  • 데이터를 저장할 때, Core Data 대신 Realm 데이터 베이스를 사용하고 싶다면 어떻게 해야 할까요?
  • 위 예시처럼 클래스의 인스턴스는 강한 결합을 만들어내므로, 만약 다른 데이터베이스를 이용하고 싶다면 코드를 다시 실행해야 할 것입니다.
  • 이것을 해결하기 위해, Core Data와 Realm이 공통적으로 채택할 프로토콜을 만들고, 데이터를 저장하는 타입을 해당 프로토콜로 두면 됩니다.
protocol Storable { }

extension Object: Storable { } /// Realm Database

extension NSManagedObject: Storable { }  /// Core Data Database

protocol StorageManager {
	/// Save Object into Realm database
    /// - Parameter object: Realm object (as Storable)
    func save(object: Storable)
}
  • Storable 프로토콜을 채택하는 User와 UserService 클래스는 StorageManager 프로토콜을 채택하므로 데이터베이스를 변경하더라도 전체 구현 코드를 변경할 필요가 없습니다.
class User: Storable {
	var name: String
    
    init(name: String) {
    	self.name = name
    }
}

class UserService: StorageManager {
	func save(object: Storable) {
    	/// Saves user to database
    }
}
프로젝트의 규모가 커질수록 이 원칙을 통해 수정에도 용이한 구조로 구현해야겠다고 다짐했습니다.

  • SOLID 원칙을 찾아보면서 아직 배울게 많고, 적용해야 할 유용한 기술들이 많다는 것을 알게 되었습니다.
  • 구현에 집중하는 것도 좋지만 그 구현이 얼마나 효율적이며 테스트에 용이하게 할 것인지를 고민하는 개발자가 되어야겠다고 다짐했습니다.