본문 바로가기
안드로이드/Kotlin

[Kotlin] 객체 지향 원칙(SRP, OCP, LSP, ISP, DIP)

by jinwo_o 2024. 8. 5.
  • 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙(SRP, OCP, LSP, ISP, DIP)
  • SOLID 객체 지향 원칙을 적용하면 코드를 확장하고 유지 보수 관리하기가 더 쉬워지며, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄임으로써 프로젝트 개발의 생산성을 높일 수 있다.

 

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

  • 클래스(객체)는 단 하나의 책임만 가져야 한다는 원칙
  • 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행하는 데 집중되도록 클래스를 따로따로 여러 개 설계하라는 것
  • 책임의 범위는 딱 정해져있는 것이 아니고, 어떤 프로그램을 개발하느냐에 따라 개발자마다 생각 기준이 달라질 수 있다.

 

BEFORE

  • 문제점 : 서버에 데이터를 보내는 동작과 작업 결과를 파일에 기록하는 동작은 서로 관련된 동작이 아니다.
class EmployeeManagement {
    // Create 작업을 담당하는 CRUD 메소드
    fun addEmployee(employee: String) {
        if (employee == "") {
            postServer(employee) // 서버에 직원 정보를 보냄
            logResult("[LOG] EMPLOYEE ADDED") // 로그 출력
        } else {
            logResult("[ERROR] NAME MUST NOT BE EMPTY")
        }
    }

    // 서버에 데이터를 전달하는 메소드
    fun postServer(employees: String) {}

    // 로그를 출력하는 메소드
    fun logResult(message: String) {
        println(message) // 로그를 콘솔에 출력
        writeToFile(message) // 로그 내용을 로그 파일에 저장
    }

    // 파일에 내용을 저장하는 메소드
    fun writeToFile(msg: String) {}
}

 

AFTER

  • 해결책 : 로깅만을 담당하는 클래스를 따로 분리하고, EmployeeManagement 클래스에서 합성하여 사용한다.
class EmployeeManagement {
    private val logger = Logger() // 합성

    // Create 작업을 담당하는 CRUD 메소드
    fun addEmployee(employee: String) {
        if (employee == "") {
            postServer(employee) // 서버에 직원 정보를 보냄
            logger.logResult("[LOG] EMPLOYEE ADDED") // 로그 출력
        } else {
            logger.logResult("[ERROR] NAME MUST NOT BE EMPTY")
        }
    }

    // 서버에 데이터를 전달하는 메소드
    fun postServer(employees: String) {}
}

class Logger {
    // 로그를 출력하는 메소드
    fun logResult(message: String) {
        println(message) // 로그를 콘솔에 출력
        writeToFile(message) // 로그 내용을 로그 파일에 저장
    }

    // 파일에 내용을 저장하는 메소드
    fun writeToFile(msg: String) {}
}

 

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

  • 클래스는 확장에 열려있어야 하며, 수정에는 닫혀있어야 한다는 원칙
  • 기능 추가 요청이 오면 클래스를 확장을 통해 손쉽게 구현하면서, 확장에 따른 클래스 수정은 최소화하도록 프로그램을 작성해야 하는 설계 기법
    • 확장에 열려있다 : 새로운 변경 사항이 발생했을 때 유연하게 코드를 추가함으로써 큰 힘을 들이지 않고 애플리케이션의 기능을 확장할 수 있음.
    • 변경에 닫혀있다 : 새로운 변경 사항이 발생했을 때 객체를 직접적으로 수정을 제한함.
  • 추상화 사용을 통한 관계 구축을 권장을 의미하는 것
  • 즉, 다형성과 확장을 가능케 하는 객체지향의 장점을 극대화하는 기본적인 설계 원칙

 

BEFORE

  • 문제점 : 고양이와 개 외에 양이나 사자를 추가하면, 각 객체의 필드 변수에 맞게 when문을 분기하여 구성해줘야 한다.
class Animal(val type: String)

