iOS/Common

[Swift] SOLID 원칙에 관하여

TDCIAN 2023. 4. 10. 22:01

 

 

Q. Swift 코드를 예시로 SOLID 원칙에 대해 설명해 줘

 

A. 좋습니다! SOLID 원칙은 소프트웨어 개발에서 유지보수, 확장성, 유연성 등의 측면에서 코드의 품질을 높이기 위해 사용되는 원칙들의 집합입니다.

 

Swift 언어를 예시로 설명하면서 SOLID 원칙을 적용하는 방법을 알아보겠습니다.

 

1. Single Responsibility Principle (SRP)

- SRP는 하나의 클래스는 하나의 책임만을 가져야 한다는 원칙입니다.

- 예를 들어, ViewController가 UI 관리와 데이터 로딩을 모두 담당하면 SRP를 위반하는 것입니다.

- 따라서, ViewController를 화면 표시를 담당하는 View와 데이터 로딩을 담당하는 ViewModel로 나누어서 구현하는 것이 좋습니다.

 

예시 코드를 통해 살펴보겠습니다.

class ViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    
    var items: [Item] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        loadData()
        setupTableView()
    }
    
    func loadData() {
        // 데이터 로딩 로직 구현
        // ...
    }
    
    func setupTableView() {
        // 테이블뷰 설정 구현
        // ...
    }
    
    // ...
}

위 코드에서 ViewController는 UI 관리와 데이터 로딩을 모두 담당합니다.

이를 SRP를 준수하도록 개선해보겠습니다.

class ViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    
    var viewModel: ItemListViewModel
    
    init(viewModel: ItemListViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupTableView()
        bindViewModel()
    }
    
    func setupTableView() {
        // 테이블뷰 설정 구현
        // ...
    }
    
    func bindViewModel() {
        viewModel.items.bind { [weak self] items in
            self?.items = items
            tableView.reloadData()
        }
        
        viewModel.loadData()
    }
    
    // ...
}

ViewModel을 추가하고, 데이터 로딩 로직과 UI 관련 로직을 ViewModel과 ViewController로 분리하였습니다.

이를 통해 ViewController는 단일 책임을 가지게 되었습니다.

 

 

 

2. Open-Closed Principle (OCP)

- OCP는 클래스나 모듈은 확장에는 열려있고 변경에는 닫혀있어야 한다는 원칙입니다.

- 새로운 기능이 추가되더라도 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있도록 설계해야 합니다.

- 예를 들어, 데이터 로딩 로직을 구현한 DataManager 클래스가 있다면, DataManager 클래스의 코드를 변경하지 않고도 새로운 데이터 소스를 추가할 수 있도록 인터페이스를 추상화하는 것이 좋습니다.

 

예시 코드를 통해 살펴보겠습니다.

class DataManager {
    func loadData() -> [Item] {
        // 데이터 로딩 로직 구현
        // ...
    }
}

위 코드에서 DataManager 클래스는 loadData() 메서드를 사용하여 데이터를 로딩합니다.

이를 OCP를 준수하도록 개선해보겠습니다.

protocol DataLoading {
    func loadData() -> [Item]
}

class DataManager: DataLoading {
    func loadData() -> [Item] {
        // 데이터 로딩 로직 구현
        // ...
    }
}

class APIManager: DataLoading {
    func loadData() -> [Item] {
        // API 데이터 로딩 로직 구현
        // ...
    }
}

인터페이스인 DataLoading을 정의하고, DataManager와 APIManager 클래스가 이를 구현하도록 변경하였습니다.

이를 통해 새로운 데이터 소스를 추가하더라도 기존 코드를 변경하지 않고도 사용할 수 있게 되었습니다.

 

 

 

3. Liskov Substitution Principle (LSP)

- LSP는 하위 클래스는 상위 클래스를 대체할 수 있어야 한다는 원칙입니다.

- 예를 들어, Shape 클래스를 상속받은 Circle 클래스와 Rectangle 클래스가 있다면, Shape 타입을 인자로 받는 함수에서도 Circle과 Rectangle 인스턴스를 모두 사용할 수 있어야 합니다.

 

예시 코드를 통해 살펴보겠습니다.

class Shape {
    func draw() { }
}

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

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

func drawShapes(shapes: [Shape]) {
    for shape in shapes {
        shape.draw()
    }
}

위 코드에서 'Shape' 클래스는 도형의 공통된 속성과 메서드를 가지고 있습니다.

'Circle'과 'Rectangle' 클래스는 각각 'Shape' 클래스를 상속받아 도형을 그리는 로직을 구현합니다.

 

'drawShapes()' 함수는 'Shape' 배열을 받아서 각 도형을 그리는 메서드를 호출하는 함수입니다.

 

