호댕의 iOS 개발

Swift를 기반으로 본 SOLID에 대하여 (객체지향 설계 원칙) 본문

Software Engineering

Swift를 기반으로 본 SOLID에 대하여 (객체지향 설계 원칙)

호르댕댕댕 2024. 2. 3. 10:42

 

객체 지향 언어를 사용하다보면 반드시 듣게 되는 원칙 중 하나가 바로 SOLID이다. 

(Swift도 객체 지향 언어 중 하나인 만큼 SOLID는 공부하면 만나게 되는 것 중 하나이다)

 

제르시 님이 정리해주신 iOS 면접 질문 리스트 중에서도 SOLID는 등장한다.

https://github.com/JeaSungLEE/iOSInterviewquestions

 

GitHub - JeaSungLEE/iOSInterviewquestions: 👨🏻‍💻👩🏻‍💻iOS 면접에 나올 질문들 총 정리

👨🏻‍💻👩🏻‍💻iOS 면접에 나올 질문들 총 정리 . Contribute to JeaSungLEE/iOSInterviewquestions development by creating an account on GitHub.

github.com

 

항상 공부는 했지만, 막상 말하려 하면 단일 책임의 원칙(Single Responsibility Principle)만 생각나는 SOLID... 

이 참에 정리를 해보고자 한다. 

 

물론 SOLID 원칙에 대한 블로그 포스팅은 이미 차고 넘치지만... 스스로도 기록하면서 좀 더 잘 알기 위해 정리를 해보기로 했다. 

 

SOLID 원칙

바로 각각의 원칙으로 들어가기 전에 간단히 SOLID 원칙에 대해 살펴보자. 

 

일단 SOLID는 객체 지향 프로그래밍을 하면서 어떻게 설계하면 좋은 지에 대해 5가지 원칙으로 정리한 것이다. 

이를 통해 객체를 좀 더 확장하기 쉽도록 하고 가독성이 좋도록 할 수 있다. 

 

이는 다음 5가지 원칙으로 구성되어 있고 SOLID는 이 원칙들의 앞 글자만 가지고 온 것이다. 

  • Single Responsibility Principle : 단일 책임의 원칙
  • Open / Closed Principle : 개방 폐쇄 원칙
  • Liskov Substitution Principle : 리스코프 치환 원칙
  • Interface Segregation Principle : 인터페이스 분리의 원칙
  • Dependency Inversion Principle : 의존성 역전의 원칙

 

🔴 Single Responsibility Principle (단일 책임의 원칙)

단일 책임의 원칙은 이름에서도 보이는 것처럼 객체는 하나의 책임만 가져야 한다는 것이다. 

즉, 이는 객체를 변경할 이유가 생긴다면 이유는 한 개가 되어야 하는 것이다. 앞서 말했던 것처럼 하나의 책임만 가져야 하기 때문에 해당 책임에 대한 수정이 있을 때에만 객체를 수정해야 한다. 

 

이를 통해 사이드 이펙트를 최소화할 수 있다. 

 

물론 현재 객체의 설계 원칙인 SOLID에 대해 이야기를 하고 있긴 하나 함수 / 모듈에서도 적용이 되는 개념이다. 

함수 또한 다양한 책임을 가지고 있으면 해당 함수로 인한 사이드 이펙트가 발생할 가능성도 높아지고 테스트 코드를 작성하기에도 어려움이 있기 때문에 단일 책임의 원칙을 적용시키는 것이 중요하다.

 

func add(num1: Int, num2: Int) -> Int {
    let answer = num1 + num2
    print(answer)

    return answer
}

 

예를 들어 2개의 파라미터를 받아서 이를 더해주는 간단한 함수가 있다고 가정해보자.

 

이때 해당 함수 내부에는 결과값을 print해주는 기능까지 포함되어 있다. 

(예제여서 간단한 print를 사용했지만 실제 프로덕트를 만들 때에는 Google Analytics에 결과값을 전달하거나 하는 기능이 있을 수 있다)

 

