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

Youngwoo Lee·2021년 3월 28일
3

iOS

목록 보기
3/46
post-thumbnail

리뷰어 붱이와 함께한 SOLID!

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

그럼 그 다섯 가지의 원칙이 무엇인지 확인해보자!!!

1. SRP (Single-Responsibility Principle)
2. OCP (Open-Close Principle)
3. LSP (Liskov Substitution Principle)
4. DIP (Dependency-Inversion Principle)
5. ISP (Interface-Segregation Principle)

  1. 소프트웨어 요소(클래스, 함수 등)는 응집도 있는 하나의 책임을 갖는다. 클래스를 변경해야 하는 이유는 단지 - 응집도여야 한다.

  2. 소프트웨어 요소는 확장 가능하도록 열려있고, 변경에는 닫혀있어야 한다. 새 기능을 추가할 때 변경하지 말고 새 클래스나 함수를 만들어라

  3. 서브타입은 (상속받은) 기본 타입으로 대체가능해야 한다. 자식 클래스는 부모 클래스 동작(의미)를 바꾸지 않는다.

  4. 상위레벨 모듈은 하위레벨 모듈에 의존하면 안된다. (둘 다 추상화된 인터페이스에 의존해야 한다.)

  5. 클라이언트 객체는 사용하지 않는 메소드에 의존하면 안된다.


솔직히 정확히 어떤 말인지 와닿지 않는다. 매우 추상적인 개념이다... 근데 우리는 다섯 가지 원칙에 대해서 공부하기 앞서, 우리가 추구하고자 하는 가치가 무엇인지 알아야한다. 그럼 일단, 그 가치가 무엇인지 알아보자!!



추구해야 하는 가치

1순위 가독성, 커뮤니케이션

  • 개발자는 코드를 통한 커뮤니케이션
  • 읽고 이해할 수 없는 코드가 더욱 가치가 없다.

2순위 단순성

  • 코드는 단순해야 한다.
  • 커뮤니케이션에 도움이 된다.
  • 버그가 생길 틈이 없어짐
  • 미래의 확장을 위한 복잡한 패턴은 경계의 대상

3순위 유연성

  • 기존의 코드를 수정하는 데에 많은 시간을 소비함
  • 유연성과 단순성은 trade off
    그리고 "구현 패턴"을 쓴 저자 켄트 의 말을 빌리자면 "가치는 원칙보다 높은 수준의 개념이다", "원칙은 가치를 지키기위해서 존재해야 한다."

3가지의 가치는 서로 하나의 목표만을 바라보는데 그것은 바로 시간이라고 한다!. 프로그래밍 세계에서 비용이란? 즉 비용==시간 이다.

개발에 있어서 가독성, 단순성, 유연성은 모두 개발, 리팩토링하는데 있어 시간을 줄여주기 때문에 매우 중요한 가치이다


근데 유연성과 단순성이 trade off라면?? 유연성은 언제 챙겨??

처음엔 단순하게!!
=> 기획 또는 정책의 변경이 발생!!
=> 이 기획 또는 정책의 변경의 원인은 무엇일까? 그리고 이러한 변경은 계속 발생 가능한 일일까?
=> 리팩토링을 통해 확장성을 고려해 변경해 보자!!


<주객전도 주의🚫>
그래서 우리는 이러한 가치를 추구하기 위해서 굵직한 다섯 가지 원칙을 정해둔 것인데, 이러한 가치를 추구하는 것이 중요하지, 무조건 원칙을 지켜야 한다는 것은 아니다. 목표가 원칙이 되면 안된다는 것이다.

silver bullet : 소프트웨어 공학 분야에 있어서는 프레더릭 브룩스가 1986년에 발표한 논문에 No Silver Bullet 이라는 말을 사용, 모든 문제에 통용되는 만능 해결책 따위는 존재하지 않는다고 논하였는데, 이는 이상적인 소프트웨어 설계에 대해 부정적인 의미로 사용되는 경우가 많다.

