SOLID 원칙

고영민·2023년 9월 10일

SOLID란 무엇일까?

객체지향 설계에 더 좋은 아키텍쳐를 설계하기 위해 지켜야하는 원칙들이며, 5가지로 구성된다.

SRP (단일 책임 원리)
OCP (개방폐쇄의 원칙)
LSP (리스코브 치환의 법칙)
ISP (인터페이스 분리의 법칙)
DIP (의존성 역전의 원칙)

SOLID 원칙을 지킴으로써 기대할 수 있는 점은,
유지보수가 쉽고, 유연하고, 확장이 쉬운 소프트웨어를 기대할 수 있다!

결합도

서로 다른 모듈간의 상호 의존하는 정도 또는 연관된 관계

  • 결합도가 높으면
  • 모듈간의 의존하는 정도가 크기 때문에
  • 다른 모듈에 영향 → 오류가 생길 때도 다른 모듈에 영향을 줌

⇒ 고로, 결합도는 낮을수록 좋다!

응집도

모듈 내부의 요소들 간의 기능적 연관성을 나타내는 척도

  • 모듈이 얼마나 독립적으로 되어있는 정도
  • 수정이나 오류가 발생했을 때 하나의 모듈 안에서 처리

⇒ 응집도 또한 낮을수록 유지보수가 용이해짐!

SRP (단일 책임 원리)

클래스나 함수를 설계할 때, 각 단위들은 단 “하나의 책임”만을 가져야 한다

  • 하나의 클래스가 많은 메서드 및 역할을 가지게 된다면, 클래스 내부 함수끼리 강한 결합이 발생함
  • 클래스 내에 수정 추가가 진행될 때 → 가독성 저하 + 유지보수 비용 증가

⇒ 각 클래스 별로 책임을 적절히 나눠서 응집도를 높이고 결합도를 낮추는 프로그램 설계

  • MVC 패턴 → ViewController는 결합도와 응집도 측면에서 모여있습니다.
  • MVVM 패턴 → 코드 분리: 뷰와 모델 간에 컨트롤러가 존재하지 않아 뷰와 모델이 느슨하게 결합되어 있습니다.
💡 ✅ 체크 포인트

‼️ 클래스의 인스턴스 변수가 너무 많다.

‼️ 속성과 상관없는 메소드가 많다.

‼️ 클래스나 메소드를 설명하기 위해 ‘and’, ‘if’, ‘or’을 많이 사용한다.

class SRPViewController {
    
    private func login(id: String, password: String) {}
        
    private func requestLogin() {
        // Call API
    }
        
    private func decodeUserInfo(data: Data) -> User {
        // Decoding User Inform from Data
        return User(name: "", age: 10)
    }
        
    private func saveLocalData() {
        // Local Data
    }
}

위에 예시는 SRPViewController가 너무 여러가지 역할을 동시에 진행하는 모습이 보인다.
→ 즉 클래스 내부에서 하나의 책임이 아닌 여러가지 책임을 동시에 지고 있는 SRP를 위반한 사례!

class SRPViewController {
    
    let loginManager: LoginManager
    let userManager: UserManager
    let localManager: LocalManager
    
    init(loginManager: LoginManager,
         userManager: UserManager,
         localManager: LocalManager) {
        self.loginManager = loginManager
        self.userManager = userManager
        self.localManager = localManager
    }
    
    func handler() {
        
        loginManager.requestLogin()
        loginManager.login(id: "", password: "")
        
        userManager.decodeUserInform(data: Data)
        localManager.saveLocalData()
    }
}

class LoginManager {
    
    func login(id: String, password: String) {}
    func requestLogin() {
        // Call API
    }
}

class UserManager {
    
    func decodeUserInform(data: Data) -> User {
            // Decoding User Inform from Data
            return User(name: "", age: 10)
    }
}

class LocalManager {
    
    func saveLocalData() {
        // Local Data
    }
}

LoginManager, UserInfoManager, LocalManager의 각각 클래스가 네이밍에 맞게 역할과 책임을 가지고, SRPViewController의 역할과 책임을 분리 → 결합도가 낮아진다!

⇒ 습관적으로 특정 클래스 내에 있는 내용이 꼭 이곳에 있어야 하는지 끊임없이 의심
⇒ 의식적으로 메서드나 클래스의 내용을 최대한 간결하게 처리
⇒ but. 지나친 추상화는 오히려 가독성과 유지보수를 해치기에 → 어느 레벨까지 처리할지 고민!

  • 10-200?
    • 함수는 10줄 이내, 클래스는 200줄 이내로 만드는 것이라고 합니다.
    • ‘클래스는 200줄’ 룰을 지키려면 프로퍼티나 함수, 메서드를 그냥 손이 가는 곳 아무데나 만들어서 쓸 수 없다. 꼭 얘가 이 곳에 있어야 하는 이유를 찾아야 한다. 해당 클래스에 있을 이유가 없으면 SRP 위반이다. 가장 쉽게 판단할 수 있는 방법 하나는, 함수나 메서드 내부에서 self의 프로퍼티나 메서드를 얼마나 쓰고 있는지 보는 것이다. 만약 하나도 쓰고 있지 않다면 그 클래스 안에 있을 이유가 전혀 없는 것이다. 또는 self의 호출 빈도가 적을수록 클래스와 연관성이 떨어지는 것이니 나중에 클래스를 리팩토링 하거나 다이어트 시켜야 할 상황이 오면 우선적으로 내쫓을 후보가 되는 것이다.

