728x90
SOLID 원칙
- 단일 책임 원칙 (Single Responsibility Principle, SRP)
- 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
- 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
- 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
- 의존성 역전 원칙 (Dependency Inversion Principle, DIP)
📌 단일 책임 원칙 (Single Responsibility Principle, SRP)
클래스는, 오직 하나의 대해서만 책임져야 한다.
- 만약 클래스가 여러 가지 작업을 책임져야 한다면, 이는 버그 발생 가능성을 높인다.
- 많은 기능 중 한 가지를 변경할 때, 자신도 모르게 다른 기능에 영향을 줄 수 있기 때문이다.
- 단일 책임 원칙의 목적은 행동들을 분리하는 것이고, 이로 인해 어떤 기능을 수정하더라고, 연관 없는 기능에 영향을 가지 않도록 하는 것이다.
예를 들어 다음과 같은 4가지 기능을 가지고 있는 Work라는 클래스가 있다고 가정해보자
- 이 4개의 책임 중에 하나만 변경이 되더라도 Work 클래스가 수정되어야 한다 (바람직하지 않다).
- 또한 이 클래스와 연결되어 있는 클래스가 있다고 한다면 그 클래스들 또한 Work 클래스의 수정에 맞추어 수정되어야 한다.
그렇다면 어떻게 해야 할까?
해결책
- Work의 책임을 4개의 클래스로 나누면 된다.
- 이렇듯 하나의 클래스에 하나의 책임을 부여하는 것이 단일 책임 원칙이다.
📌 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
클래스는 확장에는 개방적이어야 하고, 변경에는 폐쇄적이어야 한다.
- 클래스의 현재 코드를 변경하는 것은 해당 클래스를 사용하고 있는 모든 시스템에 영향을 주게 된다.
- 만약 클래스에 더 많은 기능을 부여하고 싶다면, 이상적인 접근방법은 기존 기능을 변경하는 것이 아니라 새로운 기능을 추가하는 것이다.
- 개방-폐쇄 원칙의 목적은 클래스의 존재하는 기능의 변경 없이 해당 클래스의 기능을 확장시키는 것이다.
- 기능을 확장하는 부분은 인터페이스를 사용하여 추상화하고 새로운 기능의 추가에는 상속을 적용함으로써 클래스를 유연하게 변경할 수 있다.
- 이로 인해 사용 중인 클래스의 변경으로 인한 버그 발생을 방지할 수 있다.
📌 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
만약 S가 T의 서브타입이라면, T는 어떠한 이슈 없이 S로 대체 가능해야 한다.
- 자식 클래스가 부모 클래스의 기능을 똑같이 수행할 수 없을 때, 이는 버그를 발생시키는 요인이 된다.
- 만약 어떤 클래스가 자신으로부터 다른 클래스를 생성했다면, 이제 그 클래스는 부모 클래스가 되고, 생성된 클래스는 자식 클래스가 된다.
- 자식 클래스는 부모 클래스가 할 수 있는 모든 것을 할 수 있어야 하고 이를 상속이라 한다.
- 자식 클래스는 부모 클래스처럼 똑같은 요청에 똑같은 응답을 할 수 있어야 하고 응답의 타입 또한 같아야 한다.
- 위 그림은 부모 클래스가 커피를 주문받는 모습을 보여준다.
- 이때 자식 클래스는 부모의 역할 즉 커피를 똑같이 주문받을 수 있어야 한다.
- 리스코프 치환 법칙의 목적은 일관성을 유지하며 부모 클래스 또는 자식 클래스를 오류 없이 동일한 방식으로 사용할 수 있도록 하는 것이다.
예를 들어 보자
open class Rectangle {
open var width = 0
open var height = 0
val area: Int
get() = this.width * this.height
}
class Square : Rectangle() {
override var width: Int
get() = super.width
set(width) {
super.width = width
super.height = width
}
override var height: Int
get() = super.height
set(width) {
super.width = height
super.height = height
}
}
fun calculateArea(r: Rectangle) {
r.width = 2
r.height = 3
assert(r.area == 6)
}
fun main() {
calculateArea(Rectangle()) // 성공
calculateArea(Square()) // 성공해야 하나 실패
}
- 직사각형은 네 각이 직각인 사각형이고 정사각형은 네 각이 직각이면서 네 변의 길이가 같은 사각형이다.
- 사각형의 면적을 구하는 함수에 직사각형 즉 Rectangle을 전달하면 assert이 성공한다.
- 리스코프 치환 원칙에 따르면 그 하위 타입인 Square을 전달해도 면적이 정상적으로 구해져야 하나 실패한다.
- 따라서 위의 코드는 리스코프 치환 원칙을 위반한다.
이것은 개념적으로는 정사각형과 직사각형이 상속관계로 보이더라도 실제 구현은 상속관계가 아니기 때문이다.
해결책
따라서 여기서는 더 추상화된 Shape 객체를 만들어서 직사각형과 정사각형이 모두 Shape를 상속하게 하고 이 Shape 안에서 면적을 구하는 함수를 만드는 것이 더 적절하다.
📌 인터페이스 분리 원칙 (Interface Segregation Principle)
클라이언트는 사용하지 않는 메서드에 대해 의존적이지 않아야 한다.
- 클래스가 서로 관계없는 기능을 가지고 있다면 낭비가 되고, 예상치 못한 버그를 발생시킬 수 있다.
- 클래스는 해당 역할에 대한 기능만 수행해야 하고, 이를 제외한 다른 기능은 삭제하거나 다른 클래스로 이동시켜야 한다.
- 인터페이스 분리 원칙의 목적은 기능의 모음을 더 작은 기능의 모음으로 쪼개서, 클래스가 필요한 기능들만 실행할 수 있도록 하는 것이다.
예를 들어 보자
interface Animal {
fun eat()
fun run(from: String, to: String)
fun fly(from: String, to: String)
}
class Eagle : Animal {
override fun eat() = println("eat")
override fun run(from: String, to: String) = println("run")
override fun fly(from: String, to: String) = println("fly")
}
class Lion : Animal {
override fun eat() = println("eat")
override fun run(from: String, to: String) = println("run")
override fun fly(from: String, to: String) = println("fly") // 사자는 날 필요 없음
}
- Animal을 상속받는 Eagle과 Lion을 구현한다.
- Eagle은 먹기도, 뛰기도, 날기도 하기 때문에 Animal의 모든 기능을 구현해도 상관없지만
- Lion은 날지도 못하는데 의무적으로 fly를 구현해야 한다.
- 혹시 fly의 파라미터나 리턴 타입이 변경된다면 Lion은 사용하지도 않는 메서드를 변경 타입에 따라 같이 변경해주어야 한다.
해결책
interface Bird {
fun eat()
fun run(from: String, to: String)
fun fly(from: String, to: String)
}
interface Mammal {
fun eat()
fun run(from: String, to: String)
}
class Eagle : Bird {
override fun eat() = println("eat")
override fun run(from: String, to: String) = println("run")
override fun fly(from: String, to: String) = println("fly")
}
class Lion : Mammal {
override fun eat() = println("eat")
override fun run(from: String, to: String) = println("run")
}
- 클라이언트 입장에서 사용하는 기능만 제공하도록 인터페이스를 분리하라는 것이 인터페이스 분리 원칙의 핵심이다.
- 따라서 Animal 인터페이스를 Bird와 Mammal로 분리하여 준다
📌 의존성 역전 원칙 (Dependency Inversion)
추상(abstraction)은 구체(detail)에 의존하지 않아야 하며, 구체는 추상에 의존적이어야 한다. 또한 고수준 모듈과 저수준 모듈은 둘 다 추상에 의존적이어야 한다.
용어를 조금 정리하도록 하자.
- 고수준 모듈 : 어떤 의미 있는 단일 기능을 제공하는 모듈
- 저수준 모듈 : 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현
- 추상 : 두 클래스를 연결하는 인터페이스
암호화를 예제로 들면 고수준 모듈과 저수준 모듈을 다음과 같이 볼 수 있다.
- 데이터를 암호화한다는 것은 프로그램의 의미 있는 단일 기능으로서 고수준 모듈에 해당한다.
- 이때 이 고수준 모듈은 데이터 읽기, 암호화, 데이터 쓰기라는 하위 기능으로 구성되는데, 이 각각의 하위기능이 저수준 모듈에 해당한다.
이제 의존성 역전 원칙의 의미에 대해 생각해보자.
- 의존성 역전 원칙의 목적은 인터페이스를 통해 고수준 클래스가 저수준 클래스에 대한 의존성을 줄이는 것이다.
- 추상 클래스 또는 상위 클래스는 구체적인 구현 클래스 또는 하위 클래스의 의존적이면 안된다는 뜻이며
- 이를 쉽게 말해서 자신보다 변하기 쉬운 것에 의존하지 말라는 것이다.
예를 들어보자
class GasolineEngine {
val fuel = "gasoline"
}
class Car {
val engine = GasolineEngine()
}
class DieselEngine {
val fuel = "Disel"
}
class Car {
// val engine = GasolineEngine()
val engine = DieselEnging()
}
- 처음에 가솔린 엔진이었던 Car는 엔진이 디젤로 바뀌자 Car 내부에 코드가 변경되었다.
- 하지만 의존성 역전 원칙의 핵심은 의존하는 하위 레벨 모듈이 변하더라도 상위 레벨의 모듈은 변하지 않아야 된다는 것이다.
- 다시 말해 엔진이 가솔린 엔진에서 디젤로 바뀌더라도 Car 클래스의 코드는 변하면 안 된다는 것이다.
해결책
- 그러면 어떻게 하면 될까? 현재 Car는 가솔린 엔진에 의존하고 있고 가솔린 엔진은 어디에도 의존하고 있지 않다.
- 이 흐름 중간에 위와 같이 Engine 인터페이스를 도입해보자
- 그러면 의존이 없었던 저수준 모듈인 가솔린 엔진이 역으로 고수준 모듈인 엔진에 의존하게 되는 의존성 역전이 발생한다.
- Car는 Engine 인터페이스에 의존하고 있기 때문에 엔진을 가솔린 엔진에서 디젤 엔진으로 엔진을 바꾸어도 Car 코드는 변하지 않는다.
간단히 정리하면 DIP 즉 의존역전원칙은 클래스 간 결합을 느슨히 하여 한 클래스의 변경에 따른 다른 클래스들의 영향을 최소화함으로써 프로그램을 지속가능하고 확장성 있게 만든다.
'OOP' 카테고리의 다른 글
OOP - DI(Dependency Injection)와 서비스 로케이터 (0) | 2023.02.26 |
---|---|
OOP - 재사용: 상속보다 조립 (0) | 2022.04.07 |
OOP - 다형성과 추상 타입 (0) | 2022.02.14 |
OOP - 객체 지향 (0) | 2022.02.04 |