[Kotlin] Kotlin에서의 SOLID 원칙

DaeHoon·2022년 12월 22일
0

SOLID

  • 로버트 마틴이 2000년대 초에 명명한 객체 지향 프로그래밍의 다섯 가지 기본 원칙을 마이클 페더스가 원칙의 앞 글자를 따서 다시 SOLID라는 이름으로 소개한 것.

    • S — Single Responsibility Principle (SRP)
    • O — Open/Closed Principle (OCP)
    • L — Liskov Substitution Principle (LSP)
    • I — Interface Segregation Principle (ISP)
    • D — Dependency Inversion Principle (DIP)

SOLID로 해결할 수 있는 문제

Code Rigidity

  • 너무 많은 방향으로 종속이 되어 있어 분리시킬 수 없는 코드
  • 코드의 한 부분을 수정하면 호출하는 클래스, 종속된 클래스가 깨지고 그 코드도 수정해야 한다. 결국, 한 부분을 수정함으로써 이와 연관된 모든 코드를 수정해야 한다.

Code Fragility

  • 코드를 수정하고 이와 관련 없는 코드가 손상된 경우

Tight Coupling

  • 하나의 클래스가 다른 클래스에 의존하는 경우

SOLID가 필요한 이유

  • 유연한 코드 (Flexible Code)
  • 유지 보수의 용이(Maintainable Code)
  • 코드 이해가 쉬움 (Understandable Code)
  • 코드 변경이 가능 (Code can tolerate changes)

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

  • 클래스를 변경하는 이유가 한 가지이기 위해서, 하나의 액터(클래스, 모듈)에 대한 책임만 가지고 있어야 한다.

Example - Order

SRP 위반



class Order {
    
    fun sendOrderUpdateNotification() {
        // sends notification about order updates to the user.
    }
    
    fun generateInvoice() {
        // generates invoice
    }

    fun save() {
        // insert/update data in the db
    }
}
  • Order Class에서 주문 업데이트, 송장 생성, 데이터 저장에 대한 책임을 가지고 있다.

해결

data class Order(
    val id: Long,
    val name: String,
    // ... other properties. 
)
class OrderNotificationSender {

    fun sendNotification(order: Order) {
        // send order notifications
    }
class OrderRepository {

    fun save(order: Order) {
        // insert/update data in the db.
    }
}
  • 주문 관련 데이터를 책임 지는 Order
  • 송장 생성에 대한 책임을 지는 OrderNotificationSender
  • 주문 정보를 database에 저장하는 책임을 지는 OrderRepository
  • 위와 같이 3개의 클래스를 생성해 단일 책임을 지게 했다.



  • 이전의 Order에서 SRP를 적용해 단일 책임을 지게 했고, 더 나아가 책임을 위임(delegate)시켜주는 OrderFacade 클래스를 만들 수 있다.

class OrderFacade(
    private val orderNotificationSender: OrderNotificationSender,
    private val orderInvoiceGenerator: OrderInvoiceGenerator,
    private val orderRepository: OrderRepository
) {

    fun sendNotification(order: Order) {
        // sends notification about order updates to the user.
        orderNotificationSender.sendNotification(order)
    }

    fun generateInvoice(order: Order) {
        // generates invoice
        orderInvoiceGenerator.generateInvoice(order)
    }

    fun save(order: Order) {
        // insert/update data in the db
        orderRepository.save(order)
    }
}
  • SRP를 적용한 최종 코드

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

  • 소프트웨어 아티팩트(클래스, 모듈, 함수 등)는 확장을 위해 열려 있지만 수정을 위해 닫혀 있어야 한다.
  • 즉, 소프트웨어 아티팩트의 동작은 해당 아티팩트를 수정할 필요 없이 확장 가능해야 한다.

Example - 알림 서비스

OCP 위반

enum class Notification {
    PUSH_NOTIFICATION, EMAIL
}
class NotificationService {

    fun sendNotification(notification: Notification) {
        when (notification) {
            Notification.PUSH_NOTIFICATION -> {
                // send push notification
            }

            Notification.EMAIL -> {
                // send email notification
            }
        }
    }
}
  • 현재 알림 서비스에서 푸쉬 알림과 이메일로 서비스를 하고 있다. 만약 SMS라는 새로운 요구사항이 들어오면 어떻게 될까?

enum class Notification {
    PUSH_NOTIFICATION, EMAIL, SMS
}

class NotificationService {

    fun sendNotification(notification: Notification) {
        when (notification) {
            Notification.PUSH_NOTIFICATION -> {
                // send push notification
            }

            Notification.EMAIL -> {
                // send email notification
            }

            Notification.SMS -> {
                // send sms notification
            }
        }
    }
}
  • 타입이 추가될 때 마다 NotificationService 클래스에 sendNotification 함수를 수정해야 한다.

해결



interface Notification {
    fun sendNotification()
}
  • Notification라는 인터페이스를 만들고 sendNotification이라는 추상 메소드를 정의했다.
class PushNotification : Notification {
    override fun sendNotification() {
        // send push notification
    }
}
class EmailNotification : Notification {
    override fun sendNotification() {
        // send email notification
    }
}
  • Notification 인터페이스의 구현 클래스
class NotificationService {
	fun sendNotification(notification: Notification) {
        notification.sendNotification()
    }
}
  • 서비스 코드에서 Notification 함수를 인자로 받아 함수만 호출하게 수정했다.
class SMSNotification : Notification {
    override fun sendNotification() {
        // send sms notification
    }
}
  • 여기서 SMS 알림 방식이 추가된다 하더라도 기존의 서비스 코드는 수정할 필요가 없다.

Liskov Substitution Principle (LSP) - 리스코프 치환 원칙