그렇다면 하나의 함수가 값을 더하는 기능 외에도 print를 하는 기능까지 2가지 책임이 존재하는 것이다. 

 

따라서 이는 단일 책임의 원칙을 위배했다고 볼 수 있다. 

이를 좀 더 넓은 개념으로 객체에 동일시켜도 객체를 캡슐화한 후 설계할 때 1개 이상의 책임을 가지고 있다면 단일 책임의 원칙을 위배하는 것이다. 

 

실제 개발을 하다보면 이를 매번 철저하게 준수하는 것은 쉽지 않다. 

책임에 맞게 함수나 객체를 분리해야 하고 이로 인해 개발에 공수가 늘어날 수 있기 때문이다. 

 

하지만 여러 책임이 혼재되어 있는 경우 일단 하나의 객체 / 함수에 들어가 있는 코드의 절대적인 양 또한 증가하게 되면서 가독성이 떨어지게 된다. 

또한 앞서 말했던 것처럼 테스트 코드를 작성하는 데에도 어려움이 생긴다. 

 

이를 준수하기 위해선 객체 / 함수를 설계할 때 정확히 추상화 / 캡슐화를 하고 설계를 하는 것도 도움이 되겠지만 러프한 기준을 세워본다면 객체와 함수의 Line 수에 상한을 두는 것도 방법이 될 수 있다. 

 

그래서 SwiftLint에서도 File / 객체 / 함수의 길이에 기본적인 제한을 둔 것이 아닌가 생각이 든다. 

https://realm.github.io/SwiftLint/rule-directory.html

 

Rule Directory Reference

 

realm.github.io

 

- File Length

warning 400
error 1000

 

- Function Body Length

warning 50
error 100

 

- Type Body Length

warning 250
error 350

 

 

🟠 Open / Closed Principle (개방 폐쇄의 원칙)

확장에는 열려있고 변경에는 닫혀있어야 한다는 원칙이다. 

하지만 이 설명은 나에게 너무 추상적이다... 좀 더 구체적으로 알아보자. 

 

개발을 하다보면 요구사항이 변경되는 경우가 종종(자주...?) 발생한다. 

이때 변경으로 인한 비용은 가능한 줄이고(Closed), 기존 구성 요소를 쉽게 확장(Open)하여 재사용이 가능하도록 해야 한다는 것이다. 

 

코드를 통해 좀 더 살펴보자. 

class Guitar {
    enum Shape {
        case lespaul
        case strat
    }
    
    let shape: Shape

	init(shape: Shape) {
    	self.shape = shape
    }
    
    func pickUp() -> String {
    	switch shape {
        case .lespaul:
        	return "humbucker"
        case .strat:
        	return "single"
        }
    }
}

Guitar(shape: .lespaul).pickup() // "humbucker"
Guitar(shape: .strat).pickup() // "single"

 

요렇게 기타 타입에 따라 어떤 픽업이 달려있는지를 보여주는 Computed Property가 있다고 생각해보자.

(요새 기타에 빠져서... 예시도 기타로 들었는데 생소한 용어라도 그냥 요런게 있구나 하고 예시로만 봐주세요~)

 

만약 Guitar의 Shape에 종류가 추가된다고 가정해보자.

그러면 Shape 열거형에도 값을 추가해야 하고 pickUp이라는 프로퍼티도 수정을 해줘야 한다. 

 

변경 사항이 생겼을 때에는 Closed되어야 하는데 수정이 필요해진 것이다. 

따라서 위 코드는 개방-폐쇄 원칙(OCP)을 위배한 사례로 볼 수 있다. 

 

이를 해결하기 위해선 프로토콜을 통해 추상화를 해서 해결 가능하다. 

 

protocol Guitar {    
    func pickUp() -> String
}

class Lespaul: Guitar {    
    func pickUp() -> String {
		return "humbucker"
    }
}