// 동물 타입을 받아 각 동물에 맞춰 울음소리를 내게 하는 클래스 모듈
class HelloAnimal {
    fun hello(animal: Animal) {
        when (animal.type) {
            "Cat" -> println("냐옹")
            "Dog" -> println("멍멍")
            else -> println("알 수 없는 동물")
        }
    }
}

fun main() {
    val hello = HelloAnimal()

    val cat = Animal("Cat")
    val dog = Animal("Dog")

    hello.hello(cat) // 냐옹
    hello.hello(dog) // 멍멍
}

 

AFTER

  • 해결책 : 추상화 클래스를 구성하고 이를 상속하여 확장시키는 관계로 형성한다.
abstract class Animal {
    abstract fun speak()
}

class Cat : Animal() {
    override fun speak() {
        println("냐옹")
    }
}

class Dog : Animal() {
    override fun speak() {
        println("멍멍")
    }
}

class Sheep : Animal() {
    override fun speak() {
        println("매에에")
    }
}

class Lion : Animal() {
    override fun speak() {
        println("어흥")
    }
}

// 기능 확장으로 인한 클래스가 추가되어도, 더이상 수정할 필요가 없어진다 (closed)
class HelloAnimal {
    fun hello(animal: Animal) {
        animal.speak()
    }
}

fun main() {
    val hello = HelloAnimal()

    val cat: Animal = Cat()
    val dog: Animal = Dog()

    val sheep: Animal = Sheep()
    val lion: Animal = Lion()

    hello.hello(cat) // 냐옹
    hello.hello(dog) // 멍멍
    hello.hello(sheep) // 매에에
    hello.hello(lion) // 어흥
}

 

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

  • 서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다는 원칙
  • 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로 흘러가야 하는 것을 의미하는 것

 

BEFORE

  • 문제점 : Fish 물고기 클래스에 Animal 추상 클래스를 상속하면, 물고기는 행할 수 없는 speak() 메서드를 구현해야 한다.
abstract class Animal {
    abstract fun move()
    abstract fun speak()
}

class Cat : Animal() {
    override fun move() {
        println("고양이가 움직이다.")
    }

    override fun speak() {
        println("냐옹")
    }
}

class Dog : Animal() {
    override fun move() {
        println("개가 움직이다.")
    }

    override fun speak() {
        println("멍멍")
    }
}

class Fish : Animal() {
    override fun move() {
        println("물고기가 헤엄치다.")
    }

    override fun speak() {
        throw Exception("물고기는 말할 수 없다.")
    }
}

 

AFTER

  • 해결책 : 인터페이스로 분리하는 작업을 통해 수정해야 한다.
abstract class Animal {
    abstract fun move()
}

interface Speakable {
    fun speak()
}

class Cat : Animal(), Speakable {
    override fun move() {
        println("고양이가 움직이다.")
    }

    override fun speak() {
        println("냐옹")
    }
}

class Dog : Animal(), Speakable {
    override fun move() {
        println("개가 움직이다.")
    }

    override fun speak() {
        println("멍멍")
    }
}

class Fish : Animal() {
    override fun move() {
        println("물고기가 헤엄치다.")
    }
}

 

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

  • 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 원칙
  • SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하는 것
  • 즉, SRP 원칙의 목표는 클래스 분리를 통하여 이루어진다면, ISP 원칙은 인터페이스 분리를 통해 설계하는 원칙
  • 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공하는 것이 목표
  • 한번 인터페이스를 분리하여 구성해놓고 나중에 무언가 수정사항이 생겨서 또 인터페이스를 분리하는 행위를 가지지 말아야 한다.

 

BEFORE

  • 문제점 : 최신 기종 스마트폰 뿐만 아니라 구형 기종 스마트폰 클래스도 다뤄야 할 상황처럼 갤럭시 S3 클래스를 구현해야 한다면 무선 충전, 생체인식과 같은 기능은 포함되어 있지 않다.
