SOLID with Swift

Overview

객체지향프로그래밍의 다섯가지 설계원칙이란 Single responsibility(단일 책임 원칙), Open/Closed(개방/폐쇄 원칙), Liskov Substitution(리스코프 치환 원칙), Interface Segregation(인터페이스 분리 원칙), Dependency Inversion(의존관계 역전 원칙)를 말하며, 이들의 앞자를 따서 만든 단어가 SOLID이다. SOLID 원칙을 적용하면 나쁜 설계에서 나타나는 몇가지 문제들을 해결할 수 있다.

나쁜 설계에서 나타나는 주요 문제점:

  • Fragility: 코드 일부의 변화가 예측하지 못한 부분에서 버그를 일으킬 수 있다. 이는 테스트로도 발견하기 매우 여럽다.
  • Immobility: 너무 많이 결합된(coupled) 의존성으로 인해 재사용성이 떨어진다.
  • Rigidity: 작은 변경도 프로젝트의 많은 부분에 영향을 주어 상당한 노력이 필요하다.

물론 SOLID 원칙을 반드시 따라야하는 것은 아니고, 프로젝트 설계의 퀄리티를 개선하는데 도움을 주는 가이드라인일 뿐이다.

The Single Responsibility Principle(SRP) - 단일 책임 원칙

하나의 클래스는 하나의 책임만 가져야 한다.

SRP의 정의에서 “책임”은 “변경하려는 이유”를 뜻한다. 즉, 어떤 클래스를 변경해야 하는 이유는 오직 하나 뿐이어야 한다는 것이다. 만약 클래스가 여러 책임을 가진다면 서로 의존하게 되어 한 책임의 변경이 같은 클래스의 다른 책임에 영향을 줄 수 있다. 또한, 단순히 클래스 내부에서의 변경 뿐만 아니라, 관련된 외부 클래스를 연쇄적으로 수정해야할 가능성도 존재한다. 이렇게 서로 다른 책임간의 결합도가 높아지면 변경/확장이 복잡해지고 여러워질 수 있다.

예제를 통해 알아보자:

class Handler {
    func handle() {
        let data = requestDataToAPI()
        let array = parse(data: data)
        saveToDB(array: array)
    }

    private func requestDataToAPI() -> Data {
        // send API request and wait the response
    }

    private func parse(data: Data) -> [String] {
        // parse the data and create the array
    }

    private func saveToDB(array: [String]) {
        // save the array in a database (CoreData/Realm/...)
    }
}

Handler 클래스는 API에서 데이터를 가져오고(1), 이 데이터를 문자열 배열로 파싱한 뒤(2), 이를 데이터베이스에 저장한다(3). Handler 클래스는 총 3가지 책임을 가지는 것이다.

이는 각각의 책임을 새로운 클래스로 넘겨주어 해결하면 된다.

class Handler {
    let apiHandler: APIHandler
    let parseHandler: ParseHandler
    let dbHandler: DBHandler

    init(apiHandler: APIHandler, parseHandler: ParseHandler, dbHandler: DBHandler) {
        self.apiHandler = apiHandler
        self.parseHandler = parseHandler
        self.dbHandler = dbHandler
    }

    func handle() {
        let data = apiHandler.requestDataToAPI()
        let array = parseHandler.parse(data: data)
        dbHandler.saveToDB(array: array)
    }
}

class APIHandler {
    func requestDataToAPI() -> Data {
        // send API request and wait the response
    }
}

class ParseHandler {
    func parse(data: Data) -> [String] {
        // parse the data and create the array
    }
}

class DBHandler {
    func saveToDB(array: [String]) {
        // save the array in a database (CoreData/Realm/...)
    }
}

SRP는 클래스를 간결하게 만들어준다. 뿐만 아니라, 처음 예제 코드는 각각의 기능이 private 메소드였기 때문에 직접적으로 테스트를 적용할 수 없었지만, 분리 이후에는 APIHandler, ParseHandler, DBHandler에 쉽게 테스트를 적용할 수 있다.

The Open-Closed Principle(OCP) - 개방/폐쇄 원칙

소프트웨어의 요소(클래스, 모듈, 함수 등)은 확장에는 열려있고, 변경에는 닫혀 있어야 한다.

OCP는 클래스에 새로운 기능을 추가하는 것에 최소한의 노력과 변경이 있어야 한다는 원칙이다. 확장에 열려있다는 것은 새로운 기능을 추가하여 소프트웨어의 요소를 확장할 수 있다는 말이고, 확장이 발생했을때 다른 영역에 변경이 발생하지 않으면 그것을 변경에 대해 닫혀있다고 말할 수 있다.

