SOLID with Swift

SOLID?

SOLID란 로버트 마틴이 제시한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙의 줄임말로 프로그래머가 유지 보수와 확장이 쉬운 프로그램을 만들 때 적용할 수 있다.

각 원칙은 다음과 같다.

  • Single resopnsibility(단일 책임 원칙)
  • Open-Closed(개방-폐쇄 원칙)
  • Liskov Substitution(리스코프 치환 원칙)
  • Interface Segregation(인터페이스 분리 원칙)
  • Dependency Inversion(의존관계 역전 원칙)

이점

이러한 원칙들을 따르면 다음과 같은 문제를 해결할 수 있다고 한다.

  • Fragility: 작은 변화가 큰 버그를 일으킬 수도 있다. 테스트가 용이하지 않아 미리 발견하기가 어렵다.
  • Immobility: 재사용성의 저하. 불필요하게 묶여 있는(coupled) 의존성 때문에 재사용이 어렵게 된다.
  • Rigidity: 여러 곳에 묶여 있어 조그만 변화도 많은 노력을 들여야 한다.

이러한 원칙들이 Swift에서는 어떻게 적용이 되는지 SOLID Principles Applied To Swift를 통해 공부해 보았다.

Single Responsibility Principle(단일 책임 원칙)

“한 클래스는 하나의 책임만을 가져야 한다.”

쉽게 생각해서 한 클래스는 하나의 역할만 하자는 얘기이고 이는 코드의 응집성을 높이자는 얘기와 연결된다.

예를 들어 이런 클래스가 있다고 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Handler {
func handle() {
let data = requestDataToAPI()
let array = parse(data: data)
saveToDB(array: array)
}

private func requestDataToAPI() -> Data {
// send API request and wait the response
}

private func parse(data: Data) -> [String] {
// parse the data and create the array
}

private func saveToDB(array: [String]) {
// save the array in a database (CoreData/Realm/...)
}
}

위의 Handler 클래스는 보면 다음과 같은 일을 한다.

  1. 데이터를 받아오는 API 호출
  2. 받아온 데이터 파싱
  3. 파싱한 값을 DB에 저장

결국 Handler 클래스는 세 가지 책임을 가지고 있다.

해결

이런 경우에는 각각의 역할을 하는 클래스를 만들어 책임을 넘겨주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Handler {

let apiHandler: APIHandler
let parseHandler: ParseHandler
let dbHandler: DBHandler

init(apiHandler: APIHandler, parseHandler: ParseHandler, dbHandler: DBHandler) {
self.apiHandler = apiHandler
self.parseHandler = parseHandler
self.dbHandler = dbHandler
}

func handle() {
let data = apiHandler.requestDataToAPI()
let array = parseHandler.parse(data: data)
dbHandler.saveToDB(array: array)
}
}

class APIHandler {

func requestDataToAPI() -> Data {
// send API request and wait the response
}
}

class ParseHandler {

func parse(data: Data) -> [String] {
// parse the data and create the array
}
}

class DBHandler {

func saveToDB(array: [String]) {
// save the array in a database (CoreData/Realm/...)
}
}

이렇게 각각의 책임을 다른 클래스에게 넘겨주면 각각의 하위 핸들러 클래스에서 테스트도 용이해 질 것이다.

Open-Closed Principle(개방-폐쇄 원칙)

“소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”

사실 이해가 잘 안 가는 얘기였다. 확장도 결국 변경이라 생각했기 때문이다. 그러나 여기서 말하는 확장은 변경 없이 기능을 추가하는 것으로 이해된다.

만약 기능을 추가함에 있어 기존의 요소를 변경을 요한다면 부작용이 따라 올 확률이 매우 높을 것이다. 다른 소프트웨어 요소에서도 그 요소를 참조하여 사용하고 있을 수 있기 때문이다. 이는 곧 결합도가 높은 안 좋은 코드라고 인식이 된다.

이런 코드가 있다고 하자. Logger 클래스는 Car의 배열을 순회하며 각각의 detail을 프린트하는 역할을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Logger {

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)"
}
}

여기서 만약, 다른 타입의 탈 것을 추가하여 그것의 detail도 로그를 찍고 싶다면 어떻게 해야 할까?

다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Logger {

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)"
}
}

위와 같은 코드에서는 printData() 메서드를 수정함으로써 OCP 원칙을 어긴 셈이 된다. 이렇게 되면 새로운 클래스가 나타날 때마다 메서드를 수정해야 할 것이다.

해결

어떻게 하면 이 원칙을 지킬 수 있을까? 답은 추상화이다.

위의 코드를 보면 printDetail() 메서드를 Car, Bicycle 모두 가지고 있다. 이를 하나의 인터페이스, 즉 프로토콜로 묶고 이 프로토콜의 메서드를 호출하는 형식으로 변경한다면 Logger에서 바꿀 필요가 없게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
protocol Printable {
func printDetails() -> String
}

