객체지향 프로그래밍을 하다 보면 한 번쯤은 객체지향 5원칙이라는 말을 들어봤을 것이다. 그렇다면 객체지향 5원칙 (SOLID)가 무엇일까?
나무위키의 개요에 보면 이렇게 적혀있다.
객체지향에서 꼭 지켜야 할 5개의 원칙을 통틀어 객체지향 5원칙이라 칭한다. 일단 한번 보면 개념은 알아 듣긴 하지만 막상 실현하려면 생각보다 어려움이 따른다. 이 5개의 원칙의 앞글자를 따서 SOLID라고도 부른다.
한번 보면 알아 듣는다고 하는데, 나는 알아 듣지도 못했다...
그래서 블로그로 정리하면서 학습하려한다. 사실 비공식 숙제이다..
하나의 모듈이 하나의 책임을 가져야 한다.
단일 책임 원칙의 정의는 이렇다.
무슨 말인지 잘 와닿지 않는다. 모듈이 변경되어야 하는 이유가 한가지여야 한다. 라고 생각하면 이해하기에 더 쉬운 것 같다.
class Car(val name: String){
fun move () {} // 이동
fun wash () {} // 세차
}
이 코드는 단일 책임 원칙을 지키고 있는가?
아니다, 현재 Car 클래스
는 단일 책임 원칙을 위배하고 있다.
Car라는 클래스안에 이동과 관련된 move 함수와, 세차와 관련된 wash 함수
가 있다.
만약 자동차를 세차하는 로직이 변경된다면, wash 함수가 변경되어야 한다.
그 뿐만 아니라 자동차의 이동 방식이 변경되는 경우에도 move 함수는 변경되어야 한다.
위에서 이야기 했듯이, 모듈이 변경되어야 하는 이유가 한가지일 때, 단일 책임 원칙을 지키고 있는 것이다. 하지만 현재는 모듈이 변경되어야 하는 이유가 세차 방식이 변경되는 경우, 이동 방식이 변경되는 경우로 두가지 이유 때문에 모듈이 변경될 수 있다.
그럼 위 코드를 어떻게 수정하면 단일 책임 원칙을 지킬 수 있을까?
class Car(val name: String){
fun move () {} // 이동
}
class CarWash {
fun washWithMachine(car: Car) {}
fun washWithHands(car: Car) {}
}
이렇게 Car 클래스
와 CarWash 클래스
로 분리하면, 단일 책임 원칙을 지킬 수 있다.
Car 클래스는 자동차의 핵심 기능에만 집중하고, 세차와 같은 외부 동작에 대해 알 필요가 없게 된다.
CarWash 클래스에서는 자동차의 세차와 관련된 로직을 담당하고, Car 클래스의 세부적인 내용에는 의존하지 않을 수 있다.
개방 폐쇄 원칙은 확장에 대해서는 개방적(open)이면서 수정에 대해서는 폐쇄적(closed)이어야 한다.
이 또한 잘 와닿지 않는다. 개방적이고 폐쇄적이 무엇을 뜻하는 걸까?
객체지향의 4가지 특징 중 추상화를 사용하면 개방 폐쇄 원칙을 적용할 수 있을 것 같다.
class Animal(val type: String)
class AnimalVoice {
fun cry(animal: Animal) {
when (animal.type) {
"cat" -> println("야옹")
"dog" -> println("멍멍")
}
}
}
현재 AnimalVoice
클래스에는 cry함수를 통해 동물의 타입을 분기처리 하여 울음소리를 출력하고 있다.
현재 코드에서 요구사항이 추가될 때 (다른 동물이 들어오는 경우) 기존 코드를 다음과 같이 수정해야한다.
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) 하지 않아도 된다.
리스코프 치환 원칙은 1988년 바바라 리스코프(Barbara Liskov)가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것으로, 서브 타입은 언제나 기반 타입으로 교체...
이것 또한 잘 모르겠다.
요약하자면, 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용했을 때도 원래 의도대로 동작해야 한다.
자식클래스
는 부모 클래스의 책임을 무시하거나 재정의하지 않고 확장
만 수행하도록 해야 LSP를 만족한다.
객체지향의 4가지 특징 중 다형성을 지원하기 위한 원칙을 리스코프 치환이라고 한다.
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
인터페이스를 MallardDuck
과 RubberDuck
에서 구현하고 있다.
MallardDuck
에서 fly()
메서드를 사용했을 때는 제대로 동작한다.
하지만 인터페이스의 명시대로 RubberDuck
에서 fly()
를 사용하면 예상치 못하게 오류가 발생한다.
이 코드를 어떻게 수정하면 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로직을 분리했다.
위키백과의 정의에 따르면,
인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.
인터페이스 분리 원칙은 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다.
네 번째 원칙에 와서야 무슨 말인지 조금 이해가된다..
클라이언트가 필요한 기능에만 의존하도록 인터페이스를 작게 분리하라는 뜻인 것 같다.
바로 코드로 알아보도록 하자 !
다음과 같이 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을 사용하지 않아도 불필요하게 구현해주어야 한다.
이러한 문제를 인터페이스를 작게 분리하여 해결할 수 있다.
다음과 같이 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
}
}
인터페이스를 분리하면, 각 클래스가 필요한 인터페이스만 구현할 수 있다.
이렇게 하면 코드의 가독성 및 유지보수가 쉬워진다.
마지막으로, 객체지향 5원칙의 D인 의존관계 역전 원칙이다.
이것 역시 위키백과에 따르면,
객체 지향 프로그래밍에서 의존관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다. 이 원칙은 다음과 같은 내용을 담고 있다.
첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
이 원칙은 '상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다'는 객체 지향적 설계의 대원칙을 제공한다.
요약하자면, 하위 모듈의 직접적인 인스턴스를 가져다 쓰지않고 인터페이스로 추상화하여 통신하라는 의미이다.
다음 코드는 고수준모듈인 PaymentService
에서 저수준모듈인 SamsungPay
와 ApplePay
를 직접적으로 의존하고 있어 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를 적용할 수 있다.
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를 적용하면, 코드의 응집도가 높아지고 결합도는 낮아지므로 유연성과 확장성이 향상된다. 또한 유지보수와 테스트하기에 용이하다는 장점이 있다.
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