Architecture Software Engineering [SOLID] 객체 지향의 5가지 설계 원칙

[SOLID] 객체 지향의 5가지 설계 원칙

객체 지향의 5가지 설계 원칙이 있다. 아주 오래전부터 내려오던 디자인 패턴이다.

설계는 ‘객체’의 기능의 관계이다.

코딩을 하면서 항상 생각하는 것이 중요하며, 시간이 지남에 따라 경험이 쌓이고 프로젝트를 관리하며 OOP를 인지하는 것은 매우 중요하다.

시니어가 되면 이러한 패턴을 익혀 후배 개발자에게 경험을 바탕으로 전달하는 것이 필요하고, 코드 리뷰는 항상 이러한 원칙을 기반으로 생각하고 진행하는 것이 좋다.

객체에게 데이터를 요구하지 말고 작업을 요청하라.

  • 객체 : class
  • 작업 : 기능

「무엇을 객체화할 것인가?」로 시작하는 것에 대한 정답은 없다.
다만 「데이터를 요구하지 말라」는 매우 명확한데, 객체에게 데이터를 요청하는 것은 객체 지향을 깨트리는 것이다.

객체 지향의 5원칙(SOLID)을 알아보자.

[SRP] 단일 책임 원칙 - Single Responsibility Principle

Single Responsibility Principle은 하나의 class는 하나의 책임만 가지도록 하는 것을 의미한다.

로그인 기능을 구현한다고 가정한다면, 로그인 기능에 필요한 모든 것을 포함하는 Login class를 작성할 수 있다. 하지만 이렇게 하면 Login class가 너무 많은 책임을 갖게 되어 SRP를 위배하게 된다.

그래서 Login class의 책임을 분리하고, User Model을 다루는 User class와 네트워크 통신을 다루는 NetworkManager class를 추가로 만들 수 있다. 이렇게 분리하면 각 class는 자신의 책임에만 집중하게 되어 코드 유지보수가 쉬워지고, 다른 class에 영향을 주지 않고 수정할 수 있다.

class User {
  var email: String
  var password: String
  
  init(email: String, password: String) {
    self.email = email
    self.password = password
  }
}

class NetworkManager {
  func login(user: User, completion: (Bool) -> Void) {
    // 네트워크 통신으로 로그인 기능을 구현
  }
}

class LoginViewModel {
  var user: User
  
  init(user: User) {
    self.user = user
  }
  
  func login(completion: (Bool) -> Void) {
    let networkManager = NetworkManager()
    networkManager.login(user: user) { success in
        completion(success)
    }
  }
}

위의 코드에서 User class는 User model을 다루는 책임을 갖고, NetworkManager class는 네트워크 통신을 다루는 책임을 갖는다. 마지막으로 LoginViewModel class는 User와 NetworkManager를 조합하여 로그인 기능을 구현하는 책임을 갖는다. 이렇게 책임을 분리하면 각 class가 하나의 책임만 갖게 되어 SRP를 준수하게 된다.

콘웨이(Conway) 법칙에 따름 정리 : 소프트웨어 시스템이 가질 수 있는 최적의 구조는 시스템을 만드는 조직의 사회적 구조에 커다란 영향을 받는다. 따라서 각 소프트웨어 모듈은 변경의 이유가 하나, 단 하나여야만 한다.

「Clean Architecture 中」

[OCP] 개방-폐쇄 원칙 - Open-Closed Principle

Open/Closed Principle은 소프트웨어 개체(class, 모듈, 함수 등)는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다는 원칙이다. 이 원칙을 준수하면 기존 코드의 수정 없이도 새로운 기능을 추가할 수 있으므로 코드의 유지보수성이 높아진다.

다양한 종류의 음악을 재생하는 MusicPlayer class를 작성한다면, MusicPlayer class는 다양한 유형의 음악 파일을 재생할 수 있어야 하므로 각 음악 파일의 유형에 따라 재생 방법이 달라져야 하며, 초기에는 MP3 파일과 AAC 파일만 지원한다고 가정한다.

이 경우, MusicPlayer class는 다음과 같이 작성할 수 있다.

class MusicPlayer {
  func playMP3(_ file: String) {
    // MP3 파일 재생
  }
  
  func playAAC(_ file: String) {
    // AAC 파일 재생
  }
}

