[Swift] SOLID 원칙에 대해 알아보자

Fezravien·2021년 10월 26일
1

Design Patterns

목록 보기
1/3
post-thumbnail

객체 지향 프로그래밍(OOP)과 SOLID 원칙

SOLID

SOLID는 로버트 마틴이 명명한 객체 지향 프로그램 및 설계의 다섯 가지 기본 원칙이다.

  • S : 단일 책임의 원칙 (SRP, Single Responsibility Principle)
  • O : 개방-폐쇄의 원칙 (OCP, Open-Closed Principle)
  • L : 리스코프 치환 원칙 (LSP, Liskov Substitution Principle)
  • I : 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)
  • D : 의존관계 역전의 원칙 (DIP, Dependency Inversion Principle)

좋은 객체 지향 설계를 하게되면 코드의 재사용, 확장, 유지보수의 용이성 등의 장점을 가질 수 있고,
이로 인해 개발 비용, 시간을 절약할 수 있다.

개발을 함에 있어서 코드는 유연한 확장, 유지보수, 재사용에 용이해야 한다!
이러한 OOP의 방식을 최대한 준수하기 위해 만들어진 것이 SOLID 원칙이다.

하지만, 이 원칙에 목매어 원칙을 위한 개발을 하는 것을 주의해야합니다.
우리는 개발을 더 용이하게 하기 위해서 원칙을 지키는 것입니다.


1️⃣ 단일 책임의 원칙 (SRP, Single Responsibility Principle)

객체는 하나의 책임만 가져야한다.

SRP를 코드로 살펴보면,

class 네트워크 {
    func 요청생성하기() -> URLReqeust {
    	...
    }
    
    func 데이터파싱하기() -> Item {
    	...
    }
    
    func 네트워크실행하기() -> Data {
    	...
    }
}

하나의 객체(네트워크)에 3가지의 책임이 존재하게 됩니다.

  • 요청 만들기
  • 데이터 파싱하기
  • 요청/응답

이는 하나의 객체에 여러 책임을 가지고 있으므로 SRP를 위배하고 있습니다.
하나의 객체가 하나의 책임을 갖도록하여 SRP를 준수하려면 책임을 분리해야 합니다.

class 네트워크 {
    let 파싱객체: 파싱?
    let 요청객체: 요청?

    func 네트워크실행하기() -> Data {
    	...
    }
}

class 파싱 {
    func 데이터파싱하기() -> Item {
    	...
    }
}

class 요청 {
    func 요청생성하기() -> URLReqeust {
    	...
    }
}

이런 방식으로 책임을 분리시켜주어 SRP를 준수할 수 있습니다.


2️⃣ 개방-폐쇄의 원칙 (OCP, Open-Closed Principle)

확장에는 열려있어야 하지만, 변경에는 닫혀있어야 한다.

확장에 열려있다.

객체의 기능을 확장(추가)할 수 있다.

변경에 닫혀있다.

기존에 구현되어 있는 것들을 변경하지 않고 객체를 확장할 수 있어야 한다는 의미이다.
변경에 닫혀있다라는 것이 처음에 이해가 잘 가지 않았는데 확장과 변경을 다른것이라고 생각해서 그런것 같다.
(확장하는게 결국 코드 바꾸는거 아냐? 그게 코드가 변경되는거구??)
즉, 기능을 추가할때 기존의 코드에 변경이 생기지 말아야된다를 의미한다!

이런 특성들은 Swift에서 Protocol을 통해 준수할 수 있다.

코드를 통해 살펴보자.

class 음료수 {
    func 주문하다() {
        let 탄산음료수 = [
        탄산(이름: 콜라, 크기: 작은),
        탄산(이름: 사이다, 크기: 중간)
        ]
        
        ...
    }
}

class 탄산 {
    let 이름: String?
    let 크기: String?
    
    ...
}

만약 음료수를 시키는 이 상황에서 다른 음료를 추가하는 경우 주문하다() 메소드를 다음 코드와 같이 바꿔줘야 한다.

class 음료수 {
    func 주문하다() {
    	let 탄산음료수 = [
        탄산(이름: 콜라, 크기: 작은),
        탄산(이름: 사이다, 크기: 중간)
        ]
        let 이온음료수 = [
        이온(이름: 포카리, 크기: 중간),
        이온(이름: 토레타, 크기:)
        ]
        
        ...
    }
}

class 탄산 {
    let 이름: String?
    let 크기: String?
    
    ...
}

class 이온 {
    let 이름: String?
    let 크기: String?
    
    ...
}

