객체지향 5원칙 (SOLID)

kkosang·2024년 3월 25일
0
post-thumbnail

객체지향 프로그래밍을 하다 보면 한 번쯤은 객체지향 5원칙이라는 말을 들어봤을 것이다. 그렇다면 객체지향 5원칙 (SOLID)가 무엇일까?

나무위키의 개요에 보면 이렇게 적혀있다.

객체지향에서 꼭 지켜야 할 5개의 원칙을 통틀어 객체지향 5원칙이라 칭한다. 일단 한번 보면 개념은 알아 듣긴 하지만 막상 실현하려면 생각보다 어려움이 따른다. 이 5개의 원칙의 앞글자를 따서 SOLID라고도 부른다.

한번 보면 알아 듣는다고 하는데, 나는 알아 듣지도 못했다...

그래서 블로그로 정리하면서 학습하려한다. 사실 비공식 숙제이다..


단일 책임 원칙 (SRP : Single Responsibility Principle)

하나의 모듈이 하나의 책임을 가져야 한다.
단일 책임 원칙의 정의는 이렇다.

무슨 말인지 잘 와닿지 않는다. 모듈이 변경되어야 하는 이유가 한가지여야 한다. 라고 생각하면 이해하기에 더 쉬운 것 같다.


class Car(val name: String){

	fun move () {} // 이동
    
	fun wash () {} // 세차
}

SRP 원칙 위배 코드


이 코드는 단일 책임 원칙을 지키고 있는가?

아니다, 현재 Car 클래스는 단일 책임 원칙을 위배하고 있다.
Car라는 클래스안에 이동과 관련된 move 함수와, 세차와 관련된 wash 함수가 있다.

만약 자동차를 세차하는 로직이 변경된다면, wash 함수가 변경되어야 한다.
그 뿐만 아니라 자동차의 이동 방식이 변경되는 경우에도 move 함수는 변경되어야 한다.

위에서 이야기 했듯이, 모듈이 변경되어야 하는 이유가 한가지일 때, 단일 책임 원칙을 지키고 있는 것이다. 하지만 현재는 모듈이 변경되어야 하는 이유가 세차 방식이 변경되는 경우, 이동 방식이 변경되는 경우로 두가지 이유 때문에 모듈이 변경될 수 있다.

그럼 위 코드를 어떻게 수정하면 단일 책임 원칙을 지킬 수 있을까?

SRP 원칙 적용


class Car(val name: String){
	fun move () {} // 이동
}


class CarWash {
    fun washWithMachine(car: Car) {} 
    
    fun washWithHands(car: Car) {}
}

이렇게 Car 클래스CarWash 클래스로 분리하면, 단일 책임 원칙을 지킬 수 있다.
Car 클래스는 자동차의 핵심 기능에만 집중하고, 세차와 같은 외부 동작에 대해 알 필요가 없게 된다.
CarWash 클래스에서는 자동차의 세차와 관련된 로직을 담당하고, Car 클래스의 세부적인 내용에는 의존하지 않을 수 있다.


개방 폐쇄 원칙 (OCP : Open/Closed Principle)

개방 폐쇄 원칙은 확장에 대해서는 개방적(open)이면서 수정에 대해서는 폐쇄적(closed)이어야 한다.

이 또한 잘 와닿지 않는다. 개방적이고 폐쇄적이 무엇을 뜻하는 걸까?

  • 확장에 대해 개방적이다 : 요구사항이 변경될 때 새로운 동작을 추가하여 기능을 확장할 수 있다.
  • 수정에 대해 폐쇄적이다 : 기존의 코드를 수정하지 않고 동작을 추가하거나 변경할 수 있다.

객체지향의 4가지 특징 중 추상화를 사용하면 개방 폐쇄 원칙을 적용할 수 있을 것 같다.


OCP 원칙 위배 코드

class Animal(val type: String)

class AnimalVoice {
    fun cry(animal: Animal) {
        when (animal.type) {
            "cat" -> println("야옹")
            "dog" -> println("멍멍")
        }
    }
}

위의 코드는 동물의 울음소리를 나타내는 코드이다.

현재 AnimalVoice 클래스에는 cry함수를 통해 동물의 타입을 분기처리 하여 울음소리를 출력하고 있다.
현재 코드에서 요구사항이 추가될 때 (다른 동물이 들어오는 경우) 기존 코드를 다음과 같이 수정해야한다.

OCP 원칙 적용


class Animal(val type: String)

class AnimalVoice {
    fun cry(animal: Animal) {
        when (animal.type) {
            "cat" -> println("야옹")
            "dog" -> println("멍멍")
            "pig" -> println("꿀꿀")
            "chicken" -> println("꼬끼오")
        }
    }
}

