안녕하세요~!
이번 글은 클린 아키텍처 책 내용을 기반으로 SOLID 원칙에 대해 이야기해보려고 합니다.
다들 한 번쯤은 들어보셨을 원칙일 텐데요,
클린 아키텍처 저자이신 로버트 C. 마틴은 건물을 지을 때 좋은 벽돌을 사용하더라도 건물의 아키텍처를 엉망으로 만들면 결국엔 큰 의미가 없는 것과 같다고 말하고 있습니다.
마찬가지로 소프트웨어도 시스템에서도 깔끔한 코드를 작성하더라도 시스템의 설계가 엉망이라면 깔끔한 코드도 결국엔 의미가 없다는 것이죠. (물론, 그 반대도 마찬가지입니다.)
그래서 좋은 벽돌(깔끔한 코드)로 좋은 아키텍처를 정의하는 원칙이 필요한데, 그것이 바로 SOLID 원칙이다라고 소개하고 있습니다.
SOLID 원칙은 각 원칙의 첫 번째 글자를 이어 붙인 이름으로 각 원칙은 다음과 같습니다.
- 단일 책임 원칙 : SRP (Single Responsibility Principle)
- 개방-폐쇄 원칙 : OCP (Open-Closed Principle)
- 리스코프 치환 원칙 : LSP (Liskov Substitution Principle)
- 인터페이스 분리 원칙 : ISP (Interface Segregation Principle)
- 의존성 역전 원칙 : DIP (Dependency Inversion Principle)
이 책에서는 SOLID 원칙에 대해 다음과 같이 설명하고 있습니다.
함수와 데이터 구조를 클래스로 배치하고 이들 클래스를 서로 결합하는 방법
보통 SOLID 원칙이라 하면 객체지향 설계 원칙으로 생각할 수 있는데요, (저 또한 그랬고...)
여기서 말하는 클래스는 단순히 함수와 데이터를 결합한 집합을 가리키고 있습니다.
예를 들면, 구조체, 열거형, 클로저 등이 있겠죠?
소프트웨어 시스템이라면 모두 이러한 집합을 포함하고 있기 때문에 객체지향 외 프로그래밍에서도 이 SOLID 원칙을 적용할 수 있다고 말합니다.
그래서 SOLID 원칙을 객체지향 설계 원칙보다 더 넓은 범위인 소프트웨어 설계 원칙으로 바라보는 것이 적절한 거 같습니다.
SOLID 원칙의 목적은 중간 수준의 소프트웨어 구조가 다음과 같이 만드는 데 있다고 설명하고 있습니다.
(여기서 '중간 수준'은 '모듈 수준'을 뜻합니다.)
- 변경에 유연하다.
- 이해하기 쉽다.
- 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트 기반이 된다.
이제부터는 각 원칙에 대해 좀 더 상세하게 알아가 보도록 하겠습니다!
(각 원칙에 대한 예제 코드는 Swift 언어로 작성되었습니다.)
단일 책임 원칙 : SRP (Single Responsibility Principle)
단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다.
여기서 말하는 모듈은 소스파일 혹은 함수와 데이터 구조로 구성된 응집된 집합으로 정의합니다.
이를 객체지향에서는 클래스로 표현할 수 있기 때문에 이 책의 저자는 SRP를 메서드와 클래스 수준의 원칙으로 설명하고 있습니다.
보통 SRP를 들었을 때 보통은 "클래스는 하나의 책임만을 가져야 한다."라고 생각하곤 합니다.
틀린 말은 아니지만, 사람마다 생각하는 책임의 범위가 전부 다를 것입니다.
즉, 여러 개발자가 참여하는 프로젝트에서 SRP를 지키기 위한 기준을 정할 때 명확하게 잡히지 않을 수 있습니다.
그렇다면 저자는 어떤 기준으로 변경의 이유가 하나뿐이어야 한다고 말했을까요?
소프트웨어 시스템은 사용자와 이해관계자를 만족시키기 위해 변경된다고 합니다.
그래서 '변경의 이유'란 바로 이들 사용자와 이해관계자를 가리키고 있습니다.
하지만, 시스템이 동일한 방식으로 변경되기를 원하는 사용자나 이해관계자가 한 명이 아닌 두 명 이상일 수도 있겠죠?
그래서 이 집단을 액터라고 부르고 있습니다.
SRP에 대해 다시 정리를 하자면 다음과 같습니다.
하나의 모듈은 오직 하나의 액터에 대해서만 책임져야 한다.
서로 다른 액터가 의존하는 코드를 서로 분리시켜라.
그렇다면 이 액터를 기준으로 어떻게 SRP를 적용시킬 수 있는지 코드로 한번 살펴보겠습니다.
// 보고서 생성을 담당하는 클래스
class ReportGenerator {
// 보고서 생성 로직
func generateReport(title: String, content: String) -> Report {
return Report(title: title, content: content)
}
}
위와 같이 보고서를 생성하는 클래스가 있다고 가정해 보겠습니다.
'클래스에 함수가 하나밖에 없는데 무슨 문제가 있나?'라고 생각하실 수 있습니다.
SRP 정의에 대해 다시 한번 생각해 볼까요?
하나의 모듈(클래스)은 하나의 액터에 대해서만 책임을 져야 한다고 했습니다.
그렇다면 위 클래스를 사용하는 액터를 사용자, 관리자, 자동화 시스템으로 가정해 보겠습니다.
세 명의 액터가 동일한 클래스를 사용하는 상황에서 다음과 같이 자동화 시스템 액터를 위한 요구사항이 추가되었다고 가정해 보겠습니다.
일정 시간마다 보고서를 생성할 때 제목에 날짜도 같이 표시해 주면 좋겠다.
이 요구사항을 추가하게 된다면 다음과 같이 코드가 수정이 될 것입니다.
// 보고서 생성을 담당하는 클래스
class ReportGenerator {
// 보고서 생성 로직
func generateReport(title: String, content: String) -> Report {
let date = Date().toString()
let newTitle = "\(title) - \(date)"
return Report(title: newTitle, content: content)
}
}
문제가 보이시나요?
자동화 시스템 액터를 위한 요구사항은 만족시키게 되지만, 다른 두 액터인 사용자와 관리자는 변경에 대한 영향을 받게 됩니다.
위와 같이 서로 매우 다른 액터들이 동일한 클래스에 의존하는 상황에서 한 액터에 의한 로직 변경이 다른 액터들이 인지하지 못하게 될 경우 예상하지 못한 동작 결과를 맞이하게 될 것입니다.
이는 SRP의 원칙인 하나의 모듈(클래스)은 하나의 액터에 대해서만 책임을 져야 한다를 위반하고 있는 상황입니다.
그렇다면 SRP 원칙을 지키기 위해 코드를 어떻게 수정해야 할까요?
// 사용자 액터가 사용하는 보고서 생성 객체
class UserReportGenerator {
func generateReport(title: String, content: String) -> Report {
return Report(title: title, content: content)
}
}
// 관리자 액터가 사용하는 보고서 생성 객체
class AdminReportGenerator {
func generateReport(title: String, content: String) -> Report {
let newTitle = "[admin] \(title)"
return Report(title: newTitle, content: content)
}
}
// 자동화 시스템 액터가 사용하는 보고서 생성 객체
class BackgroundTaskReportGenerator {
func generateReport(title: String, content: String) -> Report {
let date = Date().toString()
let newTitle = "\(title) - \(date)"
return Report(title: title, content: content)
}
}
위처럼 각 액터마다 보고서 생성을 위한 클래스를 생성해 주게 되면 한 액터에서 요구사항으로 코드가 변경되더라도 다른 액터는 영향을 받지 않을 수 있습니다.
개방-폐쇄 원칙 : OCP (Open-Closed Principle)
변경에는 닫혀있고 확장에는 열려 있어야 한다.
위 문장만 보면 무슨 뜻인지 이해가 잘 안 가실 수도 있습니다.
그래서 문장을 좀 더 풀어서 설명하게 된다면 다음과 같습니다.
기존의 코드를 수정하지 않고(변경에 닫힘)
기능을 추가할 수 있도록(확장에 열림)
설계되어야 한다.
소프트웨어가 계속 발전함에 따라 기능이 추가 또는 변경이 자주 일어날 것입니다.
이렇게 설계해야 하는 이유는 기능 추가에 따른 코드 변경량을 최소화하여 시스템이 너무 많은 영향을 받지 않도록 하기 위함입니다.
이는 소프트웨어 시스템의 확장성과 유지보수성을 향상시킬 수 있다는 장점이 있습니다.
코드로 한 번 살펴보겠습니다.
class NotificationManager {
func sendNotification(to user: String, message: String, method: String) {
sendEmail(to: user, message: message)
}
private func sendEmail(to: user, message: message) { ... }
}
알림 전송을 담당하는 NotificationManager클래스가 있고 알림 전송을 위한 수단으로 이메일을 사용하고 있습니다.
여기서 다음과 같은 요구사항이 추가된다고 가정해 보겠습니다.
이메일 알림 말고 SMS 알림 전송 방법을 추가하고 싶다.
그렇게 된다면 기존 코드는 다음과 같이 수정해야 할 것입니다.
class NotificationManager {
func sendNotification(to user: String, message: String, method: String) {
// 전송 방식에 따른 분기 처리
if method == "email" {
sendEmail(to: user, message: message)
} else if method == "sms" {
sendSMS(to: user, message: message)
}
}
private func sendEmail(to: user, message: message) { ... }
private func sendSMS(to: user, message: message) { ... } // 새롭게 추가
}
새로운 기능이 추가됨에 따라 기존 코드인 NotificationManager가 수정이 된 것을 확인할 수 있습니다.
더 많은 알림 전송 방법이 추가가 된다면 그때마다 NotificationManager 내부 코드도 계속해서 변할 것입니다.
그래서 기존 코드를 수정하지 않고 기능을 확장시킬 수 있는 방법이 필요한데, 아래와 같이 코드를 수정할 수 있습니다.
// 프로토콜 생성
protocol NotificationService {
func send(to user: String, message: String)
}
// 프로토콜을 채택한 EmailService 구현체
class EmailService: NotificationService {
func send(to user: String, message: String) { ... }
}
// 프로토콜을 채택한 SMSService 구현체
class SMSService: NotificationService {
func send(to user: String, message: String) { ... }
}
class NotificationManager {
private let notificationService: NotificationService // 프로토콜에 의존
// 외부에서 NotificationService을 채택한 구현체 주입
init(notificationService: NotificationService) {
self.notificationService = notificationService
}
func sendNotification(to user: String, message: String) {
notificationService.send(to: user, message: message)
}
}
위처럼 NotificationService 프로토콜을 생성하고 이를 채택한 구현체를 각각 생성하게 됩니다.
그리고 이 구현체들을 NotificationManager 외부에서 주입하는 방식을 통해 기존 코드인 NotificationManager를 수정하지 않고 새로운 기능을 추가할 수 있게 됩니다.
여기서 NotificationManager 내부에 추상 요소(프로토콜)에 의존하도록 하는 DIP도 같이 적용이 되었는데, 이는 뒤에서 자세히 다뤄보도록 하겠습니다.
또 다른 예시로 열거형 타입에서의 OCP 사례를 살펴보겠습니다.
enum Shape {
case circle
case triangle
case rectangle
// 도형 이름
var name: String {
switch self {
case .circle: return "원"
...
}
}
// 변의 개수
var sideCount: Int {
switch self {
case .circle: return 0
...
}
}
// 꼭짓점의 개수
var vertex: Int {
switch self {
case .circle: return 0
...
}
}
}
열거형 타입을 통해 각 도형의 종류와 각 도형에 따른 데이터를 저장한 Shape 타입을 정의해 보았습니다.
여기서 또 다른 도형, 예를 들어 오각형 또는 육각형이 필요해서 추가를 한다면 기존 코드인 Shape 열거형 타입을 수정하게 될 것입니다.
또한, 다른 함수 또는 클래스에서 Shape 열거형 타입으로 분기 처리를 해놓은 곳이 있다면 해당 코드도 케이스가 추가됨에 따라 코드를 수정해야 할 것입니다.
OCP에 맞춰 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있는 방법은 다음과 같습니다.
protocol Shape {
var name: String { get set }
var lineCount: Int { get set }
var vertex: Int { get set }
}
struct Circle: Shape {
var name: String = "원"
var lineCount: Int = 0
var vertex: Int = 0
}
struct Triangle: Shape {
var name: String = "삼각형"
var lineCount: Int = 3
var vertex: Int = 3
}
struct Rectangle: Shape {
var name: String = "사각형"
var lineCount: Int = 4
var vertex: Int = 4
}
위와 같이 공통된 기능들을 묶어 프로토콜로 정의하고 해당 프로토콜을 채택한 각각의 도형들을 구현하게 되면서 새로운 도형이 추가되더라도 기존 코드를 수정하지 않을 수 있게 됩니다.
리스코프 치환 원칙 : LSP (Liskov Substitution Principle)
상위 타입을 하위 타입으로 대체해도 프로그램의 행위가 유지되어야 한다.
미국의 컴퓨터 과학자인 바바라 리스코프가 소개한 원칙으로 치환 원칙에 대해 설명하고 있습니다.
바바라 리스코프는 하위 타입을 다음과 같이 정의했습니다.
S타입 객체 o1과 T타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.
위 문장만 보고서는 이해가 잘 안 될 수 있기 때문에 아래 예시로 다시 한번 살펴보겠습니다.
스마트폰인 아이폰(S 타입 객체 o1)과 2G 핸드폰인 폴더폰(T 타입 객체 o2)이 있다.
상담원 센터(프로그램 P)는 폴더폰(o2)을 사용하여 고객과 전화 통화를 한다.
만약 폴더폰(o2)을 스마트폰(o1)으로 치환하더라도 상담원이 기존과 동일한 방식으로 통화할 수 있다면,
즉, 상담원의 업무 방식이나 시스템이 깨지지 않는다면, 스마트폰(o1)은 폴더폰(o2)의 하위 타입이다.
리스코프가 이야기하는 하위 타입의 개념에서 가장 중요한 점은 '하위 타입으로 확장 시 상위 타입에서 수행되던 기존 동작을 유지할 수 있어야 한다'입니다.
위 예시에서 스마트폰은 2G 핸드폰의 하위 타입이 될 수 있지만 2G 핸드폰이 스마트폰의 하위 타입이 될 수 없는 이유는 스마트폰에서 수행되는 '전화하기' 동작을 그대로 유지할 수 없기 때문입니다.
예시를 들자면 스마트폰은 전화하는 동시에 통화 녹음을 한다던지, 통화 중 다른 앱을 실행하는 등의 동작이 추가적으로 이뤄질 수 있는데, 2G 핸드폰에서는 이러한 스마트폰에서 이루어진 동작을 온전히 유지할 수 없기 때문입니다.
또 다른 예시로 코드와 함께 살펴보도록 하겠습니다.
LSP를 위반하는 전형적인 문제로 알려진 정사각형/직사각형 문제가 있습니다.
수학에서는 정사각형과 직사각형의 관계는 직사각형 안에 정사각형이 포함된 즉, 직사각형이 상위 타입이고 정사각형이 하위 타입으로 설명이 가능합니다.
하지만, 프로그래밍에서도 위와 같은 관계를 유지할 수 있을까요?
class Rectangle {
var width: Int = 1
var height: Int = 1
func setWidth(_ width: Int) {
self.width = width
}
func setHeight(_ height: Int) {
self.height = height
}
func getArea() -> Int {
return self.width * self.height
}
}
class Square: Rectangle {
// 정사각형의 밑변, 높이는 모두 동일하기 때문에
// 밑변의 값이 변경되는 동시에 높이의 값도 변경
override func setWidth(_ width: Int) {
self.width = width
self.height = width
}
// 위와 동일
override func setHeight(_ height: Int) {
self.width = height
self.height = height
}
}
var r: Rectangle = Rectangle()
r.setWidth(5)
r.setHeight(2)
print(r.getArea() == 10) // true
r = Square()
r.setWidth(5)
r.setHeight(2)
print(r.getArea() == 10) // false
Rectangle(직사각형) 클래스와 이에 대한 하위 타입으로 Square(정사각형) 클래스를 구현해 보았습니다.
직사각형 객체의 밑변과 높이를 각각 5, 2로 설정 후 넓이를 반환하는 동작을 수행한 결과와 정사각형 객체로 동일한 순서로 동작을 수행한 결과는 일치하지 않습니다.
이는 기존 상위 타입에서 수행되던 동작을 유지시키지 않은 채 하위 타입으로 확장한 LSP를 위반한 상황입니다.
그렇기 때문에 위 직사각형/정사각형 문제에서는 LSP를 위반하지 않기 위해 아래와 같이 코드를 수정해야 합니다.
protocol Shape {
func getArea() -> Int
}
class Rectangle: Shape {
var width: Int = 1
var height: Int = 1
func setWidth(_ width: Int) {
self.width = width
}
func setHeight(_ height: Int) {
self.height = height
}
func getArea() -> Int {
return self.width * self.height
}
}
class Square: Shape {
var line: Int = 1
func setLine(_ line: Int) {
self.line = line
}
func getArea() -> Int {
return self.line * self.line
}
}
var shape: Shape // 상위 타입 (도형)
// 하위 타입 (직사각형)
var rect = Rectangle()
rect.setWidth(5)
rect.setHeight(2)
shape = rect
print(shape.getArea()) // 10
// 하위 타입 (정사각형)
var square = Square()
square.setLine(5)
shape = square
print(shape.getArea()) // 25
직사각형과 정사각형을 상위, 하위 타입 관계로 바라보지 않고 도형과 직사각형/정사각형을 상위, 하위 타입으로 바라보는 관계로 수정하게 되면서 상위 타입인 Shape의 getArea() 동작을 유지시킬 수 있게 되었습니다.
인터페이스 분리 원칙 : ISP (Interface Segregation Principle)
사용하지 않는 것에 의존하지 않아야 한다.
먼저, ISP에서 말하는 '인터페이스'는 자바의 인터페이스, 스위프트의 프로토콜을 의미할 수도 있지만, 클라이언트가 의존하는 API, 즉 외부에 공개된 메서드의 집합으로 바라보는 것이 더 적절한 거 같습니다.
필요 이상으로 많은 것을 포함하는 범용적인 인터페이스에 의존할 경우 예상치 못한 문제에 빠질 수 있다고 합니다.
예로 정적 타입 언어 또는 더 고수준의 아키텍처 수준에서는 인터페이스 내 일부 요소를 사용하지 않더라도 해당 요소가 수정됨에 따라 불필요한 재컴파일과 재배포를 강제하는 문제를 불러올 수 있다고 합니다.
그렇기 때문에 범용적인 인터페이스를 책임에 맞게 잘 분리해야 한다는 것이죠.
ISP도 SRP와 마찬가지로 "책임을 적절히 분리하라."의 목표를 바라보고 있다는 점에서 유사하다고 볼 수 있습니다.
여기서 제가 생각하는 SRP와 ISP의 차이는 다음과 같습니다.
[SRP]
클래스와 메서드 수준의 원칙이다.
클래스에 정의된 모든 기능을 사용하고 있더라도 각 액터의 요구사항에 따라 다르게 구현될 수 있다.
따라서 "액터"라는 기준에 따라 책임을 분리해야 한다.
[ISP]
클래스, 메서드보다 더 상위 수준에서도 적용할 수 있는 원칙이다.
클라이언트는 사용하지 않는 기능에 의존할 경우 예상치 못한 문제에 빠질 수 있다.
따라서 "클라이언트가 사용하는 인터페이스" 기준에 따라 책임을 분리해야 한다.
예시 코드로 살펴보겠습니다.
protocol Worker {
func work()
func eat()
func sleep()
}
class Human: Worker {
func work() { ... }
func eat() { ... }
func sleep() { ... }
}
class Robot: Workable {
func work() { ... }
// 로봇은 먹고 자는 행위가 불가능함
// 따라서 사용하지 않는 기능에 의존하고 있는 상황
func eat() { ... }
func sleep() { ... }
}
Worker 프로토콜을 채택한 두 클래스 Human, Robot을 구현했습니다.
Human은 일하고 먹고 자는 행위를 모두 수행할 수 있지만 Robot은 먹고 자는 행위는 수행할 수 없습니다.
즉, Robot은 사용하지 않는 기능(인터페이스)인 eat(), sleep()에 의존하고 있는 상황입니다.
만약 두 함수에 변경이 발생하게 된다면 Robot 클래스는 이를 사용하고 있지 않음에도 재컴파일, 재배포의 문제를 겪을 수 있습니다.
그리고 Worker 프로토콜에 Robot이 사용하지 않는 함수가 추가되더라도 Robot 클래스는 강제로 해당 함수를 구현해야 하고 그에 따라 동일한 문제가 발생할 것입니다.
따라서 Robot 클래스가 사용하는 인터페이스에만 의존할 수 있도록 다음과 같이 코드를 수정할 수 있습니다.
protocol Workable {
func work()
}
protocol Sleepable {
func sleep()
}
protocol Eatable {
func eat()
}
class Human: Workable, Sleepable, Eatable {
func work() { ... }
func eat() { ... }
func sleep() { ... }
}
// 필요한 기능만 채택하여 불필요한 기능에 의존하지 않도록 함
class Robot: Workable {
func work() { ... }
}
기존에 여러 기능이 포함된 범용적인 인터페이스였던 Worker 프로토콜을 위와 같이 책임을 적절히 분리하였습니다.
그 후 각 클래스 별로 필요한 기능만 사용할 수 있도록 구현된 상태입니다.
이렇게 된다면 Robot 클래스가 사용하지 않는 Sleepable, Eatable 프로토콜에 변경이 발생하더라도 Robot 클래스는 재컴파일, 재배포 문제를 겪지 않을 수 있습니다.
의존성 역전 원칙 : DIP (Dependency Inversion Principle)
변동성이 큰 구체적인 요소에 의존하지 말고 추상 요소에 의존하라.
구체 요소는 클래스, 구조체 등의 구현체를 말하고 추상 요소는 추상 클래스, 인터페이스를 말하고 있습니다. (Swift에서는 protocol이 될 수 있죠.)
추상 요소에 의존하라는 이유는 인터페이스가 구현체보다 변동성이 낮기 때문에 안정된 소프트웨어를 구축할 수 있기 때문입니다.
좀 더 상세히 설명하자면 구현체에 변경이 생기더라도 그 구현체가 구현하는 인터페이스는 대다수의 경우 변경될 필요가 없다는 뜻입니다.
물론, 인터페이스에 변경이 생기면 이를 구체화한 구현체들도 따라서 수정해야 하기 때문에 인터페이스의 변동성을 낮추기 위해 노력해야 합니다.
이렇게 구현체가 아닌 인터페이스에 의존해야 하는 이유를 알아보았는데 과연 소프트웨어를 설계하면서 온전히 추상 요소에만 의존할 수 있을까요?
이는 현실적으로 어렵기에 DIP를 이야기할 때 '모든 구체적인 요소'가 아닌 '변동성이 큰 구체적인 요소'에 의존하지 말라고 이야기하고 있습니다.
자바의 경우 String 클래스는 구체 클래스이지만 변동성이 거의 없는 안정적인 요소이기에 DIP 대상에서 제외되는 것이죠.
자 그렇다면 DIP, 즉 의존성 역전 원칙에서 의존성 역전은 무엇을 뜻할까요?
의존성 역전은 의존 방향을 제어 흐름과는 반대로 역전시키는 것을 의미합니다.
문장만 보고서는 이해가 잘 안 가실 수 있기 때문에 다음 예시 코드로 같이 한번 살펴보겠습니다.
// 이메일 관련 작업을 담당하는 클래스
class EmailService {
func sendEmail(to user: String, message: String) { ... }
}
// 알림 전송을 담당하는 클래스
class NotificationManager {
private let emailService = EmailService() // EmailService에 직접 의존
func sendNotification(to user: String, message: String) {
emailService.sendEmail(to: user, message: message)
}
}
먼저, 제어 흐름 방향과 의존 방향을 살펴보고 가겠습니다.
View에서 NotificationManager 객체를 사용한다고 가정하면 다음과 같은 호출 흐름이 이루어질 것입니다.
- View에서 알림 전송 버튼 클릭
- NotificationManager 객체한테 알림 요청 (sendNotification(to:message))
- NotificationManager는 EmailService 객체한테 이메일 전송 요청 (sendEmail(to:message))
간단히 표현하면 View -> NotificationManager -> EmailService 순서의 제어 흐름을 가집니다.
그리고 의존 방향은 View가 NotificationManager 클래스를 의존하고 NotificationManager는 EmailService 클래스를 의존하고 있습니다.
이를 표현하면 View -> NotificationManager -> EmailService 방향으로 제어 흐름과 동일한 흐름을 가지게 됩니다.
DIP의 관점에서 바라보았을 때, NotificationManager는 구체 클래스인 EmailService를 의존하고 있는 것을 확인할 수 있습니다.
만약 알림 전송 수단을 이메일에서 SMS, 푸시 알림 등으로 변경하게 된다면 NotificationManger가 의존하는 EmailService 구현체를 변경해야 할 것입니다.
또 다른 상황에서 NotificationManager 클래스를 위한 테스트 코드를 작성하게 될 경우에도 테스트를 위해 EmailService 객체를 테스트 객체로 바뀌어야 할 것입니다.
이는 기존 코드를 수정하게 되고 "변경에는 닫혀있고 확장에는 열려야 한다"인 OCP를 위배하기도 합니다.
DIP에 맞춰 구체 요소(구현체)가 아닌 추상 요소(인터페이스)를 의존하도록 수정해 보면 어떨까요?
// 프로토콜(추상 요소) 생성
protocol NotificationService {
func send(to user: String, message: String)
}
// 프로토콜을 채택한 EmailService 구현체
class EmailService: NotificationService {
func send(to user: String, message: String) { ... }
}
// 프로토콜을 채택한 SMSService 구현체
class SMSService: NotificationService {
func send(to user: String, message: String) { ... }
}
class NotificationManager {
private let notificationService: NotificationService // 추상 요소에 의존하도록 변경
// 외부에서 NotificationService에 대한 의존성 주입
init(notificationService: NotificationService) {
self.notificationService = notificationService
}
func sendNotification(to user: String, message: String) {
notificationService.send(to: user, message: message)
}
}
추상 요소에 의존하게 되면서 EmailService, SMSService 중 어떤 객체든지 간에 NotificationManager는 내부 코드 변경 없이 동일한 기능을 수행할 수 있게 됩니다.
이 상황에서 제어 흐름 방향과 의존 방향을 다시 한번 살펴보겠습니다.
먼저 제어 흐름은 다음과 같습니다.
- View에서 알림 전송 버튼 클릭
- NotificationManager 객체한테 알림 요청 (sendNotification(to:message))
- NotificationManager는 NotificationService 객체한테 알림 전송 요청 (send(to:message))
- 외부에서 주입된 객체(EmailService 또는 SMSService)에 맞는 send(to:message:) 메서드 호출
결과적으로는 View -> NotificationManager -> (NotificationService) -> EmailService/SMSService 순서로 호출이 발생하게 됩니다.
그렇다면 의존 방향은 어떻게 변할까요?
View가 NotificationManager 클래스를 의존하고 있는 것은 동일하지만 NotificationManager는 추상 요소인 NotificationService에 의존하고 있습니다. 그리고 구현체인 EmailService/SMSService는 NotificationService를 구현하고 있죠.
이를 화살표로 표현하면 View -> NotificationManager -> (NotificationService) <- EmailService/SMSService로 표현될 수 있습니다.
제어 흐름과는 반대로 EmailService/SMSService가 NotificationService를 바라보는 방향으로 역전된 것을 확인할 수 있습니다.
DIP 적용 전, 후를 비교한 의존 방향 그림입니다.
위와 같이 변동성이 큰 구체 요소가 아닌 추상 요소에 의존하게 되면 구현체에 어떤 변경사항이 생기더라도 인터페이스에는 영향이 없기 때문에 보다 더 안정된 소프트웨어를 구축할 수 있게 됩니다.
또한 새로운 NotificationService 구현체가 추가되더라도 기존 코드를 수정하지 않아도 되는 OCP 원칙도 준수할 수 있게 됩니다.
마무리
제가 느끼기에 "클린 아키텍처"에서 설명하고자 하는 주 이야기는 다음과 같다고 생각합니다.
시간이 지남에 따라 변경될 수 있는 코드의 범위를 줄일 수 있도록 소프트웨어를 설계하자.
소프트웨어는 하드웨어와는 달리 더 쉽게, 더 빠르게 변경될 수 있기 때문이다.
이를 위한 방법 중 하나로 책에서는 SOLID 원칙에 대해 소개하고 있었고 그 외에도 책에서 다양한 설계 방법을 소개하고 있습니다.
지금까지 "클린 아키텍처" 책 내용을 기반으로 SOLID 원칙에 대해 알아보았는데요,
제가 이 원칙에 대해 이전보다 더 자세히 알게 된 내용들에 대해 정리하고 예제 코드까지 더해지니 글 내용이 길어진 거 같네요.. ㅠㅠ
긴 글 읽어주셔서 감사하고, 설명하고자 하는 내용이나 예시 코드가 와닫지 않을 수 있지만... 그래도 누군가에게는 도움이 되기를 기대합니다..!!
피드백은 언제나 환영입니다 ~!~!
[참고]
https://tech.kakaobank.com/posts/2411-solid-truth-or-myths-for-developers/
모든 개발자가 알아야 할 SOLID의 진실 혹은 거짓
기술 면접 자리에서 SOLID 5대 원칙에 대한 질문을 받아보신 분이라면 주목! 이 글에서는 SOLID 원칙의 역사와 장점, 그리고 각각의 원칙에서 중요한 점을 면접 상황 예시를 통해 가볍게 풀어보았습
tech.kakaobank.com