interface ISmartPhone {
    fun call(number: String)
    fun message(number: String, text: String)
    fun wirelessCharge()
    fun AR()
    fun biometrics()
}

class S21 : ISmartPhone {
    override fun call(number: String) { /*...*/ }

    override fun message(number: String, text: String) { /*...*/ }

    override fun wirelessCharge() { /*...*/ }

    override fun AR() { /*...*/ }

    override fun biometrics() { /*...*/ }
}

class S3 : ISmartPhone {
    override fun call(number: String) { /*...*/ }

    override fun message(number: String, text: String) { /*...*/ }

    override fun wirelessCharge() {
        println("지원하지 않는 기능입니다.")
    }

    override fun AR() {
        println("지원하지 않는 기능입니다.")
    }

    override fun biometrics() {
        println("지원하지 않는 기능입니다.")
    }
}

 

AFTER

  • 해결책 : 각각의 기능에 맞게 인터페이스를 잘게 분리하도록 구성한다.
interface IPhone {
    fun call(number: String) // 통화 기능
    fun message(number: String, text: String) // 문제 메세지 전송 기능
}

interface WirelessChargable {
    fun wirelessCharge() // 무선 충전 기능
}

interface ARable {
    fun AR() // 증강 현실(AR) 기능
}

interface Biometricsable {
    fun biometrics() // 생체 인식 기능
}

class S21 : IPhone, WirelessChargable, ARable, Biometricsable {
    override fun call(number: String) { /*...*/ }

    override fun message(number: String, text: String) { /*...*/ }
    
    override fun wirelessCharge() { /*...*/ }
    
    override fun AR() { /*...*/ }
    
    override fun biometrics() { /*...*/ }
}

class S3 : IPhone {
    override fun call(number: String) { /*...*/ }
    
    override fun message(number: String, text: String) { /*...*/ }
}

 

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

  • 어떤 클래스를 참조해서 사용해야 하는 상황이 생긴다면, 그 클래스를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙
  • 즉, 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻
  • 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는, 변화하기 어려운 것(거의 변화가 없는 것)에 의존하라는 것

 

BEFORE

  • 문제점 : 이미 완전하게 구현된 하위 모듈을 의존하고 있다.
class OneHandSword(val name: String, val damage: Int) {
    fun attack(): Int {
        return damage
    }
}

class TwoHandSword { /*...*/ }

class BattleAxe { /*...*/ }

class WarHammer { /*...*/ }

class Character(
    val name: String,
    var health: Int,
    var weapon: OneHandSword // 의존 저수준 객체
) {

    fun attack(): Int {
        return weapon.attack()
    }

    fun changeWeapon(newWeapon: OneHandSword) {
        weapon = newWeapon
    }

    fun getInfo() {
        println("이름: $name")
        println("체력: $health")
        println("무기: $weapon")
    }
}

 

AFTER

  • 해결책 : 구체 모듈을 의존하는 것이 아닌 추상적인 고수준 모듈을 의존하도록 리팩토링 한다.
interface Weaponable {
    fun attack(): Int
}

class OneHandSword(val name: String, val damage: Int) : Weaponable {
    override fun attack(): Int {
        return damage
    }
}

class TwoHandSword : Weaponable { /*...*/ }

class BattleAxe : Weaponable { /*...*/ }

class WarHammer : Weaponable { /*...*/ }

class Character(
    val name: String, 
    var health: Int, 
    var weapon: Weaponable
) {

    fun attack(): Int {
        return weapon.attack()
    }

    fun changeWeapon(newWeapon: Weaponable) {
        weapon = newWeapon
    }

    fun getInfo() {
        println("이름: $name")
        println("체력: $health")
        println("무기: $weapon")
    }
}

 

💠 객체 지향 설계의 5가지 원칙 - S.O.L.I.D

객체 지향 설계의 5원칙 S.O.L.I.D 모든 코드에서 LSP를 지키기에는 어려움. 리스코프 치환 원칙에 따르면 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대신하더라도 의도에 맞게 작동되어

inpa.tistory.com