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

객체 지향 프로그래밍(OOP)에서는 SOLID라고 불리는 중요한 다섯가지 원칙이 있다.

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

클래스는 하나의 기능 또는 역할에만 집중해야 함.

단일 책임 원칙(SRP)을 지키지 못한 코드

class Student {
  func examScore() {
   // 시험 점수 로직
 }
 func saveToDatabase() {
   // DB에 저장하는 로직
 }
}

Student 클래스에 시험 점수 로직과 DB 저장 두 가지 기능이 모두 포함되어 있는데 하나의 클래스가 여러 개의 기능을 가지면 변경이 발생할 때 다른 기능에 영향을 미칠 수 있으며, 코드가 복잡해질 수 있으므로 좋은 코드는 아니다

단일 책임 원칙(SRP)을 잘 지킨 코드


class Student {
  func examScore() {
    // 시험 점수 로직
  }
}

class StudentRepository {
  func saveToDatabase() {
    // DB에 저장하는 로직
  }
}

단일 책임 원칙(SRP)에 따라 Student 클래스와 StudentRepository 클래스로 기능을 분리했다.

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

소프트웨어의 엔티티1는 확장에는 개방적이고 변경에는 폐쇄적이어야 함.
즉, 기존 코드를 수정하지 않고도 새로운 기능을 추가할수 있도록 설계 되어야 한다는 것

개방 폐쇄 원칙(OCP)을 지키지 못한 코드

class Shape {
   func draw() {
       // 도형을 그리는 로직
   }
   
   func resize() {
       // 도형의 크기를 조정하는 로직
   }
}

Shape 클래스에 도형의 크기를 조정하는 메서드가 포함되어 있다.
이로 인해 새로운 도형을 추가하거나 기존 도형의 크기 조정 로직을 변경할 때 좋지 않은 코드가 될 수 있다

개방 폐쇄 원칙(OCP)을 잘 지킨 코드

protocol Drawable {
    func draw()
}

class Circle: Drawable {
    func draw() {
        // 원을 그리는 로직
    }
}

class Square: Drawable {
    func draw() {
        // 정사각형을 그리는 로직
    }
}

도형을 그리는 기능과 도형의 종류를 추가하는 데 개방적인 인터페이스를 사용하여
기존 도형의 코드를 변경하지 않고도 새로운 도형을 추가하거나 기능을 확장할 수 있다.

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

자식 클래스는 부모 클래스의 역할을 100% 대체 할수 있어야 한다.
즉, 기존의 부모클래스를 사용하는 코드가 자식 클래스로 대체 되어도 올바르게 동작해야 한다는 것.

리스코프 치환 원칙(LSP)을 지키지 못한 코드

class Rectangle {
   var width: Int
   var height: Int
   
   init(width: Int, height: Int) {
       self.width = width
       self.height = height
   }
   
   func setWidth(_ width: Int) {
       self.width = width
   }
   
   func setHeight(_ height: Int) {
       self.height = height
   }
}

class Square: Rectangle {
   override func setWidth(_ width: Int) {
       self.width = width
       self.height = width
   }
   
   override func setHeight(_ height: Int) {
       self.width = height
       self.height = height
   }
}

Square의 너비와 높이를 설정하는 메서드를 상속받은 Rectangle 클래스에서 오버라이딩하여 Square의 크기를 설정하였다.
하지만 이렇게 하면 Square과 Rectangle이 서로 치환 가능하지 않다.

리스코프 치환 원칙(LSP)을 잘 지킨 코드


class Shape {
    func area() -> Int {
        // 도형의 넓이를 계산하는 로직
    }
}

class Rectangle: Shape {
    var width: Int
    var height: Int
    
    init(width: Int, height: Int) {
        self.width = width
        self.height = height
    }
    
    override func area() -> Int {
        return width * height
    }
}

class Square: Shape {
    var side: Int
    
    init(side: Int) {
        self.side = side
    }
    
    override func area() -> Int {
        return side * side
    }
}

Shape 클래스를 상속받은 각각의 Rectangle 클래스와 Square 클래스가 모두 area() 메서드를 오버라이딩하여 사용하면
Square와 Rectangle는 서로 치환 가능한 인터페이스를 유지할 수 있다.

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