하지만 이렇게 작성하면 새로운 음악 파일 유형이 추가될 때마다 MusicPlayer class를 수정해야 하며, 이는 OCP를 위배하게 된다.

따라서 MusicPlayer class를 확장 가능하도록 수정해야 한다. 이를 위해 각 음악 파일 유형마다 새로운 class를 작성하고, 이를 MusicPlayer에서 사용하도록 만들어야 한다.

protocol MusicPlayable {
  func play(_ file: String)
}

class MP3Player: MusicPlayable {
  func play(_ file: String) {
    // MP3 파일 재생
  }
}

class AACPlayer: MusicPlayable {
  func play(_ file: String) {
    // AAC 파일 재생
  }
}

class MusicPlayer {
  private var players: [MusicPlayable] = [MP3Player(), AACPlayer()]
  
  func play(_ file: String) {
    for player in players {
      player.play(file)
    }
  }
}

위의 코드에서 MusicPlayable protocol은 음악 파일 유형마다 재생 방법을 추상화한 것입니다. MP3PlayerAACPlayer class는 각각 MP3 파일과 AAC 파일을 재생하는 방법을 구현한다. 마지막으로 MusicPlayer class에서는 MusicPlayable을 준수하는 class의 인스턴스를 players 배열에 저장하고, play 함수에서 players 배열을 순회하면서 파일을 재생한다.

이렇게 코드를 작성하면 새로운 음악 파일 유형이 추가될 때마다 MusicPlayable을 준수하는 새로운 class를 작성하면 되므로 기존 코드를 수정할 필요가 없어져 OCP를 준수하게 된다.

1980년대에 버트란트 마이어(Burtand Meyer)에 의해 유명해진 원칙이다. 기존 코드를 수정하기보다는 반드시 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경할 수 있도록 설계해야만 소프트웨어 시스템을 쉽게 변경 할 수 있다는 것이 이 원칙의 요지다.

「Clean Architecture 中」

[LSP] 리스코프 치환 원칙 - Liskov Substitution Principle

Liskov Substitution Principle은 자식 class는 언제나 부모 class의 자리를 대신할 수 있어야 한다는 원칙이다. 이 원칙을 준수하면 코드의 유지보수성과 확장성이 향상된다.

다양한 종류의 Shape(도형) 객체를 다루는 코드를 작성한다고 가정하면, Shape class는 다양한 종류의 도형 class(원, 사각형, 삼각형 등)를 대표하는 부모 class다.

다음은 Shape class와 이를 상속받는 Circle class가 각각 어떻게 구현되는지 보여준다.

class Shape {
  func area() -> Double {
    fatalError("This method must be overridden")
  }
}

class Circle: Shape {
  var radius: Double

  init(radius: Double) {
    self.radius = radius
  }

  override func area() -> Double {
    return Double.pi * radius * radius
  }
}
func printArea(_ shape: Shape) {
  print(shape.area())
}

let circle = Circle(radius: 10)
printArea(circle)

이제 Circle class를 사용하는 코드를 작성해 보면,

위 코드에서 printArea 함수는 Shape 객체를 매개변수로 받아 해당 객체의 면적(area)을 출력하는 함수이다. 이 함수에 Circle 객체를 전달해도 원의 면적이 정상적으로 출력된다.

하지만 이 코드에서 문제가 발생할 수 있다. 예를 들어, Circle class를 상속받는 Ellipse class를 추가하고 Ellipse class의 area 함수를 다음과 같이 재정의하는 경우를 생각해보자.

class Ellipse: Circle {
  var radius2: Double
  
  init(radius: Double, radius2: Double) {
    self.radius2 = radius2
    super.init(radius: radius)
  }
  
  override func area() -> Double {
    return Double.pi * radius * radius2
  }
}

이제 Ellipse class를 사용하는 코드를 작성해 보자.

let ellipse = Ellipse(radius: 10, radius2: 20)
printArea(ellipse)

위 코드에서는 printArea 함수에 Ellipse 객체를 전달합니다. 하지만 Ellipse class는 Circle class를 상속받았지만 Circle class의 radius 프로퍼티는 하나밖에 없는 반면, Ellipse class는 radius2 프로퍼티도 추가로 가지고 있다. 따라서 Ellipse 객체를 Circle 객체처럼 사용하는 것은 LSP를 위배하게 된다.

