객체지향 프로그래밍(OOP)의 SOLID원칙

고라니·2023년 12월 2일
0

TIL

목록 보기
49/67

이전에 객체지향 프로그밍과 특징에 대해 알아보았다.
이번에는 객체지향 프로그래밍을 위한 5가지 원칙 SOLID에 대해 알아보자.

SOLID

SOLID는 객체 지향 프로그래밍에서 소프트웨어 디자인의 다섯 가지 기본 원칙을 나타낸다.
이 원칙들은 소프트웨어 시스템을 더욱 견고하고 유지보수가 쉽게 만들기위한 지침으로 사용된다.

1. SRP - 단일 책임 원칙(Single Responsibilty Principle)

  • 한 클래스는 단 하나의 책임만 가지며, 책임을 완전히 캡슐화 해야 한다.
  • 클래스의 응집성을 높이고, 유지보수를 용이하게 만든다.
// SRP를 위반한 경우
class FileManager {
    func readFile() {
        // 파일 읽기 로직
    }

    func writeFile() {
        // 파일 쓰기 로직
    }
}

// SRP를 따르는 경우
class FileReader {
    func readFile() {
        // 파일 읽기 로직
    }
}

class FileWriter {
    func writeFile() {
        // 파일 쓰기 로직
    }
}

위의 예시처럼 FileManager는 readFile과 writeFile 즉 두가지의 역할을 하고 있다. 이것은 SRP를 위반한 경우다, 반면 두 역할을 FileReader와 FileWriter로 나누어 역할을 하나씩만 책임지도록 하여 SRP를 준수 하였다.

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

  • 소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려있어야 하고, 수정에는 닫혀있어야 한다.
  • 새로운 기능을 추가할 때 기존 코드를 변경하지 않고 확장할 수 있도록 하는 원칙이다.
  • 새로운 클래스나 모듈을 추가하거나 기존 코드를 수정하지 않고도 시스템의 기능을 확장할 수 있어야 한다.
// OCP를 위반한 경우
class Drawer {
    func drawCircle(circle: Circle) {
        // 원 그리기 로직
    }

    func drawRectangle(rectangle: Rectangle) {
        // 사각형 그리기 로직
    }
}

// OCP를 따르는 경우
protocol Shape {
    func draw()
}

class Circle: Shape {
    func draw() {
        // 원 그리기 로직
    }
}

class Rectangle: Shape {
    func draw() {
        // 사각형 그리기 로직
    }
}

class Drawer {
    func drawShape(shape: Shape) {
        shape.draw()
    }
}

위의 예시 코드 중 OCP를 위반한 경우를 보면 새로운 도형을 그리는 기능을 추가 할 때마다 Drawer 클래스를 수정해야 한다. 하지만 반면 OCP를 준수하는 경우는 Shape 프로토콜을 이용하여 기존 클래스의 수정 없이 확장 가능하다. 결국 시스템을 유연하고 확장 가능하게 만들어 유지보수성을 향상 시킨다.

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

  • 서브타입은 언제나 기반 타입으로 전환될 수 있어야 한다. 즉, 기반 타입의 인스턴스를 서브 타입의 인스턴스로 대체해도 프로그램은 의도한 대로 도작해야 한다.
  • 상속을 올바르게 사용하고, 다형성을 유지하는데 중요하다.
class Animal {
    func makeSound() {
        print("Generic animal sound")
    }
}

class Dog: Animal {
    override func makeSound() {
        print("Bark!")
    }
}

func printAnimalSound(animal: Animal) {
    animal.makeSound()
}

let genericAnimal: Animal = Animal()
let dog: Dog = Dog()

printAnimalSound(animal: genericAnimal)  // Output: Generic animal sound
printAnimalSound(animal: dog)            // Output: Bark!

