Solid 원칙

 💡 객체 지향 프로그래밍의 5가지 핵심 원칙인 SOLID에 대하여 학습하였습니다.

객체지향 프로그래밍 5 설계 원칙 (SOLID)

  • SOLID란 객체 지향 프로그래밍을 하면서 지켜야하는 5대 원칙으로 SRP, OCP, LSP, DIP, ISP의 앞글자를 따서 만들었습니다.
  • SOLID 원칙을 철저히 지키면서 시간이 지나도 변경에 용이하고, 유지보수와 확장성이 쉬운 소프트웨어를 개발하는 것이 중요합니다!
    • SRP(Single Responsibility Principle): 단일 책임 원칙
    • OCP(Open Closed Principle): 개방 폐쇄 원칙
    • LSP(Listov Substitution Principle): 리스코프 치환 원칙
    • ISP(Interface Segregation Principle): 인터페이스 분리 원칙
    • DIP(Dependency Inversion Principle): 의존 역전 원칙
  • 아키텍처에서 중요한 디자인 패턴들이 SOLID 설계 원칙에 입각하여 만들어진 것이기 때문에, 탄탄한 학습이 필요합니다.

좋은 설계?

  • 시스템에 새로운 요구사항이나 변경사항이 발생한다면, 영향을 받는 범위가 적은 구조를 설계하는 것이 중요합니다.
  • 추가적인 요청에 적절하게 대응하는 것이 중요하며, 유연하게 대처하고 이후의 확장성까지 대비하는 구조가 좋은 설계라고 할 수 있습니다.
  • SOLID 원칙은 어떠한 특정 프로그래밍 언어 혹은 프레임워크를 위해 만든 원칙이 아니며, 특정 기술에 국한되지 않습니다.
  • 자유롭게 적용이 가능하며 추상화, 상속, 인터페이스, 다형성 등의 객체 지향적인 원칙을 재정립하여 적용한다면 좋은 설계를 할 수 있습니다.

단일 책임 원칙 SRP

  • Single Responsibility Principle
  • 클래스(객체)는 단 하나의 책임(기능)을 가져야 한다는 원칙입니다.
  • 하나의 클래스는 하나의 기능을 담당하여 책임을 수행하는데 집중해야 하며, 여러가지 책임을 담당한다면 수정해야할 코드가 많아지게 됩니다.
  • 한 책임의 변경으로부터 다른 책임의 변경으로 연쇄작용은 좋은 설계라고 볼 수 없으며, 이를 극복하기 위한 설계 원칙입니다.
class UserService {
	fun addUser(user: User)
	fun removeUser(userId: String)
	fun sendWelcomeEmail(user: User)
}
  • 위와 같은 설계가 있다고 가정해보겠습니다.
  • UserService는 여러가지 책임을 가지고 있는데, 이러한 설계는 SRP를 위반하여 클래스의 응집도를 낮추고, 유지보수성과 변경에 대한 복잡성을 증가시킵니다.
  • 변경에 대한 이유가 여러 가지가 될 수 있어, 클래스 수정 시 다른 기능에 영향을 미칠 위험이 큽니다.
class UserService {
	fun addUser(user: User)
	fun removeUser(userId: String)
}

class EmailService {
	fun sendWelcomeEmail(user: User)
}
  • 위와 같은 설계로 책임을 분리할 수 있습니다.
  • 이로 인해 SRP를 지킬 수 있으며, EmailService를 수정하기 위해서 UserService를 수정할 필요가 없으므로 변경에 대한 이유가 하나로 제한됩니다.

개방 폐쇄 원칙 OCP

  • Open Closed Principle
  • 확장에는 열려있어야 하며, 수정에는 닫혀있어야 한다는 뜻을 가지고 있습니다.
  • 기능 추가 요청이 왔을 때, 클래스를 확장을 통해 손쉽게 구현하고 확장에 다른 클래스 수정을 최소화하도록 프로그래밍하는 설계 기법입니다.
  • 유연하게 코드를 추가함으로써 애플리케이션의 기능을 확장할 수 있고, 새로운 변경 사항이 발생했을 때 객체를 직접적으로 수정을 제한할 수 있어야 합니다.
  • 추상화 사용을 통한 관계 구축을 권장하며, 다형성과 확장이 가능하도록 지원합니다.
  • OCP를 잘 지키지 못한 사례
	class MessageService { 
	fun sendMessage(message: String, method: String) {
		when(method) {
			"email" -> sendEmail(message)
			"sms: -> sendSms(message)
			else -> throw IllegalArgumentException()
		}
	}
}
  • 위 클래스에서 새러운 메시지 전송 방식을 추가하려면 sendMessage() 메서드에 새로운 조건을 추가하여 구현해야합니다.
  • 이는 기존 코드를 수정하게 되어, OCP 원칙을 위반한 사례라고 할 수 있습니다.
  • OCP를 잘 지킨 사례
    • 위 코드를 아래와 같이 수정할 수 있습니다.
interface MessageSender {
	fun send(message: String)
}

class EmailSender : MessageSender {
	override fun send(message: String) {
		println("Sending email: $message")
}

class SmsSender : MessageSender {
	override fun send(message: String) {
		println("Sending sms: $message")
}

class MessageService(private var sender: MessageSender) {
	fun setSender(sender: MessageSender) {
		this.sender = sender
	}
	
