본문 바로가기
iOS/Swift

SOLID

by Jiseong 2022. 2. 13.

SOLID

솔옴 옷 예쁘지 ㅋㅋ

역사? 의의?

SOLID 원칙의 의의,, 생겨난 이유가 무엇일까?

  1. 재사용성 고려, 재사용성을 높임으로써 높은 확장성을 가지게 된다.
  2. 재사용성을 높이기 위해 하나의 클래스는 하나의 책임을 가지게 한다.
  3. 낮은 결합도 (높은 응집력)를 가져 작은 변화가 큰 Side Effect를 가져오지 않게한다. 
  4. 유지보수가 쉬워진다. 

써놓고 보니 뭔가 위의 목록은 이유를 말하는 것 같진 않다.

좀 풀어서 써보면..

  1. 재사용성을 높인다
    • 재사용성을 높이기위해 불필요하게 묶여있는 실들을 제거 -> 의존성 분리
    • 의존성을 분리하기 위핸 하나의 클래스는 하나의 책임만을 가짐
    • 하나의 클래스가 하나의 책임을 가짐으로써 Testable Code와 동시에 높은 확장성, 유지보수성을 가지게 되는 긍정적인 효과를 가져옴
  2. 변경에 유연하게
    • 낮은 결합도를 가짐으로써 개발자가 변경하는 코드가 위치한 클래스 내부 로직이 변경되어도 다른 클래스가 연달아 터지는 Side Effect를 줄임
  3. 가독성
    • 여러 책임을 지는 클래스는 클래스명조차 짓기가 어려울 때가 많음
    • 즉, 개발자조차 제대로 정의내리지 못한 클래스명은 남이 읽을 땐 더더욱 해당 클래스가 정확히 무엇을 하는 놈인지 파악하기 어려워짐
    • 나아가 시간이 자나면 코드를 작성한 사람조차 파악못함 -> 유지보수성 극히 저하
    • 이를 최대한 해결하기 위한 원칙 = SOLID

좋은 객체 지향 프로그래밍은 유연하고 확장할 수 있으며 유지보수가 용이하고 재사용할 수 있는 코드이다.

객체 속성(데이터)을 가져오지 말고 객체가 일하도록 시켜라(연산 실행)

이러한 OOP를 위해 만들어진 것이 SOLID 원칙이다.

SOLID 5원칙

SOLID

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

단일 책임 원칙 - SRP

한 책임(함수)에 두 depth (조건안의 조건)이 존재하는 경우 검사를 2번을 하는 것이므로 한 함수에 하나의 책임만이 존재하지 않는 것이다.

 

위는 함수의 기본 원칙을 말하는 것이고, 클래스의 관점으로 보자

하나의 모듈(클래스)는 하나의 책임만을 가진다.

책임은 변경의 이유, 즉 하나의 모듈을 변경하는 이유는 오직 하나여야한다.

class RestaurantFood {
    func cookResult() -> String {
        let ingredient = manageIngredient()
        let imcompleteFood = cook(with: ingredient)
        return plating(with: imcompleteFood)
    }
    
    func manageIngredient() -> [String] {
        // 재료 관리
        return ["재료"]
    }
    
    func cook(with: [String]) -> String {
        // 요리
        return "접시에 안담긴 미완성 요리"
    }
    
    func plating(with: String) -> String {
        // 플레이팅
        return "완성된 요리"
    }
}

위 코드는 단일 책임 원칙을 지키지 않은 코드이다.

 

RestaurantFood라는 하나의 클래스에 재료 관리, 요리, 플레이팅 세가지 기능이 모두 들어있다.

 

레스토랑 가본지 한참되서 기억도 잘 안나지만 좀 더듬어보면..

음식이 나오기까지 거쳐오는 사람들이 몇 있다.

요리를 만드는 셰프, 재료 관리하는 알바, 플레이팅부터 배우고 있는 보조 셰프

헤드 셰프가 재료 관리하고, 요리하고, 플레이팅까지하면 너무 힘들지않을까?

 

객체지향 프로그래밍에서도 마찬가지로 한 클래스가 많은 책임을 가지고 있다면 유지보수, 재사용성이 현저히 떨어지 떨어지기에 단일책임원칙을 지키기 위해 이 세 사람들을 클래스로 나눠야한다.