class Logger {

func printData() {
let printables: [Printable] = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey"),
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]

printables.forEach { printable in
print(printable.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)"
}
}

개방-폐쇄 원칙은 OOP의 핵심 원칙이라고 한다. 이 원칙을 무시한다면 OOP의 가장 큰 장점인 유연성, 재사용성, 유지보수성 등을 결코 얻을 수 없기 때문에 반드시 지켜야할 기본 원칙이다.

Liskov Substitution Principle(리스코프 치환 원칙)

“프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.”

용어만 봤을 때 와 닿지는 않지만 쉽게 생각하면 부모 클래스의 인스턴스를 사용하는 곳에 자식 클래스의 인스턴스를 넣어도 자연스럽게 동작해야 한다라는 뜻이다. 자식 클래스의 인스턴스를 부모 클래스의 인스턴스 자리에 넣어도 동작은 할 것이다. 하지만 그 동작이 우리가 원하는 방식일까?

LSP에서 가장 잘 드는 예시가 직사각형과 정사각형의 예이다. 정사각형은 직사각형의 일종이기 때문에 직사각형의 자식으로 정사각형을 만드는 것이 일반적일 것이다. 그런데 만약 직사각형의 너비와 높이를 설정하는 setter 메서드가 있다고 하면 얘기가 달라진다. 직사각형이 개별적으로 setWidthsetHeight를 호출하는 행위는 독립적으로 너비와 높이를 바꾸는 행위이다. 그러나 정사각형이 이 메서드를 호출한다면 하나만 바꿀 수 없기 때문에 동시에 둘 다 바꾸어 주어야 한다. 이는 개별적으로 바꿀 수 있다고 한 직사각형의 setter와 맞지 않는 일이다.

다시 말해, LSP는 부모 클래스에서 정의된 행위대로 자식 클래스에서도 똑같이 동작해야 한다는 의미이다. 그렇지 않은 경우에는 상속이 맞는지 다시 생각해 보아야 한다. 상속은 결합도의 측면에서 매우 비용이 큰 작업이기 때문에 더욱 더 LSP의 원칙에 유의해서 고려해야 한다. 맞지 않는 경우에는 프로토콜을 통해서 묶는 것이 더 나은 선택이 될 것이다.

Precondition(전제 조건) changes

Handler 클래스가 있다고 하자. 그리고 Cloud service에 문자열을 저장하는 메서드를 가지고 있다고 하자. 그리고 후에 로직 변경에 대한 요청이 들어오는데, 문자의 갯수가 5개가 넘을 때만 저장하도록 바꿔달라고 했다고 하자. 요청을 적용하기 위해 FilteredHandler 클래스를 만들기로 하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Handler {

func save(string: String) {
// Save string in the Cloud
}
}

class FilteredHandler: Handler {

override func save(string: String) {
guard string.characters.count > 5 else { return }

super.save(string: string)
}
}

FilteredHandlersave(string:) 메서드는 저장을 하기 위한 전제 조건이 추가되었다. Handler를 사용하는 클라이언트에서는 FilteredHandlersave(string:) 메서드에 동일한 행위를 기대하고 사용하겠지만 결과는 예상 밖일 것이다. 이 경우 LSP가 지켜지지 않았다고 한다.

이 경우에는 FilteredHandler를 제거하고 원래 Handler 클래스에 전제 조건 로직을 넣어 주면 될 것이다.

1
2
3
4
5
6
7
8
class Handler {

func save(string: String, minChars: Int = 0) {
guard string.characters.count >= minChars else { return }

// Save string in the Cloud
}
}

Postconditions(사후 조건) changes

위에서 언급한 직사각형과 정사각형의 문제가 여기에 해당될 것이다. 다음과 같이 정사각형이 직사각형을 상속받았다고 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class 직사각형 {
var 너비: Float = 0
var 높이: Float = 0

var 넓이: Float {
return 너비 * 높이
}
}

class 정사각형: 직사각형 {
override var 너비: Float {
didSet {
높이 = 너비
}
}
}

이것만 보았을 때는 문제가 없겠지만 이 두 클래스를 사용하는 곳에서 넓이를 구한다고 하면 달라진다.

1
2
3
4
5
6
7
8
9
10
11
func printArea(of 직사각형: 직사각형) {
직사각형.높이 = 5
직사각형.너비 = 2
print(직사각형.넓이)
}

let rectangle = 직사각형()
printArea(of: rectangle) // 10

let square = 정사각형()
printArea(of: square) // 4

직사각형의 넓이를 출력하는 메서드를 실행하면 직사각형정사각형의 인스턴스의 넓이가 다르게 나오는 것을 볼 수 있다. 이는 위의 코드에서 볼 수 있듯이 너비의 프로퍼티 감시자 때문이고 다시 말해 사후 조건이 달라진 것이다. 직사각형을 사용하고 있는 클라이언트에서는 정사각형을 사용할 때 의도치 않은 결과를 얻게 될 것이다.

