EEYatHo 앱 깎는 이야기

CS ) SOLID 원칙 ( 객체지향 설계 5원칙 ) - EEYatHo iOS 본문

CS

CS ) SOLID 원칙 ( 객체지향 설계 5원칙 ) - EEYatHo iOS

EEYatHo 2022. 12. 30. 15:03
반응형

객체지향 프로그래밍 : OOP


제공해야하는 기능을 찾고 세분화하여,

필요한 데이터와 기능을 가진 객체를 만들고,

객체간의 상호작용으로 프로그램을 만든다

 

 

 

객체지향 설계 5원칙 : SOLID


  • SRP ( Single Responsibility Principle ): 단일 책임 원칙
  • OCP ( Open Closed Priciple ): 개방 폐쇄 원칙
  • LSP ( Listov Substitution Priciple ): 리스코프 치환 원칙
  • ISP ( Interface Segregation Principle ): 인터페이스 분리 원칙
  • DIP ( Dependency Inversion Principle ): 의존 역전 원칙

왜 따라야 하나?

수십년간 선배 개발자들이 시행착오를 거쳐 정제하여 만들어진 원칙.

 

주의 : Silver Bullet 은 없다. 맹신하지말고 적절한 trade-off 가 필요.

 

 

 

SRP : 단일 책임 원칙


  • 클래스는 단 한개의 책임을 가져야 한다.
  • 클래스를 변경하는 이유는 단 하나여야 한다.
더보기

책임이란 단어가 너무 모호하다.

 

어디까지 세분화 해야하는가..?

아래처럼, 프로퍼티로 가지는 데이터를 서브클래싱으로 분기(치환) 할 수 있다.

  • 동물 -> 포유류 -> 강아지 .. OK
  • 강아지 -> 수컷강아지 -> 부산출신 수컷강아지 -> 부산출신이고 다리가 3개인 수컷강아지 .. NO!! 

 

딱봐도 부산출신이고 다리가 3개인 수컷 강아지는 좋은 클래스가 아닌 것 같다...

하지만 한국어로 해석했을 때, 정말 단 한개의 책임을 가지는 class는 부산출신이고 다리가 3개인 수컷 강아지아닌가?

 

흠.. 대체 명확한 기준이 무엇인가?

 

떠오르는 아이디어는,

데이터에 따라 메소드의 구현이 바뀌는가? -> 부산 출신이라고 해서 바뀌는 메소드들은 거의 없으니 데이터로 나두는게 좋다.

 

즉, 서브클래싱 했을 때, 오버라이딩 해야하는 메소드가 많으면 서브클래싱 해라...?

 

 

 

OCP : 개방 폐쇄 원칙


  • 클래스, 함수는 확장에는 열려있고, 변경에는 닫혀 있어야 한다.
  • 기존 코드를 변경하지 않고, 기능을 수정하거나 추가할 수 있어야 한다.
더보기

우리는 도형을 구현할 때 enum과 protocol 두개로 표현할 수 있다.

 

enum

enum 도형 {
    case 정삼각형(변: Double)
    case 정사각형(변: Double)

    var 넓이: Double {
        switch self {
        case .정삼각형(let 변):  return 변 * 변 / 2.0
        case .정사각형(let 변):  return 변 * 변
        }
    }
    
    var 둘레: Double {
        switch self {
        case .정삼각형(let 변):  return 변 * 3
        case .정사각형(let 변):  return 변 * 4
        }
    }
}

 

 

protocol

protocol 도형 {
    var 넓이: Double { get }
    var 둘레: Double { get }
}

struct 정삼각형: 도형 {
    private let 변: Double
    var 넓이: Double { 변 * 변 / 2.0 }
    var 둘레: Double { 변 * 3 }
}

struct 정사각형: 도형 {
    private let 변: Double
    var 넓이: Double { 변 * 변 }
    var 둘레: Double { 변 * 4 }
}

 

새로운 도형을 추가할 때,

enum은 case를 추가하고, 모든 프로퍼티를 수정 해야하지만,

enum 도형 {
    case 정삼각형(변: Double)
    case 정사각형(변: Double)
    case 정오각형(변: Double) // 수정

    var 넓이: Double {
        switch self {
        case .정삼각형(let 변):  return 변 * 변 / 2.0
        case .정사각형(let 변):  return 변 * 변
        case .정오각형(let 변):  return 변 * sqrt(25 + 10 * sqrt(5)) / 4 // 수정
        }
    }
    
    var 둘레: Double {
        switch self {
        case .정삼각형(let 변):  return 변 * 3
        case .정사각형(let 변):  return 변 * 4
        case .정오각형(let 변):  return 변 * 5 // 수정
        }
    }
}

 

