[Swift] OOP의 SOLID 원칙
👨🏻‍💻iOS 공부/iOS & Swift

[Swift] OOP의 SOLID 원칙

728x90
반응형

객체 지향 프로그래밍이란?

먼저 작은 문제들을 해결할 수 있는 객체들을 만들고, 이 객체들을 조합하여 큰 문제를 해결하는 방식이다. 

좋은 객체 지향 설계를 하게 되면 코드의 재사용, 유지보수의 용이성 등의 장점으로 가져갈 수 있고 개발 기간/비용들을 감축할 수 있다!

 

항상 코드는 유연하고 확장할 수 있고 유지보수가 용이하고 재사용할 수 있어야 한다. 

이러한 OOP 방식을 잘 준수하기 위해 만들어진 것이 SOLID원칙이다. 

 

OOP의 SOLID

1. 단일 책임의 원칙 (SRP: Single Responsibility Principle)

하나의 객체는 하나의 책임을 가져야 한다. 즉 하나의 class가 여러 기능을 담당하면 안된다는 것이다. 
class Search {
    func searchResult() -> String {
        let result = firstSearch()
        let keywords = filterResult(result: result)
        return findTargetWord(result: keywords)
    }
    
    func firstSearch() -> [String] {
    }
    
    func filterResult(result: [String]) -> [String] {
    }
    
    func findTargetWord(_ result: [String]) -> String {
    }
}

 

예를 들어서 이렇게 구성된 코드가 있다고 했을 때, 하나의 클래스에 "검색", "정제", "원하는 단어 찾기"의 기능이 모두 들어있는 것을 볼 수 있다.

 

이는 SRP를 지키지 않는 코드로 하나의 클래스가 하나의 책임을 갖도록 하려면 기능들을 분리해줘야 한다. 

 

class Search {
    var searchHandler = SearchHandler()
    var filterHandler = FilterHandler()
    var findTargetHandler = FindTargetHandler()
    
    init(searchHandler: SearchHandler, filterHandler: FilterHandler, findTargetHandler: FindTargetHandler) {
        self.searchHandler = searchHandler
        self.filterHandler = filterHandler
        self.findTargetHandler = findTargetHandler
    }

    func searchResult() -> String {
        let result = searchHandler.firstSearch()
        let keywords = filterHandler.filterResult(result: result)
        return findTargetHandler.findTargetWord(keywords)
    }
}

class SearchHandler {
    func firstSearch() -> [String] {
    
    }
}

class FilterHandler {
    func filterResult(result: [String]) -> [String] {
    
    }
}

class FindTargetHandler {
    func findTargetWord(_ result: [String]) -> String {
    
    }
}

이렇게 각각의 책임을 클래스에 넘겨주는 식으로 작성하면 하위 핸들러들의 테스트도 용이해지게 된다!

 

그림으로 보면 아래와 같다.

 

SRP

 

2. 개방-폐쇄의 원칙 (OCP: Open-Closed Principle)

소프트웨어 엔티티(클래스, 모듈, 함수 등)들은 확장에는 열려있어야 하지만, 변경에는 닫혀있어야 한다.

확장에 열려있다는 것은, 수고롭지 않게 클래스의 기능을 확장할 수 있다는 것이다. 

또한 변경에 닫혀있다는 것은, 기존에 구현되어 있는 것들을 바꾸지 않고 클래스를 확장할 수 있어야 한다는 것이다. 

 

이러한 특성들은 "추상화" 를 통해 이루어질 수 있다. 

(Swift에서 추상화는 주로 Protocol을 통해 이뤄진다)

 

Vehicle이라는 클래스를 통해 Car 클래스의 객체들의 detail을 출력하는 코드를 한 번 봐보자. 

class Vehicle {
    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)"
    }
}

만약 이 상황에서 다른 탈 것(다른 클래스)이 추가될 경우 우리는 printData를 다음과 같이 바꿔줘야 하는데, 이는 OCP 규칙을 깨뜨리는 방식이다. 우선 구현해보자면 다음과 같이 추가 및 수정해주어야 한다. 

 

class Vehicle {
    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)"
    }
}

코드를 보기만 해도 불필요하게 반복되는 모습을 볼 수 있다. 

 

이러한 문제는 Protocol을 새로 만듬으로써 해결할 수 있다 (= 추상화) 