이 문제는 넓이를 구하는 로직을 프로토콜로 둘 다 가지게 하되 다르게 구현하면 될 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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)
}
}

Interface Segregation Principle(인터페이스 분리 원칙)

“클라이언트는 그들이 사용하지 않는 인터페이스에 의존하지 말아야 한다.”

가장 쉽게 이해되는 원칙이다. 불필요한 것들은 빼고 꼭 필요한 요소들만 적용해서 사용하라는 것이다. 이 원칙은 OOP의 문제 중 하나인 Interface bloat(Fat interface)을 경계하고 있다.

Interface bloat - Protocol

처음에는 GestureProtocoldidTap() 메서드만을 가지고 있었다고 하자. 그런데 이후에 더블 탭과 롱 프레스까지도 만들었다고 가정해보자.

1
2
3
4
5
protocol GestureProtocol {
func didTap()
func didDoubleTap()
func didLongPress()
}

우리가 전에 만들어 놓은 SuperButtonGestureProtocol을 준수하고 세 가지 터치 이벤트를 받게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class SuperButton: GestureProtocol {
func didTap() {
// send tap action
}

func didDoubleTap() {
// send double tap action
}

func didLongPress() {
// send long press action
}
}

하지만 우리의 앱은 탭 터치 이벤트만 받는 PoorButton도 가지고 있다. GestureProtocol을 준수하면 불필요한 메서드를 구현해야 한다. 여기서 ISP가 깨진다.

1
2
3
4
5
6
7
8
9
class PoorButton: GestureProtocol {
func didTap() {
// send tap action
}

func didDoubleTap() { }

func didLongPress() { }
}

해결

이 경우는 프로토콜을 좀 더 세분화해서 적용하면 쉽게 끝난다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
}
}

Interface bloat - Class

영상에 관한 다양한 정보를 포함하고 있는 Video 클래스가 있다고 하자.

1
2
3
4
5
6
7
8
9
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()
}

Video 클래스의 인스턴스를 재생하는 메서드는 다음과 같다.

1
2
3
4
5
6
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(video:) 메서드를 살펴보면 꼭 필요한 정보는 영상의 url, title, duration 이렇게 세 가지 밖에 없다. 굳이 Video의 인스턴스가 가지고 있는 나머지 정보들을 받을 필요가 없다.

해결

이러한 경우에도 프로토콜을 정의해 줌으로써 메서드가 필요한 정보만을 전달하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
}

Dependency Inversion Principle(의존관계 역전 원칙)

“추상화에 의존해야지, 구체화에 의존하면 안 된다.”

이 원칙은 다음과 같은 세부 사항을 담고 있다.

  1. 상위 모듈은 하위 모듈에 의존해서는 안 된다. 두 모듈 모두 추상화에 의존해야 한다.
  2. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.

여기서 추상화란 무엇일까? 내가 아는 추상화는 복잡한 것에서 핵심적인 요소만 뽑아 간결하게 보기 쉽게 만드는 것이다. OOP에서 가장 중요한 것 중에 하나지만 자세히 알지는 못한다. 하지만 Swift에서 추상화를 떠올리면 가장 먼저 생각나는 것은 Protocol이다.

Protocol은 어떤 특정 역할을 하기 위한 청사진을 정의하는 것이다. 기능에 필요한 공통되는 값(프로퍼티), 행위(메서드) 등을 정의해놓고 실제 구현은 프로토콜을 준수하는 객체에 맡긴다.

예를 들어, Handler 클래스가 있고 파일 시스템에 문자열을 저장하는 역할을 한다. 이 때 FileSystemManager 클래스의 인스턴스를 내부적으로 생성하고 실제로 저장하는 역할을 맡긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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는 고수준의 모듈인데 저수준의 모듈인 FileSystemManager에 의존하고 있다. 때문에 Handler 클래스를 재사용하기가 어려워진다.

해결

이 경우에도 저수준의 모듈에 의존하기 보다 추상화된 객체에 의존하도록 만들어 해결한다. 역시 프로토콜을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Handler {

let storage: Storage

init(storage: Storage) {
self.storage = storage
}

func handle(string: String) {
storage.save(string: string)
}
}

protocol Storage {

func 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 프로토콜을 만들어 두고 내부 구현은 저수준의 모듈이 알아서 하게 함으로써 Handler의 재사용성이 높아졌다. Handler 클래스 내부에서 저수준의 요소를 생성하는 것이 아니라 Handler 클래스의 초기화 시에 Storage 프로토콜 타입의 객체를 주입해 줌으로써 의존 관계를 역전(하위 모듈이 상위 모듈의 역할을 결정)시켰다.

Reference