이렇게 추가할 수 있는데, 이는 OCP 규칙에 위배된다.
그 이유는 이온이라는 음료의 종류만 추가 되었음에도 음료수 객체의 메소드의 내용에 새롭게 프로퍼티를 생성하기 때문이다.
(예시가 편협(?)할 수 있는데, 간단하게 생각해보면 새로 기능을 추가 했더니 중복된 작업을 또 작성해 줘야하는 번거러움을 생각해보자!)
즉, 불필요한 반복으로 인해 재사용성이 떨어지는 것을 볼 수 있다.

해결방안으로 Protocol을 통해 추상화함으로써 해결할 수 있다.
(제네릭을 통해서도 해결가능하지 않을까 생각된다. 추후에 제네릭은 따로 포스팅할게요 ㅎ)

protocol 마실수있는 { ... }

class 음료수 {
    func 주문하다() {
    	let 음료: [마실수있는] = [
                탄산(이름: 콜라, 크기: 작은),
                탄산(이름: 사이다, 크기: 중간)
                이온(이름: 포카리, 크기: 중간),
                이온(이름: 토레타, 크기:)
            ]
        
        ...
    }
}

class 탄산: 마실수있는 {
    let 이름: String?
    let 크기: String?
    
    ...
}

class 이온: 마실수있는 {
    let 이름: String?
    let 크기: String?
    
    ...
}

여러가지 마실 수 있는 음료를 마실수있는 Protocol의 타입으로 채택하여 기능이 추가되어도 재사용성이 있는 프로퍼티로 받을 수 있다.
즉, 코드 추가가 아닌 데이터만 추가해주면 된다.


3️⃣ 리스코프 치환 원칙 (LSP, Liskov Substitution Principle)

부모(Super Class)로 동작이 가능한 곳에 자식(Sub Class)을 넣어도 동작한다는 원칙이다.

자식 클래스부모 클래스의 기능을 물려받는데(상속), 자식 클래스의 동작은 부모 클래스의 기능들을 제한하면 안된다.
즉, 부모 클래스의 타입에 자식 클래스의 인스턴스를 넣어도 동일하게 동작해야된다.

코드로 살펴보면,

class 사각형 {
    var 넓이: Float = 0
    var 높이: Float = 0
    
    var 면적: Float {
        return 넓이 * 높이
    }
}

class 정사각형: 사각형 {
    override var 높이: Float {
        didSet { 
            넓이 = 높이
        }
    }
}

func 면적출력하기(네모: 사각형) {
    네모.넓이 = 2
    네모.높이 = 3
    
    print(네모.면적)
}

let 직네모 = 사각형()
let 정네모 = 정사각형()

면적출력하기(네모: 직네모) // 6
면적출력하기(네모: 정네모) // 9

사각형은 정사각형을 포함하는 개념이다.
그래서 정사각형은 사각형을 상속받는 코드를 작성했는데, 결과 값이 다르게 출력되는 것을 볼 수 있었다.
이는 LSP를 위배했기 때문이다. 즉, 하위 클래스로는 상위 클래스의 기능을 대체할 수 없는 문제점이 생긴 것이다.

LSP를 준수하도록 만들어보자.

protocol 도형 {
    var 면적: Float { get }
}

class 사각형: 도형 {
    private var 넓이: Float = 0
    private var 높이: Float = 0
    
    var 면적: Float {
        return 넓이 * 높이
    }
    
    init(넓이: Float, 높이: Float) {
    	self.넓이 = 넓이
        self.높이 = 높이
    }
}

class 정사각형: 도형 {
    private var 길이: Float = 0
    
    var 면적: Float {
        return 길이 * 길이
    }
    
    init(길이: Float) {
        self.길이 = 길이
    }
}

func 면적출력하기(네모: 사각형) {
    print(네모.면적)
}

let 직네모 = 사각형(넓이: 2, 높이: 3)
let 정네모 = 정사각형(길이: 4)

면적출력하기(네모: 직네모) // 6
면적출력하기(네모: 정네모) // 16

도형이라는 Protocol을 정의 함으로써 도형이라는 타입을 만들었고, 면적이라는 메소드를 정의하여 각각의 객체에 메소드가 존재하게 했다.
이제 어떠한 객체를 넣어도 성공적인 결과를 낼 수 있게되었다. 이것으로 LSP 준수하도록 수정되었다.


4️⃣ 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)

불필요한 인터페이스에 의존해서는 안된다.

사용되지 않는(불필요한) 인터페이스 요소를 포함시키지 말아야한다.

class 무기 {
    var 발수: Int
    var 위력: Int
    
