[iOS] SOLID 원칙 (feat. Swift로 알아보는..)

 코딩을 공부하다 보면 객체지향이라는 것을 알게 되면서 SOLID 원칙을 만나게 된다. SOLID 원칙은 여러 면접에서도 물어보는 질문이기도 하지만 객체지향 프로그래밍을 더 잘하기 위해 공부해 두면 좋기에 이렇게 정리해 본다.

 

 

 

SOLID 원칙이란??

 

 SOLID란 객체 설계에 필요한 5가지 원칙으로써 유지보수가 쉽고, 유연하고, 확장이 쉬운 소프트웨어를 만들기 위한 원칙이다. 즉, 객체지향 설계에 더 좋은 아키텍처를 설계하기 위해 지켜야 하는 원칙들로 5가지 원칙의 앞글자를 따서 정리한 단어이다.

 

  1. SRP(단일 책임 원칙)
  2. OCP(개방 폐쇄 원칙)
  3. LSP(리스코프 치환 원칙)
  4. ISP(인터페이스 분리 원칙)
  5. DIP(의존관계 역전 원칙)

 

지금도 충분한데, 알아야 하나..?

 

 이런 의문이 든다면, 당신은 아직 구현만 경험한 개발자이거나 작은 서비스만 운영하던 개발자일 수도 있다. 물론 개발을 할 때 당장 기능을 구현하는 것도 중요하다. 하지만 어떤 기능이 추가되거나 유지보수가 필요할 때, 관련된 코드를 모두 고쳐야 하는 상황 등은 피할 수 있다면 좋지 않을까?

 

 즉 SOLID 원칙을 적용하여 설계한다면

  1. 재사용과 유지관리가 쉬운, 변경에 유연한 코드를 가지게 됨 -> 높은 확장성, 유지보수성
  2. 높은 응집력과 낮은 결합도
  3. Swift의 Foundation, UIKit 등 코코아터치 프레임워크들은 기본적으로 OOP에 근간을 두고 설계되어 이를 이해하는 것이 곧 코드의 품질을 높이는 길...

 

 

결합도와 응집도

 

 위 설명 중에서 결합도응집도라는 키워드가 나왔는데 간단하게 알아보자면,

 

결합도는 서로 다른 모듈 간의 상호 의존하는 정도 또는 연관된 관계를 뜻한다.

그래서 만약 결합도가 높다면 모듈간의 의존하는 정도가 크기 때문에 코드를 수정할 때 다른 모듈에 영향을 끼칠 수도 있다.

또 오류가 발생했을 때 다른 모듈에 영향을 끼치거나 다른 모듈의 영향을 받아 오류가 발생할 수도 있다.

 

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

모듈이 얼마나 독립적으로 되어있는 지를 나타내는데, 만일 응집도가 높다면 수정을 할 때나 오류가 발생했을 때 하나의 모듈 안에서 처리할 수 있다.

 

 

 

 

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

 

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

 

하나의 클래스가 많은 메서드 및 역할을 가지게 된다면 클래스 내부 함수끼리 강한 결합이 발생할 수 있다.

그래서 클래스 내에서 수정 및 기능 추가를 할 때, 가독성 저하 및 유지보수 비용이 증가하는 등의 문제가 발생하게 된다.

 

이러 문제를 예방하기 위해 각 클래스 별로 책임을 적절하게 나눠서

응집도는 높고 결합도는 낮은 프로그램을 설계하는 것이 단일 책임 원칙이다.

 

그런데 클래스 별로 책임을 적절하게 나눈다라는 문장을 보고 떠오르는 게 있을 것이다.

UIKit으로 앱을 만들 때 ViewController에 각종 비즈니스 로직, 뷰 객체, 네트워크 통신, 데이터 레포지토리 등을 쓰는 경우가 있다. 이런 경우가 바로 SRP의 가장 대표적인 위반 사례인 Massive View Controller 현상이다.

 

보통 MVC 패턴에서 나타나는 문제로 이를 해결하기 위해 다양한 패턴들이 나타나고 있는데,

해결하는 방식이 너무 많은 역할을 하고 있는(책임을 가지고 있는) 뷰컨트롤러를 쪼개서 단일 책임만을 가진 여러 클래스를 만들려고 하는 것이다.

 

 

간단한 코드로 예를 들자면

class BookStore {
    func manage() {
    	lent()
        sell()
        deliver()
    }
    
    private func lent() {
    	// 
    }
    
    private func sell() {
    	// 
    }
    
    private func deliver() {
    	// 
    }    
}

 

위와 같은 코드가 있다고 가정해 보자. 이 코드에서는 stock, sell, deliver 역할까지 Market이 모두 관리하고 있다. 이 코드를 각각의 역할을 하는 클래스를 만들어 나눠보자.

 

class BookStore {
    let lentManager: LentManager
    let sellManager: SellManager
    let deliveryManager: DeliveryManager
    
    init(lentManager: LentManager, sellManager: SellManager, deliveryManager: DeliveryManager) {
        self.lentManager = lentManager
        self.sellManager = sellManager
        self.deliveryManager = deliveryManager
    }
    
