Search

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)
이번 포스팅에서는 설계 원칙 중에서도 SRP, OCP를 다뤄보려고 합니다.

 단일 책임 원칙: Single Responsibility Principle

소프트웨어 시스템이 가질 수 있는 최적의 구조는 시스템을 만드는 조직의 사회적 구조에 커다란 영향을 받습니다.
그렇기 때문에, 각 소프트웨어 모듈은 변경의 이유가 단 하나여야만 합니다.
이는 모든 모듈이 하나의 일만 해야 한다는 의미가 아닙니다. 이 원칙은 함수에 적용됩니다. 함수는 반드시 단 하나의 일만 해야 합니다. 하지만, 함수가 SOLID를 따라서 하나의 일만 한 것이 아니기에 이는 SRP를 지켰다고 볼 수는 없습니다.
SRP에서 말하는 변경의 이유는 바로 사람입니다.
소프트웨어 시스템은 사용자와 이해 관계자를 만족시키기 위해서 변경됩니다. 이는 하나의 모듈은 오직 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다는 것을 의미합니다.
하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다는 건 무슨 뜻일까요?
사용자 또는 이해관계자는 서로 다른 이유로 변경을 원합니다. 서로 다른 이유의 코드가 섞이면서 코드는 혼란해지게 됩니다. 우리는 같은 이유로 변화하는 것들을 함께 모으고, 다른 이유로 인해 변화하는 것들을 분리합니다.
Single Responsibility의 Single은 일부 작업이 고립되었다는 걸 의미합니다. 즉, 한 가지 작업을 수행하지만 두 가지 작업은 수행하지 않는다는 뜻입니다.
그럼, 현재 클래스가 한 가지 작업만 수행하고 있다는 걸 어떻게 알 수 있나요?
코드가 이미 알고 있는 것보다 조금 더 많이 알고 있을 때 한 가지 작업만 수행하지 않는다는 걸 알 수 있습니다.
함수나 클래스 이름은 구현된 기능을 반영하고 있습니다. 예를 들어서 메서드 이름이 sum 인데, 안에서 제공하는 작업에 제곱을 하는 작업이 있다면 메서드는 이름과 다르게 두 가지 작업을 하는 것이 됩니다. 따라서, 제곱을 하는 부분을 square라는 메서드로 분리해서 사용해야 합니다.
또한, 응집력을 통해서 클래스의 Content가 얼마나 밀접한지 알 수 있습니다.
응집력이 낮은 구성 요소는 현재 객체의 책임과 관련 없는 작업을 수행하고 있습니다. 즉, 해당 객체에서 해야할 일이 아닌 것이죠. 응집력이 높은 객체는 자신이 해야 하는 책임만을 수행하고 있습니다.
우리는 응집력이 높은 객체를 만들어야 합니다.
그렇다면, 여러 책임이 뭉쳤을 때 어떤 문제가 있기에 우리는 책임을 분리해야 한다는 걸까요?
예를 들어서, Employee 라는 클래스가 있다고 해봅시다.
Employee는 세 가지 메서드를 가지고 있습니다. calculatePay(), reportHours(), save() 입니다. 각 메서드는 서로 다른 세 명의 사용자 또는 이해 관계자를 책임지고 있습니다. calculatePay는 회계팀에서 사용하고, reportHours는 인사팀에서 사용합니다. save는 데이터 베이스 관리자가 사용하고 있습니다.
Employee 클래스 안에서 이해 관계자들이 서로 결합되어 버립니다. 이해 관계자의 행동이 다른 이해 관계자의 무언가에 영향을 줄 수 있습니다.
예를 들어서, calculatePayreportHours 메서드에서 사용할 regularHours라는 메서드를 만들었습니다. 해당 메서드는 초과 근무 시간을 제외한 업무 시간을 계산합니다.
만약, 회계팀에서 업무 시간을 계산하는 방식을 바꾼다고 해봅시다. 하지만, 인사팀은 원래 하던 방식대로 계산을 하려고 합니다. 이 때, 문제가 발생하게 됩니다. 회계팀에서는 바뀐 방식을 사용해서 원하는 결과를 받게 되겠지만, 인사팀에서는 자기도 모르게 이상한 결과를 받게 될 겁니다. 실제 회사에서 이런 일이 일어난다면 큰일입니다.
또한, 회계팀과 인사팀에서 각각 해당 메서드를 수정한다면, 해당 소스 파일에서 충돌이 발생할 겁니다. 메서드가 서로 다른 이해관계자 또는 사용자를 책임진다면 충돌이 발생할 확률이 높아집니다.
이런 문제가 왜 발생했을까요?
서로 다른 이해 관계자가 의존하는 코드를 너무 가까이에 배치했기 때문입니다.
이 문제에서 벗어나기 위해서는 서로 다른 이해관계자 또는 사용자를 뒷받침하는 코드를 서로 분리해야 합니다. 서로 관계 없는 책임들을 각기 다른 클래스로 이동시켜야 하는거죠.

