본문 바로가기

정보

SOLID 원칙이란?

SOLID 원칙은 객체지향 설계에서 유지보수성과 확장성을 높이기 위해 사용되는 5가지 핵심 원칙입니다. 이를 스위프트로 예를 들어 설명하며, 각 원칙의 위반 전과 후를 비교해서 어떤 문제가 있었는지, 이를 어떻게 수정했는지 그리고 그 결과는 무엇인지 살펴보겠습니다.

 

1. 단일 책임 원칙 (Single Responsibility Principle, SRP)

  • 클래스는 하나의 책임만 가져야 합니다.
  • 즉, 클래스는 하나의 기능(역할)만을 담당하고, 그 책임이 변경될 이유가 하나여야 합니다.

위반 전

아래 코드는 UserManager 클래스사용자 데이터 관리로깅까지 동시에 담당하고 있습니다.

class UserManager {
    func saveUser() {
        print("Saving user to database...")
        // 사용자 데이터를 저장하는 코드
    }
    
    func logActivity() {
        print("Logging user activity...")
        // 로깅하는 코드
    }
}

 

문제점

  • 두 가지 책임이 있습니다: 사용자 관리와 로그 관리.
  • 하나의 책임이 변경되면 다른 책임에도 영향을 줄 수 있습니다.
  • 예를 들어, 로깅 로직이 바뀌면 UserManager 클래스가 수정되어야 합니다.

 

수정 후

각 책임을 별도의 클래스로 분리합니다.

class UserManager {
    let logger: Logger
    
    init(logger: Logger) {
        self.logger = logger
    }
    
    func saveUser() {
        print("Saving user to database...")
        // 사용자 데이터를 저장하는 코드
        logger.logActivity(message: "User saved to database")
    }
}

class Logger {
    func logActivity(message: String) {
        print("Logging: \(message)")
    }
}

결과

  • UserManager는 사용자 데이터 관리만을 담당하게 되었고, Logger는 로깅만을 책임집니다.
  • 로깅 로직이 바뀌어도 Logger 클래스만 수정하면 되므로 변경이 훨씬 유연하고 유지보수가 쉬워집니다.

 

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

  • 확장에는 열려 있고, 변경에는 닫혀 있어야 합니다.
  • 즉, 새로운 기능이 추가될 때 기존 코드를 수정하지 않고 확장할 수 있어야 합니다.

위반 전

아래 코드는 Discount 클래스가 할인 정책에 따라 수정되고 있습니다.

class Discount {
    func calculate(price: Double, type: String) -> Double {
        if type == "percentage" {
            return price * 0.9
        } else if type == "fixed" {
            return price - 10
        }
        return price
    }
}

문제점

  • 새로운 할인 정책이 추가되면 Discount 클래스에 계속해서 if-else 조건이 추가되어야 합니다.
  • 기존 코드를 수정해야 하므로 폐쇄 원칙에 위배됩니다.

수정 후

할인 정책을 확장할 수 있도록 프로토콜을 도입합니다.

 protocol DiscountStrategy {
    func calculate(price: Double) -> Double
}

class PercentageDiscount: DiscountStrategy {
    func calculate(price: Double) -> Double {
        return price * 0.9
    }
}

class FixedDiscount: DiscountStrategy {
    func calculate(price: Double) -> Double {
        return price - 10
    }
}

class Discount {
    private let strategy: DiscountStrategy
    
    init(strategy: DiscountStrategy) {
        self.strategy = strategy
    }
    
    func applyDiscount(price: Double) -> Double {
        return strategy.calculate(price: price)
    }
}

결과

  • 새로운 할인 정책이 필요하면 DiscountStrategy를 준수하는 새로운 클래스를 추가하면 됩니다.
  • 기존 코드는 전혀 수정하지 않아도 되므로 확장에 열려 있고, 변경에는 닫혀 있습니다.

 

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

  • 하위 클래스는 상위 클래스의 기능을 대체할 수 있어야 합니다.
  • 즉, 상위 클래스 타입의 객체를 하위 클래스 타입으로 교체해도 프로그램의 동작이 일관되어야 합니다.
class Bird {
    func fly() {
        print("Flying...")
    }
}

class Penguin: Bird {
    override func fly() {
        fatalError("Penguins can't fly!")
    }
}

문제점

  • Penguin은 Bird를 상속했지만 fly() 메서드를 정상적으로 수행하지 못합니다.
  • Bird의 메서드를 믿고 호출했을 때 예상치 못한 런타임 에러가 발생합니다.

수정 후

Bird의 공통 동작만을 상위 클래스로 만들고, Flyable 프로토콜을 도입해 행동을 분리합니다.

class Bird {
    func eat() {
        print("Eating...")
    }
}

protocol Flyable {
    func fly()
}