    func manage() {
        lentManager.lent()
        sellManager.sell()
        deliveryManager.deliver()
    }
}

class LentManager {
    func lent() {
        // 
    }
}

class SellManager {
    func sell() { 
        // 
    }
}

class DeliveryManager {
    func deliver() {
        // 
    }
}

 

각각 다른 역할을 하는 클래스를 만들어 책임을 나누었다. 하지만 위처럼 코드를 짜면 의존성 역전 원칙에 위반하게 된다. 

 

프로토콜을 사용해 의존성 역전을 해주자면

(이 부분은 의존성 역전 법칙(DIP)에 대한 설명을 이해하고 와서 봐도 된다.)

 

class BookStore {
    let lentManager: Lentable
    let sellManager: Sellable
    let deliveryManager: Deliverable
    
    init(lentManager: Lentable, sellManager: Sellable, deliveryManager: Deliverable) {
        self.lentManager = lentManager
        self.sellManager = sellManager
        self.deliveryManager = deliveryManager
    }
    
    func manage() {
        lentManager.lent()
        sellManager.sell()
        deliveryManager.deliver()
    }
}

class LentManager: Lentable {
    func lent() {
        // 
    }
}

class SellManager: Sellable {
    func sell() { 
        // 
    }
}

class DeliveryManager: Deliverable {
    func deliver() {
        // 
    }
}

protocol Lentable {
    func lent()
}

protocol Sellable {
    func sell()
}

protocol Deliverable {
    func deliver()
}

 

위와 같은 코드를 완성할 수 있다.

 

 

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

 

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

 

클래스의 기능을 확장하는 것은 쉬워야 하되, 기존에 구현되어 있는 것들을 바꾸지 않고 클래스를 확장할 수 있어야 한다는 것이다.

바꿔 말하면, 요구사항의 변경이나 추가사항이 생기더라도 기존 구성요소는 수정하지 않고, 새로 동작하는 기능에 대해서만 코드가 작성이 돼야 한다.

 

어떤 방식으로 이 원칙을 지킬 수 있을까? 바로 추상화를 통해 이루어질 수 있다. 스위프트에서는 추상화가 주로 Protocol을 통해 이루어진다. 그래서 프로토콜을 이용해 구현한다면 프로토콜을 구현하고 있는 객체를 외부에서 주입하면 되기 때문에 변경사항이 있어도 대응이 쉬워진다.

 

그럼 이를 위반하는 대표적인 사례는 무엇일까? 바로 어떤 타입에 대한 반복적인 분기문이다. 즉, 하나의 enum에 대해 여러 곳에서 반복적으로 if/switch 문을 쓰고 있는 경우를 말한다. 이 경우 기능 추가는 case를 한 줄 추가하면 되지만, enum을 스위칭하고 있는 모든 코드 역시 찾아 수정을 해줘야 한다. 해결법으로는 if/switch를 최대한 안 쓰는 방법이 있다.

 

예시로는

enum Drink {
    case cola
    case ade
    case juice
    
    var name: String {
        switch self {
        case .cola:
            return "cola"
        case .ade:
            return "ade"
        case .juice:
            return "juice"
        }
    }
}

class VendingMachine {
    let drink: [Drink] = []    
    
    func sell(drink: Drink) {
        print(drink.name)
    }
}

 

이런 코드가 있을 때, Drink에 sprite를 추가하면 기존 코드를 수정하여야 한다.

 

하지만 이를 추상화하면

protocol Drink {
    var name: String { get }
}

class Cola {
    let name = "cola"
}

class Ade {
    let name = "ade"
}

class Juice {
    let name = "juice"
}

class VendingMachine {
    let drink: [Drink] = []    
    
    func sell(drink: Drink) {
        print(drink.name)
    }
}

 

sprite라는 음료를 추가해도 기존 코드를 수정할 필요가 없게 된다.

 

 

 

 

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

 

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

 

자식 클래스를 구현할 때, 기본적으로 부모 클래스의 기능이나 능력들을 물려받는다. 

이때 자식 클래스는 동작을 할 때 부모 클래스의 기능들을 제한하면 안 된다는 것이다.

즉, 부모 클래스의 타입에 자식 클래스의 인스턴스를 넣어도 똑같이 동작하여야 한다.

 

만일 자식 클래스가 부모 클래스의 기능을 오버라이딩해서 기능을 변경하거나 제한하는 경우엔 LSP 원칙을 위반한 것이다.

이를 위반한 대표적인 사례는 직사각형을 상속받아서 만든 정사각형 클래스이다.

 

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

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

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

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

 

이 코드에서는 정사각형 클래스가 직사각형 클래스를 상속받았지만, didSet 부분에서 부모의 프로퍼티를 오버라이딩을 하여 변경하고 있기 때문에, 자식 인스턴스를 생성해서 똑같은 메서드에 넣으면 다른 결괏값이 나오게 된다. 이 경우가 LSP 원칙을 위반한 사례라고 할 수 있다.

 

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 변의길이 * 변의길이
  }
}

 

