Search

Clean Architecture - 3부 - 설계 원칙(LSP, ISP)

“클린 아키텍처: 소프트웨어 구조와 설계의 원칙” 책을 바탕으로 정리를 진행했습니다. 제가 이해한 바를 정리하고 싶어서 적은 포스팅이라 책에 있는 내용과는 맞지 않거나 오류가 있을 수 있습니다. 더 많은 내용을 보고 싶으신 분들은 책을 직접 읽으시는걸 추천드립니다.
Clean Architecture - 3부 - 설계 원칙(SRP, OCP) 에서 이전 내용을 보실 수 있습니다.

 SOLID

좋은 소프트웨어 시스템은 깔끔한 코드로부터 시작합니다. 좋은 벽돌로 좋은 아키텍처를 만들어야 한다는 겁니다.
그러기 위해선 좋은 아키텍처를 정의하는 원칙이 필요했습니다. 그게 바로 SOLID 입니다.
SOLID는 함수와 데이터 구조를 클래스로 배치하는 방법과 이들 클래스를 서로 결합하는 방법을 정의합니다.
SOLID는 ⓵변경에 유연하고 ⓶이해하기 쉬우며 ⓷많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 되는 소프트웨어 구조를 만드는데 도움이 됩니다.
SOLID는 좋은 아키텍처를 정의하는 5가지 원칙을 나타냅니다.
단일 책임 원칙: Single Responsibility Principle(SRP)
개방-폐쇄 원칙: Open-Closed Principle(OCP)
리스코프 치환 원칙: Liskov Substitution Principle(LSP)
인터페이스 분리 원칙: Interface Segregation Principle(ISP)
의존성 역전 원칙: Dependency Inversion Principle(DIP)
이번 포스팅에서는 설계 원칙 중에서도 LSP, ISP를 다뤄보려고 합니다.

 리스코프 치환 원칙: Liskov Substitution Principle