[ iOS와 단일 책임 원칙 ]

iOS 개발을 하면 이런 경우를 흔하게 볼 수 있습니다.
우리는 개발을 하면서 작은 기능을 클래스에 추가하게 됩니다. 작은 기능을 5개 추가하게 되면 다섯개의 작은 기능을 가진 클래스가 됩니다. 즉, 클래스 안에서 5개의 책임을 가지게 되는거죠. 복잡성이 증가하고 클래스는 곧 난장판이 됩니다.
5개의 기능과 관련된 이야기는 ViewController에서 흔하게 일어납니다.
우리는 ⓵View를 보여주고, ⓶Cell를 준비하고, ⓷다른 화면으로 이동하고, ⓸네트워크를 호출하고, ⓹화면의 상태를 추적하는 일을 ViewController에 모두 집어 넣습니다.
우리는 곧 ViewController를 만지기를 두려워 합니다. ViewController를 건드렸다가는 어떤 일이 발생할 지 모릅니다. 다른 화면으로 이동하는 코드를 수정하려는 사람과 네트워크 호출 부분을 수정하려는 사람 모두가 같은 ViewController 파일을 건드려야 하기 때문에 충돌이 발생하는 문제에서도 자유로울 수 없습니다.
어떻게 이 문제에서 벗어날 수 있을까요?
먼저, 작은 기능들을 ViewController에 추가하는 걸 멈춰야 합니다. 한 가지 일만 하는 소규모 클래스로 분리해야 합니다.
HTTP API 호출, 유효성 검사, Notification, Error Handling, Logging, Formatting, Parsing, Mapping, Navigation, Business Logic, Creation of View 코드를 ViewController로부터 분리해서 소규모 클래스로 만듭니다. 이를 통해서, ViewController는 이전보다 적은 책임을 가지게 됩니다.
또한, 클래스를 작게 만드는 일을 시작해야 합니다.
10-200룰을 기준으로 코딩을 하는 겁니다. 10-200룰은 함수는 10줄로, 클래스는 200줄로 제한해서 코딩을 하는 겁니다. 함수를 10줄로 제한하게 되면 이를 지키기 위해서 추상화 레벨을 고민하게 됩니다. 함수를 어느 수준까지 쪼개서 설명할 것인지에 대해 고민을 하게 됩니다.
클래스도 200줄로 제한되게 된다면, 아무 메서드, 아무 데이터를 클래스에 넣을 수 없습니다. 해당 메서드나 데이터가 클래스에 있어야 하는 이유를 찾게 됩니다. 있을 이유가 없다면 SRP를 위반하게 되는거죠. 이는 self 키워드를 사용해서 쉽게 판단할 수 있습니다. self 키워드가 없다면 굳이 해당 클래스에 있을 필요가 없는 메서드나 데이터인 겁니다.
단일 책임 원칙을 제대로 지키지 못한 채로 코딩을 하게 되면 그 어떤 패턴을 도입해보려고 해도 잘 안될 가능성이 높습니다. 클래스가 하나의 역할만 할 수 있도록 해야 합니다.

 개방-폐쇄 원칙: Open-Closed Principle