만약 동물이 계속해서 추가되는 경우, 기존의 코드를 수정하면서 기능을 추가해 나가야한다.
이때 사용할 수 있는 방법이 추상화를 사용하여 OCP 원칙을 지킬 수 있다.


interface AnimalSound {
    fun makeSound()
}

class Cat : AnimalSound {
    override fun makeSound() {
        println("야옹")
    }
}

class Dog : AnimalSound {
    override fun makeSound() {
        println("멍멍")
    }
}

class Pig : AnimalSound {
    override fun makeSound() {
        println("꿀꿀")
    }
}

class Chicken : AnimalSound {
    override fun makeSound() {
        println("꼬끼오")
    }
}

class AnimalVoice {
    fun cry(animal: AnimalSound) {
        animal.makeSound()
    }
}

이처럼 추상화를 사용하면 요구사항이 변경될 때 새로운 동작을 추가(open) 할 수 있고 , 기존의 코드를 수정(closed) 하지 않아도 된다.


리스코프 치환 원칙 (LSP : Liskov’s Substitution Principle)

리스코프 치환 원칙은 1988년 바바라 리스코프(Barbara Liskov)가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것으로, 서브 타입은 언제나 기반 타입으로 교체...

이것 또한 잘 모르겠다.

요약하자면, 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용했을 때도 원래 의도대로 동작해야 한다.

자식클래스는 부모 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행하도록 해야 LSP를 만족한다.

객체지향의 4가지 특징 중 다형성을 지원하기 위한 원칙을 리스코프 치환이라고 한다.

LSP 원칙 위배 코드

interface Duck {
    fun speak()

    fun fly()
}

class MallardDuck : Duck {
    override fun speak() {
        println("꽉꽉")
    }

    override fun fly() {
        println("오리날다")
    }
}

class RubberDuck : Duck {
    override fun speak() {
        println("꽉")
    }

    override fun fly() {
        throw IllegalStateException("장난감 오리는 날 수 없어요 ㅠㅠ")
    }
}

fun main() {
    val ducks = listOf(MallardDuck(), RubberDuck())
    ducks.forEach { duck -> duck.fly() }
}

위의 코드는Duck 인터페이스를 MallardDuckRubberDuck에서 구현하고 있다.

MallardDuck에서 fly()메서드를 사용했을 때는 제대로 동작한다.

하지만 인터페이스의 명시대로 RubberDuck에서 fly()를 사용하면 예상치 못하게 오류가 발생한다.

이 코드를 어떻게 수정하면 LSP를 위배하지 않고 작동시킬 수 있을까?


LSP 원칙 적용


interface Duck {
    fun speak()
}

interface Flyable : Duck {
    fun fly()
}

class MallardDuck : Flyable {
    override fun speak() {
        println("꽉꽉")
    }

    override fun fly() {
        println("오리날다")
    }
}

class RubberDuck : Duck {
    override fun speak() {
        println("꽉")
    }
}

fun main() {
    val ducks = listOf(MallardDuck(), RubberDuck())
    ducks.forEach { duck ->
        duck.speak()
        if (duck is Flyable) {
            duck.fly()
        }
    }
}

LSP 원칙을 적용하기위해, flying로직을 분리했다.

인터페이스 분리 원칙 (ISP: Interface Segregation Principle)

위키백과의 정의에 따르면,

인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.
인터페이스 분리 원칙은 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다.

네 번째 원칙에 와서야 무슨 말인지 조금 이해가된다..

클라이언트가 필요한 기능에만 의존하도록 인터페이스를 작게 분리하라는 뜻인 것 같다.

바로 코드로 알아보도록 하자 !

ISP 원칙 위배 코드

다음과 같이 Document 인터페이스를 정의하자.

interface Document {
    fun open()
    
    fun save()
    
    fun print()
    
    fun sendViaEmail()
}

Document 인터페이스를 사용하는 PdfDocument와 WordDocument가 있다.

class PdfDocument : Document {
    override fun open() { 
    	// pdf open
    } 

    override fun save() {
     	// pdf save	
    }

    override fun print() {
        // pdf print
    }

    override fun sendViaEmail() {
        // pdf send
    }
}

class WordDocument : Document {
    override fun open() {
        // word open
    }

    override fun save() {
        // word save
    }

    override fun print() {
        // word print
    }

    override fun sendViaEmail() {
        // word send
    }
}

여기서 문제는 WordDocument에서 sendViaEmail을 사용하지 않아도 불필요하게 구현해주어야 한다.