그래서 LSP를 위반하지 않기 위해 사각형이라는 Protocol을 만들고, 직사각형과 정사각형은 이 프로토콜을 채택한 후, 실제 구현부를 클래스에게 넘기는 형태로 설계를 하였다.

 

하지만 실제로 LSP를 절대 어기지 않고 프로그래밍을 한다는 것은 어려운 일이다. 그러나 너무 많이 LSP를 위반한다면 문제가 발생한다. 

예를 들어 UITableView는 UITableViewCell을 기준으로 만들어져 있기 때문에, 우리가 만든 커스텀 셀이 LSP를 지키지 않는다면 테이블뷰도 제대로 동작하지 않을 것이다. 

 

즉, 상속은 객체 지향 프로그래밍의 중요한 부분이고 잘 쓰면 유용하지만 잘못된 상속을 만들면 문제가 발생할 수 있다. 반면 때에 따라서는 LSP를 위반함으로써 편리함과 단순함을 얻을 수 있다. 따라서 개발자가 LSP를 다 지키려고 하지 말고 그렇다고 지나치게 위반도 하지 않는 정도를 판단해야 한다.

 

 

 

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

 

 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 원칙

 

이 원칙은 인터페이스를 설계할 때, 굳이 사용하지 않는 인터페이스를 채택해 구현하지 말고 한 가지의 기능만을 가지더라도 사용하는 기능만을 가지는 인터페이스로 분리하라는 것이다.

 

프로토콜을 설계하다 보면 여러 객체를 고려해 다양한 메서드가 들어가는 경우가 있다. 하지만 몇몇 개의 메서드는 어떤 객체에서 필요하지 않은 경우가 있다. 이 경우 ISP 원칙을 위반했다고 보는 것이다. 해결법으로는 프로토콜을 더욱 상세하게 분리하면 된다.

 

안 좋은 예부터 보자면

protocol Movable {
    func run()
    func fly()
    func ride()
}

class Person: Movable{
    func run() { }
    func ride() { }
    
    // 사용하지 않음
    func fly() { }
}

class Bird: Movable {
    func fly() { }
    
    // 사용하지 않음
    func run() { }
    func ride() { }
}

 

사용하지 않는 메서드도 포로토콜 채택으로 인해 구현을 하였다. 

 

이를 해결하기 위해 프로토콜을 더 세분화해서 분리해 본다면

protocol Running {
    func run()
}

protocol Flying {
    func fly()
}

protocol Ridable {
    func ride()
}

class Person: Running, Ridable {
    func run() { }
    func ride() { }
}

class Bird: Flying {
    func fly() { }
}

 

위 코드와 같이 프로토콜을 분리하고, 필요한 것만 채택해서 사용할 수 있다.

 

 

 

5. DIP(Dependency Inversion Principle) - 의존성 역전 원칙

 

 상위 모듈이 하위 모듈에 의존하면 안 되고 두 모듈 모두 추상화에 의존하게 만들어야 한다는 원칙

 

클래스 사이에는 의존관계가 존재할 수밖에 없다. 하지만 의존 관계가 존재하되, 직접적으로 구체적인 클래스끼리 의존하지 말고 그 사이에 추상화된 인터페이스를 활용해 의존하라는 것이다.

 

이 원칙을 적용하여 설계하면 재사용에도 유용하고 하나를 수정하더라도 수정해야 하는 사항이 적어지게 된다.

그리고 DIP 원칙은 Unit Test를 할 때도 중요하다!!

 

예를 들어보자면,

class Movie {
    let ticket = Ticket()
}

class Ticket {
    var price: Int = 10000
}

let movie = Movie()
print(movie.ticket.price) // 10000

 

위와 같이 코드를 짜면 Ticket 클래스의 인스턴스를 Movie 클래스가 프로퍼티로 가지고 있게 되면서 Movie 클래스가 Ticket 클래스에 의존성이 생기게 된다.

 

하지만 의존성 주입을 한다면

class Movie {
    let ticket: Ticket
    init(ticket: Ticket) {
    	self.ticket = ticket
    }
}

class Ticket {
    var price: Int = 10000
}

let ticket = Ticket()
let movie = Movie(ticket: ticket)
print(movie.ticket.price) // 10000

 

내부에서 인스턴스를 생성하는 것이 아니라 외부에서 인스턴스를 생성 후 넣어줄 수 있다.

 

하지만 위의 코드에서는 Movie 클래스의 생성자에서 여전히 Ticket 클래스 타입만 받을 수 있게 되었다.

이 부분 역시 분리해 주면

protocol Price {
    var price: Int { get set }
}

class Movie {
    let ticket: Price
    init(ticket: Price) {
    	self.ticket = ticket
    }
}

class Ticket: Price {
    var price: Int = 10000
}

let ticket = Ticket()
let movie = Movie(ticket: ticket)
print(movie.ticket.price) // 10000

 

 

의존성 역전 원칙에 따라 추상화에 의존하게 만들어보면, 두 개의 클래스가 추상화된 프로토콜(Price)에 의존함으로써 두 클래스의 의존 관계는 독립적이게 된다.

 

 

 

 

참고