기존 코드를 수정하기보다는 반드시 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경할 수 있도록 설계해야만 소프트웨어 시스템을 쉽게 변경할 수 있습니다.
소프트웨어 개체는 확장에 열려 있어야 하고, 변경에는 닫혀 있어야 합니다.
우리가 요구 사항을 살짝 확장하기 위해서 소프트웨어를 엄청 나게 수정해야 한다면 이는 엄청난 실패라는 겁니다.
소프트웨어 아키텍처가 훌륭하다면 변경되는 코드의 양이 가능한 한 최소화됩니다.
⓵서로 다른 목적으로 변경되는 요소를 적절하게 분리했고(SRP), ⓶요소 사이의 의존성을 체계화시킴(DIP)으로써 변경량을 최소화 했기 때문에 훌륭한 아키텍처는 기능 추가 시에 그 모듈을 쓰고 있는 코드들을 줄줄이 수정하지 않아도 됩니다.
⓵서로 다른 책임을 분리했기 때문에 두 책임 중 하나에서 변경이 발생해도 다른 하나는 변경되지 않도록 소스 코드의 의존성이 확실히 조직화했기에 행위를 확장해도 변경이 발생하지 않는다는 걸 보장할 수 있습니다.
우리는 같은 책임이 있는 것들을 클래스 단위로 분할합니다. 그리고 컴포넌트 단위(점선으로 묶인 단위)로 구분하게 됩니다.
모든 의존성이 소스 코드 의존성을 나타냅니다. 화살표가 A클래스에서 B클래스로 향하면 A클래스는 B클래스를 호출하게 됩니다. B클래스에서는 A클래스를 전혀 호출하지 않습니다.
모든 컴포넌트 관계는 위의 다이어그램에서 보시다시피 단방향으로 이루어 집니다. 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려집니다. A클래스에서 발생한 변경으로부터 B클래스를 보호하려면 A클래스가 B클래스에 의존하도록 하면 됩니다.
프로그램 내부에서 가장 높은 수준의 정책을 포함하는 곳은 다른 모든 것으로부터 보호 받아야 합니다. 현재 Interactor는 가장 높은 수준의 개념입니다. 따라서, 최고의 보호를 받아야 합니다. 반대로 View는 가장 낮은 수준의 개념이기 때문에 거의 보호받지 못합니다.
계층 구조를 이와 같이 조직화하게 되면 저수준의 컴포넌트에서 발생하는 변경으로부터 고수준의 컴포넌트를 보호할 수 있게 됩니다.
우리는 고수준의 컴포넌트를 보호하기 위해서 인터페이스를 사용합니다. 컴포넌트에서 다음 컴포넌트로 이어지는 부분을 인터페이스를 사용해서 연결합니다. 의존성이 해당 클래스로 바로 향하는 것을 막기 위해서죠.
또한, 인터페이스는 정보 은닉을 위해서도 존재합니다. 인터페이스를 사용하지 않았을 때에 추이 종속성이 발생할 수 있습니다. 자신이 직접 사용하지 않는 요소에 의존하게 되는 것이죠. 이는 OCP를 위반하게 됩니다. 따라서, 인터페이스를 사용해서 인터페이스 구현체를 보호하면서 의존하는 요소도 보호하게 됩니다.
개방-폐쇄 원칙은 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템에 너무 많은 영향을 받지 않도록 하는데 중점을 둡니다. 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조를 만드는 겁니다.

[ iOS와 개방-폐쇄 원칙 ]

추상화

