Robert C. Martin의 Clean Architecture 3부 '설계 원칙' 7-11장을 읽고 메모한 글입니다. 요약글은 아닙니다. 개인적인 의견도 포함되어 있으니 참고해주세요. 모든 예제코드는 Swift로 작성되었습니다.
SOLID 원칙
개요
- 함수와 데이터 구조를 클래스로 배치하고 이 클래스들을 서로 결합하는 방법에 대한 원칙
- SRP, OCP, LSP, ISP, DIP
목표
- 변경에 유연하게
- 가독성
- 재사용성
- 등등
7장. SRP (단일 책임 원칙)
- Single Responsibility Principle
- A class should have one, and only one, reason to change.
- 하나의 모듈은 하나의 책임만 가진다 (*하나의 모듈은 하나의 일만 한다, 가 아님. 그건 이미 함수의 원칙)
- 여기서 책임이란? > 변경을 위한 이유. 즉, 하나의 모듈을 변경할 이유가 오직 하나여야 한다는 것
- 응집성(cohesion)을 높이는 방향
- iOS개발과 연관시켜보면, 기존의 cocoa MVC 패턴이 SRP를 위반하고 있기 때문에 MVP, MVVM, VIPER 등의 패턴이 등장했다. 관련하여 재밌게 읽었던 글 > iOS 아키텍처 패턴들 (원문). 이 패턴들의 등장배경을 따라가볼 수 있다.
- 아래에 나름대로 SRP의 간단한 예제를 작성해보았다. 언뜻 보면 책임을 나눔으로써 코드가 길어지는 것처럼 보일 수 있지만, 시스템이 커질수록 SRP를 지키는 것이 유리하다는 것을 잊지 말자.
class Paint {
func draw(point: CGPoint) {
self.canvas.addPoint(point)
}
func quickSave() {
self.quickSaveFile = self.saveFileFromCurrentContext()
}
func quickLoad() {
guard let quickSaveFile = self.quickSaveFile else { return }
self.canvas = quickSaveFile.canvas
}
func saveFileFromCurrentContext() -> SaveFile {
return SaveFile(canvas: self.canvas)
}
var quickSaveFile: SaveFile?
var canvas = Canvas()
}
// 위 페인트 클래스는 draw, save/load 의 책임들을 가지고 있다. (SRP 위반)
// 이 책임들을 분배해보자.
// 캔버스에 그리는 UI부분만 책임진다
protocol CanvasGUIProtocol {
var canvas: Canvas { set get }
func draw(point: CGPoint)
}
// save, load를 위한 context 관리만 책임진다
protocol CanvasMementoProtocol {
func save(canvas: Canvas, forId: String)
func restore(id: String) -> Canvas?
}
// 편의를 위한 protocol extension 구현. 필수는 아닌 부분이다
extension CanvasMementoProtocol {
subscript(id: String) -> Canvas? {
set {
guard let canvas = newValue else { return }
self.save(canvas: canvas, forId: id)
}
get {
return self.restore(id: id)
}
}
}
class Paint {
init(canvasGUIProtocol: CanvasGUIProtocol, canvasMementoProtocol: CanvasMementoProtocol) {
self.canvasGUI = canvasGUIProtocol
self.saveHandler = canvasMementoProtocol
}
func draw(_ point: CGPoint) {
self.canvasGUI.draw(point: point)
}
func quickSave() {
let canvas = self.canvasGUI.canvas
self.saveHandler.save(canvas: canvas, forId: "quick")
}
func quickLoad() {
guard let canvas = self.saveHandler.restore(id: "quick") else { return }
self.canvasGUI.canvas = canvas
}
private var canvasGUI: CanvasGUIProtocol
private var saveHandler: CanvasMementoProtocol
}
8장. OCP (개방-폐쇄 원칙)
- Open-Closed Principle
- You should be able to extend a classes behavior, without modifying it.
- 확장에 열려 있고, 변경에 닫혀 있어야 한다
- A 모듈의 변경으로부터 B 모듈을 보호하는 방법: A가 B에 의존하게 한다 (B가 A가 의존하는 것이 아니라!)
- 어떤 모듈을 가장 보호해야 하는가
- 가장 높은 수준(level)의 정책을 포함하는 모듈을 가장 보호해야 한다. 즉, 다른 모듈들이 그 모듈에 의존하는 계층구조를 만든다
- 예시) 업무규칙을 가지는 모듈(가장 높은 수준의 개념)에 View, Presenter 모듈들(상대적으로 낮은 수준의 개념)이 의존해야 한다. 업무규칙이 바뀌면 View도 바뀔 수 있지만, View가 바뀐다고 업무규칙이 바뀌는 것은 이상하지 않은가.
- DIP를 활용하여 OCP를 지킬 수 있다
- 이번에도 간단히 예제를 작성해보았다.
struct Book {
let id: String
let title: String
let author: String
let rentalPrice: Int
}
struct Video {
let id: String
let title: String
let director: String
let rentalPrice: Int
}
class RentalPriceCalculator {
var totalRentalPrice: Int {
var result = 0
result += self.books.reduce(0, { $0 + $1.rentalPrice })
result += self.videos.reduce(0, { $0 + $1.rentalPrice })
return result
}
func addBook(_ book: Book) {
self.books.append(book)
}
func addVideo(_ video: Video) {
self.videos.append(video)
}
private var books = [Book]()
private var videos = [Video]()
}
// 위 예제는 OCP를 위반하고 있다.
// why?
// 책, 비디오만이 아닌 보드게임도 상품으로 추가하고 싶을 때 귀찮은지 그렇지 않은지를 생각해보면 된다
// 이 구조를 확장에 열려있고 변경에는 닫혀있게 바꿔보자.
// (이때의 '확장'은 새로운 렌탈 아이템을 추가하는 것을 뜻하고,
// '변경'은 기존의 RentalPriceCalculator 안의 코드를 변경하지 않는 것을 뜻한다)
protocol RentalItem {
var id: String { get }
var rentalPrice: Int { get }
}
class RentalPriceCalculator {
var totalRentalPrice: Int {
return self.items.reduce(0, { $0 + $1.rentalPrice })
}
func addRentalItem(_ item: RentalItem) {
self.items.append(item)
}
private var items = [RentalItem]()
}
// 보드게임 아이템을 추가하고 싶다면
// 단순히 BoardGame 모델이 RentalItem 프로토콜을 따르게만 하면 된다 (쉽게 열려있는 확장)
// RentalPriceCalculator 의 코드는 변경할 필요가 없다 (변경에 닫혀있음)
9장. LSP (리스코프 치환 원칙)
- Liskov Substitution Principle
- Derived classes must be substitutable for their base classes.
- 부모 클래스의 자리에 자식 클래스를 그대로 치환해도 문제없이 돌아가야 한다 (by 리스코프라는 사람이 정의함)
- 왜 필요한가? 이 원칙을 지키지 않으면 부모 클래스의 타입을 이용하는 코드마다 인스턴스의 실제 타입이 무엇인지 확인하는 코드가 들어가야 한다. (다시 말하면, 이 원칙만 지키면 그렇게 귀찮은 일을 할 필요가 없다는 것)
- 이 원칙은 상속의 가이드만이 아니라 인터페이스와 구현체에도 적용되는 광범위한 소프트웨어 원칙으로 변모했다.
- 유명한 예제로 와닿게 이해해보자.
직사각형과 정사각형 중 어떤 것이 부모 클래스가 되어야 할까?
먼저 두 가지의 속성을 생각해보자. 우리가 수학 시간에 배운 직사각형과 정사각형의 개념은 다음과 같다.
- 직사각형: 네 각이 모두 직각인 사각형
- 정사각형: 네 변의 길이가 모두 같은 사각형.
수학적인 개념으로는 정사각형이 직사각형에 포함되는 개념이다. 모든 정사각형은 일종의 직사각형이지만, 모든 직사각형이 정사각형인 것을 아니기 때문이다. 따라서 언뜻 생각하기에는 직사각형이 부모 클래스이고 정사각형이 자식 클래스가 되어야 한다고 생각하기 쉽다.
과연 그럴까?
우선, 상속 관계를 생각하지 않고 작성한 두 개의 모델을 살펴보자.
class Rectangle {
var area: CGFloat { get }
init(width: CGFloat, height: CGFloat)
func setWidth(_ width: CGFloat)
func setHeight(_ height: CGFloat)
}
class Square {
var area: CGFloat { get }
init(side: CGFloat) // side: 한 변의 길이
func setSide(_ side: CGFloat)
}
상세 구현을 살피면 답이 명확해질 수 있기 때문에, 일부러 인터페이스만 표시해보았다.
이제 직감을 따라 직사각형을 한번 부모 클래스로 만들어보자.
// 직사각형. 네 각이 모두 직각인 사각형.
// 내가 부모 클래스
class Rectangle {
var area: CGFloat {
return self.width * self.height
}
init(width: CGFloat, height: CGFloat) {
self.width = width
self.height = height
}
func setWidth(_ width: CGFloat) {
self.width = width
}
func setHeight(_ height: CGFloat) {
self.height = height
}
private var width: CGFloat
private var height: CGFloat
}
// 정사각형. 항상 모든 변의 길이가 같음이 보장되어야 하는 속성을 지님.
// 나는 직사각형의 자식 클래스
class Square: Rectangle {
override init(width: CGFloat, height: CGFloat) {
super.init(width: width, height: height)
self.setSide(width) // width == height 속성을 위해
}
override func setWidth(_ width: CGFloat) {
self.setSide(width) // width == height 속성을 위해
}
override func setHeight(_ height: CGFloat) {
self.setSide(height) // width == height 속성을 위해
}
func setSide(_ side: CGFloat) {
self.setWidth(side)
self.setHeight(side)
}
}
// 직사각형(부모클래스)을 사용하고 있는 부분을 정사각형(자식클래스)으로 곧바로 치환할 수 있는가?
// 테스트해보자.
// 함수 목적: area 를 100으로 만들기
func updateAreaOfRectangle(_ rectangle: Rectangle) {
// Rectangle 클래스의 setWidth, setHeight 동작을 믿고 작성한 코드
rectangle.setWidth(25)
rectangle.setHeight(4)
print(rectangle.area)
}
var target = Rectangle(width: 0, height: 0)
self.updateAreaOfRectangle(target) // print "100" (GOOD!)
// 그러나 이 자리에 자식클래스인 정사각형이 들어오게 된다면...
var target = Square(width: 0, height: 0)
self.updateAreaOfRectangle(target) // print "16" (why..?)
// 부모클래스의 속성을 믿고 코드를 짠 함수에서 문제가 생기게 되었다.
// updateAreaOfRectangle 함수는 더이상 Rectangle의 인터페이스를 믿을 수 없게 되었다.
// 이 상황을 해결하는 옵션1. 함수 내부를 수정
func updateAreaOfRectangle(_ rectangle: Rectangle) {
// Rectangle 클래스의 자식클래스인 Square 의 동작까지 다 숙지하고 함수를 작성해야 함 (불편하다..)
rectangle.setWidth(10)
rectangle.setHeight(10)
print(rectangle.area)
}
// 이 상황을 해결하는 옵션2. 함수 호출부를 수정
if target is Square {
self.updateAreaOfSquare(target)
} else {
self.updateAreaOfRectangle(target)
}
// 상속 구조를 만든 의미가...?
LSP 를 위반하면 어떤 불편함이 생기는지 충분히 드러났다고 생각한다. 정사각형이 부모인 직사각형의 동작을 지켜주지 않아 발생한 부작용을 염두에 두면서, 이번에는 정사각형을 부모클래스로 만들어보자.
// 정사각형. 항상 모든 변의 길이가 같음이 보장되어야 하는 속성을 지님.
// 내가 부모 클래스
class Square {
var area: CGFloat {
return self.width * self.height
}
init(side: CGFloat) {
self.width = side
self.height = side
}
func setSide(_ side: CGFloat) {
self.width = side
self.height = side
}
fileprivate var width: CGFloat
fileprivate var height: CGFloat
}
// 직사각형. 네 각이 모두 직각인 사각형.
// 나는 정사각형의 자식 클래스
class Rectangle: Square {
// 부모의 setSide를 해치지 않으면서도,
// width 와 height 각각 셋팅하는 인터페이스를 제공해줄 수 있다
func setWidth(_ width: CGFloat) {
self.width = width
}
func setHeight(_ height: CGFloat) {
self.height = height
}
}
// 정사각형(부모클래스)을 사용하고 있는 부분을 직사각형(자식클래스)으로 곧바로 치환할 수 있는가?
// 테스트해보자.
// 함수 목적: area 를 100으로 만들기
func updateAreaOfSquare(_ square: Square) {
// Square 클래스의 setSide 동작을 믿고 작성한 코드
square.setSide(10)
print(square.area)
}
var target = Square(side: 0)
self.updateAreaOfSquare(target) // print "100" (GOOD!)
// 이제 정사각형 자리에 자식클래스인 직사각형을 그대로 치환해보자
var target = Rectangle(side: 0)
self.updateAreaOfSquare(target) // print "100" (GOOD!)
// 부모클래스의 속성을 믿고 코드를 짠 함수가 제대로 돌아간다!
// 힘수 구현부나 호출부에서 어떤 타입 체크도 필요 없는 것을 확인할 수 있다
10장. ISP (인터페이스 분리 원칙)
- Interface Segregation Principle
- Make fine grained interfaces that are client specific.
- Fat Interface 를 만들지 말자
- 내가 필요로 하는 것 이상으로 많은 것을 포함하는 모듈에 의존하는 것은 해로운 일이다.
ISP를 위반하면 어떤 일이 생길 수 있는지, 실제 업무에서의 내 경험을 각색하여 예제로 만들어 보았다.
<요구사항> 유튜브 비디오(WKWebView로 재생)와 HLS 비디오(AVPlayer로 재생)를 둘 다 재생할 수 있는 뷰어 개발하기
protocol MediaViewProtocol {
func load(completion: @escaping (_ isSuccess: Bool) -> Void)
func play()
}
class MyServerVideoView: MediaViewProtocol {
func load(completion: @escaping (_ isSuccess: Bool) -> Void) {
self.loadAsset(completion: completion)
}
func play() {
self.player.play()
}
}
class YouTubeVideoView: MediaViewProtocol {
func load(completion: @escaping (_ isSuccess: Bool) -> Void) {
YouTubeAPI.isValidVideo(id: self.videoId, completion: completion)
}
func play() {
self.webView.loadYouTubeVideo(id: self.videoId)
}
}
// Media Viewer 에서 사용하는 함수라고 가정해보자.
func showMediaView(_ mediaView: MediaViewProtocol) {
mediaView.load { (isSuccess) in
guard isSuccess else { return }
mediaView.play()
}
}
이때 다음과 같은 스펙이 추가되었다.
<요구사항> 이 미디어 뷰어에 비디오 말고 이미지도 보여줄 수 있도록
개발자는 MediaViewProtocol 을 사용해서 이미지뷰어를 만들고 싶은 충동에 휩싸일 것이다. 그렇게만 하면 기존의 미디어 뷰어를 건들 필요가 없고, 프로토콜을 활용하는 방향이 되기 때문이다. 그럼 한번 이 충동을 따라보자.
class LocalImageView: MediaViewProtocol {
func load(completion: @escaping (_ isSuccess: Bool) -> Void) {
self.imageView.image = self.image
completion(true)
}
func play() {
// Nothing to do
}
}
Nothing to do 부분에서 살짝 안 좋은 냄새가 나는 것 같기도 하지만, 일단 외면하기로 하고...
어쨌든 기존의 프로토콜을 따르는 커스텀 뷰를 하나 만드는 것으로, 미디어 뷰어도, 비디오 뷰들도 건들 필요 없이 스펙을 완성했다!
Is everything going well?
<신규 요구사항> 미디어 사운드 on/off 기능 추가
개발자는 MediaViewProtocol 에 사운드 제어 인터페이스를 추가한다. (그러면 안 되었는데...)
protocol MediaViewProtocol {
var isMuted: Bool { get set }
func load(completion: @escaping (_ isSuccess: Bool) -> Void)
func play()
}
// 이제 새로 추가된 isMuted 를 각각의 미디어뷰들에서 구현해보자.
class MyServerVideoView: MediaViewProtocol {
var isMuted: Bool {
get { return self.player.isMuted }
set { self.player.isMuted = newValue }
}
func load(completion: @escaping (_ isSuccess: Bool) -> Void) {
self.loadAsset(completion: completion)
}
func play() {
self.player.play()
}
}
class YouTubeVideoView: MediaViewProtocol {
var isMuted: Bool = false {
didSet { self.updateMuted() }
}
func load(completion: @escaping (_ isSuccess: Bool) -> Void) {
YouTubeAPI.isValidVideo(id: self.videoId, completion: completion)
}
func play() {
self.webView.loadYouTubeVideo(id: self.videoId)
}
}
class LocalImageView: MediaViewProtocol {
var isMuted: Bool = false // Do not use
func load(completion: @escaping (_ isSuccess: Bool) -> Void) {
self.imageView.image = self.image
completion(true)
}
func play() {
// Nothing to do
}
}
이미지 뷰어에서 냄새나는 부분이 하나 더 늘었지만, 이번에도 그냥 외면하기로 하고..
그 후 코드를 잊을 정도로 많은 시간이 흐르고, 잊고 있던 미디어뷰에 새로운 스펙이 추가되었다.
<요구사항> 사운드 off 시에는 재생을 하지 말아주세요.
개발자는 간단한 일이라고 생각하며, 미디어뷰어 모듈에서 한 줄만 추가한다.
func showMediaView(_ mediaView: MediaViewProtocol) {
guard mediaView.isMuted == false else { return } // 스펙 완료!
mediaView.load { (isSuccess) in
guard isSuccess else { return }
mediaView.play()
}
}
그런데 ...
배포 후 기존의 이미지 뷰어들에서 이미지가 나오지 않는다는 항의가 빗발친다.
LocalImageView 에서는 전혀 쓸모없던 isMuted 의 값을 무조건 false 로 셋팅했던 것을 잊고 있었기 때문이었다.
이런 문제가 생긴 원인은 무엇일까?
개발자의 기억력 감퇴일까?
그렇지 않다. 이것은 ISP의 위반이 가져온 문제다.
이미지 뷰어에서는 필요하지도 않은 비디오(오디오)관련 인터페이스를 억지로 가져가야 했다. 그렇기 때문에 미디어 뷰어의 스펙이 고도화될수록 필연적으로 부작용이 발생할 수 밖에 없는 구조였던 것이다.
조금 더 ISP를 지키는 방향의 구조는 다음과 같다.
protocol MediaViewProtocol {
func load()
}
protocol VideoPlayble {
var isMuted: Bool { get set }
}
class MyServerVideoView: MediaViewProtocol, VideoPlayble {
var isMuted: Bool {
get { return self.player.isMuted }
set { self.player.isMuted = newValue }
}
func load() {
self.loadAsset { loaded in
guard loaded else { return }
self.play()
}
}
private func play() {
self.player.play()
}
}
class YouTubeVideoView: MediaViewProtocol, VideoPlayble {
var isMuted: Bool = false {
didSet { self.updateMuted() }
}
func load() {
YouTubeAPI.isValidVideo(id: self.videoId) { [weak self] isValid in
guard isValid else { return }
self?.play()
}
}
private func play() {
self.webView.loadYouTubeVideo(id: self.videoId)
}
}
class LocalImageView: MediaViewProtocol {
func load() {
self.imageView.image = self.image
}
}
11장. DIP (의존성 역전 원칙)
- Dependency Inversion Principle
- Depend on abstractions, not on concretions.
- 추상에 의존하라 (구체적인 구현모듈에 의존하지 말고, 추상화된 레벨에 의존하기)
- OOP의 핵심인 추상과 다형성을 이해해야 이 개념을 이해할 수 있다. DIP에 대한 상세한 예제 및 깊은 이해에 대해서는 이전에 작성한 포스트를 참조: https://wlaxhrl.tistory.com/78
- 개발 중인 모듈, 변동성이 큰 모듈에서 빛을 발할 수 있다.
SOLID with Swift 추천 아티클
https://github.com/ochococo/OOD-Principles-In-Swift
https://marcosantadev.com/solid-principles-applied-swift/#
'개발서적 읽으며 끄적끄적 > Clean Architecture' 카테고리의 다른 글
클린 아키텍처 3-6장 (0) | 2019.11.16 |
---|---|
클린 아키텍처 1-2장 (0) | 2019.11.02 |