💡 객체 지향 프로그래밍의 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 같은 저수준 모듈에 의존하지 않습니다.
- 이로써 고수준 모듈은 저수준 모듈에 의존하지 않고, 추상화에 의존하게 됩니다.
- 이는 의존 역전 원칙을 잘 지킨 사례입니다.