Printable이라는 프로토콜을 만들어 printDetails 메서드를 정의만 해둘 것이다. 

이제는 새 클래스가 추가될 때마다 Vehicle의 printData를 수정해야할 필요가 없고, 데이터만 추가해주면 된다. 

 

protocol Printable {
    func printDetails() -> String
}

class Vehicle {
    func printData() {
        let vehicles: [Printable] = [
            Car(name: "Batmobile", color: "Black"),
            Car(name: "SuperCar", color: "Gold"),
            Car(name: "FamilyCar", color: "Grey"),
            Bicycle(type: "BMX"),
            Bicycle(type: "Tandem")
        ]

        vehicles.forEach { car in
            print(car.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)"
    }
}

OCP

3. 리스코프 치환 원칙 (LSP: The Liskov Substitution Principle)

상위 타입(S)와 하위 타입(T) 사이에서, 프로그램의 속성(정확성, 수행하는 업무 등)의 변경 없이 상위 타입(S)의 객체를 하위 타입(T)로 교체(치환)할 수 있어야 한다는 원칙

즉, 상위 클래스의 자리에 하위 클래스를 치환해도 문제 없이 수행되어야 한다는 의미이다. 이 원칙은 상속을 혼란스럽게 하지 않을 수 있도록 큰 도움을 줄 수 있다. 

 

리스코프의 치환 법칙하면 나오는 직사각형, 정사각형 문제를 가지고 한 번 살펴보자. 

 

직사각형과 정사각형 중 어느 것이 더 큰 범주해 속할까?

돌이켜보면, 직사각형이 개념적으로 더 큰 범위에 속한다는 것을 알 수 있다. 

그렇게 직사각형과 정사각형을 구현할 때에 정사각형(square)이 직사각형(rectangle)을 상속 받았다고 해보자. 그리고 넓이를 구하는 프로퍼티를 추가하여 각각의 넓이를 구해보자. 

 

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

 

결과를 보면 직사각형과 정사각형 인스턴스의 넓이가 다르게 나오는 것을 볼 수 있다. 즉, 하위 클래스로는 상위 클래스의 넓이를 구할 수 없기에 LSP를 위반한 것이라고 볼 수 있다. 이는 width의 setter 때문이어서 rectangle을 사용하는 클라이언트에서는 정사각형을 사용할 때 의도치 않은 결과를 받게 되는 것이다. 

 

이 또한 Protocol을 만들어줌으로써 해결할 수 있다. 

 

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

우선 private를 통해 접근자를 제어해준 것이 다르다는 것을 알 수 있다. 또한 넓이를 구해주는 식은 protocol의 기능을 각각 구현해줌으로써 문제를 해결한 것을 볼 수 있다. 

LSP

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

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

불필요한 인터페이스 요소들을 포함시키지 말라는 의미이다. 불필요한 요소들이 포함되면서 복잡해지고, 무거워짐에 따라 진작 원하는 정보를 얻을 수 없는 지경까지 이르기도 한다. 이 문제는 클래스나 프로토콜 모두에게 영향을 줄 수 있다. 

 

Protocol

protocol의 경우를 먼저 봐보자.

자 여기 "터치"를 했을 때 반응을 구현해줄 didTap 메서드를 가지고 있는 GestureProtocol을 봐보자. 

protocol GestureProtocol {
    func didTap()
}

이후에 더 많은 제스처들을 추가해주고 싶어서 프로토콜에 추가한다면 어떻게 될까? 

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

그리고 실제 사용할 때는 프로토콜을 채택하고 그 메서드들을 구현해주면 된다.

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

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

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

하지만 여기서 문제가 발생한다. 만약에 didTap만을 지원하는 버튼을 만들고자 한다면 어떻게 해야할까? 따로 메서드를 만들어야 할까? 아니면 프로토콜을 채택하고 나머지 메서드들은 더미하게 구현해야하나? 싶을거다. 

 

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

    func didDoubleTap() { }

    func didLongPress() { }
}

하지만 이렇게 구현하는 것 자체가 ISP를 위반하는 "Fat"한 인터페이스이다. 그렇기에 필요한 기능만 골라서 사용할 수 있도록 프로토콜을 나눠서 구현해주는 방식으로 구성해야한다. 

 

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