class Strat: Guitar {
	func pickUp() -> String {
		return "single"
    }
}

Lespaul().pickup() // "humbucker"
Strat().pickup() // "single"

 

이렇게 구현하면 어떤 기타 종류가 추가되든 기존 프로토콜을 수정하지 않고 확장이 가능하다. 

 

🟡 Liskov Substitution principle (리스코프 치환의 원칙)

다른 원칙들은 이름을 보면 그래도 어떤 원칙인지 대략 짐작이라도 가는데 요 원칙은 전혀 감이 오지 않는 원칙이다. 

 

위키 백과에선 요렇게 어렵게 설명을 하고 있다.

 

컴퓨터 프로그램에서 자료형 S가 자료형 T의 서브타입라면 필요한 프로그램의 속성의 변경 없이 자료형 T의 객체를 자료형 S의 객체로 교체할 수 있어야 한다는 원칙

 

이는 위에서 들었던 기타 예시로 좀 더 풀어서 보자면

 

기타에는 Lespaul과 Strat 등 하위 타입들이 존재하고 기존 기타를 Strat으로 바꾸더라도 잘 동작해야 한다는 것이다.

즉, 어떤 타입의 기타든 연주라는 기능은 잘 수행이 되어야 하는 것이다.

 

class Guitar {
    func play() {
        print("딩가딩가")
    }
}

class Strat: Guitar {
    override func play() {
        print("징징징")
    }
}

var guitar = Guitar()
guitar.play() // "딩가딩가"

guitar = Strat()
guitar.play() // "징징징"

 

부모 클래스 타입인 guitar 프로퍼티가 자식 클래스로 대체되더라도 기본적으로 play는 동작을 잘 하는 것을 볼 수 있다. 

이런 식으로 부모 클래스를 자식 클래스로 대체하더라도 정상적으로 동작을 해야 한다는 원칙이 리스코프 치환 원칙이다.

 

이는 위에서 설명한 개방 폐쇄의 원칙(OCP)와도 관련이 있으며 리스코프 치환 원칙을 위반할 경우 OCP 또한 위반할 가능성이 높아진다.

 

새로운 하위 타입이 상위 클래스를 대체할 수 없도록 구현이 된다면 계속해서 상위 클래스에 대한 수정이 필요할 것이고 그렇다면 이는 OCP를 위반한 것이기 때문이다. 

 

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

객체는 반드시 자신이 이용하는 메서드만 의존해야 한다는 원칙이다. 즉 상속으로 인해 불필요한 구현을 최대한 방지하도록 하는 것이다. 

이를 위해선 인터페이스를 너무 큰 단위로 만들지 말고 작게 분리하는 것이 좋다. 

 

protocol Guitar {
    func stroke()
    func arpegio()
}

class Lespaul: Guitar {
    func stroke() { }
    func arpegio() { }
}

class Strat: Guitar {
    func stroke() { }
    func arpegio() { }
}

Swift에선 Interface가 따로 없지만 프로토콜과 유사한 개념으로 볼 수 있다.

 

그렇다면 만약 드럼처럼 따로 스틱으로 칠 수도 있는 기타가 있다고 가정해보자...

 

이를 프로토콜로 구현하면 다음과 같을 것이다. 

protocol DrumGuitar {
    func stroke()
    func arpegio()
    func beat()
}

class Lespaul: DrumGuitar {
    func stroke() { }
    func arpegio() { }
    func beat() { } // 사용하지 않는 함수도 구현을 해줘야 함
}

class Strat: DrumGuitar {
    func stroke() { }
    func arpegio() { }
    func beat() { } // 사용하지 않는 함수도 구현을 해줘야 함
}

class NewTypeGuitar: DrumGuitar {
    func stroke() { }
    func arpegio() { }
    func beat() { }
}

이렇게 인터페이스를 너무 크게 구현을 해놓는다면 Lespaul과 Strat처럼 사용하지 않는 함수를 저렇게 구현해놔야 한다. 

 