OCP (개방폐쇄의 원칙)

확장에는 열려있으나, 변경에는 닫혀있어야 한다.
→ 기존의 코드를 변경하지 않으며(closed) 기능을 추가할 수 있도록 설계 해야 한다(open)

⇒ 클래스의 본질적인 특징과 확장되는 부분에 대한 차이가 명확해야 한다.

💡 ✅ 체크 포인트

‼️ 새로운 기능이나 케이스가 추가될 때마다 기존의 코드를 수정해야한다.

‼️ 자신의 속성보다는 외부의 속성을 의존하고 있지 않은가?

‼️ 인터페이스보다는 구현한 타입에 의존하고 있지 않은가?

아래 예시를 통해서 조금 더 필요성을 알아봅시다!

게임캐릭터 1 클래스가 있고 해당 캐릭터는 앞구르기와 공격하기 메서드가 있담!

근데 아래와 같은 두가지 상황이 일어난다면?

  1. 앞구르기 → 뒷구르기로 변경해주세요
  2. 신규 게임캐릭터2를 추가해주세요!

앞구리기 → 뒷구르기로 변경하니, 외부에서 호출하는 attack 메서드에 오류가 생깁니다!

게임캐릭터2를 추가하면 → 기존 attack 메서드는 사용하지 못하고, 불필요한 메서드만 늘어납니다

즉 OCP를 위반하게 되면

  • 클래스를 수정하면 메서드를 계속 수정해야 됨(상황 1)
  • 비슷한 형태의 메서드가 계속 추가됨 (상황 2)

⇒ 클래스의 본질적인 특징 + 확장되는 부분을 구분해야 한다

어떤식으로 바뀔 수 있을까?

  • 각각 캐릭터별 스킬 2개 Q,W 버튼을 눌렀을 때 반응을 한다고 가정하며
    • Q, W 버튼을 눌렀을 때 동작 → 절대 변하지 않는 부분 ⇒ Protocol을 사용해 인터페이스화
    • 버튼이 눌렀을 때 어떤 동작을 하는지 → 계속 확장되는 요소 ⇒ Protocol 채택, 클래스 내 직접 구현
protocol 캐릭터 {
		func QPressed()
		func WPressed()	
} // 절대 변하지 않는 부분

class 게임캐릭터1: 캐릭터 {
		func QPressed() {
				앞구르기() // 확장되는 부분
		}
		func WPressed() {
				공격하기() // 확장되는 부분
		}

		private func 앞구르기() {}
		private func 공격하기() {}
}

LSP (리스코브 치환의 법칙)

부모 클래스가 동작하는 곳에서, 자식 클래스 인스턴스를 넣어줘도 대체가능 해야한다.

💡 ✅ 체크 포인트

‼️ 자식클래스에 너무 많은 override가 구현되어 있다.

‼️ 수직적 확장과 수평적 확장 중 어느 것이 필요한 상황인지 생각해본다.

‼️ 상속을 하면 강한 결합도가 생기기 때문에 주의 !

⇒ 자식 클래스가 부모 클래스의 기능을 오버라이딩해서 기능을 변경하거나 제한하는 경우 다르게 나오면 위반!

class 직사각형 {
    
    var 너비: Float = 0
    var 높이: Float = 0
    
    var 넓이: Float = 0 {
        didSet {
            return 너비 * 높이
        }
    }
}

class 정사각형: 직사각형 {
  override var 너비: Float {
    didSet {
      높이 = 너비
    }
  }
}

func printArea(of 직사각형: 직사각형) {
  직사각형.높이 = 5
  직사각형.너비 = 2
  print(직사각형.넓이)
}

let rectangle = 직사각형()
printArea(of: rectangle) //10
let square = 정사각형()
printArea(of: square) //4

위에 코드를 보면

  • 정사각형 클래스는 직사각형 클래스를 상속받아서 사용하지만
  • 예측한 결과값이 나오지 않는다 → 오버라이딩을 할 필요가 없다!

어떻게 수정해야 될까?

  • protocol로 넓이를 구현하고
  • 실제 구현부를 클래스에게 넘기는 형태로 설계한다.
protocol 사각형 {
  var 넓이: Float { get }
}

class 직사각형: 사각형 {
  private let 너비: Float
  private let 높이: Float
  