  • S가 T의 하위 유형이면 프로그램에서 자료형 T의 객체는 해당 프로그램의 원하는 속성을 변경하지 않고 자료형 S의 객체로 교체(치환)할 수 있어야 한다.
  • 로버트 마틴은 이를 자식 클래스는 부모 클래스를 대체할 수 있어야 한다. 라고 요약했다.

Example

LSP 위반 - 사각형

  • 직사각형은 네 각이 모두 90°이고 4개의 변을 가진 다각형이라는 것을 안다.
  • 이는 사각형은 모든 변의 길이가 같은 특수한 직사각형 유형으로 정의할 수 있다는 것을 의미한다.
  • 정사각형과 직사각형이 LSP를 따른다면 하나를 다른 것으로 대체할 수 있어야 한다.
public class Rectangle {

    private int height;
    private int width;

    public void setHeight(int height) {
        this.height = height;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int area() {
        return height * width;
    }
}

public class Square extends Rectangle {

    @Override
    public void setHeight(int height) {
        setSide(height);
    }

    @Override
    public void setWidth(int width) {
        setSide(width);
    }

    private void setSide(int side) {
        super.setHeight(side);
        super.setWidth(side);
    }
}
  • Rectangle 클래스와 이를 상속 받은 Square 클래스.
fun main() {
    val rectangle = Rectangle()
    rectangle.setHeight(5)
    rectangle.setWidth(2)

    val rectangleCheck = rectangle.area() == 10 // true

    val square: Rectangle = Square()
    square.setHeight(5) // width is also set to 5
    square.setWidth(2) // height is also set to 2

    val squareCheck = square.area() == 10 // 4 false - not substitutable 
}
  • 위의 코드에서 우리는 직사각형과 정사각형이 서로 대체할 수 없다는 것을 분명히 볼 수 있다. 그러므로 위의 예제는 LSP를 위반한다.
  • 어떤 경우에도 위의 문제는 LSP를 따르지 않는다. Solution은 다른 문제를 예로 살펴보자.

LSP 예제 - 폐기물 관리 서비스

  • 유기성 폐기물과 플라스틱 폐기물 등 다양한 유형의 폐기물을 처리하는 폐기물 관리 서비스를 예제로 들자.

interface Waste {
    fun process()
}
  • Waste라는 인터페이스를 만들자.
class OrganicWaste : Waste {
    override fun process() {
        println("Processing Organic Waste")
    }
}

class PlasticWaste : Waste {
    override fun process() {
        println("Processing Plastic Waste")
    }
}

-그리고 Waste를 구현한 OrganicWaste, PlasticWaste 클래스를 만들었다.

class WasteManagementService {

    fun processWaste(waste: Waste) {
        waste.process()
    }
}
  • 이를 호출하는 서비스 코드
fun main() {

    val wasteManagementService = WasteManagementService()

    var waste: Waste

    waste = OrganicWaste()
    wasteManagementService.processWaste(waste) // Output: Processing Organic Waste

    waste = PlasticWaste()
    wasteManagementService.processWaste(waste) // Output: Processing Plastic Waste
}
  • 메인 함수에서 서로 다른 유형의 Waste, 즉 OrganicWaste, PlasticWaste 클래스가 Waste를 대체할 수 있음을 볼 수 있다. 따라서 위의 예제는 리스코프 치환 원칙을 따르고 있다.

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

  • 클라이언트(개발자)는 사용하지 않는 인터페이스에 의존하도록 강요되어서는 안 된다고 말한다.
  • 즉, 인터페이스를 구현하는 클래스는 필요하지 않은 메소드를 사용하도록 강요되어서는 안된다.

Example - UI Library

ISP 위반

  • component가 있는 UI 라이브러리를 구축하고 있고, component가 Click events - single-click 및 long-click같은 다양한 UI 상호 작용을 할 수 있다고 가정하자.
  • 다른 클릭 동작을 하는 OnClickListener 인터페이스가 있는데, UI component가 이 동작을 가지려면 OnClickListener 인터페이스를 구현해야 한다.

interface OnClickListener {
    fun onClick()
    fun onLongClick()
}
class CustomUIComponent : OnClickListener {
    override fun onClick() {
        // handles onClick event.
    }

    // left empty as I don't want the [CustomUIComponent] to have long-click behavior.
    override fun onLongClick() {

    }
}
  • UIComponent에서 필요한 것은 onClick 메소드 하나인데 OnClickListener의 요구 사항에 따라 반드시 LongClick 메서드를 재정의해야 한다.

해결

