객체 지향 프로그래밍이란?
먼저 작은 문제들을 해결할 수 있는 객체들을 만들고, 이 객체들을 조합하여 큰 문제를 해결하는 방식이다.
좋은 객체 지향 설계를 하게 되면 코드의 재사용, 유지보수의 용이성 등의 장점으로 가져갈 수 있고 개발 기간/비용들을 감축할 수 있다!
항상 코드는 유연하고 확장할 수 있고 유지보수가 용이하고 재사용할 수 있어야 한다.
이러한 OOP 방식을 잘 준수하기 위해 만들어진 것이 SOLID원칙이다.
OOP의 SOLID
1. 단일 책임의 원칙 (SRP: Single Responsibility Principle)
하나의 객체는 하나의 책임을 가져야 한다. 즉 하나의 class가 여러 기능을 담당하면 안된다는 것이다.
class Search {
func searchResult() -> String {
let result = firstSearch()
let keywords = filterResult(result: result)
return findTargetWord(result: keywords)
}
func firstSearch() -> [String] {
}
func filterResult(result: [String]) -> [String] {
}
func findTargetWord(_ result: [String]) -> String {
}
}
예를 들어서 이렇게 구성된 코드가 있다고 했을 때, 하나의 클래스에 "검색", "정제", "원하는 단어 찾기"의 기능이 모두 들어있는 것을 볼 수 있다.
이는 SRP를 지키지 않는 코드로 하나의 클래스가 하나의 책임을 갖도록 하려면 기능들을 분리해줘야 한다.
class Search {
var searchHandler = SearchHandler()
var filterHandler = FilterHandler()
var findTargetHandler = FindTargetHandler()
init(searchHandler: SearchHandler, filterHandler: FilterHandler, findTargetHandler: FindTargetHandler) {
self.searchHandler = searchHandler
self.filterHandler = filterHandler
self.findTargetHandler = findTargetHandler
}
func searchResult() -> String {
let result = searchHandler.firstSearch()
let keywords = filterHandler.filterResult(result: result)
return findTargetHandler.findTargetWord(keywords)
}
}
class SearchHandler {
func firstSearch() -> [String] {
}
}
class FilterHandler {
func filterResult(result: [String]) -> [String] {
}
}
class FindTargetHandler {
func findTargetWord(_ result: [String]) -> String {
}
}
이렇게 각각의 책임을 클래스에 넘겨주는 식으로 작성하면 하위 핸들러들의 테스트도 용이해지게 된다!
그림으로 보면 아래와 같다.
2. 개방-폐쇄의 원칙 (OCP: Open-Closed Principle)
소프트웨어 엔티티(클래스, 모듈, 함수 등)들은 확장에는 열려있어야 하지만, 변경에는 닫혀있어야 한다.
확장에 열려있다는 것은, 수고롭지 않게 클래스의 기능을 확장할 수 있다는 것이다.
또한 변경에 닫혀있다는 것은, 기존에 구현되어 있는 것들을 바꾸지 않고 클래스를 확장할 수 있어야 한다는 것이다.
이러한 특성들은 "추상화" 를 통해 이루어질 수 있다.
(Swift에서 추상화는 주로 Protocol을 통해 이뤄진다)
Vehicle이라는 클래스를 통해 Car 클래스의 객체들의 detail을 출력하는 코드를 한 번 봐보자.
class Vehicle {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
만약 이 상황에서 다른 탈 것(다른 클래스)이 추가될 경우 우리는 printData를 다음과 같이 바꿔줘야 하는데, 이는 OCP 규칙을 깨뜨리는 방식이다. 우선 구현해보자면 다음과 같이 추가 및 수정해주어야 한다.
class Vehicle {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
// 새로운 클래스 추가로 인한 불필요한 반복 = 재활용 못하고 있음
let bicycles = [
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
bicycles.forEach { bicycles in
print(bicycles.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle {
let type: String
init(type: String) {
self.type = type
}
func printDetails() -> String {
return "I'm a \(type)"
}
}
코드를 보기만 해도 불필요하게 반복되는 모습을 볼 수 있다.
이러한 문제는 Protocol을 새로 만듬으로써 해결할 수 있다 (= 추상화)
Printable이라는 프로토콜을 만들어 printDetails 메서드를 정의만 해둘 것이다.
이제는 새 클래스가 추가될 때마다 Vehicle의 printData를 수정해야할 필요가 없고, 데이터만 추가해주면 된다.
protocol Printable {
func printDetails() -> String
}
class Vehicle {
func printData() {
let vehicles: [Printable] = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey"),
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
vehicles.forEach { car in
print(car.printDetails())
}
}
}
class Car: Printable {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
// 프로토콜 채택 이후, 각 클래스 내에서 기능 구현
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle: Printable {
let type: String
init(type: String) {
self.type = type
}
// 프로토콜 채택 이후, 각 클래스 내에서 기능 구현
func printDetails() -> String {
return "I'm a \(type)"
}
}
3. 리스코프 치환 원칙 (LSP: The Liskov Substitution Principle)
상위 타입(S)와 하위 타입(T) 사이에서, 프로그램의 속성(정확성, 수행하는 업무 등)의 변경 없이 상위 타입(S)의 객체를 하위 타입(T)로 교체(치환)할 수 있어야 한다는 원칙
즉, 상위 클래스의 자리에 하위 클래스를 치환해도 문제 없이 수행되어야 한다는 의미이다. 이 원칙은 상속을 혼란스럽게 하지 않을 수 있도록 큰 도움을 줄 수 있다.
리스코프의 치환 법칙하면 나오는 직사각형, 정사각형 문제를 가지고 한 번 살펴보자.
직사각형과 정사각형 중 어느 것이 더 큰 범주해 속할까?
돌이켜보면, 직사각형이 개념적으로 더 큰 범위에 속한다는 것을 알 수 있다.
그렇게 직사각형과 정사각형을 구현할 때에 정사각형(square)이 직사각형(rectangle)을 상속 받았다고 해보자. 그리고 넓이를 구하는 프로퍼티를 추가하여 각각의 넓이를 구해보자.
class Rectangle {
var width: Float = 0
var length: Float = 0
var area: Float {
return width * length
}
}
class Square: Rectangle {
override var width: Float {
didSet {
length = width
}
}
}
직사각형은 너비와 높이가 다를 수 있으니 각각 구현해주고, 정사각형은 한 변만 선언해준다.
하지만 아래와 같이 넓이를 구하게 된다면 LSP 원칙을 어기는 행위가 되어버린다.
func printArea(of rectangle: Rectangle) {
rectangle.length = 5
rectangle.width = 2
print(rectangle.area)
}
let rectangle = Rectangle()
printArea(of: rectangle) // 10
// -------------------------------
let square = Square()
printArea(of: square) // 4
결과를 보면 직사각형과 정사각형 인스턴스의 넓이가 다르게 나오는 것을 볼 수 있다. 즉, 하위 클래스로는 상위 클래스의 넓이를 구할 수 없기에 LSP를 위반한 것이라고 볼 수 있다. 이는 width의 setter 때문이어서 rectangle을 사용하는 클라이언트에서는 정사각형을 사용할 때 의도치 않은 결과를 받게 되는 것이다.
이 또한 Protocol을 만들어줌으로써 해결할 수 있다.
protocol Polygon {
var area: Float { get }
}
class Rectangle: Polygon {
private let width: Float
private let length: Float
init(width: Float, length: Float) {
self.width = width
self.length = length
}
var area: Float {
return width * length
}
}
class Square: Polygon {
private let side: Float
init(side: Float) {
self.side = side
}
var area: Float {
return pow(side, 2)
}
}
// Client Method
func printArea(of polygon: Polygon) {
print(polygon.area)
}
// Usage
let rectangle = Rectangle(width: 2, length: 5)
printArea(of: rectangle) // 10
let square = Square(side: 2)
printArea(of: square) // 4
우선 private를 통해 접근자를 제어해준 것이 다르다는 것을 알 수 있다. 또한 넓이를 구해주는 식은 protocol의 기능을 각각 구현해줌으로써 문제를 해결한 것을 볼 수 있다.
4. 인터페이스 분리 원칙 (ISP: The Interface Segregation Principle)
클라이언트는 그들이 사용하지 않는 인터페이스에 의존해서는 안된다.
불필요한 인터페이스 요소들을 포함시키지 말라는 의미이다. 불필요한 요소들이 포함되면서 복잡해지고, 무거워짐에 따라 진작 원하는 정보를 얻을 수 없는 지경까지 이르기도 한다. 이 문제는 클래스나 프로토콜 모두에게 영향을 줄 수 있다.
Protocol
protocol의 경우를 먼저 봐보자.
자 여기 "터치"를 했을 때 반응을 구현해줄 didTap 메서드를 가지고 있는 GestureProtocol을 봐보자.
protocol GestureProtocol {
func didTap()
}
이후에 더 많은 제스처들을 추가해주고 싶어서 프로토콜에 추가한다면 어떻게 될까?
protocol GestureProtocol {
func didTap()
func didDoubleTap()
func didLongPress()
}
그리고 실제 사용할 때는 프로토콜을 채택하고 그 메서드들을 구현해주면 된다.
class SuperButton: GestureProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
하지만 여기서 문제가 발생한다. 만약에 didTap만을 지원하는 버튼을 만들고자 한다면 어떻게 해야할까? 따로 메서드를 만들어야 할까? 아니면 프로토콜을 채택하고 나머지 메서드들은 더미하게 구현해야하나? 싶을거다.
class PoorButton: GestureProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() { }
func didLongPress() { }
}
하지만 이렇게 구현하는 것 자체가 ISP를 위반하는 "Fat"한 인터페이스이다. 그렇기에 필요한 기능만 골라서 사용할 수 있도록 프로토콜을 나눠서 구현해주는 방식으로 구성해야한다.
protocol TapProtocol {
func didTap()
}
protocol DoubleTapProtocol {
func didDoubleTap()
}
protocol LongPressProtocol {
func didLongPress()
}
class SuperButton: TapProtocol, DoubleTapProtocol, LongPressProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
class PoorButton: TapProtocol {
func didTap() {
// send tap action
}
}
이렇게 프로토콜마다 메서드를 하나씩 만들어주게되면, 원하는 프로토콜만 채택하여 기능을 구현할 수 있게 된다!
Class
영상에 대한 정보를 포함하고 있는 Video라는 클래스가 있다고 해보자.
class Video {
var title: String = "My Video"
var description: String = "This is a beautiful video"
var author: String = "Marco Santarossa"
var url: String = "https://marcosantadev.com/my_video"
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
그리고 play라는 메서드에 비디오 정보를 넣어주자.
func play(video: Video) {
// load the player UI
// load the content at video.url
// add video.title to the player UI title
// update the player scrubber with video.duration
}
사실 play 메서드에는 "title", "url", "duration"만 필수적인 정보이고 나머지는 정보들은 굳이 받아야 할 필요가 없다.
이 또한 protocol을 정의해 줌으로써 play 메서드가 필요로하는 정보만 전달해주면 된다.
protocol Playable {
var title: String { get }
var url: String { get }
var duration: Int { get }
}
class Video: Playable {
var title: String = "My Video"
var description: String = "This is a beautiful video"
var author: String = "Marco Santarossa"
var url: String = "https://marcosantadev.com/my_video"
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
func play(video: Playable) {
// load the player UI
// load the content at video.url
// add video.title to the player UI title
// update the player scrubber with video.duration
}
5. 의존관계 역전 원칙 (DIP: Dependency Inversion Principle)
A. 고차원의 모듈들은 하위 모듈에 의존하면 안된다. 두 개 모두 추상 개념에 의존해야한다
B. 추상 개념은 세부 사항에 의존하면 안된다. 세부 사항이 추상 개념에 의존해야한다.
추상화라는 것은 앞서 본 것 처럼 swift의 protocol과 같다. 즉 복잡한 것을 제쳐두고, 핵심이 되는 요소만 가져다가 간결하게 보기 쉽게 만드는 것을 말한다.
DIP는 OCP와 굉장히 유사하다
예를 들어, 파일 시스템에 문자열을 저장하는 역할을 하는 Handler라는 클래스가 있다고 해보자. 이 때 FilesystemManager 클래스의 인스턴스를 내부적으로 생성하고 실제로 저장하는 역할을 맡긴다고 했을 때, DIP를 지키고 있는 걸까?
class Handler {
let fm = FilesystemManager()
func handle(string: String) {
fm.save(string: string)
}
}
class FilesystemManager {
func save(string: String) {
// Open a file
// Save the string in this file
// Close the file
}
}
- Handler : High-Level(고수준)
- FilesystemManager: Low-Level(저수준)
그런데 위 코드를 보면 Handler(고수준)가 FilesystemManager(저수준)에 의존하고 있는 것을 볼 수 있다. 이 때문에 Handler를 재사용하기 까다로워진다.
이 때도 뭐다?
저수준 모듈에 의존하는 것이 아니라! 추상화, 즉 프로토콜을 정의하여 이에 의존하도록 해줘야 한다.
protocol Storage {
func save(string: String)
}
class Handler {
let storage: Storage
init(storage: Storage) {
self.storage = storage
}
func handle(string: String) {
storage.save(string: string)
}
}
class FilesystemManager: Storage {
func save(string: String) {
// Open a file in read-mode
// Save the string in this file
// Close the file
}
}
class DatabaseManager: Storage {
func save(string: String) {
// Connect to the database
// Execute the query to save the string in a table
// Close the connection
}
}
Storage라는 프로토콜을 정의함으로써 내부 구현은 저수준 모듈(FilesystemManager, DatabaseManager)에게 맡기게 됨으로써 Handler 사용이 용이해지고, 테스트 또한 용이해진다.
신중하게 생각하고 SOLID 원칙을 지키게 되면, 코드의 질이 향상시킬 수 있을 것이다. 또한 요소들의 재사용과 유지보수가 가능해져서 더욱더 효율적인 코드를 작성할 수 있게 된다.
그리고 SOLID 원칙을 따르게 되면 아래 세 가지 문제를 해결할 수 있게 된다
- Fragility: 작은 변화가 버그를 일으킬 수 있는데, 테스트가 용이하지 않아 미리 파악하기 어려운 것
- Immobility: 재사용성의 저하. 불필요하게 묶인(coupled) 의존성 때문에 재사용성이 낮아진다.
- Ridgidity: 여러 곳에 묶여 있어서 작은 변화에도 많은 곳에서 변화(노력)가 필요하다.
ref : https://marcosantadev.com/solid-principles-applied-swift/