class RestaurantFood {
    let partTimeMan: PartTimeMan
    let chef: Chef
    let assistantChef: AssistantChef
    
    init(partTimeMan: PartTimeMan, chef: Chef, assistantChef: AssistantChef) {
        self.partTimeMan = partTimeMan
        self.chef = chef
        self.assistantChef = assistantChef
    }
    
    func cookResult() -> String {
        let ingredient = partTimeMan.manageIngredient()
        let imcompleteFood = chef.cook(with: ingredient)
        return assistantChef.plating(with: imcompleteFood)
    }
}

class PartTimeMan {
    func manageIngredient() -> [String] {
        // 재료 관리
        return ["재료"]
    }
}

class Chef {
    func cook(with: [String]) -> String {
        // 요리
        return "접시에 안담긴 미완성 요리"
    }
}

class AssistantChef {
    func plating(with: String) -> String {
        // 플레이팅
        return "완성된 요리"
    }
}

이렇게 세가지의 책임을 각각의 클래스에 넘겨준다면 누가 일을 못하는지(오류, 유지보수 용이), 어떤 문제가 발생하였는지, 누가 일을 잘하는지까지 테스트하기 쉬워진다.

클래스, 코드의 양은 많아질 수 있지만 이를 그림으로 그려보면 훨씬 간단해진 모습, 즉 책임이 분할된 모습을 볼 수있다.

개방폐쇄 원칙 - OCP

확장에는 열려있으나 변경에는 닫혀 있어야 한다.

 

이게 무슨 말일까?

 

기존 모듈에 케이스를 추가할 때 해당 모듈을 사용한 모든 곳에서 오류가 발생했던 적이 분명 있을 것이다.

위같은 상황이 개방폐쇄 원칙을 위반하여 발생하는 일이다.

struct Rice {
    let price: Int
}

struct Noodle {
    let price: Int
}

class SellBot {
    func sellRice(rice: Rice) {
        print("밥 판매 완료")
    }
    
    func sellNoodle(noodle: Noodle) {
        print("면 판매 완료")
    }
}

위 예제는 개방폐쇄 원칙을 위반하고 있다.

 

식당에서 신메뉴를 개발했다고 생각해보자.

Chicken 이라는 메뉴를 추가했을 때 판매하려면 기존 SellBot 내부 코드를 변경해야한다.

이를 기존 SellBot의 내부 코드를 변경치 않고 새로운 메뉴를 추가하도록 변경해보자.

protocol SellFood {
    var name: String { get set }
    var price: Int { get set }
}

struct Rice: SellFood {
    var name: String = "볶음밥"
    var price: Int = 2000
}

struct Noodle: SellFood {
    var name: String = "라면"
    var price: Int = 1500
}

struct Chicken: SellFood {
    var name: String = "후라이드 치킨"
    var price: Int = 3000
}

class SellBot {
    func sell(food: SellFood) {
        print("\(food.name) 판매 완료")
    }
}

위 코드처럼 만들어주면 신메뉴가 개발되어 추가되었을 때 기존 SellBot 클래스 내부의 코드를 변경하지 않고 SellFood라는 프로토콜만 신메뉴에 채택해주면 된다.

 

이처럼 기존 코드(SellBot)는 건드리지 않는 것, 변경에 닫혀있는 것을 폐쇄, 새로운 모델(Chicken)이 추가될 때 SellFood 프로토콜만 따르게 하면 되는 것을 개방이라고 한다.

 

개방폐쇄 원칙을 위반하는 추가적인 예로는 열거형이 있다.

메뉴를 추가해주니 Sell 메소드 내부 코드 또한 변경을 해줘야 되므로..

 

메소드, 행동을 추가할 땐 Enum, 타입을 추가할 땐 Protocol로 확장해주는 것이 좋아보인다.

리스코프 치환 원칙 - LSP

자식 클래스는 부모 클래스로써의 역할을 완벽히 할 수 있어야한다.

 

리스코프라는 사람이 정의하였고 자식 클래스는 부모 클래식의 역할을 치환하여도 동일하게 문제없이 동작해야 한다는 의미이다.

 