	fun sendMessage(message: String) {
		sender.send(message)
	}
}
  • 위와 같이 수정한다면, 새로운 메시지 전송 방식이 추가되면 MessageSender를 구현한 새로운 클래스를 작성하는 형식을 사용할 수 있습니다.
  • 이는 변경을 최소화하고 확장성을 고려한 좋은 설계입니다.

리스코프 치환 원칙 LSP

  • Liskov Substitution Principle
  • 서브 타입은 언제나 부모 타입으로 교체할 수 있어야 한다는 원칙입니다.
  • 다형성의 원리를 기본 개념으로 하며, 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 활용하려는 설계입니다.
  • 반드시 업캐스팅된 상태에서 부모의 메서드를 사용하더라도, 의도한대로 코드가 동작해야 합니다.
  • LSP를 지키지 못한 사례
    • 사각형과 정사각형을 나타내는 Rectangle, Square 클래스가 있다고 가정해보겠습니다.
open class Rectangle {
	open var width: Int = 0
	open var height: Int = 0
	
	fun area() : Int {
		return width * height
	}
}

class Square : Rectangle() {
	override var widht: Int
		get() = super.width
		set(value) {
			super.width = value
			super.height = value
		}
		
	override var height: Int
		get() = super.height
		set(value) {
			super.width = value
			super.height = value
		}
}
  • 위 코드에서 Square(정사각형) 클래스는 Rectangle(직사각형) 클래스를 상속받습니다.
  • 하지만 Squre 클래스는 setter 메서드를 오버라이드하여 width와 height를 동일하게 유지합니다.
  • **val rectangle = Square()**로 선언하고, width와 height를 각 4,5로 준다면 예상 값 20이 아닌 25를 반환하게 됩니다.
  • 이는 LSP를 위반한 사례라고 볼 수 있습니다.
  • LSP를 지키기 위해서는 자식 클래스가 부모 클래스의 계약을 준수해야 하며, 자식 클래스가 부모 클래스를 대체할 때 예기치 않은 동작을 초래하지 않도록 설계해야 합니다.

인터페이스 분리 원칙 ISP

  • Interface Segregation Principle
  • 인터페이스를 각각 사용에 맞게 끔 잘 분리해야 한다는 설계 원칙입니다.
  • SRP 원칙이 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하고 있습니다.
  • ISP는 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공하는 것이 목표입니다.
  • 기본 원칙으로 한 번 분리하여 구성한 인터페이스를 수정 사항이 생겨서 또다시 분리하는 행위를 금지하고 있습니다. (인터페이스의 수정 역시 최소화)
  • ISP를 잘지키지 못한 경우
interface MultiFunctionDevice {
	fun print(document: String)
	fun scan(): String
}

class SimplePrinter : MultiFunctionDevice {
	override fun print(document: String) {
		print("printing: $document")
	}
	
	override fun scan(): String{
		throw Exception()
	}
}
  • 인터페이스를 분리할 때, 모든 기능을 하나의 인터페이스에 포함시켜 필요하지 않은 기능에 의존하게 되었습니다.
  • 실제로 사용되지 않은 메서드를 구현해야하므로, ISP를 잘 지키지 못했다고 할 수 있습니다.
  • ISP를 잘 지킨 경우
interface Printer {
	fun print(document: String)
}

interface Scanner {
	fun scan(): String
}

class SimplePrinter : Printer {}
class SimpleScanner : Scanner {}
  • 위 경우는 인터페이스 분리 원칙을 잘 지켰다고 볼 수 있습니다.
  • 각기 다른 기능을 분리하여 클라이언트가 자신이 필요로 하는 기능에만 의존하게 되었습니다.

의존 역전 원칙 DIP

  • Dependency Inversion Principle
  • 어떤 클래스를 참조해서 사용해야 하는 상황이 생긴다면, 그 class를 직접 참조하는 것이 아닌, 그 대상의 상위 요소를 참조하라는 원칙입니다.
  • 상위 요소란 추상 클래스나 인터페이스가 될 수 있습니다.
  • 의존 관계를 맺을 때 변화하기 쉬운 것, 자주 변화하는 것보다는 변화하기 어려운 것에 의존하라는 원칙입니다.
  • DIP를 잘 지키지 못한 경우
class SMSNotification {
	fun sendSMS(message: String) {
		val emailSender = EmailSender()
		emailSender.sendMessage("message")
	}
}
  • 위의 예에서 SMSNotification 클래스는 EmailSender 클래스에 직접 의존하고 있습니다.
  • 구현에 종속되어 있으며, 이는 의존 역전 원칙을 위반하고 있습니다.
  • 아래와 같이 수정할 수 있습니다.
  • DIP를 잘 지킨 경우
interface MessageSender { 
	fun sendMessage(message: Stirng)
}

class EmailSender: MessageSender {
	override fun sendMessage(message: String) {
		print("message")
	}
}

class SMSNotification(private val sender: MessageSender) {
	fun sendSMS(message: String) {
		sender.sendMessage("message")
	}
}

fun main(){
	val emailSender = EmailSender()
	val smsNotification = SMSNotification(emailSender)
	smsNotification.sendSMS("message")
}
  • 위 예제에서 SMSNotification 클래스가 MessageSender 인터페이스에 의존하고 있습니다.
  • SMSNotification는 오직 메시지를 전송하는 기능인 sendMessage() 메서드에만 관심이 있고, 이 기능을 구체적으로 구현한 EmailSender 같은 저수준 모듈에 의존하지 않습니다.
  • 이로써 고수준 모듈은 저수준 모듈에 의존하지 않고, 추상화에 의존하게 됩니다.
  • 이는 의존 역전 원칙을 잘 지킨 사례입니다.