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" 출력
설명
- Lamp 객체는 Switchable 프로토콜을 구현하므로 Switch의 device에 주입될 수 있습니다.
- Switch는 Switchable을 통해 구체적인 Lamp 클래스에 의존하지 않고 추상화에만 의존합니다.
- 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 클래스는 변경되지 않습니다.
'정보' 카테고리의 다른 글
performAction<T: Animal>, animal: Animal 차이? (0) | 2024.12.18 |
---|---|
디자인 패턴 - 싱글톤 패턴 (0) | 2024.12.17 |
Hashable과 Equatable은 밀접한 관계 (0) | 2024.12.16 |
해시란? Hash, Hashable (1) | 2024.12.15 |
Static Dispatch vs Dynamic Dispatch (1) | 2024.12.13 |