목차
- SOLID 원칙은 누가 만들었을까?
- 단일 책임 원칙 SRP Single Responsibility Principle
- 개방/폐쇄 원칙 OCP Open Closed Principle
- 리스코프 치환 원칙 LSP Liskov Substituion Principle
- 인터페이스 분리 원칙 ISP Interface Segregation Principle
- 의존관계 역전 원칙 DIP Dependency Inversion Principle
- SOLID는 아직도 유효한가?
SOLID 원칙은 누가 만들었을까?
2000년대 초 로버트 마틴이 주장한 객체지향 5원칙을 마이클 페더스가 두문자어 기억술로 소개한 것이라고 한다.
이러한 SOLID 원칙들은 시간이 지나도 유지보수와 확장이 쉬운 시스템을 만들고자 할 때 적용하면 좋은 원칙들이다!
단일 책임 원칙 SRP Single Responsibility Principle
단일 책임 원칙 (SRP, Single Responsibility Principle)은 모든 클래스는 각각 하나의 책임만 가져야 한다는 원칙입니다.
예를 들어 A라는 로직이 존재한다면 어떠한 클래스는 A에 관한 클래스여야 하고 이를 수정한다고 했을 때도 A와 관련된 수정이어야 합니다.
class Person {
func cook() {} // 요리 - 요리사
func plating() {} // 플레이팅 - 요리사
func order() {} // 주문 - 손님
func pickup() {} // 픽업 - 손님
func eat() {} // 냠냠 - 손님
}
조금 더 와닿는 예시를 들어보자면, 위와 같은 Person 클래스는 요리사의 역할과 손님의 역할이 함께 있으므로 하나의 책임만 가져야 한다는 단일 책임 원칙에 어긋납니다. 따라서 단일 책임 원칙을 준수하는 코드로 리팩토링한다면 아래 코드처럼 작성할 수 있습니다.
class Chef {
func cook() {} // 요리 - 요리사
func plating() {} // 플레이팅 - 요리사
}
class Customer {
func order() {} // 주문 - 손님
func pickup() {} // 픽업 - 손님
func eat() {} // 냠냠 - 손님
}
이렇게 작성하면 요리사의 역할이 새로 추가되도 Customer 클래스에는 영향을 주지 않기 때문에 응집도는 올라가고 결합도는 낮아지는 장점을 가질 수 있습니다!
개방/폐쇄 원칙 OCP Open Closed Principle
개방/폐쇄 원칙이란 유지보수 사항이 생겼을 때 코드를 쉽게 확장할 수 있도록 하고 수정할 때는 닫혀 있어야 한다는 원칙입니다.
즉, 기존 코드는 잘 변경하지 않으면서도 확장은 쉽게 할 수 있어야 합니다. 그러나 이 원칙은 의미를 알고 있는 사람이라면 쉽게 이해할 수 있지만 모르는 사람에겐 이게 무슨 말인지 이해하기 쉽지 않습니다.
이해를 돕기 위해 코드를 예시로 들어보겠습니다.
class Song {
var name = ""
var artist = ""
}
class Player {
var list = [Song]()
func play() { ... }
func append(_ song: Song) {
list.append(song)
}
}
위 코드는 Player 클래스가 곡을 뜻하는 Song 클래스 객체 리스트를 포함하는 간단한 코드입니다.
이 코드는 OCP 원칙을 지키지 않은 예시인데요.
예를 들어 "우리 플레이어도 노래 +쇼츠영상도 재생할 수 있게 수정해줘" 라는 요청이 들어왔습니다.
Player 클래스는 지금 Song 타입만 받을 수 있어 Shorts 타입을 만들게 되면 어쩔 수 없이 Player 클래스를 수정해야 합니다.
이러한 점이 수정에 대해 닫혀 있어야 한다는 개방/폐쇄 원칙에 위배되게 됩니다.
아래 코드는 개선한 코드입니다.
protocol Playable {
var name: String { get set }
var artist: String { get set }
}
class Shorts: Playable {
var name = ""
var artist = ""
}
class Song: Playable {
var name = ""
var artist = ""
}
class Player {
var list = [Playable]()
func play() { ... }
func append(_ item: Playable) {
list.append(item)
}
}
재생가능한 Playable 프로토콜을 만들어 Shorts 클래스와 Song 클래스가 이를 준수하도록 하면, Player 클래스는 더이상 Song 클래스에 의존하지 않게 되고 Playable 프로토콜에 의존하게 되는데요. 이렇게 되면 노래+쇼츠영상 말고도 또 다른 재생형태를 추가해달라는 요청이 들어오더라도 프로토콜을 준수하는 클래스를 추가하기만 하면 되서 문제가 발생하지 않습니다.
Crystal Ball
하지만 개방/폐쇄 원칙을 실제 적용하는데에는 'Crystal Ball' 이라는 문제가 있습니다.
우리는 미래의 요구사항 변화를 점성술사의 수정구 처럼 정확하게 예상할 수 없기에 항상 완벽 대비된 코드를 짤 수 없습니다.
그러나 개발 경험이 쌓이다 보면 변화가 예상될 때가 있긴 합니다. 그렇기에 변화가 예상되는 부분들은 Protocol 과 같은 추상화 기법을 통해 보호하고, 아직 오지 않은 요구사항에 대해선 맘 편히 기다리자!
리스코프 치환 원칙 LSP Liskov Substituion Principle
리스코프 치환 원칙은 프로그램의 객체는 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 하는 것을 의미합니다.
클래스는 상속이 되기 마련이고 부모, 자식이라는 계층 관계가 만들어집니다. 이때 부모 객체에 자식 객체를 넣어도 시스템이 문제없이 돌아가도록 만드는 것을 말합니다.
긴 말 필요 없이 코드를 보겠습니다.
class Shape {
func doSomething() { }
}
class Square: Shape {
func drawSquare() { }
}
class Circle: Shape {
func drawCircle() { }
}
// 코드출처 : https://medium.com/movile-tech/liskov-substitution-principle-96f15559e363
위 코드는 Shape 클래스를 상속받은 Square, Circle 클래스로 각자 자신을 draw 하는 메소드를 가지고 있습니다.
func draw(shape: Shape) {
if let square = shape as? Square {
square.drawSquare()
} else if let circle = shape as? Circle {
circle.drawCircle()
}
}
// 코드출처 : https://medium.com/movile-tech/liskov-substitution-principle-96f15559e363
만약 draw 메소드의 매개변수로 Shape 타입을 받고 이를 Square, Circle로 형변환을 한 뒤 draw하는 코드가 있다고한다면,
이는 LSP를 위반하게 됩니다. 왜냐하면 매개변수로 받은 상위 객체인 Shape타입과 전혀 다르게 동작하고 있기 때문인데요.
만약 하위 객체인 Square, Circle이 아닌 또 다른 Shape 타입을 매개변수로 전달받게 되면 이 draw메소드는 아무런 동작을 하지 않게 될겁니다.
(추가로 OCP도 위반했는데요. 만약 또다른 Shape의 하위 클래스를 전달받아 사용하려면 draw 메소드도 수정해야 하기 때문입니다.)
아래는 LSP+OCP를 지키도록 수정한 코드입니다.
protocol Shape {
func draw()
}
class Square: Shape {
func draw() { }
}
class Circle: Shape {
func draw() { }
}
func draw(shape: Shape) {
shape.draw()
}
// 코드출처 : https://medium.com/movile-tech/liskov-substitution-principle-96f15559e363
Shape 를 프로토콜로 만들고 Square, Circle 클래스가 채택해 draw를 호출하도록 했습니다.
이렇게 하면 draw 메소드 내 복잡한 if 문을 제거할 수 있어 가독성 향상에도 유용합니다.
인터페이스 분리 원칙 ISP Interface Segregation Principle
인터페이스 분리 원칙은 하나의 일반적인 인터페이스보다 구체적인 여러 개의 인터페이스를 만들어야 한다는 원칙입니다.
참고 링크 중 좋은 예시가 있었는데요.
사람이라는 인터페이스에 뛰기, 밥먹기, 놀기, 연애하기, 코딩하기, 나라 지키기, 잠자기, ... 등등 선언되어 있고 이 인터페이스를 구현하는 프로그래머 클래스, 군인 클래스, 어린이 클래스가 있다고 가정해보겠습니다.
만약 군인 클래스를 수정하기 위해 나라 지키기 기능을 '불침번 서기', '사격하기' 로 변경한다면, 그 기능과 관련이 없는 프로그래머, 어린이 클래스에도 변경의 영향을 받게 됩니다.
요약하자면 지나치게 역할이 많은 인터페이스는 결합도를 낮춘다 라고 할 수 있습니다.
의존관계 역전 원칙 DIP Dependency Inversion Principle
의존 역전 원칙은 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향받지 않게 하는 원칙을 말합니다. 예를 들어 타이어를 갈아끼울 수 있는 틀을 만들어 놓은 후 다양한 타이어를 교체할 수 있어야 합니다. 즉, 상위 계층은 하위 계층의 변화에 대한 구현으로부터 독립해야 합니다.
정리해보면 다음과 같습니다.
- 추상적인 것이 구체화된 것에 의존해선 안된다.
- 고수준 모듈이 저수준 모듈의 구현에 의존해선 안된다.
- 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
이 원칙을 보고 바로 의존성 주입, 의존성 역전 등 사이드 프로젝트들을 진행하며 유지보수성을 높이기 위해 자연스럽게 공부하던 내용들이 떠올라 반가웠습니다. 당시엔 의존성이 뭐고 주입, 역전은 또 뭘까 이해하기 정말 힘들었는데, 그 과정을 겪고난 다음 DIP 를 공부하니까 이해가 되네요
Swift에선 의존성 역전을 Protocol 을 사용하여 일으킬 수 있습니다.
아래는 Protocol을 활용해 의존성 역전을 시킨 예시 입니다.
// 구독이 가능한 ott들이 준수할 프로토콜
// 프로토콜(~= 인터페이스)을 준수함으로서,
// DIP를 만족하게 되고 의존성 역전이 일어났다고 할 수 있습니다.
protocol Subscribable {
var subscribeDate: String { get set }
}
// 프로토콜 채택
class Tving: Subscribable {
var subscribeDate: String = ""
init(subscribeDate: String) {
self.subscribeDate = subscribeDate
}
}
// 프로토콜 채택
class Netflix: Subscribable {
var subscribeDate: String = ""
init(subscribeDate: String) {
self.subscribeDate = subscribeDate
}
}
class User {
var ottList: [Subscribable] = []
// 프로토콜 타입으로 주입
init(ottList: [Subscribable]) {
self.ottList = ottList
}
func append(ott: Subscribable) {
ottList.append(ott)
}
}
let tving = Tving(subscribeDate: "04/11")
let netflix = Netflix(subscribeDate: "04/12")
// 외부에서 의존성 주입
let user = User(ottList: [tving, netflix])
SOLID는 아직도 유효한가?
출처: 웨지의 개발 블로그
2020년 10월, 한 레퍼런스에서 로버트 마틴은 다음과 같은 메일을 받게 된다.
요점은 이렇습니다.
업계가 대형 단일체 아키텍처가 아닌 마이크로 서비스로 전환하고 있기 때문에 OCP가 더이상 유효하지 않다.
SOLID원칙이 처음 등장한 20년전처럼 상속을 잘 쓰지 않는 추세이기 때문에 LSP 또한 구식이다.
https://speakerdeck.com/tastapod/why-every-element-of-solid-is-wrong 의 발표처럼, SOLID는 "단순하게 코드를 작성해라 Just write simple code"로 대체되어야 한다.
그리고 로버트 마틴은 다음과 같이 반박했습니다.
1. SRP이 준수되지 않는다면 여러 코드가 복합적으로 엮이게 되고, 이는 마이크로 서비스로 해결할 수 있는 것이 아니다. 얽힌 마이크로 서비스가 생겨날 뿐이다. SRP는 간단한 코드작성을 위한 방법 중 하나이다.
2. OCP는 수정없이 확장할 수 있는 모듈을 위한 기본이다. 요구사항이 변경되면 '기존 코드의 일부만' 잘못 된 것이다. 기존 코드의 대부분은 여전히 옳다. 우리는 잘못된 코드를 다시 작동하도록 하기 위해 올바른 코드를 수정하고 싶지 않을 것이다. 아이러니 하게도, Simple code is both open and closed.
3. LSP는 세간의 오해와 달리 '클래스' 간의 법칙이 아니다. 인터페이스를 사용하는 프로그램은 해당 인터페이스의 구현과 혼동되어서는 안 된다는 원칙이다. 이는 서브 타이핑을 의미하는 것으로, 인터페이스를 사용하는 모든 사용자(개발자)는 해당 인터페이스의 의미에 동의해야 한다는 내용이다. 구현자가 인터페이스의 의미를 바꾸어버리면 if 문과 switch문이 난무하는 코드가 된다.
4. ISP. 우리는 2020년에도 컴파일 언어로 작업한다. 여전히 어떤 모듈을 재 컴파일하고 재배포해야 하는지 결정하는데 수정날짜에 의존한다. 이런 한계 속에서 A모듈이 B모듈을 컴파일 타임에만 의존하고 실행 타임엔 의존하지 않는다고 하더라도, B모듈이 변경되면 A모듈까지 재컴파일 해야한다. 이런 문제는 정적타입 언어(Java, C++, C#, Go, Swift)에서 더 심각하고, 동적유형 언어는 덜하지만 면역은 아니다.
5. DIP. 우리는 구현체의 디테일에 의존하는 비즈니스 규칙을 원하지 않는다. 비즈니스 로직의 돈 계산이 SQL로 오염되거나 저수준의 validation이나 표현 포맷팅 문제를 겪기를 원하지 않는다. 모듈이 역할별로 잘 분리되어 있는 코드는 모든 소스 코드 의존성, 특히 아키텍처 경계를 넘는 의존성이 DIP를 준수하도록 신중하게 관리함으로써 달성할 수 있다.
그리고 마틴은 다음과 같이 글을 마쳤습니다.
Just write simple code. 이것은 좋은 조언입니다. 그러나 오랜 세월이 우리에게 가르쳐 준 것이 있다면 단순함을 위해서는 원칙에 따른 훈련이 필요하다는 것입니다. 단순함을 정의하는 것은 바로 이러한 원칙입니다. 프로그래머가 단순성을 지향하는 코드를 생성하도록 돕는 것은 이러한 분야입니다.
마침 저도 SOLID 원칙을 공부하며 의문이었던 부분이 LSP에서 상속을 예시로 사용하는 부분이었습니다.
프로젝트를 하며 상속을 쓴 기억이 없어 SOLID 전체에 대해 의문을 품었습니다. 물론 저의 짧은 개발 지식으론 Swift에서 상속을 권장하지 않는건지, iOS 개발 진영에서 상속을 잘 사용하지 않는건지, 요즘 MSA 아키텍쳐가 대세로 떠오르며 잘 사용하지 않는건지 정확한 이유는 모르겠지만 상속이 피부에 와닿지 않아 SOLID 에 의문을 품었던건데 위 글 덕분에 의문이 해결되었네요 🙂
앞으로 SOLID 원칙을 새기고 단순한 코드를 짜기 위해 더 노력해야겠습니다.
참고 링크
https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84)
http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
https://sihyung92.oopy.io/oop/solid
https://inuplace.tistory.com/943
'CS' 카테고리의 다른 글
SQL 기본 쿼리문 정리 (0) | 2024.08.05 |
---|---|
Heap이 Stack에 비해 느릴 수 밖에 없는 이유 (0) | 2024.07.21 |
[컴퓨터 구조] 시스템 버스(System Bus) 및 동작 방식 (0) | 2024.03.12 |
[소프트웨어 공학] 블랙박스 테스트-1 (0) | 2022.11.07 |
[소프트웨어 공학] JUnit (0) | 2022.10.10 |