예제 코드를 통해 살펴보자. 먼저 Logger 클래스는 Cars 배열을 순회하며 각각의 세부 사항을 출력한다.

class Logger {
    func printData() {
        let cars = [
            Car(name: "Batmobile", color: "Black"),
            Car(name: "SuperCar", color: "Gold"),
            Car(name: "FamilyCar", color: "Grey")
        ]

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

class Car {
    let name: String
    let color: String

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

    func printDetails() -> String {
        return "I'm \(name) and my color is \(color)"
    }
}

위 코드에 새로운 클래스의 세부사항을 출력하는 기능을 추가해보자.

class Logger {

    func printData() {
        let cars = [
            Car(name: "Batmobile", color: "Black"),
            Car(name: "SuperCar", color: "Gold"),
            Car(name: "FamilyCar", color: "Grey")
        ]

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

				// 추가 기능
        let bicycles = [
            Bicycle(type: "BMX"),
            Bicycle(type: "Tandem")
        ]

        bicycles.forEach { bicycles in
            print(bicycles.printDetails())
        }
    }
}

class Car {
    let name: String
    let color: String

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

    func printDetails() -> String {
        return "I'm \(name) and my color is \(color)"
    }
}

// 추가 기능
class Bicycle {
    let type: String

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

    func printDetails() -> String {
        return "I'm a \(type)"
    }
}

만약 추후에 또 다른 클래스를 추가하고 싶다면, 그때마다 printData의 구현을 바꿔줘야 한다! 이는 OCP를 위반한 것이다. 우리는 추상화를 통해서 이 문제를 해결할 수 있다.

Car 클래스와 Bicycle 클래스가 공통적으로 갖는 printDetails() 메소드를 Printable 프로토콜을 사용하여 하나의 인터페이스로 만들자.

protocol Printable {
    func printDetails() -> String
}
 
class Logger {

    func printData() {
        let printables: [Printable] = [
            Car(name: "Batmobile", color: "Black"),
            Car(name: "SuperCar", color: "Gold"),
            Car(name: "FamilyCar", color: "Grey"),
            Bicycle(type: "BMX"),
            Bicycle(type: "Tandem")
        ]
 
        printables.forEach { printable in
            print(printable.printDetails())
        }
    }
}
 
class Car: Printable {
    let name: String
    let color: String
 
    init(name: String, color: String) {
        self.name = name
        self.color = color
    }
 
    func printDetails() -> String {
        return "I'm \(name) and my color is \(color)"
    }
}
 
class Bicycle: Printable {
    let type: String
 
    init(type: String) {
        self.type = type
    }
 
    func printDetails() -> String {
        return "I'm a \(type)"
    }
}

The Liskov Substitution Principle(LSP) - 리스코프 치환 원칙

베이스 클래스의 기능과 역할을 하위 클래스가 그것을 알지 않고도 잘 사용할 수 있어야한다. 즉, 서브 클래스가 베이스 클래스를 완벽하게 대체할 수 있어야 한다.

예제 코드를 살펴보자.

Preconditions changes - 전제 조건 변경

Handler 클래스는 문자열을 클라우드에 저장하는 역할을 한다. 현재 비지니스 로직의 변경 요청이 들어왔고, 문자열의 길이가 5 이상일 때만 저장하기를 원한다. 이를 적용하고자 FilterdHandler 서브 클래스를 새로 만들기로 결정했다.

class Handler {
    func save(string: String) {
        // Save string in the Cloud
    }
}

class FilteredHandler: Handler {
    override func save(string: String) {
        guard string.characters.count > 5 else { return }

        super.save(string: string)
    }
}

서브클래스에는 문자열의 길이에대한 전제 조건이 추가되었다. Handler를 사용하는 클라이언트는 Handler와 서브클래스가 모두 같을 것이라는 생각에 FilteredHandler에 추가된 전제조건이 있음을 예측할 수 없고, 이 경우가 LSP를 위반한 것이다.

이러한 문제는 FilteredHandler를 제거하고 문자열의 최소 길이를 걸러내기 위한 새로운 파라미터를 추가하면 해결할 수 있다.

class Handler {
    func save(string: String, minChars: Int = 0) {
        guard string.characters.count >= minChars else { return }

        // Save string in the Cloud
    }
}

Postconditions changes - 사후 조건 변경

직사각형 객체의 넓이를 계산하는 Rectangle 클래스가 있다고 생각해보자. 몇달 후 정사각형 객체의 넓이 계산이 필요해 새로운 Square 서브클래스를 생성했다. 정사각형의 경우 넓이 계산에 한 변의 길이만 필요하기 때문에 area를 오버라이드하지 않고 너비의 값을 높이에 할당하기로 결정했다.

class Rectangle {
    var width: Float = 0
    var length: Float = 0

    var area: Float {
        return width * length
    }
}

class Square: Rectangle {
    override var width: Float {
        didSet {
            length = width
        }
    }
}

이러한 접근 방식은 만약 클라이언트가 다음과 같은 유형의 메소드를 가진다면 LSP의 원칙을 위반한다.

func printArea(of rectangle: Rectangle) {
    rectangle.length = 5
    rectangle.width = 2
    print(rectangle.area)
}

let rectangle = Rectangle()
printArea(of: rectangle) // 10

// -------------------------------

let square = Square()
printArea(of: square) // 4

printArea(of:) 메소드를 실행하면 직사각형과 정사각형의 넓이가 다르게 나오는 것을 볼 수 있다. 이는 Square의 너비의 프로퍼티 옵저버 때문이고 다시 말해 사후 조건이 달라진 것이다.

이 문제는 넓이를 구하는 메소드를 정의한 프로토콜을 사용하면 해결할 수 있다. RectangleSquare는 이 프로토콜을 채택하여 각자 다른 방식으로 area 메소드를 구현하면 된다. 마지막으로 사각형의 넓이를 구하는 메소드는 파라미터 타입을 이 프로토콜을 채택하는 타입으로 바꿔주면 된다.

protocol Polygon {
    var area: Float { get }
}

class Rectangle: Polygon {

    private let width: Float
    private let length: Float

    init(width: Float, length: Float) {
        self.width = width
        self.length = length
    }

    var area: Float {
        return width * length
    }
}

class Square: Polygon {

    private let side: Float

    init(side: Float) {
        self.side = side
    }

    var area: Float {
        return pow(side, 2)
    }
}

// Client Method
func printArea(of polygon: Polygon) {
    print(polygon.area)
}

// Usage
let rectangle = Rectangle(width: 2, length: 5)
printArea(of: rectangle) // 10

let square = Square(side: 2)
printArea(of: square) // 4

The Interface Segregation Principle (ISP) - 인터페이스 분리 원칙

클라이어트는 그들이 사용하지 않는 인터페이스에 의존해선 안된다.

인터페이스(Swift에서는 protocol)은 꼭 필요한 기능과 메소드만 포함해야한다는 뜻이다. 이를 채택하는 클라이언트가 불필요한 메소드까지 구현하는 상황이 있어선 안된다. 이 문제는 프로토콜과 클래스 모두에게서 나타난다.

Fat interface (Protocol)

didTap 메소드를 정의한 GestureProtocol을 구현하고, 후에 몇가지 새로운 제스쳐관련 메소드를 추가했다고 생각해보자.

protocol GestureProtocol {
    func didTap()
    func didDoubleTap()
    func didLongPress()
}

이 기능이 모두 필요한 SuperButton의 경우 이 프로토콜이 유용할 수 있지만,

class SuperButton: GestureProtocol {
    func didTap() {
        // send tap action
    }

    func didDoubleTap() {
        // send double tap action
    }

    func didLongPress() {
        // send long press action
    }
}

그러나, 기본적인 탭만 필요한 PoorButton의 경우 필요없는 메소드를 구현해줘야하고, 이는 ISP를 위반한다.

class PoorButton: GestureProtocol {
    func didTap() {
        // send tap action
    }

    func didDoubleTap() { }

    func didLongPress() { }
}

이 문제는 하나의 큰 프로토콜을 여러 작은 프로토콜들로 나누어주면 해결할 수 있다.

protocol TapProtocol {
    func didTap()
}

protocol DoubleTapProtocol {
    func didDoubleTap()
}

protocol LongPressProtocol {
    func didLongPress()
}

class SuperButton: TapProtocol, DoubleTapProtocol, LongPressProtocol {
    func didTap() {
        // send tap action
    }

    func didDoubleTap() {
        // send double tap action
    }

    func didLongPress() {
        // send long press action
    }
}

class PoorButton: TapProtocol {
    func didTap() {
        // send tap action
    }
}

Fat interface (Class)

여러 비디오 정보를 가진 클래스가 있다고 가정해보자.

class Video {
    var title: String = "My Video"
    var description: String = "This is a beautiful video"
    var author: String = "Marco Santarossa"
    var url: String = "https://marcosantadev.com/my_video"
    var duration: Int = 60
    var created: Date = Date()
    var update: Date = Date()
}

Video 클래스의 인스턴스를 재생하는 메소드도 존재한다.

func play(video: Video) {
    // load the player UI
    // load the content at video.url
    // add video.title to the player UI title
    // update the player scrubber with video.duration
}

그러나 이 메소드는 play, url, title, duration 정보만 필요하고 Video가 가진 다른 데이터들은 필요하지 않다.

이 문제는 Playable 프로토콜에 플레이어 메소드가 필요한 정보들만 정의하여 해결할 수 있다.

protocol Playable {
    var title: String { get }
    var url: String { get }
    var duration: Int { get }
}

class Video: Playable {
    var title: String = "My Video"
    var description: String = "This is a beautiful video"
    var author: String = "Marco Santarossa"
    var url: String = "https://marcosantadev.com/my_video"
    var duration: Int = 60
    var created: Date = Date()
    var update: Date = Date()
}

func play(video: Playable) {
    // load the player UI
    // load the content at video.url
    // add video.title to the player UI title
    // update the player scrubber with video.duration
}

이 접근방식은 단위 테스트에 매우 유용하다. 우리는 Playable 프로토콜을 채택한 스텁(stub) 클래스를 생성할 수 있다.

class StubPlayable: Playable {
    private(set) var isTitleRead = false

    var title: String {
        self.isTitleRead = true
        return "My Video"
    }

    var duration = 60
    var url: String = "https://marcosantadev.com/my_video"
}

func test_Play_IsUrlRead() {
    let stub = StubPlayable()

    play(video: stub)

    XCTAssertTrue(stub.isTitleRead)
}

The Dependency Inversion Principle (DIP) - 의존관계 역전 원칙

A. 상위 모듈이 하위 모듈에 의존해선 안된다. 두 모듈 모두 추상화에 의존해야 한다.

B. 추상화는 세부사항에 의존해선 안된다. 세부사항이 추상화에 의존해야한다.

DIP는 Open-Closed Principle과 매우 유사하다. 사용하기 위한 접근법, 클린 아키텍처를 위한 접근법 모두 OCP와 유사하게 종속성을 분리하는 것이며, 이는 추상 레이어를 통해 달성할 수 있다.

Handler 클래스를 생각해보자. 이 클래스는 문자열을 파일시스템에 저장하고, 저장방식을 관리하는 FilesystemManager 클래스를 호출한다.

class Handler {
    let fm = FilesystemManager()

    func handle(string: String) {
        fm.save(string: string)
    }
}

class FilesystemManager {
    func save(string: String) {
        // Open a file
        // Save the string in this file
        // Close the file
    }
}

FilesystemManager은 저수준 모듈이고 다른 프로젝트에서 쉽게 재사용할 수 있다. 문제는 고수준 모듈인 Handler인데, 이 클래스는 FilesystemManager와 강하게 결합되어(의존하여) 재사용성이 떨어진다. 우리는 고수준 모듈을 데이터베이스, 클라우드 같은 다른 종류의 스토리지에 재사용할 수 있어야한다.

이 의존성 문제는 Storage 프로토콜을 사용하여 해결할 수 있다. 이 방식에서 Handler는 추상 프로토콜을 스토리지의 종류에 상관없이 사용할 수 있다. 이 접근 방식에서는 쉽게 파일시스템을 데이터베이스로 바꿀수도 있다.

class Handler {
    let storage: Storage

    init(storage: Storage) {
        self.storage = storage
    }

    func handle(string: String) {
        storage.save(string: string)
    }
}

protocol Storage {
   func save(string: String)
}

class FilesystemManager: Storage {

    func save(string: String) {
        // Open a file in read-mode
        // Save the string in this file
        // Close the file
    }
}

class DatabaseManager: Storage {

    func save(string: String) {
        // Connect to the database
        // Execute the query to save the string in a table
        // Close the connection
    }
}

이 원칙은 테스트에도 매우 용이하다. Storage 프로토콜을 채택한 스텁 클래스를 쉽게 사용할 수 있고, handlerStorage 객체의 save 메소드를 호출하는 방식으로 테스트를 진행할 수 있다.

Reference

SOLID Principles Applied To Swift

Tags:

Categories:

Updated: