[OOP] 객체지향 설계의 5가지 원칙 SOLID

신민준·6일 전
0

SOLID 원칙은 소프트웨어 개발시 보다 Understandable, Flexible, Maintainable 하도록 도와주는 5가지 원칙을 말한다.
즉 프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 사용하는 원칙이다.

S.O.L.I.D

SOLID는 5가지 원칙의 앞글자를 따서 약어로 만든 단어로 각 원칙은 다음과 같다,

  • Single-Reponsibility Principle(SRP)
  • Open Close Principle(OCP)
  • Liskov Subsituation Principle(LSP)
  • Interface Segregation Principle(ISP)
  • Dependency Inversion Principle(DIP)

Single-Responsibility Principle

Single-Responsibility Principle(단일 책임 원칙)은 한 마디로 다음과 같다.

객체는 한 가지 책임/역할만 가져야 한다.

즉 객체가 변경될 경우 하나의 이유로만 변경이 되어야 한다고 볼 수 있다.

왜 지켜야 하는가?

객체가 모든 일을 다하면 편하고 좋을텐데 왜 책임을 분리할까?
그 이유는 만약 객체가 너무도 많은 책임을 가지고 있을 때 객체의 크기가 굉장히 커짐
-> 해당 객체에서 문제가 발생했을 때 연쇄적인 변화, side effect가 커진다.

어떻게 적용할 수 있을까?

추상화를 통해 혼재된 책임을 각각의 책음으로 분리하여 하나의 책임만 갖도록 설계한다.
SRP를 통해 다음과 같은 문제점을 해결할 수 있다.

  • Divergent Change
    -> 떨어뜨려야 할 것을 모아둘 때 발생하는 문제로 혼재된 책임을 각각의 개별 클래스로 분할하여 하나의 책임만을 갖도록 하여 해결
  • Shotgun Surgery
    -> 모아둬야 할 것이 산발적으로 분포되어 있을 때 발생하는 문제로 책임을 기존의 클래스에 모으거나 새로운 크래스를 만들어 해결

Open Close Principle

Open Close Principle(개방폐쇄원칙)은 한 마디로 다음과 같다.

객체는 확장에 열려있고 변경에는 닫혀있어야 한다.

그렇다면 확장에 열려있다는 것은 무엇이고 변경에 닫혀있다는 것은 무엇일까?

  • 확장에 열려있다 : 객체의 행위가 확장될 수 있다. 행위를 추가해 객체가 하는 일을 바꿀 수 있다.
  • 변경에 닫혀있다 : 객체의 확장에 기존 구성요소의 수정이 일어나지 말아야 한다.

왜 지켜야 하는가?

기능을 확장할 때마다 기존의 코드를 수정해야 한다면 비용이 너무 많이 소모된다.
-> 기존 코드를 변경하지 않고 쉽게 확장할 수 있어 재사용성과 유연성과 같은 객체 지향의 장점을 극대화

어떻게 적용할 수 있을까?

다음의 단계를 통해 OCP를 적용할 수 있다.

  1. 변경(확장)할 것과 변하지 않을 것을 구분한다.
  2. 이 두 모듈이 만나는 지점에 인터페이스를 정의한다. (두 지점이 소통하는 부분)
  3. 구현에 의존하기 보다는 정의한 인터페이스를 의존하도록 코드를 작성한다.

코드로 보면 다음과 같다.

class PaymentProcessor {
    fun processPayment(method: String) {
        when (method) {
            "CreditCard" -> println("CreditCard 결제")
            "PayPal" -> println("PayPal 결제")
            else -> println("Unknown payment method")
        }
    }
}

위처럼 PaymentProcessor가 정의되어 있을 때 애플페이와 같은 새로운 결제 수단을 추가하려면 when문에 새로운 조건을 추가해야 한다.

위 코드는 매우 간단하여 추가가 어렵지 않지만 프로그램이 거대해질수록 직접 추가하는 데에는 비용이 많이 소모된다.

그렇기에 변경할 부분인 결제수단과 변하지 않는 부분인 결제 그 자체로 구분하고 이것을 인터페이스로 정의하고 이를 의존하도록 수정해준다.

interface PaymentMethod {
    fun pay()
}

class CreditCardPayment : PaymentMethod {
    override fun pay() {
        println("CreditCard 결제")
    }
}
class PayPalPayment : PaymentMethod {
    override fun pay() {
        println("PayPal 결제")
    }
}
class ApplePayPayment : PaymentMethod {
	override fun pay() {
    	println("ApplePay 결제")
    }
}

이렇게 수정하면 새로운 결제 수단을 추가하기에도 원활하고 기존의 결제처리방식을 수정할 때에도 해당 결제방식만 수정해주면 된다.

Liskov Substitution Principle

Liskov Subsititution Principle(리스코프 치환 원칙)은 한 마디로 다음과 같다.

서브 타입은 항상 기반 타입으로 교체할 수 있어야 한다.

서브 타입은 언제나 기반 타입과 호환될 수 있어야 하며(하위 클래스가 상위 클래스로 바뀌어도 문제 없어야함)
서브 타입은 기반 타입이 정해둔 약속을 지켜야 한다.
여기서 말하는 약속은 기반 타입이 정의한 public 인터페이스, 예외 처리등을 말한다.

왜 지켜야 하는가?

LSP가 지켜지지 않는다는 것은 OCP가 지켜지지 않는다는 것과 같다.
OCP는 상속을 통해 지켜지고 LSP는 규약이 준수된 상속 구조를 보장한다.
그렇기에 LSP에서 주장하는 기반 타입과의 호환이 보장되지 않으면 적절한 수행이 어렵다.

어떻게 적용할 수 있을까?

기반 타입과 서브 타입이 IS-A 관계를 가져야 한다.
예를 들면
"정사각형 is a 직사각형이다" -> 어색하지 않음
"정사각형의 높이 변경 is a 직사각형의 높이 변경" -> 어색함

코드로 보면 다음과 같다,

// 부모 클래스 : 직사각형
open class Rectangle(var width: Int, var height: Int) {
    open fun setWidth(newWidth: Int) {
        width = newWidth
    }

    open fun setHeight(newHeight: Int) {
        height = newHeight
    }

    fun area(): Int {
        return width * height
    }
}

// 자식 클래스 : 정사각형
class Square(side: Int) : Rectangle(side, side) {
    override fun setWidth(newWidth: Int) {
        width = newWidth
        height = newWidth // 정사각형은 너비와 높이가 항상 같아야 함
    }

    override fun setHeight(newHeight: Int) {
        height = newHeight
        width = newHeight // 정사각형은 너비와 높이가 항상 같아야 함
    }
}

fun main() {
    val rectangle: Rectangle = Square(5) // 정사각형을 직사각형으로 취급

    rectangle.setWidth(10) // 너비 변경
    println("Width: ${rectangle.width}, Height: ${rectangle.height}") // 너비와 높이가 둘 다 변경됨 -> 직사각형의 예상과 다름
}

그렇다면 이 문제를 어떻게 해결할 수 있을까?

open class Rectangle(var width: Int, var height: Int) {
    fun area(): Int {
        return width * height
    }
}

// 정사각형 클래스 정의 
class Square(side: Int) {
    private val rectangle = Rectangle(side, side) // 내부적으로 직사각형을 포함

    var side: Int
        get() = rectangle.width
        set(value) {
            rectangle.width = value
            rectangle.height = value 
        }

    fun area(): Int {
        return rectangle.area()
    }
}

다양한 방법이 있겠지만 이런 식으로
정사각형 Has-A 직사각형
관계를 갖도록 수정해볼 수 있다.

Interface Segregation Principle

Interface Segregation Principle(인터페이스 분링 원칙)은 한 마디로 다음과 같다.

인터페이스는 자신의 클라이언트가 사용할 메소드만 가지고 있어야 한다.

만약 어떤 클래스를 이용하는 클라이언트가 여러 개고 이들이 해당 클래스의 특정 부분집합만을 이용한다면 이들을 따로 인터페이스로 분리한다.

왜 지켜야 하는가?

예를 들어 인터페이스가 너무도 비대해진다면 해당 인터페이스의 일정 부분만 필요하더라도 구현이 강제되고 변경에 영향을 받는다는 문제가 발생한다.

SRP가 클래스의 단일 책임을 강조한다면 ISP는 인터페이스의 단일 책임을 강조한다.

어떻게 적용할 수 있을까?

하나의 큰 인터페이스를 여러 개의 작은 인터페이스로 나누어 각 클라이언트가 자신에게 필요한 인터페이스만 의존하도록 설계해야 한다.

코드로 보면 다음과 같다.

interface Worker {
    fun work()
    fun eat()
    fun sleep()
}

class OfficeWorker : Worker {
    override fun work() {
        println("Office worker is working.")
    }

    override fun eat() {
        println("Office worker is eating.")
    }