protocol은 struct를 하나 생성하면 되므로,

protocol이 OCP 를 잘 지켰다고 볼 수 있다.

protocol 도형 {
    var 넓이: Double { get }
    var 둘레: Double { get }
}

struct 정삼각형: 도형 {
    private let 변: Double
    var 넓이: Double { 변 * 변 / 2.0 }
    var 둘레: Double { 변 * 3 }
}

struct 정사각형: 도형 {
    private let 변: Double
    var 넓이: Double { 변 * 변 }
    var 둘레: Double { 변 * 4 }
}

// 수정
struct 정오각형: 도형 {
    private let 변: Double
    var 넓이: Double { 변 * sqrt(25 + 10 * sqrt(5)) / 4 }
    var 둘레: Double { 변 * 5 }
}

 

그렇다고 protocol이 무조건 좋은가? 그것도 아니다.


새로운 프로퍼티나 메소드(그리기)를 추가할 때는,

protocol은 protocol 자신과, 모든 struct를 수정해야하지만,

protocol 도형 {
    var 넓이: Double { get }
    var 둘레: Double { get }
    func 그리기() // 수정
}

struct 정삼각형: 도형 {
    private let 변: Double
    var 넓이: Double { 변 * 변 / 2.0 }
    var 둘레: Double { 변 * 3 }
    func 그리기() { print("정삼각형") } // 수정
}

struct 정사각형: 도형 {
    private let 변: Double
    var 넓이: Double { 변 * 변 }
    var 둘레: Double { 변 * 4 }
    func 그리기() { print("정사각형") } // 수정
}

 

enum은 enum 한 부분에 프로퍼티나 메소드를 새로 선언하면 되므로

enum이 OCP 를 잘 지켰다고 볼 수 있다.

enum 도형 {
    case 정삼각형(변: Double)
    case 정사각형(변: Double)

    var 넓이: Double {
        switch self {
        case .정삼각형(let 변):  return 변 * 변 / 2.0
        case .정사각형(let 변):  return 변 * 변
        }
    }
    
    var 둘레: Double {
        switch self {
        case .정삼각형(let 변):  return 변 * 3
        case .정사각형(let 변):  return 변 * 4
        }
    }
    
    // 수정
    func 그리기() {
        switch self {
        case .정삼각형:  print("정삼각형")
        case .정사각형:  print("정사각형")
        }
    }
}

 

Silver Bullet 은 없다.

 

개발자의 도메인 지식과 경험을 통해, 예상하고 생각하여 구현해야한다.

 

case가 많이 추가된다면 protocol 을,

프로퍼티가 많이 추가된다면 eunm 을 사용.

 

 

 

LSP : 리스코프 치환 원칙


  • 부모 타입 객체는 자식 타입 객체로 치환할 수 있어야 한다.
  • 자식 타입으로 치환 할 때, 프로그램의 정확성을 훼손하지 않아야 한다. 
  • 자식은 부모의 성질을 거부해선 안된다.
  • 상속 관계에서는 반드시 일반화 관계(IS-A) 가 성립해야 된다는 의미
  • 단순히 코드 재사용을 위한 상속을 하게 될 경우, LSP 에 위배된다.
더보기

부모를 자식으로 치환했을 때 빌드는 당연히 된다.

 

중요한 것은, 프로그램의 정확성을 훼손하지 않아야 한다는 것.

다르게 말하면,

테스트할 때, 부모 객체를 가지고 테스트하는 곳에, 자식을 그대로 넣어도 같은 값이 나와야한다는 것.

 

 

이를 달성하기 위해서는, 자식은 부모의 성질을 거부(훼손)해선 안된다.

이와 관련해서 유명한 사안은, 정사각형은 직사각형을 상속받으면 안된다는 것이다.

 

직사각형을 가지고, 넓이와 높이를 다르게 설정한 후 넓이를 확인하는 테스트를 할 때,

정사각형은 넓이와 높이가 같아야하므로, 정사각형을 어떻게 구현하든 예상 결과와 다르게 나온다.

 

따라서 LSP 를 따르다보면, 현실의 관념과 차이가 발생할 수 있다.

상속으로 발생한 커플링은 끊어내는데에 엄청난 리소스가 들기 때문에, 깊게 고민할 필요가 있다.

 

뛰어난 개발자는

LSP 를 따르면서 현실의 관념과 다르게 개발하는 것

LSP 를 따르지 않으면서 현실의 관념과 같게 개발하는 것

도메인 지식과 유지보수 경험을 통해 예상하고 trade-off 해야한다.

 

반드시 모든 상속이 LSP 를 따르지 않아도 된다는 의미.