이는 많이 알려진 예제가 있으므로 바로 에제로 들어가보겠다.

 

직사각형과 정사각형 중 범주가 더 큰 것은 무엇일까?

우리는 기초과정을 통해 아래 그림같이 이뤄져 있다는 것을 확실히 알고있다.

코드 개념으로 생각해보면 정사각형이 직사각형을 상속받고 있다고 생각해볼 수 있다.

 

한번 확인해보자

class Rectangle {
    var width: Int = 0
    var height: Int = 0

    var area: Int {
        return width * height
    }
}

class Square: Rectangle {
    override var width: Int {
        didSet {
            height = width
        }
    }
}

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

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

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

직사각형과 정사각형의 넓이가 다르게 나오는 것을 볼 수 있다.

 

그냥 보면 뭐 당연한거아님?? 이라고 생각할 수 있겠지만 슈퍼, 서브 클래스 개념을 유심히 보자

 

하위 클래스인 Square가 상속받은 상위 클래스인 Rectangle의 넓이를 구할 수 있나? ㄴㄴ 

하위 클래스가 상위 클래스 대신 사용되어도 같은 동작을 하지 않을 것이다.

 

이는 리스코프 치환 원칙을 위반한 것이다.

 

이를 해결하기 위해선 넓이를 구하는 로직을 프로토콜에 정의하여 각 클래스(직사각형, 정사각형)에 다르게 구현해주면 된다.

protocol Polygon {
    var area: Int { get }
}

class Rectangle: Polygon {
    private let width: Int
    private let height: Int
    
    init(width: Int, height: Int) {
        self.width = width
        self.height = height
    }
    
    var area: Int {
        return width * height
    }
}

class Square: Polygon {
    private let length: Int
    
    init(length: Int) {
        self.length = length
    }
    
    var area: Int {
        return length * length
    }
}

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

let rectangle = Rectangle(width: 10, height: 5)
printArea(of: rectangle)

let square = Square(length: 5)
printArea(of: square)

하위 클래스로 치환할 수 있어야한다는 얘기는 실제로 바꿀 수도 있겠지만 특정한 것을 추상화한 클래스, 프로토콜을 상속, 채택한 다른 클래스들을 추상화된 인터페이스 하나로 공통된 코드를 작성할 수 있게 하란 말이다. 

 

또한 객체지향의 전통은 현실의 사물을 프로그래밍 세계로 가져온다는 역사를 갖고 있다곤 하는데.. 객체지향의 목적은 현실 세계를 모방하는 것이 아니라 현실 세계를 기반으로 새로운 세계를 창조하는 것이다.

 

고로.현실의 관념이 무조건 적용되는 건 아니다

인터페이스 분리 원칙 - ISP

클라이언트가 자신이 이용하지 않는 메소드에 의존하지 않아야 한다는 원칙이다.

 

자신이 이용하지 않는 메소드에 의존하지 않아야 된다는 말은 서버에서 여러 메소드를 제공하는데 클라이언트는 그 중 특정 메소드 몇몇만 사용하고 있다면 서버측에서 제공하는 메소드중 사용하지 않는 메소드가 제거되든, 변경되든 클라이언트는 영향을 받지 않아야 한다는 말이다.

 

그니깐 간단하게 사용하지 않는 것들은 뺴버리게 분리를 자잘하게 해라 라는 뜻이다.

 

예제를 보자

protocol Person {
    func smoke()
    func run()
    func eat()
    func drinkAlcohol()
}

class Adult: Person {
    func smoke() {}
    func run() {}
    func eat() {}
    func drinkAlcohol() {}
}

class Kid: Person {
    func smoke() {}
    func drinkAlcohol() {}
    func run() {}
    func eat() {}
}

class PersonWithBrokenLegs: Person {
    func smoke() {}
    func eat() {}
    func run() {}
    func drinkAlcohol() {}
}

Person이라는 프로토콜을 채택한 여러 클래스들이 있는데, Kid, PersonWithBrokenLegs 클래스에 할 필요가 없는 것 (원랜 하면 안되는 것이지만;)들이 사용되고 있는 모습을 볼 수 있다.

 