따라서 LSP를 준수하기 위해서는 Circle class와 Ellipse class를 별도로 구현하는 것이 좋다. 이를 통해 각각의 class는 자신만의 속성과 함수를 갖게 되고, Shape class는 공통된 속성과 함수를 정의하는 역할을 할 수 있다. 이는 객체지향의 다형성을 유지하는데 도움을 준다.

1988년 바바라 리스코프(Barbara Liskov)가 정의한, 하위 타입(subtype)에 관한 유명한 원칙이다. 요약하면, 상호 대체 가능한 구성요소를 이용해 소프트웨어의 시스템을 만들 수 있으려면, 이들 구성요소는 반드시 서로 치환 가능해야 한다는 계약을 반드시 지켜야한다.

「Clean Architecture 中」

[ISP] 인터페이스 분리 원칙 - Interface Segregation Principle

Interface Segregation Principle(ISP)는 하나의 클라이언트가 사용하지 않는 인터페이스에 의존하지 않도록 하기 위한 객체지향 설계 원칙이다.

protocol Worker {
  func work()
  func eat()
  func sleep()
}

위의 protocol에서는 work(), eat(), sleep() 함수가 모두 포함되어 있다. 그러나, 실제로 이 protocol을 구현하는 class에서는 모든 함수를 사용하지 않을 수 있다. 예를 들어, Farmer class에서는 일하는 함수만 필요할 수 있다. 이 경우, Farmer class에서는 eat()sleep() 함수를 구현하지 않더라도, protocol을 구현하는 것이 강제됩니다.

이러한 경우, ISP를 준수하면 인터페이스를 적절하게 분리할 수 있다. Farmer class에서는 work() 함수만 필요하므로, 따로 분리된 Workable protocol을 구현하는 것이 더 적절하다.

protocol Workable {
  func work()
}

protocol Feedable {
  func eat()
}

protocol Sleepable {
  func sleep()
}

class Farmer: Workable {
  func work() {
    // do work
  }
}

위와 같이 인터페이스를 적절하게 분리하여, Farmer class에서 필요한 기능만 구현할 수 있으며, 불필요한 함수에 대한 의존성이 제거된다. 이는 코드의 유연성과 확장성을 높이는데 도움을 준다.

이 원칙에 따르면 소프트웨어 설계자는 사용하지 않은 것에 의존하지 않아야 한다.

「Clean Architecture 中」

[DIP] 의존 역전 원칙 - Dependency Inversion Principle

Dependency Inversion Principle(DIP)는 상위 수준의 모듈이 하위 수준의 모듈에 의존하지 않도록 하며, 둘 모두 추상화된 인터페이스에 의존해야 한다는 객체지향 설계 원칙이다.

class Heater {
  func on() {
    // 히터를 켜는 로직
  }
  
  func off() {
    // 히터를 끄는 로직
  }
}

class CoffeeMachine {
  private let heater = Heater()
  
  func makeCoffee() {
    heater.on()
    // 커피를 내리는 로직
    heater.off()
  }
}

위 코드에서는 CoffeeMachine 에서 Heater 를 직접 의존하고 있다. 이는 DIP를 위반한 예제이다. 이 코드에서는 CoffeeMachine가 상위 수준의 모듈이고, Heater가 하위 수준의 모듈이기 때문이다.

DIP를 준수하도록 코드를 수정하려면, 먼저, Heater class를 protocol로 추상화해야 한다.

protocol Switchable {
  func on()
  func off()
}

class Heater: Switchable {
  func on() {
    // 히터를 켜는 로직
  }
  
  func off() {
    // 히터를 끄는 로직
  }
}

다음으로, CoffeeMachine class에서 Switchable protocol을 의존하도록 수정한다.

class CoffeeMachine {
  private let switchable: Switchable
  
  init(switchable: Switchable) {
    self.switchable = switchable
  }
  
  func makeCoffee() {
    switchable.on()
    // 커피를 내리는 로직
    switchable.off()
  }
}

위와 같이, CoffeeMachine class에서는 Heater class 대신에 Switchable protocol을 의존하도록 변경했다. CoffeeMachine class는 추상화된 인터페이스에만 의존하게 되어 상위 수준의 모듈이 하위 수준의 모듈에 의존하지 않도록 할 수 있다. 이는 코드의 유연성과 확장성을 높이는데 도움을 준다.

고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대로 의존해서는 안 된다. 대신 세부사항이 정책에 의존해야 한다.

「Clean Architecture 中」

댓글남기기