  init(너비: Float, 높이: Float) {
    self.너비 = 너비
    self.높이 = 높이
  }
  
  var 넓이: Float {
    return 너비 * 높이
  }
}

class 정사각형: 사각형 {
  private let 변의길이: Float
  
  init(변의길이: Float) {
    self.변의길이 = 변의길이
  }
  
  var 넓이: Float {
    return 변의길이 * 변의길이
  }
}

but. LSP를 “절대” 어기지 않고 프로그래밍 하는것은 어렵다

→ 지나친 LSP는 비효율성을 얻고, 지나친 LSP 위반은 안정성을 잃는다!

ex) BaseViewController의 역할 등에서 많이 고민을 해봐야겠죠?

ISP (인터페이스 분리의 법칙)

사용하지 않는 인스턴스는 구현하지 말아야 한다.

  • Swift에서 프로토콜을 설계하다보면 다양한 메서드가 들어가고 → 몇몇개의 메서드가 필요하지 않는 경우
    ISP 원칙을 위반했다고 본다
  • 결국, 프로토콜 뭉치를 더욱 더 상세하게 분리할 필요가 있는 것이다.
  • 프로토콜을 경우에 맞게 더 분리하면, 낭비하는 메서드가 없게 된다.
  • but. 얼마나 상세하게 분리할지는 프로젝트 복잡성이나 유지보수 측면에서 고민해 볼 관점.
@objc
protocol GestureProtocol {
    func()
    @objc optional func 꾸욱누르기()
    @objc optional func 더블탭()
}

class GestureBtn: GestureProtocol {
    func() {}
    func 꾸욱누르기() {}
    func 더블탭() {}
}

class DoubleTapBtn: GestureProtocol {
    func() {}
    
    //사용하지 않는 함수
    func 꾸욱누르기() {}
    func 더블탭() {}
    
}

위에 코드는 DoubleTapBtn 클래스에서 꾸욱누르기, 더블탭을 구현하지 않았기에 ISP를 위반했다고 볼 수 있죠.

protocol TapGestureProtocol {
  func()
}

protocol LongTapGestureProtocol {
  func 꾸욱누르기()
}

protocol DoubleTapGestureProtocol {
  func 더블탭()
}

class GestureBtn: TapGestureProtocol, LongTapGestureProtocol, DoubleTapGestureProtocol {
  func() {}
  func 꾸욱누르기() {}
  func 더블탭() {}
}

class DoubleTapBtn: GestureProtocol {
  func 더블탭() {}
}

class LongAndTapBtn: LongTapGestureProtocol, TapGestureProtocol {
  func() {}
  func 꾸욱누르기() {}
}

func doSomething(button: DoubleTapGestureProtocol & LongTapGestureProtocol) {
  button.더블탭()
  button.꾸욱누르기()
}

그래서 이런식으로 하나씩 다 나눠주면 되는데, 뭐 상황에 따라 잘 조절하면 된다!

DIP (의존성 역전의 원칙)

상위 모듈은 하위 모듈을 의존하고 있으면 안되고, 두 모듈 모두 추상화에 의존한다.

  • 클래스 사이에 의존관계는 존재할 수 밖에 없음
  • but 구체적인 클래스끼리가 아닌, 그 사이에 최대한 추상화된 인터페이스를 활용해 의존
✅ 체크 포인트

‼️ 내부적으로 생성하는 하위 모듈이 존재하는가? (주입)

‼️ 상위레벨 모듈이 재사용 가능한가?

‼️ 하위레벨 모듈의 구체적인 타입이 존재하는가?

Before: 스마트폰이 늘어날 수록 유지보수가 어려워짐

After: 사용자는 스마트폰을 의존하고, 나머지 아래 기종들을 스마트폰 프로토콜을 구현하는 형태로 설계

class Order {
    let menu = Menu()
}

class Menu {
    var price: Int = 1000
}

let order = Order()
print(order.menu.price) // 1000 }
}

위에 코드에서 의존성을 주입하고

class Order {
    let menu: Menu
    init(menu: Menu) {
        self.menu = menu
    }
}

class Menu {
    var price: Int = 1000
}

let menu = Menu()
let order = Order(menu: menu)
print(order.menu.price)

의존성 역전 원칙에 따라 의존성을 분리합니다

  • 바로 Price를 추상화된 프로토콜에 의존함으로 독립적으로 바꿔주는거죠!
protocol Price {
    var price: Int { get set }
}

class Order {
    let menu: Price
    init(menu: Price) {
        self.menu = menu
    }
}

class Menu: Price {
    var price: Int = 1000
}

let menu = Menu()
let order = Order(menu: menu)
print(order.menu.price)

출처:https://github.com/i-colours-u/Design-Pattern-In-Swift/blob/main/contents/1-SOLID.md

profile
iOS Developer 나의 언어로 기술을 기록하기

0개의 댓글