OOP

OOP - SOLID 원칙

🤖 Play with Android 🤖 2022. 10. 27. 21:34
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 즉 의존역전원칙은 클래스 간 결합을 느슨히 하여 한 클래스의 변경에 따른 다른 클래스들의 영향을 최소화함으로써 프로그램을 지속가능하고 확장성 있게 만든다.