3월에 작성하던 글을 지금에서야 완성한 나......정말 칭찬해..!
SOLID 원칙이란 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙을 말한다.
SRP(Single Responsibility Principle): 단일 책임 원칙
OCP(Open Closed Principle): 개방 폐쇄 원칙
LSP(Listov Substitution Principle): 리스코프 치환 원칙
ISP(Interface Segregation Principle): 인터페이스 분리 원칙
DIP(Dependency Inversion Principle): 의존 역전 원칙
단일 책임 원칙은 아래와 같이 말할 수 있다.
클래스는 단 한개의 책임을 가져야 한다
만약 클래스가 여러 책임을 갖게 된다면 각 책임마다 변경되는 이유가 발생하기에, 클래스가 하나의 이유로만 변경되려면 하나의 책임만을 가져야 한다. 이러한 이유로
클래스를 변경하는 이유는 단 한개여야 한다.
라고도 할 수 있다.
class DataViewer {
fun display() {
val data: String = loadHtml()
updateGUI(data)
}
fun loadHtml(): String {
val client: HttpClient = HttpClient()
client.connect(url)
return client.getResponse()
}
private fun updateGUI(data: String) {
val guiModel: GuiData = parseDataToGuiData(data)
tableUI.changeData(guiModel)
}
private fun parseDataToGuiData(data: String): GuiData {
// 파싱 코드
// ....
}
// ... 기타 코드들
}
위 클래스는 데이터를 읽는 책임과 보여주는 책임을 가진,즉 SRP가 지켜지지 않은 코드다.
만약 데이터를 제공하는 서버가 HTML 프로토콜 대신 소켓 기반의 프로토콜로 변경되어 응답데이터로 byte배열을 제공한다면 아래와 같은 변화가 이루어 질것이다.
class DataViewer {
fun display() {
val data: String = loadHtml()
updateGUI(data)
}
fun loadHtml(): ByteArray { // 읽어온 데이터 구조의 변화 (String -> ByteArray)
val client: HttpClient = HttpClient()
client.connect(url)
return client.getResponse()
}
private fun updateGUI(data: ByteArray) { // 파라미터의 타입 변화(String -> ByteArray)
val guiModel: GuiData = parseDataToGuiData(data)
tableUI.changeData(guiModel)
}
private fun parseDataToGuiData(data: ByteArray): GuiData { // GuiData 생성 코드 변화
// 파싱 코드도 따라서 변경
// ....
// 변경
// ...
}
// ... 기타 코드들
}
이러한 연쇄적인 코드 수정은 클래스에 여러 책임이 결합되어 발생한 증상이다.
책임이 많아 질수록 한 책임의 기능 변화가 다른 책임에 주는 영향이 비례하게 커지고 변경을 어렵게 만든다.
데이터를 읽는 책임과 보여주는 책임을 분리하고 데이터 타입을 추상화 하게 되면 데이터를 읽어 오는 부분의 변경이 있다 해도 데이터를 화면에 보여주는 부분의 코드는 변경되지 않는다.
개방 폐쇄 원칙은 확장에는 열려있어야 하며, 수정에는 닫혀있어야 한다를 의미한다.
[확장에는 열려있어야 하며]
기능을 변경하거나 확장할 수 있으면서[수정에는 닫혀있어야 한다]
그 기능을 사용하는 코드는 수정하지 않는다
class Character(val type: String)
class ExplainCharacter {
fun explain(character: Character) {
if (character.type == "player") {
println("Player Character")
} else if (character.type == "enemy") {
println("Enemy Character")
} else if (character.type == "lion") {
println("Lion Character")
}
}
}
fun main() {
val explainCharacter = ExplainCharacter()
val player = Character("player")
val lion = Character("lion")
explainCharacter.explain(player)
explainCharacter.explain(lion)
}
위 코드는 동작에서는 문제는 없다. 그러나 기능을 추가하는데 있어서 문제가 존재한다.
만약 '독수리' 라는 캐릭터가 추가된다면 ExplainCharacter
에 새로운 분기문을 추가해주면서 수정을 해주어야 할것이다.
이는 캐릭터를 추가하는데 Character
클래스가 닫혀있지 않아 발생하는 문제이다.
package lotto.test
abstract class Character {
abstract fun hello()
}
class Player : Character() {
override fun hello() {
println("Player Character")
}
}
class Enemy : Character() {
override fun hello() {
println("Enemy Character")
}
}
class Lion : Character() {
override fun hello() {
println("Lion Character")
}
}
class ExplainCharacter {
fun explain(character: Character) {
character.hello()
}
}
fun main() {
val explainCharacter = ExplainCharacter()
val player = Player()
val lion = Lion()
explainCharacter.explain(player)
explainCharacter.explain(lion)
}
위와 같이 구성하게 되면 기능(캐릭터)가 추가 됐을 때에도 코드의 수정없이 확장을 할 수 있다.
개방 폐쇄 원칙은 변경의 유연함과 관련된 원칙이다.
변화가 예상되는 것을 추상화해서 변경의 유연함을 얻도록 해준다. 이 말은 변화되는 부분을 추상화 하지 못하면 시간이 흐를수록 기능 변경이나 확장을 어렵게 만든다는 것을 뜻한다.
리스코프 치환 원칙은 다음과 같다.
상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.
코드로 표현하자면 상위 타입 SuperClass
와 하위 타입 SubClass
가 있을 때
fun testMethod(sc: SuperClass) {
sc.someMethod()
}
이 메서드에 다음과 같이 하위 타입의 객체를 전달해도 정상적으로 동작해야함을 의미한다.
testMethod(Subclass())
직사각형을 표현하기 위한 Rectangle
클래스는 가로와 세로 두 값을 구하거나 수정하는 기능을 제공한다.
open class Rectangle {
private var width: Int = 0
private var height: Int = 0
open fun setWidth(value: Int) {
width = value
}
open fun setHeight(value: Int) {
height = value
}
open fun getWidth(): Int {
return width
}
open fun getHeight(): Int {
return height
}
}
정사각형은 직사각형에 포함되어 있으니 정사각형을 표한하기 위해 Square클래스가 Rectangle 클래스를 상속받도록 구현을 해주었다.
class Square : Rectangle() {
override fun setWidth(width: Int) {
super.setWidth(width)
super.setHeight(width)
}
override fun setHeight(height: Int) {
super.setWidth(height)
super.setHeight(height)
}
}
도형의 높이와 폭을 비교 후 높이를 길게 만들어 주는 함수가 있다.
fun increaseHeight(rec: Rectangle) {
if (rec.getHeight() <= rec.getHeight()) {
rec.setHeight(rec.getWidth() + 10)
}
}
만약 rec에 정사각형이 들어가게 되면 함수를 실행하더라도 높이는 폭보다 절대 길어지지 않을 것이다. 이 문제를 해소하기 위해서는 rec의 실제 타입이 Square인 경우 기능을 실행하지 않도록 해주면 된다.
fun increaseHeight(rec: Rectangle) {
if (rec is Rectangle) return
if (rec.getHeight() <= rec.getHeight()) {
rec.setHeight(rec.getWidth() + 10)
}
}
하지만 is
를 사용한다는 것 자체가 리스코프 치환 원칙에 위반되는 것이고, 이는 increaseHeight()
메서드가 Rectangle의 확장에 열려있지 않다는 의미다.
리스코프 치환 원칙은 기능의 명세(혹은 계약)에 대한 내용이다.
앞서 직사각형-정사각형 문제에서 Rectangle 클래스의 setHeight()
메서드는 다음과 같은 명세를 가지고 있다.
setHeight()
를 호출하는 코드는 높이만 변경될 뿐 폭은 변경되지 않을 것이라 가정하는데, Square 클래스의 setHeight()
는 폭까지 변경한다. 따라서 예상을 벗어난 동작을 하게 된 것이다.
리스코프 치환 원칙은 다음과 같다.
객체는 자신이 사용하는 메서드에만 의존해야 한다.
구현 할 객체에게 필요한 메소드를 상속/구현 해주어야 한다. 만약, 상속할 객체의 크기가 크다면, 해당 객체의 메소드를 인터페이스로 분리해 주는 것이 좋다.
위는 너무 큰 객체를 상속했을 떄 발생하는 문제점을 도식화한 그림이다.
비둘기
와 사자
라는 객체가 동물
을 상속할 경우, 비둘기
는 필요한 메소드가 모두 구현되기에 문제가 발생하지 않는다. 그러나, 사자
의 경우, 걷다
라는 메서드를 제외한 나머지 메소드를 필요로 하지 않는다. 하지만, 이를 상속했기에 불필요한 메소드를 구현할 수 밖에 없다.
이런 문제를 피하기 위해 동물
의 메소드를 인터페이스로 분리해주었다.
각 객체가 필요한 동작에 대한 인터페이스를 상속하면 되므로, 각자 필요한 메소드만을 가질 수 있다.
의존 역전 원칙의 정의는 다음과 같다.
고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
말이 좀 어렵다.. 고수준은 뭐고 저수준은 뭘까?
그래도 이해가 잘 되지 않는데 일단 코드로 살펴보자!
class AirPodPro(private val nickName: String) {
override fun toString(): String {
return nickName
}
}
class AirPod(private val nickName: String) {
override fun toString(): String {
return nickName
}
}
class Human(private val name: String, private val earphone: AirPod) {
fun printEarphoneNickName() {
println(earphone.toString())
}
}
만약 위 코드에서 사람이 이어폰을 바꾸게 된다면, Human
의 코드를 수정해주어야 한다(개방-폐쇄원칙을 위배). 매번 이어폰을 바꿔주려면 Human
을 수정해주어야 한다.
이는 Human
이 AirPod
을 직접 의존하기에 생기는 문제이다.
이를 다르게 말하자면, 완전히 구현된 저수준 모듈을 의존하고 있기에 생기는 문제이다.
이 문제를 해결하기 위해선, 추상타입인 고수준의 모듈을 의존하도록 리팩토링을 해주어야 한다.
interface Earphone
class AirPodPro(private val nickName: String) : Earphone {
override fun toString(): String {
return nickName
}
}
class AirPod(private val nickName: String) : Earphone {
override fun toString(): String {
return nickName
}
}
class Human(private val name: String, private val earphone: Earphone) {
fun printEarphoneNickName() {
println(earphone.toString())
}
}
fun main() {
val human = Human("seogi", AirPodPro("AirPod Pro"))
val huma2 = Human("seogi", AirPod("AirPod"))
}
AirPod
과 AirPodPro
를 Earphone
으로 추상화해주었다.
그리고 Human
은 추상타입인 Earphone
에 의존하게된다. 즉, 고수준 모듈을 의존하게 된것이다.
이제 이어폰을 바꾸더라도 Human
의 코드를 수정할 필요는 없어진다!
의존 역전 원칙은 코드의 확장 및 재사용성을 높여주고 모듈 간의 결합도를 낮춰 유지보수가 용이하도록 해주는 원칙이다.
개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