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
원칙이다.
하지만, 이 원칙에 목매어 원칙을 위한 개발을 하는 것을 주의해야합니다.
우리는 개발을 더 용이하게 하기 위해서 원칙을 지키는 것입니다.
객체는 하나의 책임만 가져야한다.
SRP를 코드로 살펴보면,
class 네트워크 {
func 요청생성하기() -> URLReqeust {
...
}
func 데이터파싱하기() -> Item {
...
}
func 네트워크실행하기() -> Data {
...
}
}
하나의 객체(네트워크)에 3가지의 책임이 존재하게 됩니다.
이는 하나의 객체에 여러 책임을 가지고 있으므로 SRP
를 위배하고 있습니다.
하나의 객체가 하나의 책임을 갖도록하여 SRP
를 준수하려면 책임을 분리해야 합니다.
class 네트워크 {
let 파싱객체: 파싱?
let 요청객체: 요청?
func 네트워크실행하기() -> Data {
...
}
}
class 파싱 {
func 데이터파싱하기() -> Item {
...
}
}
class 요청 {
func 요청생성하기() -> URLReqeust {
...
}
}
이런 방식으로 책임을 분리시켜주어 SRP
를 준수할 수 있습니다.
확장에는 열려있어야 하지만, 변경에는 닫혀있어야 한다.
객체의 기능을 확장(추가)할 수 있다.
기존에 구현되어 있는 것들을 변경하지 않고 객체를 확장할 수 있어야 한다는 의미이다.
변경에 닫혀있다라는 것이 처음에 이해가 잘 가지 않았는데 확장과 변경을 다른것이라고 생각해서 그런것 같다.
(확장하는게 결국 코드 바꾸는거 아냐? 그게 코드가 변경되는거구??)
즉, 기능을 추가할때 기존의 코드에 변경이 생기지 말아야된다를 의미한다!
이런 특성들은 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의 타입으로 채택하여 기능이 추가되어도 재사용성이 있는 프로퍼티로 받을 수 있다.
즉, 코드 추가가 아닌 데이터만 추가해주면 된다.
부모(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
준수하도록 수정되었다.
불필요한 인터페이스에 의존해서는 안된다.
사용되지 않는(불필요한) 인터페이스 요소를 포함시키지 말아야한다.
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
를 준수할 수 있다.
상위 모듈이 하위 모듈에 의존하면 안되고 두 모듈 모두 추상화에 의존하게 만들어야 한다.
각각의 모듈을 추상화된 것에 의존시켜서 결합도를 떨어트릴 수 있다.
마치 부품을 갈아끼우는 것 처럼 한쪽을 바꾼다고 해도 다른쪽에 영향이 가지 않게 하는 것이다.
이를 통해 의존하지 않는 유닛 테스트를 진행할 수 있다. (네트워크에 의존하지 않는, 기본 타입에 의존하지 않는 등...)
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
: 여러 곳에 묶여 있어서 작은 변화에도 많은 곳에서 변화(노력)가 필요하다.