SOLID 원칙이란?
SOLID 원칙이란 로버트 마틴이 소개한 객체 지향 프로그래밍 및 설계의 5가지 기본 원칙을 마이클 페더스가 약어를 따서 정리한 것이다. SOLID 원칙은 소스 코드의 가독성을 높이고 확장하기 쉬운 구조를 만들기 위한 지침이다.
SOLID
- SRP(Single Responsibility Principle, 단일 책임 원칙)
- OCP(Open-Closed Principle, 개방-폐쇄 원칙)
- LSP(Liskov Substitution Principle, 리스코프 치환 원칙)
- ISP(Interface Segregation Principle, 인터페이스 분리 원칙)
- DIP(Dependency Inversion Principle, 의존성 역전 원칙)
Single Responsibility Principle (단일 책임 원칙)
“클래스를 변경하는 이유(책임)는 오직 하나이어야 한다.”
“클래스가 오직 하나의 책임을 갖도록 하라”라고도 한다. 그러면, 마치 함수처럼 클래스에 하나의 기능만 구현하라는 걸까? 그렇지 않다. 여기서 하나의 의미는 관련성이 적은 다른 기능의 코드까지 함께 변경하도록 만들지 말라는 것이다.
예를 들어, 요구사항A를 반영하여 클래스를 수정했다. 그 다음에 요구사항B가 새로 들어와서 그것도 반영했다. 그랬더니 요구사항A와 관련된 코드에 문제가 발생했다. 다시 수정하자니, 요구사항B를 반영할 수 없다. 서로 다른 책임의 코드가 결합된 상황이 발생한 것이다.
따라서, 이러한 충돌을 해결하고 앞으로의 변경에도 유연하게 만들기 위해 두 책임의 결합을 분리(ex: 별도의 클래스)하라는 뜻으로 이해할 수 있다.
Open-Closed Principle (개방-폐쇄 원칙)
“기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 설계하라.”
의의
이 원칙을 지킬 수록 프로젝트는 탄탄해진다. 왜냐면,
- 기존 코드의 변경은 항상 버그의 가능성을 의미한다.
- 변경은 테스트를 필요로 한다.
- 코드의 수정이 또 다른 수정을 불러오는 현상은 개발자의 개발 의지를 꺾는다.
OCP의 구현은 클라이언트 클래스와 의존성 클래스 사이에 추상화를 둠으로써 가능하다. (⇒ 의존성 역전 원칙)
예시)
- Repository 클래스가 LocalDataSource 클래스에 의존하고 있다. LocalDataSource는 File의 데이터를 읽어오는 기능만 구현되어 있다.
- Room에서 데이터를 저장,불러오는 기능을 추가하고 싶다.
- LocalDataSource를 인터페이스로 변경한다.그리고 구현을 FileDataSource 클래스로 옮기고 LocalDataSource를 상속한다.
- Room 기능을 추가하기 위해 RoomDataSource를 정의하고 똑같이 LocalDataSource를 상속한다. 그러면 기존의 Repository와 FileDataSource의 코드를 변경하지 않으면서 Room 기능을 확장할 수 있게 된다. 또한, 앞으로 다른 종류의 로컬 데이터 기능을 확장하더라도 기존의 클래스를 변경하지 않는 것이 가능해진다.
정리)
- 원칙: 기존 코드를 수정하지 않고 기능을 확장할 수 있도록 하자.
- 이유: 기존 코드의 변경은 버그의 가능성을 의미하며, 연쇄적인 수정은 개발의 의지를 꺾는다.
- 방법: 하위 기능들을 대표하는 추상 클래스(인터페이스)를 정의하고, 새 기능을 추가할 때 구현 클래스가 추상 클래스를 상속하도록 한다. (DIP)
Liskov Substitution Principle (리스코프 치환 원칙)
“서브타입(subtype)은 그것의 기반 타입(base type)으로 완벽하게 치환 가능해야 한다.”
즉, 상위 타입 자리에 하위 타입 객체를 사용할 때, 하위 타입 객체가 상위 타입의 동작을 완전히 대체할 수 있어야 한다.
위반 사례)
직사각형(Rectangle)-정사각형(Square) 문제
open class Rectangle {
open var width = 0
open var height = 0
val area: Int
get() = width * 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(height) {
super.width = height
super.height = height
}
}
fun calculateArea(rect: Rectangle): Int {
rect.width = 2
rect.height = 3
return rect.area
}
fun main() {
val rect = Rectangle()
val square = Square()
println(calculateArea(rect)) // 6
println(calculateArea(square)) // 9
}
- 문제: 같은 상황에서 같은 메서드를 호출했을 떄 Rectangle과 Square의 결과가 다르다. 이것은 클라이언트 객체 입장에서 에러 상황이다.
- 원인: 개념적으로 상속 관계처럼 보이지만 실제 동작은 상속 관계가 아닌데 직사각형을 상속했기 때문이다.
- 해결: 새로운 추상 클래스를 선언하여 둘 다 그것을 상속하게 한다.
Deep: 왜 하위 타입이 상위 타입을 완전히 대체해야만 하는가?
하위 타입은 상위 타입으로 언제나 업캐스팅 되어 사용될 수 있기 때문이다. 따라서, 하위 타입마다 상위 타입의 메서드 호출에 대한 동작이 다르다면 이것을 사용(의존)하는 클라이언트 객체 입장에선 참으로 난감할 것이다.
이것을 해결하는 방법으로, 객체를 사용할 때 if문으로 하위 클래스의 타입을 검사해서 다르게 처리할 수 있는데, 이럴 경우 하위 클래스를 추가할 때마다 사용하는 쪽에 if문을 새로 추가해야 한다. 이것은 기능을 확장할 때마다 기존의 코드를 수정하게 하여 변경을 어렵게 만든다. (OCP 원칙 위반)
Interface Segregation Principle (인터페이스 분리 원칙)
“클라이언트가 사용하지 않는 메소드를 갖지 않도록 인터페이스를 분리하라”
범용적인 인터페이스가 아니라 클라이언트가 사용하는 기능만 갖도록 인터페이스를 분리하라는 의미로 볼 수 있다.
왜 인터페이스를 분리하는 것이 좋을까?
범용적인 인터페이스를 구현(상속)하는 클래스 입장에서 아래와 같은 문제가 발생한다.
- 사용되지 않을 메서드를 억지로 구현해야 한다.
- 사용되지 않는 메서드의 시그니쳐가 변경됐을 때 불필요하게 수정해야 한다.
위반 예시
interface Animal {
fun eat()
fun run(from: Where, to: Where)
fun fly(from: Where, to: Where)
}
class Lion : Animal {
override fun eat() = println("Lion eat")
override fun run(from: Where, to: Where) = println("Lion run")
override fun fly(from: Where, to: Where) {} // 구현 필요 없음
}
Animal의 하위에 Bird와 Mammal로 인터페이스를 분리하고 Lion이 Mammal을 상속한다면 Lion은 필요한 메서드만 구현할 수 있다.
Dependency Inversion Principle (의존성 역전 원칙)
“상위 레벨 모듈은 하위 레벨 모듈에 직접 의존하지 않고 추상화에 의존해야 한다.”
클라이언트 클래스가 의존성 대상인 구체 클래스에 의존하지 않고 추상 클래스(인터페이스)에 의존하도록 하라는 것으로 해석할 수 있다.
장점)
DIP를 지키면 하위 모듈을 변경할 때 상위 모듈에 미치는 영향을 최소화 할 수 있다. 즉, 의존 대상에 변경이 생겼을 때 클라이언트에게 미치는 영향을 최소화 할 수 있다.
예시) Car → Engine
Car의 내부에서 가솔린 엔진을 쓰다가 디젤 엔진으로 변경한다.
class GasolineEngine() {
private val fuel = "Gasoline"
}
class DieselEngine() {
private val fuel = "diesel"
}
class Car {
private val engine = GasolineEngine()
}
// 디젤 엔진으로 변경
class Car {
private val engine = DieselEngine()
}
이 방식의 단점은 Engine 을 변경하는데 Car의 코드도 함께 변경해야 한다는 것이다.
하지만 가운데에 Engine 인터페이스(추상화)를 두면, 의존성을 갖지 않던 하위 레벨 모듈인 가솔린 엔진이 상위 레벨로 도입된 엔진에 의존하는 의존성 역전이 일어난다.
class Car(private val engine: Engine)
interface Engine
class GasolineEngine : Engine
class DieselEngine : Engine
class TestEngine : Engine
fun main() {
val gasolineCar = Car(GasolineEngine())
val dieselCar = Car(DieselEngine())
val testCar = Car(TestEngine())
}
이제 어떤 엔진으로 갈아 끼우더라도 Car엔 변경이 일어나지 않는다. (Engine 자체에 혁신이 일어나서 인터페이스 명세서가 변경되지 않는 한..)
구체적인 장점)
- 구조가 유연해진다. 즉, 하나의 클래스가 여러 구체 클래스에 의존 할 수 있다.
- ⇒ 기존 코드를 변경하지 않으면서 상황에 따라 의존 대상을 변경할 수 있다. ⇒ DIP는 OCP를 지키기 위한 방법이 된다는 것을 알 수 있다!
- 구조가 유연해진 덕분에 테스트용 가짜 객체를 사용할 수 있어서 단위 테스트가 편해진다.
참조
- https://www.inflearn.com/course/알기쉬운-modern-android
- https://deep-dive-dev.tistory.com/92
- https://incheol-jung.gitbook.io/docs/study/undefined/undefined-1
- https://m.blog.naver.com/xlql555/221964359498
- https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EC%95%84%EC%A3%BC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-ISP-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EB%B6%84%EB%A6%AC-%EC%9B%90%EC%B9%99#:~:text=ISP%20%EC%9B%90%EC%B9%99%EC%9D%B4%EB%9E%80%20%EB%B2%94%EC%9A%A9%EC%A0%81%EC%9D%B8,%EC%84%A4%EA%B3%84%20%EC%9B%90%EC%B9%99%EC%9D%B4%EB%9D%BC%EA%B3%A0%20%EB%B3%B4%EB%A9%B4%20%EB%90%9C%EB%8B%A4.
'TIL' 카테고리의 다른 글
[Kotlin] Sequence (feat. 중간 객체, 지연(lazy) 연산) (0) | 2023.07.16 |
---|---|
[Kotlin] 람다(lambda)란 무엇이고 왜 사용하는 걸까? (0) | 2023.06.14 |
[Kotlin] 코틀린의 scope 함수 정리 (let, with, run, apply, also) (0) | 2023.04.04 |
(1) 의존성 주입(Dependency Injection, DI)이란? - 개념과 장점 (0) | 2023.04.03 |
[Android] ViewPager2 좌우 미리 보기 + 페이지 확대/축소 애니메이션 구현 기록 (0) | 2023.03.15 |