Person이라는 클래스가 있고, Person 클래스를 가지는 House 클래스를 만들었다고 해보겠습니다.
class Person { private let name: String private let age: Int init(name: String, age: Int) { self.name = name self.age = age } } class House { private var residents: [Person] init(residents: [Person]) { self.residents = residents } func add(_ resident: Person) { residents.append(resident) } }
Swift
복사
만약, 새로운 유형의 NewPerson 이라는 새로운 클래스를 만드려고 합니다. 우리가 House 클래스에서 NewPerson를 사용하려면 House를 수정해야 합니다. 이는 OCP를 위반하는 겁니다. 확장 시에 수정이 발생하는 거니깐요.
반대로, Person 클래스를 수정하는 건 어떤가요? 이것도 결국 Person 클래스가 수정되기 때문에 원칙을 위배하는 겁니다.
왜 이게 소프트웨어 설계에 나쁜 영향을 미칠까요?
Person 클래스의 확장이 House 클래스에 영향을 미치는 겁니다. 이는 좋지 않습니다.
확실히 결정된 모듈의 변경이 다른 모듈의 변경에 영향을 미치지 않거나 의무화되지 않으면 훨씬 더 유리한 프로그램이 될 수 있습니다.
그러면, OCP에 맞춰서 코드를 수정해봅시다.
Protocol를 추가하는 겁니다. 이름은 Resident라고 하겠습니다.
protocol Resident {}
Swift
복사
그리고 Resident에 맞춰서 Person 클래스를 수정해보겠습니다.
class Person: Resident { let name: String let age: Int init(name: String, age: Int) { self.name = name self.age = age } }
Swift
복사
House 클래스는 Person이 아닌 Resident 프로토콜에 의존하게 만들어 줍시다.
class House { var residents: [Resident] init(residents: [Resident]) { self.residents = residents } func add(_ resident: Resident) { residents.append(resident) } }
Swift
복사
이렇게 코드를 작성하게 되면 NewPerson이라는 클래스를 만들었을 때, House 클래스를 수정하지 않아도 됩니다. 수정없이 확장 가능하게 된 겁니다.
struct NewPerson: Resident { let name: String let age: Int func complexMethod() { } func otherMethod() { } }
Swift
복사

열거형