ref) 위키백과


이제 가치들에 대해서 알아보았으니, 이러한 가치들을 최대한 추구하기 위해서 지키도록 노력해야 하는 원칙들에 대해서 알아보겠습니다!



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

소프트웨어 요소(클래스, 함수 등)는 응집도 있는 하나의 책임을 갖는다.

이 말을 설명하기에 적절한 예시가 있는데 바로 ViewController 이다

MVC 패턴에서 ViewController는 여러 책임을 가진다. 우리는 ViewController에서 네트워킹을 요청하기도, 요청한 데이터를 DB에 저장하는 작업을 하기도 하며, 여러 책임을 가진다.

그래서 이를 옳지 않다고 판단하는 개발자들은 Massive View Controller라고 하며 MVC의 문제점에 대해서 꼬집었다. 그 이후 MVVM, MVP 패턴이 나타난 것이고 이것들을 보면 기존 MVC에 비해서 컨트롤러의 책임을 줄여준 것을 알 수 있다.


아직 단일 책임에 대해서 이해가 안된다고?

그럼 책임을 "변경의 이유"라고 말해주고 싶다

위 사진을 보면 초록색으로 색칠된 부분을 볼 수 있을 것이다. 이 초록색은 하나의 단위 기능을 수정할 때 수정되는 코드를 표시한 것이다.

높은 응집도(낮은 결합도)의 경우는 하나의 책임을 하나의 객체가 가지고 있기 때문에 하나의 객체에 가득 색이 채우고 있다.

하지만, 반대로

높은 결합도(낮은 응집도)의 경우는 여러 부분에 분포된 수정 부분을 보여주고 있다. 또한 여러 책임을 하나의 객체가 가지고 있기 때문에 하나의 단위 기능을 수정하는데도 불구하고 하나의 객체 일부분만 수정되는 것을 볼 수 있다

응집도 : 관련성 있는 코드들이 얼마나 모여있는지
결합도 : 불필요한 의존성이 생겨 있는지

하나 더, 여러 책임을 하나의 객체가 가질 경우 생기는 문제에 대한 예시이다

여기처럼 구기 종목 스코어 계산기가 많은 종목을 책임지게 되면 문제가 생긴다. 왜냐하면 야구를 수정하기 위해 계산기를 수정하면 농구나 다른 종목이 문제가 생기기 때문이다.

그렇기 때문에 계산기에 대한 책임을 여러 방법을 통해 넘겨주어야 한다

그래도 이해가 안된다고???
그럼 코드를 보고 와보쟈!!!

import Foundation

struct 가위바위보 {
    func start() {
        var isContinue = true
        while isContinue {
            let 유저핸드 = [0,1,2].randomElement()!
            let 컴퓨터핸드 = [0,1,2].randomElement()!
            print("🧑🏻‍💻: \(유저핸드) ⚔️ 💻: \(컴퓨터핸드)")
            
            if (컴퓨터핸드 + 1) % 3 == 유저핸드 {
                print("👨🏻‍💻 유저가 이겼습니다.")
                isContinue = false
            } else if 유저핸드 == 컴퓨터핸드 {
                print("비겼습니다.")
            } else {
                print("💻 컴퓨터가 이겼습니다.")
                isContinue = false
            }
        }
    }
}

단일 책임 원칙을 지켜주기 위해서 변경

import Foundation

struct 가위바위보 {
	private let _심판관: 심판관
    func start() {
        var isContinue = true
        while isContinue {
            let 유저핸드 = [0,1,2].randomElement()!
            let 컴퓨터핸드 = [0,1,2].randomElement()!
            print("🧑🏻‍💻: \(유저핸드) ⚔️ 💻: \(컴퓨터핸드)")
            let 결과 = _심판관.승부하다(hand1: 유저핸드, hand2: 컴퓨터핸드)
            
            switch 결과 {
            case .win:
                print("유저가 이겼습니다.")
                isContinue = false
            case .draw:
                print("비겼습니다.")
            case .lose:
                print("컴퓨터가 이겼습니다.")
                isContinue = false
            }
        }
    }
}
    