리스코프 치환 원칙은 다른 것으로 대체될 수 있는 능력인 대체 가능성과 관련된 원칙입니다.
S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1를 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.
위의 개념을 간단하게 표현해보겠습니다.
상위 타입을 하위 타입의 인스턴스로 바꿔도 프로그램의 동작을 해치지 않아야 한다.
자식이 부모의 동작을 제한해서 안된다라는 겁니다. 부모 클래스에 작동하는 모든 함수가 해당 클래스의 자식 클래스에서도 작동해야 한다는게 LSP가 말하고자 하는 바입니다.
예를 들어서, License 클래스가 있습니다. 해당 클래스는 하위 클래스로 PersonalLicenseBusinessLicense를 가지고 있어요. 각 클래스에서는 서로 다른 알고리즘으로 calculateFee라는 메서드를 구현하고 있습니다.
만약, License 타입을 사용하는 클래스가 생기면, 해당 클래스는 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않습니다. License가 License 하위 타입으로 치환 가능하기 때문입니다.
반대로 이를 위반하는 경우를 보겠습니다.
Rectangle 클래스가 있습니다. 해당 클래스는 하위 클래스로 Square 클래스를 가지고 있습니다.
class Rectangle { var width: Int var height: Int init(width: Int, height: Int) { self.width = width self.height = height } func area() -> Int { return width * height } } class Square: Rectangle { override var width: Int { didSet { super.height = width } } override var height: Int { didSet { super.width = height } } }
Swift
복사
https://medium.com/movile-tech/liskov-substitution-principle-96f15559e363
하위 클래스 Square 타입의 인스턴스를 Rectangle 타입 객체 넣어줍니다. Rectangle 타입이기 때문에 우리는 height와 width를 이에 맞춰서 수정해주었습니다. 그리고 area 메서드를 사용해서 넓이를 출력했습니다.
func main() { let square = Square(width: 10, height: 10) let rectangle: Rectangle = square rectangle.height = 7 rectangle.width = 5 print(rectangle.area()) }
Swift
복사
https://medium.com/movile-tech/liskov-substitution-principle-96f15559e363
우리는 Rectangle 타입을 출력한 것이기 때문에 35가 나오길 기대할겁니다. 하지만, 뜻밖의 결과를 받게 됩니다. 바로, 25가 나오는 거죠.
우리는 해당 타입이 직사각형이라는 것만 압니다. 하지만 직사각형처럼 작동하지 않았습니다.
위의 문제를 해결해봅시다.
protocol Geometrics { func area() -> Int }
Swift
복사
프로토콜을 만들고, Rectangle과 Square 클래스가 각각의 프로토콜을 준수하도록 하면 됩니다.
protocol Geometrics { func area() -> Int } class Rectangle: Geometrics { var width: Int var height: Int init(width: Int, height: Int) { self.width = width self.height = height } func area() -> Int { return width * height } } class Square: Geometrics { var edge: Int init(edge: Int) { self.edge = edge } func area() -> Int { return edge * edge } }
Swift
복사
상속보다 두 구조가 가져야 하는 동일한 동작을 가지고 있는 프로토콜을 만들면 쉽게 LSP를 보장할 수 있습니다. 우리가 해서는 안되는 속성, 방법을 사용하지 않게 되는 겁니다. 즉, 예상치 못한 행동을 하지 않게 됩니다.
이런 생각이 들 수 있습니다. “Protocol를 사용하지 않고, Square 내부 코드를 수정하면 충분히 area 값이 잘못 설정되는 문제를 해결할 수 있지 않을까?” 하고요.
하지만, 우리는 매번 해당 타입이 Rectangle인지, Square인지 확인해야 할 수도 있습니다. 타입에 따라서 다르게 작동되는 문제를 해결하기 위해서요. 이는 타입 치환이 불가능하다는 뜻입니다.
치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 매커니즘을 추가해야 합니다. 해당 타입이 Rectangle인지, Square인지 확인하는 switch/if 문이 추가적인 매커니즘일겁니다.
하지만, 위에 있는 Geometrics 프로토콜을 사용한다면 우리는 별도의 매커니즘 없이 사용할 수 있게 됩니다. 우리는 Square일 때와 Rectangle일 때 어떤 차이가 있고 각각 어떤 메서드를 불러줘야 하는지 외우고 고민하지 않아도 됩니다. Geometrics이 가지고 있는 area 메서드만 부르면 어떤 타입인지 고민하지 않고 넓이를 받을 수 있으니깐요.
해당 문제는 이전에 설명했던 개방-폐쇄 원칙과도 관련이 있습니다. 개방-폐쇄 원칙에서는 확장을 위해서 변경을 하면 안된다고 합니다. 이전의 방식은 확장을 위해서 변경이 필요합니다. Rectangle 밑에 또 다른 하위 타입이 들어왔을 때, 해당 타입에서는 어떻게 해야할 지 switch/if 문을 변경해줘야 하는거죠.
하지만, Geometrics 프로토콜을 사용하는 지금은 변경하지 않아도 됩니다. 해당 타입이 Geometrics 프로토콜을 준수하도록만 해주면 됩니다. 변경없이 확장했네요.

[ iOS와 리스코프 치환 원칙 ]

UIViewController 내부에 많은 UIView의 Sub Class들이 들어 있다고 가정해봅시다.
var label = UILabel(frame: .zero) var button = UIButton(frame: .zero) var segmentedControl = UISegmentedControl(frame: .zero) var textField = UITextField(frame: .zero) var slider = UISlider(frame: .zero) var switchButton = UISwitch(frame: .zero) var activityIndicator = UIActivityIndicatorView(frame: .zero) var progressView = UIProgressView(frame: .zero) var stepper = UIStepper(frame: .zero) var imageView = UIImageView(frame: .zero)
Swift
복사
https://soojin.ro/blog/solid-principles-in-swift
그리고 각 SubView들을 views라는 UIView 타입 배열 안에다가 넣고, 높이를 모두 30으로 바꾼다고 해볼게요.
views.forEach { $0.frame.size.height = 30 }
Swift
복사
모든 SubView들의 높이를 더한다고 하면 300이 나와야 할겁니다. 총 10개의 SubView가 존재하니깐요. 하지만, 결과는 그렇지 않습니다. 실제로는 300이 아닌 다른 수가 나옵니다.
왜 300이 아닌 다른 수가 나오나요?
일부 뷰에 있는 intrinsicSize는 마음대로 바꿀 수 없습니다. 따라서, 부모 타입(UIView)의 동작을 제한하게 되는거죠. LSP를 위반하는 겁니다.
하지만, SubView들이 LSP를 위반하지 않게끔 할 순 없습니다. 위반하지 않게끔 만드는 일은 비효율적인 일이니깐요. 또한, intrinsicSize를 마음대로 바꿀 수 없다고 해서 문제가 되지 않습니다.
그렇다고, 리스코프 치환 원칙을 너무 위배하는 것도 좋지 않습니다. 상속을 하게 된다면 해당 상속이 LSP를 위반하는지 아닌지 숙지하고 사용하는 것이 좋고, 상속보다는 프로토콜을 사용하는 것이 예상치 못한 행동을 막는데 좋습니다.

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