  • OnClickListener 인터페이스를 OnClickListener와 OnLongClickListener의 두 개의 인터페이스로 분리한다.

interface OnLongClickListener {
    fun onLongClick()
}

interface OnClickListener {
    fun onClick()
}
class CustomUIComponent : OnClickListener {
    override fun onClick() {
        // handle single-click event
    }
}
  • 하나로 있던 인터페이스를 두 개로 분리하여 CustomUIComponent에서는 필요한 부분만 구현할 수 있게 되었다.

Dependency Inversion Principle (DIP) - 의존성 역전 원칙

  • 상위 모듈은 하위 모듈에 의존해서는 안 되고 둘 다 추상화에 의존해야 한다.
  • 추상화는 세부 사항에 의존해서는 안 되고 세부사항(구체적인 구현)은 추상화에 의존해야 한다.


class ClassA {
    fun doSomething() {
        println("Doing something")
    }
}

class ClassB {
    fun doIt() {
        val classA = ClassA() //  ClassB가 제대로 작동하려면 ClassA의 인스턴스가 필요하다.
        classA.doSomething()
    }
}
  • 위의 코드에서 classA가 정의되고 doSomething()이 호출 되는 것을 볼 수 있다.
  • ClassB가 제대로 작동하려면 ClassA의 객체가 필요하므로 ClassB는 ClassA에 의존한다고 볼 수있다.

  • 인터페이스를 이용해 ClassA와 ClassB 사이의 의존성을 반전시켜 DIP를 적용할 수 있다.

Example - 알림 서비스

DIP 위반

  • 이메일 알림만 전송하는 알림 서비스가 있다고 가정해 보자.

class EmailNotification {
    fun sendNotification(message: String) {
        println("Sending email notification with message \"$message\"")
    }
}
class NotificationService {

    fun sendNotification(message: String) {
        val emailNotification = EmailNotification() // <- here is the dependency
        emailNotification.sendNotification(message)
    }
}
fun main() {
    val notificationService = NotificationService()
    notificationService.sendNotification("Happy Coding") // Output: Sending email notification with message "Happy Coding"
}
  • 위의 코드의 문제는 알림을 보내기 위해 NotificationService 클래스가 EmailNotification 클래스에 의존한다는 것이다.
  • EmailNotification에 의존하지 않고 다른 유형의 알림도 전송할 수 있도록 dependency을 제거해야 한다.

해결

  • OCP를 통해 이미 이 문제를 해결했다.
  • NotificationService가 Notification에 독립적이기 위해 구현된 클래스 (EmailNotification)보다는 추상 클래스 또는 인터페이스에 의존해야 한다.

interface Notification {
    fun sendNotification(message: String)
}
class EmailNotification : Notification {
    override fun sendNotification(message: String) {
        println("Sending email notification with message \"$message\"")
    }
}

class SmsNotification : Notification {
    override fun sendNotification(message: String) {
        println("Sending sms notification with message \"$message\"")
    }
}
  • Notification 클래스를 만들고 구현체를 만들었다.
class NotificationService {

    // this can be injected through constructor as well and it would be constructor injection
    lateinit var notification: Notification

    fun sendNotification(message: String) {
        notification.sendNotification(message)
    }
}
  • 예제 코드는 수정자 주입 방식이지만 notification을 var에서 private val로 변경하고 생성자 주입을 통해 notification을 아예 수정 불가능하게 만드는 것이 유지보수 측면에서 좋다. (객체의 불변성 확보)
fun main() {
    val message = "Happy Coding"
    val notificationService = NotificationService()
    var notification: Notification

    notification = EmailNotification()
    notificationService.notification = notification
    notificationService.sendNotification(message)
    // Output: Sending email notification with message "Happy Coding"

    notification = SmsNotification()
    notificationService.notification = notification
    notificationService.sendNotification(message)
    // Output: Sending sms notification with message "Happy Coding"
}
  • NotificationService가 Notification 인터페이스에 의존하게 코드를 작성했다.
  • 즉, 서비스 코드를 변경할 필요 없이 알림 서비스에서 알림 방식을 이메일에서 SMS로, SMS로 이메일로 바꾸는 것이 가능해졌다.

요약

  • SRP — 각 소프트웨어 모듈에는 변경할 이유가 하나만 있어야 한다
  • OCP — 소프트웨어 시스템은 변경하기 쉬워야 하며, 기존 코드를 변경하는 대신 새로운 코드를 추가하여 시스템의 동작을 변경할 수 있도록 설계되어야 한다.
  • LSP — 교환 가능한 부품(클래스, 모듈 등)으로 소프트웨어 시스템을 구축하려면 해당 부품이 서로 교체될 수 있는 계약을 준수해야 한다.
  • ISP — 소프트웨어 설계자는 사용하지 않는 것에 의존하는 것을 피해야 한다.
  • DIP - high level policy을 구현하는 코드는 low-level 구현에 의존해서는 안된다.

https://proandroiddev.com/solid-design-principles-in-kotlin-79100c670df1

profile
평범한 백엔드 개발자

0개의 댓글