    override fun sleep() {
        println("Office worker is sleeping.")
    }
}

class Robot : Worker {
    override fun work() {
        println("Robot is working.")
    }

    override fun eat() {
        throw UnsupportedOperationException("Robots do not eat.")
    }

    override fun sleep() {
        throw UnsupportedOperationException("Robots do not sleep.")
    }
}

코드를 보면 RobotOfficeWorker는 모두 Worker 인터페이스를 구현하지만 Robot같은 경우에는 먹지도 자지도 않지만 불필요하게 구현해야 한다는 문제가 있다.
단순하게 이를 해결해보면

interface Workable {
    fun work()
}

interface Eatable {
    fun eat()
}

interface Sleepable {
    fun sleep()
}

class OfficeWorker : Workable, Eatable, Sleepable {
    override fun work() {
        println("Office worker is working.")
    }

    override fun eat() {
        println("Office worker is eating.")
    }

    override fun sleep() {
        println("Office worker is sleeping.")
    }
}

class Robot : Workable {
    override fun work() {
        println("Robot is working.")
    }
}

각각의 인터페이스를 분리하고 각 클래스가 필요한 기능만을 구현하는 방식으로 분리할 수 있다.

Dependency Inversion Principle

Dependency Inversion Principle(의존성 역전 원칙)은 한 마디로 다음과 같다.

구체적인 것이 추상적인 것에 의존해야 한다.

상위 객체가 하위 객체에 의존하지 않고 추상화된 인터페이스를 통해서 상호작용하도록 의존성을 반전시킨다.

왜 지켜야 하는가?

상위 객체가 하위 객체에 의존을 가진다면 요구사항이 변경됨에 따라 기능의 확장 및 변경, 재사용이 어렵다는 문제가 발생한다.

어떻게 적용할 수 있을까?


동일 레벨에서는 추상화에 의존하고 하위레벨이 상위 레벨의 추상화를 의존하게 만들어 관계를 역전시키는 방식으로 적용할 수 있다.
이렇게만 보면 이해가 조금 어렵다.

이해를 돕기위해 DIP가 지켜지지 않는 상황의 예시를 코드로 보면 다음과 같다.

// 저수준 : 소리 알람
class SoundAlarm {
    fun ring() {
        println("Beep! Beep! 알람이 울립니다!")
    }
}

// 고수준 : 알람 시스템
class AlarmSystem {
    private val soundAlarm = SoundAlarm() // 저수준에 직접 의존

    fun triggerAlarm() {
        soundAlarm.ring() // 소리 알람만 작동 가능
    }
}

fun main() {
    val alarmSystem = AlarmSystem()
    alarmSystem.triggerAlarm()
}

상위 개념인 알람 시스템이 하위 개념인 구체적인 소리 알람에 직접적으로 의존하여 소리 알람이 아닌 진동 알람과 같은 추가적인 확장이 어렵다.
이를 해결보면

// 추상화 : 알람 인터페이스
interface Alarm {
    fun trigger()
}

// 저수준1 : 소리 알람
class SoundAlarm : Alarm {
    override fun trigger() {
        println("Beep! Beep! 알람이 울립니다!")
    }
}

// 저수준2 : 진동 알람
class VibrationAlarm : Alarm {
    override fun trigger() {
        println("Bzzzz! 진동 알람이 울립니다!")
    }
}

// 고수준 : 알람 시스템
class AlarmSystem(private val alarm: Alarm) {
    fun triggerAlarm() {
        alarm.trigger() // 추상화된 인터페이스를 통해 호출
    }
}

fun main() {
    // 소리 알람 사용
    val soundAlarmSystem = AlarmSystem(SoundAlarm())
    soundAlarmSystem.triggerAlarm()

    // 진동 알람 사용
    val vibrationAlarmSystem = AlarmSystem(VibrationAlarm())
    vibrationAlarmSystem.triggerAlarm()
}

AlarmSystemAlarm 인터페이스만 의존하여 구체적인 알람에는 의존하지 않는다.
이렇게 구현할 경우 새로운 알람 방식에 대해서도 AlarmSystem코드를 수정할 필요없이 쉽게 추가할 수 있다.

참고자료

https://www.nextree.co.kr/p6960/
https://www.youtube.com/watch?v=WAufmN_ki4o&list=PLp_FpnyDwvuA9vzOImAAn4COaa9yK71hd
https://www.youtube.com/watch?v=7c0tqHLfxlE
https://enhancement.tistory.com/5

profile
안드로이드 외길

0개의 댓글