Skip to main content

Command Palette

Search for a command to run...

Swift 개발에 SOLID 적용하기

Published
6 min readView as Markdown


해당 글은, 원문 글쓴이의 동의를 얻어 'SOLID Pirincipels Applied to Swift Development'를 번역한 글입니다. 매끄러운 의미 전달을 위해 의역한 부분이 있습니다.


Introduction

개발자로서, 아마 SOLID 원칙을 들어보거나 사용해 본 적이 있을 것입니다. Robert C. Martin(Uncle Bob)은 그의 저서 Agile Software Development: Principles, Patterns, and Practices에서 디자인 패턴과 관련된 객체 지향 프로그래밍의 5가지 기본 원칙인 SOLID를 언급하고 있습니다.

우리가 이 다섯가지 원칙을 적용한다면:

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


SOLID란

  • S (Single Responsibility Principle) : 단일 책임 원칙
  • O (Open/Close Principle) : 개방/폐쇄 원칙
  • L (Liscov Substitution Principle) : 리스코브 치환 원칙
  • I (Interface Segregation Principle) : 인터페이스 분리 원칙
  • D (Dependency Inversion Principle) : 의존성 역전 원칙



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 (리스코브 치환 원칙)

1987년 Barbara Liskov에 의해 소개된 이 원칙은, 프로그램에서 어떤 클래스도 그 기능에 영향을 주지 않고 해당 클래스의 서브클래스로 대체 가능해야 한다고 명시하고 있습니다.

예를들어, 사용자와 연락하는(이메일 보내기와 같은) 책임을 가진 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 swimm()
    func fly()
}

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

struct Whale: AnimalProtocol {
    func swimm() {
        //Whale은 이 메소드만 구현하면 된다
        //다른 메소드는 상관 없음
    }

    func walk() {}

    func fly() {}
}


Whale이 프로토콜을 채택했음에도 메소드 두개는 구현하지 않게됩니다. 해결 방법은 각 3개의 인터페이스(프로토콜)을 만들어 각각 필요한 프로토콜만 채택하는 것입니다.

protocol WalkProtocol {
    func walk()
}

protocol SwimmProtocol {
    func swimm()
}

protocol FlyProtocol {
    func fly()
}

struct Whale: SwimmProtocol {
    func swimm() {}
}

struct Crocodile: WalkProtocol, SwimmProtocol {
    func walk()
    func swimm() {}
}




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)
    }
}


데이터를 저장할 때, CoreData 대신 Realm 데이터베이스를 사용하고 싶다면 어떻게 해야할까요? 위 예시처럼 클래스의 인스턴스는 강한 결합을 만들어내므로, 만약 다른 데이터베이스를 이용하고 싶다면 코드를 다시 실행해야 할 것입니다.

이것을 해결하기 위해, CoreData와 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 프로토콜을 채택하는 UserUserService 클래스는 StorageManager 프로토콜을 채택하므로 데이터베이스를 변경하더라도 전체 구현 코드를 변경할 필요가 없습니다.


class User: Storable {
    var name: String

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

class UserService: StorageManager {
    func save(object: Storable) {
        // Saves user to database
    }
}


Conclusion

이러한 원칙들이 재사용 가능하고, 유지보수가 용이한 더 높은 품질의 코드를 만들 수 있게 함으로써 코드의 경직되고 취약한 부분을 감소하게 할 것입니다.

More from this blog

RIBs? 왜 쓰는데?

RIBs란? Uber의 크로스 플랫폼 아키텍쳐 프레임워크로 Router-Interactor-Builder의 앞글자를 따왔음 Andorid와 iOS모두에서 유사한 개발 패턴을 사용할 수 있어 엔지니어들이 공동으로 설계된 구조를 공유할 수 있음. RIB이라는 단위로 라이프 사이클이 나뉘어져있기 때문에 전역 상태변경으로 인한 예기치않은 동작이 발생해 특정한 상태를 변경하는 것을 막을 수 있음. RIB의 구성 요소들(Router, Interactor...

May 25, 20224 min read439

Localization in iOS

앱서비스를 제공하다보면 언어나 문화를 뛰어넘어 더 많은 사용자들이 사용하기 쉽도록 다국어 지원을 생각하게 되는데요, 이번 포스트에서는 국제화와 현지화의 차이가 무엇이며 iOS 앱 개발시 현지화 지원은 어떤식으로 가능한지 알아보려 합니다😃 국제화(Internationalization)와 현지화(Localization) 다국어 지원을 고려하다보니 국제화나 현지화 등의 용어를 섞어서 쓰게 되는 것 같은데요, 각각이 정확히 무엇을 의미하는지 다시 정...

Mar 4, 20225 min read343
Localization in iOS

Energy Efficiency - 코드 작성 단계에서 에너지 효율을 높이는 방법

모든 앱은 네트워킹 작업을 수행 할 때, 사용자 인터페이스를 업데이트 할 때, CPU에서 코드를 실행 할 때마다 에너지를 소비합니다. 사용자가 이용하는 앱들이 많아지면서 에너지 효율성을 끌어올리는 것은 사용자 경험에 필수적인 요소가 되었습니다. 사용자는 언제든지 아이폰의 [설정]-[배터리 항목]에 들어가서 앱별 배터리 사용량을 볼 수 있습니다. 아마 배터리 효율성이 매우 떨어지는 앱이라면 사용자는 그 앱을 지워버리고 싶어질지도 모릅니다.😱 ...

Oct 3, 20216 min read313

CoreData 훑어보기

앱 개발을 하다보면 데이터를 서버에 저장하지 않고 로컬 데이터베이스에 저장해야 할 필요가 있다. 이 때 사용할 수 있는 로컬 저장소로는 User Defaults, CorData, Realm등이 있는데 CoreData의 컨셉과 간단한 사용법에 대해 알아보고자 한다. Core Data란 애플리케이션에서 모델 계층 개체를 관리하는 데 사용하는 프레임워크로, 객체의 라이프 사이클이나 영속성 관리를 위한 기능을 제공하는 Object Graph Mana...

Oct 3, 20215 min read223
E

explore wider

7 posts

Swift 개발에 SOLID 적용하기