이렇게 프로토콜마다 메서드를 하나씩 만들어주게되면, 원하는 프로토콜만 채택하여 기능을 구현할 수 있게 된다!

 

ISP

Class

영상에 대한 정보를 포함하고 있는 Video라는 클래스가 있다고 해보자. 

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

그리고 play라는 메서드에 비디오 정보를 넣어주자. 

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 메서드에는 "title", "url", "duration"만 필수적인 정보이고 나머지는 정보들은 굳이 받아야 할 필요가 없다. 

 

이 또한 protocol을 정의해 줌으로써 play 메서드가 필요로하는 정보만 전달해주면 된다. 

 

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
}

 

5. 의존관계 역전 원칙 (DIP: Dependency Inversion Principle)

A. 고차원의 모듈들은 하위 모듈에 의존하면 안된다. 두 개 모두 추상 개념에 의존해야한다
B. 추상 개념은 세부 사항에 의존하면 안된다. 세부 사항이 추상 개념에 의존해야한다. 

추상화라는 것은 앞서 본 것 처럼 swift의 protocol과 같다. 즉 복잡한 것을 제쳐두고, 핵심이 되는 요소만 가져다가 간결하게 보기 쉽게 만드는 것을 말한다. 

 

DIP는 OCP와 굉장히 유사하다

예를 들어, 파일 시스템에 문자열을 저장하는 역할을 하는 Handler라는 클래스가 있다고 해보자. 이 때 FilesystemManager 클래스의 인스턴스를 내부적으로 생성하고 실제로 저장하는 역할을 맡긴다고 했을 때, DIP를 지키고 있는 걸까? 

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
    }
}
  • Handler : High-Level(고수준)
  • FilesystemManager: Low-Level(저수준)

그런데 위 코드를 보면 Handler(고수준)가 FilesystemManager(저수준)에 의존하고 있는 것을 볼 수 있다. 이 때문에 Handler를 재사용하기 까다로워진다.

 

이 때도 뭐다? 

 

저수준 모듈에 의존하는 것이 아니라! 추상화, 즉 프로토콜을 정의하여 이에 의존하도록 해줘야 한다. 

 

protocol Storage {
   func save(string: String)
}

class Handler {
    let storage: Storage
 
    init(storage: Storage) {
        self.storage = storage
    }
 
    func handle(string: String) {
        storage.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
    }
}

DIP

Storage라는 프로토콜을 정의함으로써 내부 구현은 저수준 모듈(FilesystemManager, DatabaseManager)에게 맡기게 됨으로써 Handler 사용이 용이해지고, 테스트 또한 용이해진다.  


신중하게 생각하고 SOLID 원칙을 지키게 되면, 코드의 질이 향상시킬 수 있을 것이다. 또한 요소들의 재사용과 유지보수가 가능해져서 더욱더 효율적인 코드를 작성할 수 있게 된다. 

 

그리고 SOLID 원칙을 따르게 되면 아래 세 가지 문제를 해결할 수 있게 된다

  • Fragility: 작은 변화가 버그를 일으킬 수 있는데, 테스트가 용이하지 않아 미리 파악하기 어려운 것
  • Immobility: 재사용성의 저하. 불필요하게 묶인(coupled) 의존성 때문에 재사용성이 낮아진다. 
  • Ridgidity: 여러 곳에 묶여 있어서 작은 변화에도 많은 곳에서 변화(노력)가 필요하다. 

 

 

ref : https://marcosantadev.com/solid-principles-applied-swift/

 

SOLID Principles Applied To Swift - Marco Santa Dev

A maintainable component. Reusable. Just a dream? Maybe not. SOLID principles, may be the way.

marcosantadev.com

https://ko.wikipedia.org/wiki/%EB%A6%AC%EC%8A%A4%EC%BD%94%ED%94%84_%EC%B9%98%ED%99%98_%EC%9B%90%EC%B9%99

 

리스코프 치환 원칙 - 위키백과, 우리 모두의 백과사전

치환성(영어: substitutability)은 객체 지향 프로그래밍 원칙이다. 컴퓨터 프로그램에서 자료형 S {\displaystyle S} 가 자료형 T {\displaystyle T} 의 하위형이라면 필요한 프로그램의 속성(정확성, 수행하는

ko.wikipedia.org

 

728x90
반응형