이 코드에서 LSP를 지키기 위해서는 'Rectangle' 클래스의 'draw()' 메서드가 'Shape' 클래스의 'draw()' 메서드가 기대하는 결과와 일치해야 합니다. 만약 'Rectangle' 클래스에서 'draw()' 메서드가 사각형 대신 원을 그리도록 구현되어 있다면, 'drawShapes()' 함수가 'Shape' 배열과 함께 'Rectangle' 객체도 받아들일 때, 원을 그리지 않고 예기치 않은 결과를 출력할 수 있습니다.

 

LSP는 자식 클래스에서는 부모 클래스에서 정의한 것과 같거나 더 많은 것을 할 수 있어야 한다는 원칙입니다.

따라서 'Rectangle' 클래스의 'draw()' 메서드에서는 사각형을 그리는 로직만 구현해야 합니다.

 

 

 

 

4. Interface Segregation Principle (ISP)

- ISP는 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 원칙입니다.

- 예를 들어, 데이터 로딩 기능만 사용하는 경우, 데이터 로딩과 관련된 인터페이스만 제공하는 DataManagerProtocol을 정의하고, 해당 프로토콜을 구현하는 DataManager 클래스를 사용하는 것이 좋습니다.

 

예시 코드를 살펴보겠습니다.

protocol Animal {
    func walk()
    func run()
    func fly()
}

class Bird: Animal {
    func walk() {
        // 새가 걷는 로직 구현
        // ...
    }
    
    func run() {
        // 새가 달리는 로직 구현
        // ...
    }
    
    func fly() {
        // 새가 날아가는 로직 구현
        // ...
    }
}

class Dog: Animal {
    func walk() {
        // 개가 걷는 로직 구현
        // ...
    }
    
    func run() {
        // 개가 달리는 로직 구현
        // ...
    }
    
    func fly() {
        // 개는 날지 못하므로 구현할 필요가 없음
    }
}

위 코드에서 'Animal' 프로토콜은 'walk()', 'run()', 'fly()' 메서드를 가지고 있습니다.

하지만 'Dog' 클래스는 'fly()' 메서드를 구현하지 않아도 됩니다.

이는 'Dog' 클래스에서 'Animal' 프로토콜의 일부 메서드를 사용하지 않기 때문입니다.

이 경우, 'Animal' 프로토콜은 더 작은 인터페이스로 분리하여 'Dog' 클래스에서 불필요한 메서드를 구현하지 않도록 해야 합니다.

protocol Walkable {
    func walk()
    func run()
}

protocol Flyable {
    func fly()
}

class Bird: Walkable, Flyable {
    func walk() {
        // 새가 걷는 로직 구현
        // ...
    }
    
    func run() {
        // 새가 달리는 로직 구현
        // ...
    }
    
    func fly() {
        // 새가 날아가는 로직 구현
        // ...
    }
}

class Dog: Walkable {
    func walk() {
        // 개가 걷는 로직 구현
        // ...
    }
    
    func run() {
        // 개가 달리는 로직 구현
        // ...
    }
}

위 코드에서 'Animal' 프로토콜을 'Walkable'과 'Flyable' 프로토콜로 분리하여, 'Dog' 클래스에서는 'Flyable' 프로토콜을 구현하지 않도록 하였습니다.

 

 

 

5. Dependency Inversion Principle (DIP)

- DIP는 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 된다는 원칙입니다.

- 예를 들어, ViewController가 데이터 로딩 기능을 사용할 때, DataManager 클래스에 직접 의존하면 DIP를 위반하는 것입니다.

- 따라서, DataManagerProtocol과 같은 추상화된 인터페이스를 사용하고, 이를 구현하는 클래스는 외부에서 주입받도록 설계하는 것이 좋습니다.

 

예시 코드를 통해 살펴보겠습니다.

class UserService {
    func getUser() -> String {
        return "user"
    }
}

class UserController {
    let userService = UserService()
    
    func getUser() -> String {
        return userService.getUser()
    }
}

위 코드에서 'UserController' 클래스는 'UserService' 클래스에 직접 의존합니다.

이는 'UserController' 클래스가 고수준의 모듈이며, 'UserService' 클래스가 저수준의 모듈이기 때문에 DIP를 위반합니다.

 

이 경우, 추상화를 통해 DIP를 준수하는 방법이 있습니다.

아래와 같이 'UserProvider' 프로토콜을 만들고, 'UserService' 클래스가 이를 구현하도록 합니다.

protocol UserProvider {
    func getUser() -> String
}

class UserService: UserProvider {
    func getUser() -> String {
        return "user"
    }
}

class UserController {
    let userProvider: UserProvider
    
    init(userProvider: UserProvider) {
        self.userProvider = userProvider
    }
    
    func getUser() -> String {
        return userProvider.getUser()
    }
}

이제 'UserController' 클래스는 'UserProvider' 프로토콜에 의존하고 있으며,

'UserService' 클래스가 'UserProvider' 프로토콜을 구현하고 있으므로 DIP를 준수하게 되었습니다.

추상화를 통해 느슨한 결합을 유지할 수 있게 되어 코드 유지보수성과 확장성을 향상시킬 수 있습니다.

 

 

 

출처: ChatGPT(https://chat.openai.com/chat)