class 심판관 {
    func 승부하다(hand1: 핸드, hand2: 핸드) -> 결과 {
        if (hand1.rawValue + 1) % 3 == hand2.rawValue {
            return .win
        } else if hand1 == hand2 {
            return .draw
        } else {
            return .lose
        }
    }
}

enum 결과 {
    case win
    case draw
    case lose
}

가위바위보().start()

제일 처음 코드에서는 하나의 객체안의 메서드에서 로직을 전부 처리해주었다!...
근데 이렇게 해주면 승부 결과를 도출해내는 것에 대해서만 테스트를 해주고 싶을 때 할 수 없다...

왜냐?? 하나의 로직으로 전부 묶여 있으니깐..ㅠㅠ

근데, 이렇게 클래스로 심판관을 만들어 준다면? 승부하다 에 대해서 로직을 테스트해줄 수 있다!!

👍🏼

할 수 있다면, "심판관" 처럼 컴퓨터핸드메이커, _유저핸드메이커, isContinue를 결정해주는 looper를 모두 다른 class로 만들어서 프로퍼티로 넣어주자!!

그럼 좀 더 작은 단위로 Test를 진행해볼 수 있을 것이다!



개방-폐쇄 원칙 (OCP: Open-Close-Principle)

확장에 열려있고 변경에 닫혀있다!!

즉, 확장을 할때는 기존의 코드를 최대한 건드리지 않고 확장하고,
만약 기존의 코드를 수정하게되면 연쇄적인 수정을 하지 않을 수 있게 하자

기존 코드의 수정은 버그가능성이 있고, 그걸 테스트해야한다

코드 레벨에서 이에 대한 설명을 잘 할 수 있는 예제가 있다

우리는 도형이라는 타입을 enum 혹은 protocol로 표현할 수 있으며, 구체적인 다각형을 case 혹은 protocol을 채택한 struct로 표현할 수 있다.

두 표현 모두 괜찮아 보이는데, 사실 두 표현은 수정을 하는데 있어서 큰 차이가 있다.

오각형을 추가한다고 하면, enum 같은 경우는 전반적인 코드에서 넓은 분포에서 수정을 해주어야 한다.

but, protocol의 경우 오각형 struct만 추가해주면 되어서 확장만 실시하면 된다.

즉, 개방-폐쇄 원칙은 protocol에서 더 잘 지켰음을 알 수 있다.

그러면, 무조건 protocol이 enum보다 좋아?? 그건 또 아니다

이렇게, 넓이라는 프로퍼티를 추가해야 되는 경우는 protocol보다 enum이 OCP를 더 잘 수행했음을 볼 수 있다.

결국, 원칙이라는 것은 지키는 방법이 정확히 있는 것이 아니라 프로그래머가 상황에 맞게, 미래에 어떤 것을 더 많이 추가할 것인지를 예측하고, 더 적절한 경우를 채택해야된다는 것을 볼 수 있다!!



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

"자식클래스는 부모 클래스로써의 역할을 완벽히 할 수 있어야한다"라는 원칙이다

즉, A가 B를 상속받았으면, B로도 역할을 완벽히 할 수 있어야 한다.

위 내용을 잘 보면, 우리도 알겠지만 정사각형은 직사각형이기도 하다.
그래서 정사각형은 직사각형을 상속받고 있다.

하지만 정사각형은 자신만의 성질을 지키기 위해서 override를 하였고, 부모의 성질을 받는 것을 거부하고 자신만의 성질을 가졌다

여기서 말해주고 싶은 것은 현실의 관념이 코드에서도 항상 똑같이 적용되는 것은 아니라는 것이다...ㅎ