적절한 예로, Swift 내부에도 LSP 를 따르지 않는 경우가 있다

 

UIView 를 상속받지만, frame 의 변경이 자유롭지 않은 뷰들이 있다.

하지만 frame 변경이 자유롭지 않은 것이 충분히 납득이 되고,

대부분의 경우 LSP 를 따르기 때문에, 괜찮다고 볼 수 있다.

 

TDD를 따르다보면 자연스럽게 LSP 를 따르게 된다는 말이 있다.

 

 

 

ISP : 인터페이스 분리 법칙


  • 클라이언트는 자신이 사용하지 않는 불필요한 인터페이스에 의존하지 말아야한다.
  • Fat 한 인터페이스를 만들지 말고, 잘게 쪼게라.
더보기

여태까지 중에 가장 당연하고 답이 잘 보이는 법칙 

 

불필요한 프로퍼티나 메소드가 있는 protocol 을 채택해서는 안된다.

 

불필요한 프로퍼티나 메소드가 있는 protocol 을 채택해서 dummy 로 구현했을 때,

기능이 고도화되면서 어떤 사이드 이펙트가 발생할 지 모르기 때문.

 

아래처럼 상호작용 전체를 커버하는 fat 인터페이스를 만들면

Tap 만 가능한 class 는 dummy 메소드들이 발생한다.

protocol Interactable {
    func didTap()
    func didScroll()
    func didWrite()
}

class MyButton(): UIButton, Interactable {
    func didTap() { print("didTap") }
    
    func didScroll() {} // dummy
    func didWrite() {} // dummy
}

 

딱봐도 코드에서 냄새가 날 뿐더러,

"5글자 이상 Write 되었을 때, Tap 할 수 있게 해주세요" 같은 추가 요구사항을 처리하다가,

Tap 만 할 수 있는 버튼은 전부 Tap 할 수 없게 작업하는 실수를 할 수 있다.

 

또한, 불필요한 프로퍼티나 메소드를 대충 dummy 처리하여,

상위 개념의 형질을 거부하게 되면 LSP 도 위반하게 된다.

 

ISP 를 만족하려면, 아래와 같이 상세하게 나눠야한다.

protocol Tapable {
    func didTap()
}

protocol Scrollable {
    func didScroll()
}

class MyButton(): UIButton, Tapable {
    func didTap() { print("didTap") }
}

 

 

 

DIP : 의존 역전 원칙


  • 고차원 모듈이 저차원 모듈에 의존해서는 안된다.
  • (의존하지 않기 위해) 구체화된 것이 아닌 추상 모델에 의존하라.
  • 사용하는 쪽에서 구현체가 아닌 추상화(protocol)에 의존해야하는 원칙이므로, 클래스를 만들땐 protocol을 만든 후 구현을 지향
더보기

부모에게 자식의 의존성을 주입할 때, 인터페이스를 만들라는 말이다.

 

자동차 class 에 타이어 프로퍼티를 넣을 때,

구체화된 겨울타이어로 넣으면, 자동차가 겨울타이어에 의존하게 된다.

 

의존하지 않게 타이어라는 인터페이스를 만들어라.

protocol 자동차 {
    var 타이어: 타이어
}

protocol 타이어 {}
class 산악용타이어: 타이어 {}
class 여름타이어: 타이어 {}
class 겨울타이어: 타이어 {}

 

 

 

 

 

Reference


 

 

 

GitHub - Tedigom/Swift: Swift Self-study

Swift Self-study. Contribute to Tedigom/Swift development by creating an account on GitHub.

github.com

 

[Swift/iOS] 객체지향 설계 원칙 - SOLID

리뷰어 붱이와 함께한 SOLID! SOLID는 "클린 소프트웨어", "클린 코드", "클린 아키텍처"라는 책의 저자인 로버트.C마틴이 객체 설계를 할 때 중요하게 생각하는 것으로 제시한 원칙 다섯 가지이다!!

velog.io

 

 

[iOS] SOLID 원칙 in Swift

SOLID (객체 지향 설계) SOLID란? 객체 지향 프로그래밍 및 설계의 다섯가지 기본 원칙입니다. SOLID 원칙을 지킴으로써 유지보수가 쉽고, 유연하고, 확장이 쉬운 소프트웨어를 만들 수 있습니다. 약

hellozo0.tistory.com

 

 

SOLID 원칙을 Swift 코드로 이해해보기

Robert C. Martin의 Clean Architecture 3부 '설계 원칙' 7-11장을 읽고 메모한 글입니다. 요약글은 아닙니다. 개인적인 의견도 포함되어 있으니 참고해주세요. 모든 예제코드는 Swift로 작성되었습니다. SOLID

wlaxhrl.tistory.com

 

Comments