위의 예시 코드처럼 "printAnimalSound"의 매개변수 animal의 타입은 Animal이고
기반 타입(상위 클래스)인 genericAnial을 전달하는것도 가능하고 Animal의 서브 타입(하위 클래스)인 Dog도 전달 가능하다.

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

  • 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다. 인터페이스는 그 인터페이스를 구현한 클래스에 의존해야 한다.(객체가 프로토콜에 의존하는 것이 아닌 프로토콜이 객체에 의존하도록 하는 원칙이다.)
  • 즉, 하나의 클라이언트에게 너무 많은 메서드를 강제하지 않도록 인터페이스를 분리해야 한다.
protocol Worker {
    func work()
    func eat()
}

class Employee: Worker {
    func work() {
        print("Employee is working")
    }

    func eat() {
        print("Employee is eating")
    }
}

class Robot: Worker {
    func work() {
        print("Robot is working")
    }

    func eat() {
        // Robot은 먹지 않는다. 하지만 프로토콜에서는 eat 메서드를 갖고 있음.
    }
}

위의 코드를 보면 Robot은 음식을 먹지 않지만 Woker프로토콜을 채택하여 eat()함수를 강제로 구현하게 된다.
이것은 ISP 원칙을 위반한 것이다.

protocol Workable {
    func work()
}

protocol Eatable {
    func eat()
}

class Employee: Workable, Eatable {
    func work() {
        print("Employee is working")
    }

    func eat() {
        print("Employee is eating")
    }
}

class Robot: Workable {
    func work() {
        print("Robot is working")
    }
}

Worker 프로토콜을 Workable과 Eatabble로 분리하여 각 필요한 프로토콜을 따로 채택할 수 있다.
결국 필요없는 프로토콜 메서드를 구현하지 않아도 되며, ISP원칙을 준수할 수 있다.
ISP원칙을 준수하여 클래스 간의 의존성을 최소화하고, 유연하고 확장 가능한 코드를 작성할 수 있다.

5. DIP - 의존성 역전 원칙(Dependency inversion principle)

  • 모듈 간의 의존성을 조절하는 방법
  • 고수준 모듈은 저수준 모듈에 의존하면 안 되며, 둘 모두 추상화에 의존해야 한다.
  • 추상화는 세부 사항에 의존하면 안되 며, 세부 사항이 추상화에 읜존해야 한다. 즉, 추상화를 통해 모듈 간의 결합도를 낮추는 것이 목표

고수준 모듈? 저수준 모듈?

  • 고수준 모듈은 어떤 기능을 구현하는 데 있어 더 추상화된 상위 수준의 모듈, 저수준 모듈은 구체적인 구현 내용을 나타내는 하위 수준의 모듈
  • DIP 에서는 고수준 모듈이 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.
// 고수준 모듈
class DataManager {
    private var dataStorage: DataStorage

    init(dataStorage: DataStorage) {
        self.dataStorage = dataStorage
    }

    func fetchData() {
        // 데이터를 가져오는 로직
        dataStorage.readData()
    }
}

// 저수준 모듈
protocol DataStorage {
    func readData()
}

class FileDataStorage: DataStorage {
    func readData() {
        // 파일에서 데이터를 읽는 로직
        print("Reading data from file")
    }
}

class DatabaseDataStorage: DataStorage {
    func readData() {
        // 데이터베이스에서 데이터를 읽는 로직
        print("Reading data from database")
    }
}

위의 코드에서 DataManager는 DataStoreage 프로토콜에 의존하고 있다. DataManager은 실제 데이터 저장소의 세부 구현에 대해 알 필요가 없다.
DIP를 따르면 크라이언트 코드가 구체적인 클래스에 의존하는 대신, 추상화된 인터페이스에 의존다. 코드의 유지보수와 유연성이 향상된다.

마치면서

SOLID 원칙은 유지보수성과 확장성 그리고 많은것들을 향상시키는 좋은 원칙이라고 느꼈다.
하지만 동시에 현실적으로 모든 프로젝트에서 완벽하게 지켜지는것도 어려울 것이라는 것도 느꼈다.

이 원칙들을 잘 적용하는 것도 중요하지만, 무조건 따르기 보다는 특정 상황에 부합하는 방법을 적용하기 위해 많은 고민을 하고 적절하게 적용해야 한다고 생각한다.

profile
🍎 무럭무럭

0개의 댓글