클라이언트는 자신이 사용하지 않는 인터페이스에 의존해서는 안됨.
즉 여러개의 작은 인터페이스들로 분리 하는것이 좋다.

리스코프 치환 원칙(LSP)을 지키지 못한 코드

protocol Vehicle {
   func start()
   func stop()
   func fly()
   func swim()
}

Vehicle 인터페이스에 start(), stop(), fly(), swim() 메서드가 포함되어 있다.
모든 탈 것이 수영하거나 비행할 필요는 없기 때문에 이렇게 모든 기능을 한 인터페이스에 포함하는 것은 적절하지 않음

리스코프 치환 원칙(LSP)을 잘 지킨 코드

protocol Vehicle {
    func start()
    func stop()
}

protocol FlyingVehicle {
    func fly()
}

protocol SwimmingVehicle {
    func swim()
}

Vehicle 인터페이스를 여러 개의 세분화된 인터페이스로 분리하여 FlyingVehicle과 SwimmingVehicle 등의 인터페이스로 나눴다.
이렇게 하면 각 탈 것이 필요한 기능만 구현할 수 있어서 불필요한 기능을 포함하지 않을 수 있다.

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

고수준 모듈2은 저수준 모듈3에 의존해서는 안되고 둘다 추상화에 의존해야 한다.
즉, 상위 수준의 모듈이 하위 수준의 모듈에 의존하는 것이아니라
둘다 모두 추상 인터페이스나 추상 클래스와 같은 추상화의 의존해야 한다는 것.

이러한 SOLID원칙을 따르면
코드의 유연성, 확장성 ,재사용성, 유지보수성 등이 향상되며
객제 지향적인 설계를 구현하는데 도움이 된다.

의존성 역전 원칙(DIP)을 지키지 못한 코드

class UserService {
   let database: Database
   
   init() {
       self.database = Database()
   }
   
   func getUser() {
       // 데이터베이스에서 사용자 정보를 가져오는 로직
       let user = database.fetchUser()
       // ...
   }
}

UserService 클래스가 Database 클래스를 직접 생성하고 의존한다.
이렇게 하면 UserService와 Database 클래스 간의 강한 의존성이 형성되며,
변경이 발생할 때마다 UserService를 수정해야 하는 문제점이 발생한다.

의존성 역전 원칙(DIP)을 잘 지킨 코드

protocol Database {
    func fetchUser() -> User
}

class DatabaseImpl: Database {
    func fetchUser() -> User {
        // 데이터베이스에서 사용자 정보를 가져오는 로직
    }
}

class UserService {
    let database: Database
    
    init(database: Database) {
        self.database = database
    }
    
    func getUser() {
        let user = database.fetchUser()
        // ...
    }
}

의존성 주입(Dependency Injection)을 사용하여 UserService가 Database 인터페이스에 의존하도록 한다.
이렇게 하면 UserService가 데이터베이스 구현체에 독립적으로 동작할 수 있으며, 유연성과 테스트 용이성이 향상된다

SOLID 원칙을 지키는 것은 유지 보수성 향상, 재사용성 증대, 확장성 강화, 가독성 향상, 테스트 용이성 등 여러 가지 측면에서 효율성과 생산성을 높여주는 장점이 있다.


  1. 엔티티(Entity) : ↩︎

  2. 고수준 모듈(High-level Module) : 상위 레벨의 비즈니스 로직이나, 주로 다른 모듈들을 조합해서 로직을 실행하는 역할. 예를들면 유저 인터페이스(UI)를 다루거나 비즈니스 규칙을 처리하는 등의 애플리케이션의 핵심 기능을 담당한다. ↩︎

  3. 저수준 모듈(Low-level Module) : 고수준 모듈의 세부적인 구현. 즉 하위 기능들을 담당한다. 예를들면 DB접근, 파일 입출력, 네트워크 통신등이 이에 해당할 수 있다. 고수준 모듈의 기능을 실행하기 위해 필요한 도구 역할을 한다. ↩︎