즉, 인터페이스 분리의 원칙을 위반한 것이다. 

이는 프로토콜을 쪼개는 식으로 해결이 가능하다. 

 

protocol Guitar {
    func stroke()
    func arpegio()
}

protocol Drum {
    func beat()
}

class Lespaul: Guitar {
    func stroke() { }
    func arpegio() { }
}

class Strat: Guitar {
    func stroke() { }
    func arpegio() { }
}

class NewTypeGuitar: Drum, Guitar {
    func stroke() { }
    func arpegio() { }
    func beat() { }
}

 

이렇게 프로토콜을 분리해준 후 필요한 프로토콜만 채택하는 식으로 한다면 인터페이스 분리의 원칙을 위배하지 않고 구현이 가능하다. 

특히 프로토콜은 상속과는 다르게 다중 채택이 가능하기 때문에 이런 구현이 가능하다. 

 

🔵 Dependency Inversion (의존성 역전)

일단 의존성이란 파라미터나 리턴 값, 지역 변수 등으로 다른 객체를 참조할 때 발생한다. 

즉, 다른 객체를 사용했다면 일단 의존성이 생긴 것이다. 

 

객체 지향 프로그래밍을 기반으로 프로덕트를 설계한다면 객체 간 소통을 통해 동작을 하기 때문에 이런 의존성은 거의 필수로 발생하게 된다. 

 

기타는 이런 식으로 다양한 구성 요소로 이뤄져 있다. 

 

class GuitarShop {
    let electric: Electric
    let acoustic: Acoustic
    let classic: Classic
}

class Electric {

}

class Acoustic {

}

class Classic {

}

그렇다면 GuitarShop은 High level 객체이고 하위에 있는 Electric / Acoustic / Classic 들은 Low level 객체인 것이다. 

그리고 GuitarShop은 Electric, Acoustic, Classic에 의존성을 가지게 된다. 

 

만약 여기서 기타의 종류가 추가된다고 생각해보자. 이렇게 되면 의존성이 점점 많아지고, 그럼 코드의 변경과 확장에 불리해지게 된다. 

 

이를 해결하기 위해 Dependency Inversion을 사용할 수 있다. 

 

class GuitarShop {
    let guitars: [Guitar] = [] // Guitar에만 Dependency를 가지고 있음
    func add(guitar: Guitar) {
    	guitars.append(guitar)
    }
}

protocol Guitar {
    var guitarString: String { get set }
    func play()
}

class Electric: Guitar {
    var guitarString: String = "iron"
    func play() {
    	print("좡좡좡")
    }
}

class Acoustic: Guitar {
    var guitarString: String = "thick iron"
    func play() {
    	print("띵가띵가")
    }
}

class Classic: Guitar {
    var guitarString: String = "nylon"
    func play() {
    	print("띵띵")
    }
}

 

이런 식으로 되면 High level 객체인 GuitarShop은 프로토콜인 Guitar에만 의존성을 가지고 있고 각각 Electric / Acoustic / Classic에는 의존성을 가지고 있지 않게 된다. 

 

따라서 이후 Guitar 종류가 추가되더라도, GuitarShop은 수정될 필요가 없게 되면서 확장에는 유리하고 코드 변경은 최소화할 수 있게 되는 것이다.

 

이는 RIBs를 살펴보면 요런 구조로 잘 구현이 되어 있다. 

 


물론 SOLID 원칙을 전부 철저하게 준수하면서 회사에서 개발을 해나가는 것은 정말 쉽지 않다. 이미 의존성이 엄청 엮여있을 수도 있고 빠르게 피쳐를 진행해야 해서 미쳐 적용을 못할 수도 있다. 

 

하지만 막상 정리하고 보니 좋은 코드라고 생각했던 기준들이 대부분 SOLID에 적용이 됐다. 

항상 적용은 하지 못하더라도 이를 인지하고 신경을 쓰면서 앞으로 개발을 할 수 있도록 해야겠다.

 

 

Comments