class Sparrow: Bird, Flyable {
    func fly() {
        print("Flying...")
    }
}

class Penguin: Bird {
    // Penguin은 fly를 구현하지 않음
}

결과

  • Penguin은 fly()를 구현하지 않아도 되므로 런타임 에러가 제거됩니다.
  • 상속 구조가 더 유연하고, 하위 클래스는 상위 클래스의 기대를 충족합니다.

 

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

  • 클라이언트가 사용하지 않는 메서드에 의존하지 않아야 합니다.
  • 즉, 인터페이스는 구체적이고 작은 단위로 나누어져야 합니다.

위반 전

하나의 프로토콜에 모든 기능이 들어있습니다.

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

class HumanWorker: Worker {
    func work() { print("Working...") }
    func eat() { print("Eating...") }
    func sleep() { print("Sleeping...") }
}

class RobotWorker: Worker {
    func work() { print("Working...") }
    func eat() { fatalError("Robots don't eat!") }
    func sleep() { fatalError("Robots don't sleep!") }
}

문제점

  • RobotWorker는 eat()과 sleep() 메서드를 구현할 필요가 없지만 강제로 구현해야 합니다.
  • 불필요한 의존성이 생깁니다.

수정 후

프로토콜을 기능별로 분리합니다.

protocol Workable {
    func work()
}

protocol Eatable {
    func eat()
}

protocol Sleepable {
    func sleep()
}

class HumanWorker: Workable, Eatable, Sleepable {
    func work() { print("Working...") }
    func eat() { print("Eating...") }
    func sleep() { print("Sleeping...") }
}

class RobotWorker: Workable {
    func work() { print("Working...") }
}

결과

  • RobotWorker는 불필요한 메서드를 구현하지 않아도 됩니다.
  • 불필요한 의존성이 제거되고 인터페이스가 더 유연해졌습니다.

 

5. 의존관계 역전 원칙 (Dependency Inversion Principle, DIP)

  • 고수준 모듈은 저수준 모듈에 의존하지 않아야 하며, 둘 다 추상화에 의존해야 합니다.

위반 전

아래 코드에서는 Lamp 클래스에 직접 의존하고 있습니다.

class Lamp {
    func turnOn() {
        print("Lamp is On")
    }
}

class Switch {
    let lamp = Lamp()
    
    func operate() {
        lamp.turnOn()
    }
}

문제점

  • Switch는 Lamp에 강하게 의존합니다. Lamp가 변경되면 Switch도 수정되어야 합니다

수정 후

추상화(프로토콜)를 도입합니다.

protocol Switchable {
    func turnOn()
}

class Lamp: Switchable {
    func turnOn() {
        print("Lamp is On")
    }
}

class Switch {
    let device: Switchable
    
    init(device: Switchable) {
        self.device = device
    }
    
    func operate() {
        device.turnOn()
    }
}

결과

  • Switch는 Switchable에 의존하므로 Lamp 외 다른 장치도 쉽게 교체할 수 있습니다.
  • 의존성이 추상화를 통해 역전되어 더 유연해졌습니다

사용 예시

아래와 같이 Lamp 클래스는 Switchable 프로토콜을 준수하므로 Switch 객체를 생성할 때 Lamp 인스턴스를 주입할 수 있습니다.

let lamp = Lamp()  // Switchable 프로토콜을 준수하는 Lamp 인스턴스 생성
let lightSwitch = Switch(device: lamp)  // Lamp를 Switch에 주입

lightSwitch.operate()  // "Lamp is On" 출력

설명

  1. Lamp 객체는 Switchable 프로토콜을 구현하므로 Switch의 device에 주입될 수 있습니다.
  2. Switch는 Switchable을 통해 구체적인 Lamp 클래스에 의존하지 않고 추상화에만 의존합니다.
  3. Switch 객체의 operate() 메서드를 호출하면 device.turnOn()을 호출하며, 실제 동작은 Lamp 클래스의 turnOn() 메서드가 수행됩니다.

다른 장치를 사용해보기

Switchable 프로토콜을 준수하는 다른 장치를 만들어 Switch에 주입할 수 있습니다.

class Fan: Switchable {
    func turnOn() {
        print("Fan is On")
    }
}

let fan = Fan()  // Fan 인스턴스 생성
let fanSwitch = Switch(device: fan)  // Fan을 Switch에 주입

fanSwitch.operate()  // "Fan is On" 출력

결과

  • Switch 클래스는 Lamp나 Fan과 같은 구체적인 클래스에 의존하지 않고, Switchable이라는 추상화에만 의존합니다.
  • 의존성 역전 원칙이 적용되어 다른 장치를 쉽게 교체할 수 있습니다.
  • 유연성확장성이 높아져 새로운 장치(Switchable을 따르는 클래스)를 추가하더라도 Switch 클래스는 변경되지 않습니다.