이러한 문제를 인터페이스를 작게 분리하여 해결할 수 있다.

ISP 원칙 적용

다음과 같이 Document 인터페이스를 작게 분리했다.

interface OpenSave {
	fun open()
    
    fun save()
}

interface Print {
	fun print()
}

interface Send {
	fun sendViaEmail()
}

이제 필요한 인터페이스만 가져다가 사용하면 된다 !

class PdfDocument : OpenSave,Send {
    override fun open() { 
    	// pdf open
    } 

    override fun save() {
     	// pdf save	
    }

    override fun sendViaEmail() {
        // pdf send
    }
}

class WordDocument : OpenSave,Print {
    override fun open() {
        // word open
    }

    override fun save() {
        // word save
    }

    override fun print() {
        // word print
    }
}

인터페이스를 분리하면, 각 클래스가 필요한 인터페이스만 구현할 수 있다.
이렇게 하면 코드의 가독성 및 유지보수가 쉬워진다.

의존관계 역전 원칙 (DIP : Dependency Inversion Principle)

마지막으로, 객체지향 5원칙의 D인 의존관계 역전 원칙이다.

이것 역시 위키백과에 따르면,

객체 지향 프로그래밍에서 의존관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다. 이 원칙은 다음과 같은 내용을 담고 있다.

첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
이 원칙은 '상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다'는 객체 지향적 설계의 대원칙을 제공한다.

요약하자면, 하위 모듈의 직접적인 인스턴스를 가져다 쓰지않고 인터페이스로 추상화하여 통신하라는 의미이다.

DIP 원칙 위배 코드

다음 코드는 고수준모듈인 PaymentService에서 저수준모듈인 SamsungPayApplePay를 직접적으로 의존하고 있어 DIP를 위배하고 있다.

class PaymentService {
	// 직접적으로 의존
    private val samsungPay = SamsungPay()
    private val applePay = ApplePay()

    fun processSamsungPay(amount: Double): Boolean {
        return samsungPay.processPayment(amount)
    }

    fun processApplePay(amount: Double): Boolean {
        return applePay.processPayment(amount)
    }
}

class SamsungPay {
    fun processPayment(amount: Double): Boolean {
        // SamsungPay 
        return true
    }
}

class ApplePay {
    fun processPayment(amount: Double): Boolean {
        // ApplePay
        return true
    }
}


fun main() {
    val paymentService = PaymentService()
    println(paymentService.processSamsungPay(1500.0)) 
    println(paymentService.processApplePay(1500.0)) 
}

저수준모듈에 직접적으로 의존하고 있는것이 아닌 추상화된 인터페이스에 의존하면 DIP를 적용할 수 있다.

DIP 원칙 적용

PaymentProcessor라는 인터페이스를 선언한 뒤, 인터페이스에 의존하도록 수정한다.

interface PaymentProcessor {
    fun processPayment(amount: Double): Boolean
}

class SamsungPay : PaymentProcessor {
    override fun processPayment(amount: Double): Boolean {
        // SamsungPay
        return true
    }
}

class ApplePay : PaymentProcessor {
    override fun processPayment(amount: Double): Boolean {
        // ApplePay
        return true
    }
}

class PaymentService(private val paymentProcessor: PaymentProcessor) {
    fun processPayment(amount: Double): Boolean {
        return paymentProcessor.processPayment(amount)
    }
}

fun main() {
    val samsungPay = SamsungPay()
    val applePay = ApplePay()
    
    val samsungPaymentService = PaymentService(samsungPay)
    val applePaymentService = PaymentService(applePay)

    println(samsungPaymentService.processPayment(1500.0)) 
    println(applePaymentService.processPayment(1500.0)) 
}

DIP 원칙을 적용하면, 유지보수성과 확장성을 향상 시킬 수 있다.

결론

객체지향의 설계 원칙인 SOLID를 적용하면, 코드의 응집도가 높아지고 결합도는 낮아지므로 유연성과 확장성이 향상된다. 또한 유지보수와 테스트하기에 용이하다는 장점이 있다.


SOLID에 대해 글로 읽었을 때는 잘 와닿지 않았다. 블로그로 정리하기 위해 직접 코드를 작성하면서 SOLID에 대해 조금 더 명확하게 알게 되었고 실제 프로젝트에서 SOLID 원칙을 적용시켜 보고자 노력할 것이다!

참고자료

https://amrsaeedhosny.medium.com/solid-principles-liskov-substitution-principle-7ed01fe51f23
https://danielangel22.medium.com/the-fourth-solid-principle-interface-segregation-in-java-4338475b29
https://blog.hexabrain.net/395

0개의 댓글