    func 쏘다 { ... }
    func 찌르다 { ... }
}

class: 무기 { ... }
class: 무기 { ... }
class: 무기 { ... }

무기라는 부모 클래스에서 각각 총, 칼, 포 클래스로 상속을 한다고 했을때,

  • 총 : 발수, 위력, 쏘다
  • 칼 : 위력, 찌르다
  • 포 : 발수, 위력, 쏘다

이렇게 필요한 것 들을 나열해보면 사용되지 않는 프로퍼티, 메소드가 존재함을 알 수 있다.
즉, 불필요한 요소도 상속에 의해 받게되는 겨우이다.

Swift에서는 다중 상속이 불가능 하므로 클래스 상속으로는 분리할 수 없다.

여기서는 Protocol을 통해 해결해보고자 한다.
Portocol을 통해 다중 상속의 한계를 넘을 수 있고, 값 타입에도 채택할 수 있는 장점이 있다.

protocol 피해를줄수있는 {
    var 위력: Int { get }
}

protocol 쏠수있는 {
    var 발수: Int { get }
    func 쏘다()
}

protocol 찌를수있는 {
    func 찌르다()
}

class: 피해를줄수있는, 쏠수있는 { ... }
class: 피해를줄수있는, 찌를수있는 { ... }
class: 피해를줄수있는, 쏠수있는 { ... }

관계가 조금 복잡해보일 수 있지만 필요한 인터페이스만 채택한다.
이렇게 불필요하게 사용될 수 있는 인터페이스를 분리함으로써 ISP를 준수할 수 있다.

5️⃣ 의존관계 역전의 원칙 (DIP, Dependency Inversion Principle)

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

각각의 모듈을 추상화된 것에 의존시켜서 결합도를 떨어트릴 수 있다.
마치 부품을 갈아끼우는 것 처럼 한쪽을 바꾼다고 해도 다른쪽에 영향이 가지 않게 하는 것이다.

이를 통해 의존하지 않는 유닛 테스트를 진행할 수 있다. (네트워크에 의존하지 않는, 기본 타입에 의존하지 않는 등...)

class 네트워크매니저 {
    let 네트웤 = 네트워크()
    
    func 네트워크를실행하다() {
        네트웤.네트워킹()
    }
}

class 네트워크 {
    func 네트워킹() { ... }
}

아주 간단하게 네트워크 예제를 만들어봤다.
네트워크매니저는 네트워크를 핸들링하는 객체로써 상위 모듈이고, 네트워크는 네트워킹을 실행시키는 하위 모듈이다.
코드를 보면 상위 모듈(네트워크매니저)가 하위 모듈(네트워크)에 의존하고 있는 것을 볼 수 있다.
DIP를 위배하고 있다.

protocol 연결할수있는 {
    func 네트워킹()
}
class 네트워크매니저 {
    let 네트웤: 연결할수있는 
    
    init(네트웤: 연결할수있는) {
        self.네트웤 = 네트웤
    }
    
    func 네트워크를실행하다() {
        네트웤.네트워킹()
    }
}

class 네트워크: 연결할수있는 {
    func 네트워킹() { ... }
}

class 가짜네트워크: 연결할수있는 {
    func 네트워킹() { ... }
}

let 네트웤 = 네트워크()
let 짭트웤 = 가짜네트워크()

let 매니저 = 네트워크매니저(네트웤: 네트웤)
let 짭매니저 = 네트워크매니저(네트웤: 짭트웤)

protocol 연결할수있는에 의존성을 역전시킴으로써 네트워킹이라는 메소드는 연결할수있는 타입이라면 부품처럼 바꿀 수 있다.
가짜네트워크 객체 역시 연결할수있는 타입이므로 사용할 수 있다.

여기서 바로 의존성 주입이 나오게 되는데 외부에서 연결할수있는 타입의 객체라면 외부에서 주입이 가능하다.
즉, 부품처럼 바꿔서 사용할 수 있다는 얘기다.



해결할 수 있는 문제

SOLID 원칙을 따르게 되면 아래 세 가지 문제를 해결할 수 있게 된다

  • Fragility : 작은 변화가 버그를 일으킬 수 있는데, 테스트가 용이하지 않아 미리 파악하기 어려운 것
  • Immobility : 재사용성의 저하. 불필요하게 묶인(coupled) 의존성 때문에 재사용성이 낮아진다.
  • Ridgidity : 여러 곳에 묶여 있어서 작은 변화에도 많은 곳에서 변화(노력)가 필요하다.
profile
꺼진 뷰도 다시보자.

0개의 댓글