사실 이런 과정들은 프로그래밍에 있어서 추상화가 굉장히 어려운 과정이기 때문에 생기는 것이다

그리고, 상속은 매우 가장 결합도를 가지므로 사용할때, 반드시 고민을 하고 사용하도록...



의존성 역전 원칙(DIP: Dependency Inversion Principle)

  • 상위 수준의 모듈은 하위수준의 모듈에 의존해서는 안된다
  • 구체적인 사항은 추상화에 의존해야 한다

이 내용은 많이 추상적이여서 어려운데... 쉽게 우리 생활에서 통하는 예를 들자면,

우리는 보통 컴퓨터를 만들 때 나중에 사용할 마우스에 대해 생각하며, 의존하며 만들어야 한다. 근데, 이렇게 되면 나중에 다른 종류의 마우스를 추가할 때 문제가 생길 수 있다

그래서, 우리는 그 중간에 매개체를 생성해서 의존성을 역전시킨다

바로 어뎁터를 만들고, 마우스, 컴퓨터 모두가 어뎁터를 의존하도록 하는 것이다!!

코드로 보자면,

컴퓨터에 연결된 기기로 마우스A라는 타입만 허용되게 한다면,

다른 마우스 타입은 컴퓨터에 연결할 수 없게 된다

But, 더 큰 상위단계의 protocol을 연결된 기기의 타입으로 의존하게 된다면?? 의존성이 역전되는 것을 볼 수 있다!!

아! 그리고 "구체적인 것은 잘 변하며, 추상적인건 잘 안 변한다!!" 라는 것을 생각하시며 DIP에 대해서 공부하시면 잘 이해가 될 것이다



인터페이스 분리 법칙(ISP: Interface Segregation Principle)

클라이언트가 불필요한 자신이 사용하지 않는 인터페이스에 의존하지 말아야한다.

  • 상속받은 메서드를 퇴화시켜하는 경우가 발생할 수 있음
  • 불필요한 인터페이에 의존하여 불필요한 빌드가 유발할 수 있음
    => 해결방안 : 큰 인터페이스를 작은 인터페이스들로 분리하고, 필요한 부분만 클라이언트가 취사선택하여 사용할 수 있게 해야됨

POP : Protocol Oriented Programming의 기초가 되는 원칙이다

protocol 움직일수있는 {
    func 달리기()
    func 날기()
}

class 참새: 움직일수있는 {
    func 달리기() {
        print("총총총")
    }
    func 날기() {
        print("훨훨")
    }
}

class 타조: 움직일수있는 {
    func 달리기() {
        print("타다다다다다")
    }
    func 날기() { }
}

위 코드를 보면 움직일수있는 프로토콜을 채택하였지만 타조클래스에서는 날기 메서드를 구현하지 않는 것을 볼 수 있다.

하지만,

protocol 날아다니는 {
    func 날기()
}

protocol 달릴수있는 {
    func 달리기()
}

protocol 움직일수있는: 날아다니는, 달릴수있는 { }

class 타조: 움직일수있는 {
    func 달리기() {
        print("타다다다다다")
    }
    func 날기() { }
}

이렇게 작은 프로토콜로 나누어서 필요한 부분만 클라이언트가 취사선택하게 한다면, ISP를 추구할 수 있게된다


마지막으로 이 강의를 해준 붱이는 "SOLID 법칙을 지키기 위해서 코드를 짜는 것은 정말 어렵고, 그리고 그것이 정말로 정답은 아니다"라고 해주었다.

다시 한번 원칙과 가치 중 어느 것이 더 우선되어야 하는지 생각하자!!

그리고 팁으로 붱이의 경우 TDD를 하면 할수록 SOLID를 저절로 적절하게 지켜지게 되었다고 하였다... TDD 기억하자!!

마지막으로 재밌는 강의 해주신 붱이 감사합니다ㅎㅎ

profile
iOS Developer Student

0개의 댓글