이는 인터페이스 분리 원칙을 어긴 것이다. 

 

쓰잘데기 없는 짓을 채택받는 것이 문제이다. 

 

그렇다고 구현을 안하기엔 프로토콜 채택해놓고 안쓴다고 컴파일 오류를 던질 것이고.. 

 

그럼 방법이 뭐가 있을까? 

 

분리해보자

protocol Runable {
    func run()
}

protocol Eatable {
    func eat()
}

protocol AdultProtocol {
    func smoke()
    func drinkAlcohol()
}

class Adult: AdultProtocol, Runable, Eatable {
    func run() {}
    func eat() {}
    func smoke() {}
    func drinkAlcohol() {}
}

class Kid: Runable, Eatable {
    func eat() {}
    func run() {}
}

class AdultWithBrokenLegs: Eatable, AdultProtocol {
    func smoke() {}
    func eat() {}
    func drinkAlcohol() {}
}

프로토콜을 분리함으로써 필요한 프로토콜만 채택하게 되어 사용하지 않는 메소드에 의존하지 않는다.

 

인터페이스 분리 원칙을 통해 시스템의 내부 의존성을 약화시켜 리팩토링, 수정, 재배포를 쉽게 할 수 있다.

의존성 역전 원칙 - DIP

상위레벨 모듈은 하위레벨 모듈에 의존하면 안된다.

 

추상에 의존하라

 

구체적인 사항은 추상화에 의존해야한다.

 

예제를 보자

class Cafe {
    var barista: BaristaA?
}

class BaristaA {}

let jsCafe = Cafe()
jsCafe.barista = BaristaA()

Cafe라는 클래스가 BaristaA라는 클래스에 의존하고 있다.

BaristaA라는 클래스가 없거나 다른 BaristaB라는 클래스가 바리스타로 고용된다면 위 사진처럼 Cafe는 돌아가질 않는 것이다.

상위레벨 모듈(Cafe)이 하위레벨 모듈(BaristaA)에 의존하고있는 상황이니 의존성 역전 원칙을 위반하고 있음을 알 수 있다.

 

Cafe는 Barista인 A가 필요한 것이 아니고 그냥 Barista가 필요한 것이다. A가 일 때려친다고 카페가 안돌아가면 그게 말이될까? 바리스타가 사장이 아닌이상?

 

그러므로 우린 추상에 기존의 의존성을 역전시켜볼 수 있다.

protocol Barista {}

class Cafe {
    var barista: Barista?
}

class BaristaA: Barista {}
class BaristaB: Barista {}

let jsCafe = Cafe()
jsCafe.barista = BaristaB()

이제 Cafe라는 클래스는 Barista라는 추상에 의존을 하게되어, 하위모듈인 BaristaA에 의존하지 않기에 의존성 역전 원칙을 지킬 수 있게 됨과 동시에 특정 바리스타에만 의존하지 않음으로써 다형성, 확장성까지 잡을 수 있게 된다.

정리

단일 책임 원칙과 인터페이스 분리 원칙은 객체(클래스 인스턴스)가 커지지 않도록 해준다.

 

객체마다 다른 인터페이스(프로토콜)을 사용하고, 단일 책임을 갖게함으로써 한 기능의 변경할 때 발생할 Side Effect를 최소화할 수 있고 유지보수, 기능 확장을 보다 수월하게 하는 결과를 가져온다.

 

개방 폐쇄 원칙은 변화되는 부분을 추상화하고 이를 통해 얻는 다형성을 이용함으로써 기능 확장을 함과 동시에 기존 코드를 수정치않도록 만들어준다.

 

변화되는 부분을 추상화할 수 있도록 도와주는 원칙이 의존성 역전 원칙이고, 다형성을 도와주는 원칙이 리스코프 치환 원칙이다.

참고






와.. 역전 역전 하니깐 역전할머니맥주 먹고싶다.

'iOS > Swift' 카테고리의 다른 글

Swift) TableView  (0) 2022.02.13
Swift) Delegate  (0) 2022.02.13
Swift) App LifeCycle  (0) 2022.02.13
Swift) weak, unowned  (0) 2022.02.13
Swift) 순환 참조 , strong, weak  (0) 2022.02.13

댓글