열거형(enum)은 여러 케이스를 나타내기에 좋은 방식입니다. enum 내부 case에 맞춰서 다른 값을 반환하는 속성을 만들 수도 있고, 메서드를 만들 수도 있습니다.
하지만, 열거형에 새로운 case를 추가했을 시에 문제가 발생합니다.
새로운 case 추가 시에 우리는 모든 if/switch 문을 수정해주어야 합니다.
다른 구성 요소의 행위를 확장하기 위해서 enum를 사용한 모든 곳의 구조를 변경해줘야 하는 겁니다. enum를 사용하는 모든 메서드 내부를 변경해야 합니다. 만약, enum를 사용한 곳에서 default를 넣어줬다면, 컴파일 에러가 발생하지 않았기 때문에 우리는 변경해야 하는 걸 잊어 버릴 수도 있습니다.
열거형은 단단히 OCP를 위반합니다.
그러면, 열거형의 문제를 어떤 식으로 해결할 수 있을까요?
1.
Protocol
Protocol를 만들고 해당 Protocol를 따르도록 하는겁니다.
열거형을 사용하는 방식부터 보도록 하겠습니다. DeeplinkType 이라는 타입을 만들었습니다.
enum DeeplinkType { case home case profile }
Swift
복사
그리고 해당 열거형 타입을 속성으로 지니고 있는 프로토콜을 하나 만들어 볼게요. 그리고 해당 프로토콜을 conform하는 클래스들을 만들었습니다.
protocol Deeplink { var type: DeeplinkType { get } } class HomeDeeplink: Deeplink { let type: DeeplinkType = .home func executeHome() { // main screen 열기 } } class ProfileDeeplink: Deeplink { let type: DeeplinkType = .profile func executeProfile() { // profile screen 열기 } }
Swift
복사
이럴 경우에는 Router라는 클래스에서 실행 시에 switch 문을 사용해서 분기 처리를 해주어야 합니다.
class Router { func execute(_ deeplink: Deeplink) { switch deeplink.type { case .home: (deeplink as? HomeDeeplink)?.executeHome() case .profile: (deeplink as? ProfileDeeplink)?.executeProfile() } } }
Swift
복사
만약, Setting이라는 새로운 case가 들어오게 된다면 우리는 열거형에 새로운 case를 집어 넣고, execute 메서드 내부의 switch 문을 수정해야 합니다.
enum DeeplinkType { case home case profile case settings } class SettingsDeeplink: Deeplink { let type: DeeplinkType = .settings func executeSettings() { // Settings Screen 열기 } } class Router { func execute(_ deeplink: Deeplink) { switch deeplink.type { case .home: (deeplink as? HomeDeeplink)?.executeHome() case .profile: (deeplink as? ProfileDeeplink)?.executeProfile() case .settings: (deeplink as? SettingsDeeplink)?.executeSettings() } } }
Swift
복사
문제를 해결하기 위해서 열거형을 제거하고 Deeplink 프로토콜 내부에 execute 메서드를 넣었습니다.
protocol Deeplink { func execute() }
Swift
복사
그리고 각각의 클래스가 해당 프로토콜을 준수하도록 했습니다.
class HomeDeeplink: Deeplink { func execute() { // main screen 열기 } } class ProfileDeeplink: Deeplink { func execute() { // Profile screen 열기 } }
Swift
복사
이제 Router에서 Deeplink 프로토콜을 준수하는 클래스를 실행할 때, switch 문없이 execute 메서드를 실행할 수 있습니다.
class Router { func execute(_ deeplink: Deeplink) { deeplink.execute() } }
Swift
복사
이전처럼 Setting 케이스를 새로 집어 넣는다고 하더라도, 새로운 클래스를 만들기만 하면 됩니다.
class SettingsDeeplink: Deeplink { func execute() { // Settings Screen 열기 } }
Swift
복사
2.
Struct
예를 들어서, 특정 방식으로 Cell Style를 지정하기 위해서 enum를 UIView에 전달한다고 생각해봅시다.
용도에 따라 다른 Configuration이 필요한 클래스를 만든다고 해보겠습니다. Context에 따라서 클래스의 Configuration이 달라져야 합니다. Login 뷰와 Setting 뷰에서 같은 Cell를 사용한다고 했을 때, Setting과 Login에서 셀 내부 Configuration이 달라야 합니다.
이럴 때, 우리는 흔하게 enum를 사용하게 됩니다.
enum CellStyle { case login case profile } class CommonTableCell: UITableViewCell { var style: CellStyle { didSet { configureStyle() } } func configureStyle() { switch CellStyle { case .login: textLabel?.textColor = .red textLabel?.font = .preferredFont(forTextStyle: UIFontTextStyleBody) case .profile: textLabel?.textColor = .blue textLabel?.font = .preferredFont(forTextStyle: UIFontTextStyleBody) } } }
Swift
복사
하지만, enum Configuration를 사용하게 되면 불필요하게 제한적이고 유지 관리가 번거롭고 오류가 발생하기 쉬운 API가 만들어 집니다. Cell Style에 새로운 case가 업데이트되면 해당 enum를 사용하는 메서드에서는 switch문을 업데이트 해줘야 합니다.
그러면 CellStyle를 표현해줄 새로운 Model를 만드는 겁니다.
struct CellStyle { let labelColor: UIColor let labelFont: UIFont }
Swift
복사
Struct를 사용해서 새로운 모델을 만들면 스타일의 모든 속성을 명확하게 정의할 수 있으며 클래스의 수 많은 코드와 복잡성이 제거됩니다. 더 작고 읽기 쉬우면 추론이 쉬워집니다.
거대한 Switch 문이 사라지고, 새로운 스타일을 도입해도 원래 클래스를 변경하지 않아도 됩니다.
class CommonTableCell: UITableViewCell { // ... func apply(style: CellStyle) { textLabel?.textColor = style.labelColor textLabel?.font = style.labelFont } // ... }
Swift
복사
Struct는 기본값을 initializer에 추가할 수 있고, 사용자가 원하면 Custom Style를 추가할 수 있습니다.
struct CellStyle { let labelColor: UIColor let labelFont: UIFont init(labelColor: UIColor = .black(), labelFont: UIFont = .preferredFont(forTextStyle: UIFontTextStyleTitle1)) { self.labelColor = labelColor self.labelFont = labelFont } } extension CellStyle { static var settings: CellStyle { return CellStyle(labelColor: .purple(), labelFont: .preferredFont(forTextStyle: UIFontTextStyleTitle1)) } } cell.apply(style: .settings)
Swift
복사
간결하고 명확하면서도 다른 클래스를 변경할 필요없이 확장 가능한 API를 제공받을 수 있습니다.
Struct를 사용하는 방식은 여러 속성을 하나의 응집력 있는 단위로 합쳐주고 기본 동작을 제공해주며, 사용자가 동작을 지정할 수 있게 해줍니다. 단순히 열거형을 사용했다면 불가능한 일입니다.
그럼, 열거형은 사용하면 안되는 걸까요? 안좋은 건가요?
아닙니다. 적절히 사용하면 됩니다.
유연성을 원하지 않는 시나리오, 모든 case를 우리가 알고 있으며 모든 가능성이 enum으로 설명된다고 확신할 수 있고 그 이상은 없다고 자부할 수 있는 곳에서는 enum를 사용해도 됩니다.
추가적인 case가 더 생길 일이 없다고 생각할 수 있는 곳에서는 충분히 열거형을 사용해도 됩니다.
확장에는 열려 있고 변경에는 닫혀 있는 코드를 짜는 것은 상당히 어려워 보입니다.
하지만, 쉽게 OCP를 준수할 수 있는 방법이 있습니다.
바로, 클래스의 모든 변수를 비공개로 설정하고 전역 변수를 사용하지 않는 겁니다.
모든 변수를 비공개로 설정하는 방법은 OCP를 준수하는데 도움이 됩니다. 다른 클래스가 해당 속성에 과도하게 액세스하지 않는걸 보장하기 때문입니다. 또한, 해당 클래스를 잘 제어할 수 있습니다. 속성이 외부 클래스에 의해서 변경 가능하다면 동시성 문제를 발생시킬 수 있고, 예기치 않은 동작을 생성할 가능성도 생깁니다.
변수를 비공개로 설정하면 이런 문제를 해결해줍니다.
하지만, 모든 변수를 비공개로 설정했다고 해도 전역 변수를 사용하게 되면 해당 모듈은 닫힌 모듈이 아닙니다. 다른 모듈이 해당 변수로 접근해서 전역 변수를 사용하는 모든 클래스의 상태를 변경할 수 있기 때문입니다. 즉, 예기치 않은 동작을 발생시킬 수 있는거죠.
하지만, 전역 변수를 사용하는 방식이 현재의 코드에 맞을수도 있고, 어떤 변수는 비공개로 놔두지 않는 것이 좋을 수도 있습니다. 각 접근 방식의 주요 장점, 단점을 분석하고 현재 상황에 맞춰서 적용하는 것은 개발자의 몫입니다.
개방-폐쇄 원칙은 객체 지향 설계의 핵심으로 간주되며 단순한 변경으로 인해 수 많은 문제와 버그가 발생하여 구현이 매우 어려워지거나 불가능해질 수 있는 대규모 프로젝트에서 중요하게 인식됩니다.
OCP를 따르면, 지속적인 요구사항에 대응할 수 있고 미래의 자신을 돕고 미래의 디자인 변화에 대응할 수 있는 쉬운 방법입니다.

 참고 자료