Clients should not be forced to depend on methods that they do not use.” 고객이 사용하지 않는 메서드에 의존하도록 강요해서는 안됩니다.
인터페이스 분리 원칙은 고객이 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 여러 개로 분리해야 한다는 원칙입니다.
사용하지 않는 메서드에 의존하도록 강요한다는게 뭘까요?
우리가 인터페이스를 준수한다고 했을 때, 해당 인터페이스가 요구하는 모든 속성과 메서드를 해당 타입 안에 구현해주어야 합니다. 하지만, 해당 인터페이스가 가지고 있는 메서드가 해당 타입에서 사용하지 않는거라면 매우 난감한 상황일겁니다.
예를 들어서, 우리가 Printable라는 프로토콜을 만들었다고 해봅시다. 해당 프로토콜은 프린터로 할 수 있는 일들을 정의해두었습니다.
protocol Printable { func fax() func print() }
Swift
복사
하지만, 프린터A는 팩스를 보낼 수 없습니다. 이런 상황에서 프린터A가 Printable 프로토콜을 준수하게 된다면, fax 메서드는 빈 블럭으로 남겨야 할 겁니다. dummy method가 생기는 겁니다.
class PrinterA: Printable { func fax() { } func print() { // print 하는 방법 } }
Swift
복사
이러한 프로토콜을 fat interface라고 합니다.
fat interface는 응집력이 없습니다. 해당 interface를 준수하는 클라이언트에게 필요한 것보다 많은 메서드를 제공해주기 때문입니다. 필요한 것보다 많은 것을 제공하기 때문에 많은 문제도 함께 제공하게 됩니다.
프린터A는 fax 메서드를 사용하지 않습니다. 하지만, fax 메서드에 새로운 매개변수가 추가되면 이를 수정해주어야 합니다. 사용하지 않지만요. 그리고 fax 메서드를 위한 테스트도 진행해야 합니다. 사용하지 않지만요.
fat interface가 이러한 문제를 일으키는 이유는 여러 책임이 결합되어 있는 응집력 없는 프로토콜이기 때문입니다. Printable 프로토콜은 프린트를 하고, 팩스를 보내는 책임을 가지고 있습니다. 따라서, 하나의 책임이라도 수정되면 해당 책임이 필요없는 구현체라도 해당 책임을 수정해줘야 하는거죠.
이 문제를 해결하기 위해서는 fat interfacerole interface로 분리하는 겁니다. 더 작고 응집력 있는 프로토콜이 완성됩니다.
protocol Printable { func print() } protocol Faxable { func fax() }
Swift
복사
프린터A는 이제 필요한 메서드만 가지게 됩니다. 팩스기로 사용하고 있는 팩스기A도 print 메서드를 구현하지 않아도 됩니다. 두 가지 기능이 모두 가능한 프린터B는 두 프로토콜을 모두 준수하면 됩니다.
class PrinterA: Printable { func print() { // print 하는 방법 } } class FaxMachine: Faxable { func fax() { // fax 보내는 방법 } } class PrinterB: Printable & Faxable { func print() { // print 하는 방법 } func fax() { // fax 보내는 방법 } }
Swift
복사
인터페이스를 분리하는 것은 좋지만, 맹목적으로 ISP를 따르면 안됩니다.
프로토콜 분해 시에 얼마나 많은 이점을 얻을 수 있는지 측정 후에 프로토콜을 분해해야 합니다. ISP를 준수하기 위해서 모든 프로토콜을 조각내진 말아야 합니다.
ISP를 위배하는 일이 왜 발생하는 걸까요?
인터페이스를 구체적인 클래스에 맞춰서 만들었기 때문입니다. 즉, 인터페이스 하나만 만들고 그 안에 모든 방법을 배치했기 때문입니다. 우리는 구현된 방식을 가진 구체적인 클래스보다는 인터페이스를 먼저 생각해주어야 합니다. 인터페이스가 결정되고 나서 해당 인터페이스를 구현하는 구현체를 고민하면 됩니다.

 참고 자료