-
안녕하세요.
오늘은 SOLID 원칙에 대해서 알아보도록 하겠습니다.
https://en.wikipedia.org/wiki/SOLID
SOLID 원칙은 프로그래머가 소프트웨어를 유지보수와 확장이 쉬운 유연한 구조로 만들기 위한 5가지 원칙에 대해 앞문자를 따서 명칭한 것입니다.
SOLID 원칙은
- Single Responsibility Principle (단일 책임 원칙)
- Open/Closed Principle (개방/폐쇄 원칙)
- Liskov Substitution Principle (리스코프 치환 원칙)
- Interface Segregation Principle (인터페이스 분리 원칙)
- Dependency Inversion Principle (의존관계 역전 원칙)
으로 이루어져 있습니다.
이렇게 나열해서 보면 어려운 내용처럼 느껴지실 수 있는데요.
좋은 코드를 작성하기 위해 고민을 하신 분들이라면
아마 이러한 원칙을 몰라도 무의식 중에 위의 원칙들을 지키면서 코드를 작성하셨을 겁니다.
하나씩 알아보도록 하겠습니다.
1. 단일 책임 원칙(SRP)
SRP는 소프트웨어 요소(클래스, 모듈, 함수 등)는 하나의 책임을 갖어야 한다는 것을 의미합니다.
책임이라는 단어가 낯설게 느껴지시는 분들이 있을거라 생각됩니다.
책임은 객체에 의해 정의되는 응집도 있는 행위의 집합인데요.
응집도는 간단하게 요소들간의 서로 연관된 정도라고 생각하시면 될 것 같습니다.
어떤 객체가 어떤 요청에 대해 대답해 줄 수 있거나, 적절한 행동을 할 의무가 있는 경우 해당 객체가 책임을 가진다고 말합니다.이러한 책임의 개수를 줄이는 것을 단일 책임 원칙이라고 합니다.
간단한 예시를 보면서 살펴보겠습니다.
class Cafe { func order() {} func cash() {} func make() {} }
Cafe라는 Class를 통해 주문을 하고 결제를 하고 커피를 만들 수 있습니다.
하지만 현실세계의 카페를 생각해봅시다.
커피를 주문하는 과정에는 주문을 하는 손님, 계산를 하는 캐셔, 커피를 만드는 바리스타 적어도 3명의 역할이 필요합니다.
Cafe는 3개의 책임을 가지고 있으므로 SRP를 위반했다고 볼 수 있습니다.
enum Menu: String { case 아메리카노 = "아메리카노" } class Customer { func order(menuName: String) -> Menu { return Menu(rawValue: menuName) } } class Casher { var balance: Int = 0 let menuPrice = [Menu.아메리카노 : 10000] func cash(menu: Menu) -> Menu { self.balance += menuPrice[menu] return menu } } class Barista { func make(menu: Menu) { if menu == Menu.아메리카노 { self.americano() } } func americano() { // 아메리카노 제작 } } // 설명을 위해 작성된 코드입니다.
위 코드처럼 3개의 책임을 가지고 있던 Cafe를 Customer, Casher, Barista 3개의 Class로 분리할 수 있습니다.
위와 같은 방식으로 코드를 작성했을 경우 어떤 메리트가 있는지 살펴보겠습니다.
카페에서 손님이 꼭 메뉴의 이름으로 주문하지 않는 경우가 존재할 수 있습니다.
기프티콘으로 결제를 하는 손님이 존재한다고 생각해봅시다.
class newCustomer { func order(giftCard: GiftCard) -> Menu { return Menu(rawValue: giftCard.menuName) } }
주문 방식이 바뀐 경우에 Customer Class만 변경을 하면 되고 Casher와 Barista는 변경할 필요가 없습니다.
이렇듯 SRP를 준수한 코드는 다른 코드에 영향을 주지 않게 되어 유지보수에 용이합니다.
본인의 코드가 SRP를 준수했는지 헷갈릴 수 있는데요.
객체를 변경하는 이유가 하나인지 확인하면 알 수 있습니다.
Cafe Class의 경우처럼 주문 방식, 결제 방식, 커피 제조 방식등 다양한 이유로 변경이 필요한 경우 SRP를 위반한 것입니다.
2. 개방 / 폐쇄 원칙(OCP)
OCP는 객체를 다룸에 있어서 확장에는 열려있어야 하고 변경에는 닫혀있어야 한다는 것을 의미하는 원칙입니다.
앞서 작성했던 카페 예시를 다시 살펴보겠습니다.
현재 주문할 수 있는 커피는 아메리카노 뿐입니다.
그러나 모든 손님이 아메리카노만을 원하지는 않을 것입니다.
OCP에 위배되는 방식으로 코드를 살펴보겠습니다.
import UIKit enum Menu: String { case 아메리카노 = "아메리카노" case 라떼 = "카페라떼" case 카라멜마끼야또 = "카라멜마끼야또" } class Casher { var balance: Int = 0 let menuPrice = ["아메리카노" : 10000, "라떼" : 5000, "카라멜마끼야토" : 7500] func cash(menu: Menu) -> Menu { self.balance += menuPrice[menu.rawValue] return menu } } class Barista { func make(menu: Menu) -> Coffee { if menu == .아메리카노 { return self.americano() } else if menu == .라떼 { return self.latte() } else if menu == .카라멜마끼야또 { return self.caramelMacchiato() } } func americano() { // 아메리카노 제작 } func latte() { //라떼 제작 } func caramelMacchiato() { //카라멜 마끼야토 제작 } } // 설명을 위해 작성된 코드입니다.
메뉴를 추가했을 뿐인데 Menu 타입과 Casher의 menuPrice 속성, Barista의 make함수와 각각의 커피 제작 함수를 구현해야하는 것을 볼 수 있습니다.
하나의 메뉴를 추가할 때마다 이와 같이 굉장히 많은 곳을 수정해야 합니다.
이와 같은 문제점은 다형성을 통해서 해결할 수 있습니다.
protocol Priceable { var price: Int { get } } protocol Makeable { func make() } typealias Coffee = Priceable & Makeable
가격 정보를 포함하고 있는 Priceable 프로토콜과 제작할 수 있는 방법을 알고 있는 Makeable 프로토콜을 구현하고
두 개의 프로토콜을 모두 준수하는 타입을 Coffee라고 정의합니다.
class Casher { var balance: Int = 0 func cash(coffee: Coffee) -> Coffee { self.balance += coffee.Price return coffee } } class Barista { func make(coffee: Coffee) { coffee.make() } }
이러한 방식으로 코드를 작성하면 메뉴가 추가되더라도 Casher와 Barista Class를 수정할 필요가 없습니다.
class Americano: Coffee { var price: Int = 10000 func make() { //아메리카노 제작법 } } class Latte: Coffee { var price: Int = 7500 func make() { // 라떼 제작법 } } class CaramelMacchiato: Coffee { var price: Int = 5000 func make() { // 카라멜 마끼야또 제작법 } }
단지 커피가 추가될 때마다 Coffee 프로토콜을 준수하는 타입을 추가해 코드를 확장시켜주면 되는 것입니다.
코드를 작성할 때 기존 코드에 대한 변경이 없고 확장을 통해서만 추가가 될 경우 OCP를 준수했다고 말할 수 있습니다.
코드에 switch문이나 if-else와 같은 분기 처리가 반복될 경우 OCP를 위반하였을 가능성이 높습니다.
3. 리스코프 치환 원칙(LSP)
LSP는 앞선 두 원칙과는 다르게 이름만으로는 확 와닿지 않습니다.
LSP의 정의는 이렇습니다.
상위 타입의 객체를 하위 타입의 객체로 치환하여도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.
그렇지만 정의만으로는 완벽하게 이해가 되지는 않는데요.
LSP를 설명할 때 매우 좋은 예시인 직사각형-정사각형 예시를 통해서 알아보도록 하겠습니다.
먼저 직사각형 클래스를 만들어보겠습니다.
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 } }
우리는 정사각형이 직사각형이라는 사실을 알고 있습니다.
따라서 Rectangle 클래스를 상속받는 Squre 클래스를 만들 수 있겠죠.
class Square: Rectangle { override var width: Int { didSet { super.height = width } } override var height: Int { didSet { super.width = height } } }
그리고 우리는 직사각형의 넓이가 20인 경우에만 동작하는 프로그램을 만들었다고 생각해봅시다.
let rectangle: Rectangle = Rectangle(width: 10, height: 10) rectangle.width = 5 rectangle.height = 4 if rectangle.area() == 20 { print("work") } else { fatalError("직사각형의 넓이는 \(rectangle.area())입니다.") }
너비가 5, 높이가 4인 Rectangle 인스턴스는 문제 없이 동작하는 것을 확인 할 수 있죠.
let square = Square(width: 10, height: 10) let rectangle: Rectangle = square rectangle.width = 5 rectangle.height = 4 if rectangle.area() == 20 { print("work") } else { fatalError("직사각형의 넓이는 \(rectangle.area())입니다.") }
그러나 rectangle이 Square 인스턴스를 가리키게 하면 에러가 발생하는 것을 확인 할 수 있습니다.
이렇듯 상위 타입의 객체를 하위 타입의 객체로 치환하였을 경우 프로그램이 정상적으로 동작하지 않는 경우 LSP를 위반했다고 합니다.
개념적으로는 정사각형이 너비와 높이가 같은 직사각형이기 때문에 직사각형을 상속받는 것이 괜찮아 보이나
프로그래밍 상에서는 그렇지 않을 수도 있습니다.
따라서 하위 타입이 상위 타입에서 정한 명세를 지킬 수 있는 경우 상속을 사용해야 합니다.
직사각형-정사각형의 경우 직사각형의 높이가 변경 될 경우 높이만 변경되어야하는 명세를
정사각형이 지키지 못하기 때문에 상속이 적합하지 않습니다.
LSP를 위반한 경우 OCP를 위반할 가능성도 높아지는데요.
프로그램이 동작하기 위해서 타입 판단과 같은 분기 처리를 해야하기 때문입니다.
상속보다는 프로토콜을 통한 추상화를 사용한다면 LSP를 위반할 가능성이 낮아진다고 생각합니다.
4. 인터페이스 분리 원칙(ISP)
ISP는 클라이언트가 자신이 사용하지 않는 메소드에 의존적이면 안된다는 원칙입니다.
앞선 원칙들을 이해했다면 이 원칙은 간단합니다.
마찬가지로 카페 예시를 보면서 확인하겠습니다.
카페에서는 꼭 커피만 팔지 않습니다.
케이크, 쿠키 심지어 텀블러와 같은 상품들도 판매를 합니다.
커피와 텀블러는 가격이 존재한다는 공통점이 있지만 바리스타가 직접 만들지 않아도 된다는 차이점이 존재합니다.
protocol Priceable { var price: Int { get } } protocol Makeable { func make() } typealias Coffee = Priceable & Makeable
앞선 OCP를 설명할 때 굳이 Coffee라는 타입을 Priceable과 Makeable로 분리했었는데요.
만약 이렇게 분리해서 작성하지 않고
protocol CafeMenu { var price: Int { get } func make() }
두 개의 프로토콜을 합쳐서 만들고 텀블러 메뉴를 추가한다면
class Thumbler: CafeMenu { var price: Int func make() { // do nothing } }
이렇게 사용하지도 않는 make()함수 또한 구현해야 하는 상황이 발생합니다.
따라서 프로토콜을 분리해 필요한 메서드와 프로퍼티만 구현하도록 해야합니다.
class Thumbler: Priceable { var price: Int = 15000 }
이러한 방식으로 구현을 할 시 SRP와 마찬가지로 유지보수에 좋은 구조가 만들어지겠죠??
5. 의존관계 역전 원칙(DIP)
DIP는 상위 레벨 모듈은 하위 레벨 모듈에 의존하면 안된다는 원칙입니다.
예시를 들어볼게요.
iOS에서 네트워크 통신을 한다고 생각해봅시다.
빠른 구현을 위해서 Alamofire와 같은 외부 라이브러리를 사용하기로 결정했습니다.
class ViewController: UIViewController { @IBAction func buttonTouched(_ sender: Any) { let url = "주소" AF.request(url, method: .get, encoding: URLEncoding.default) .validate(statusCode: 200..<300) .responseDecodable(of: Something.self){ response in switch response.result { case .success(let data): print(data) case .failure(let error): print(error.localizedDescription) } } } //... } // 설명을 위해 작성된 코드입니다.
위와 같이 ViewController에서 Alamofire를 직접 의존해서 구현할 수가 있겠죠?
버튼을 눌렀을 경우 Alamofire를 통해 네트워크 요청을 할 것입니다.
그런데 어느날 Alamofire에서 문제가 발생해서 네트워크 모듈을 URLSession으로 바꿔야 하는 상황이 발생했는데요.
그럴 경우 위의 코드를 수정할 필요가 생깁니다.
@IBAction func buttonTouched(_ sender: Any) { let url = URL(string: "주소") guard let url = url else { return } URLSession.shared.dataTask(with: url) { data, response, error in guard error == nil else { print(error?.localizedDescription) return } if let data = data, let response = response as? HTTPURLResponse { if 200..<300 ~= response.statusCode { let data = try? JSONDecoder().decode(Something.self, from: data) print(data) } } }.resume() } // 설명을 위해 작성된 코드입니다.
심지어 Alamofire를 여러 곳에서 사용했다면??
프로그램이 커질수록 수정해야하는 코드가 걷잡을 수 없이 늘어나겠죠.
지금 이러한 상황을 상위 모듈이 하위 모듈에 의존적이라고 말할 수 있습니다.
이러한 문제는 추상화를 통해 해결할 수 있습니다.
protocol Network { func request() }
이러한 프로토콜을 구현하고
class ViewController: UIViewController { private var network: Network? @IBAction func buttonTouched(_ sender: Any) { network?.request() } convenience init(network: Network) { self.init(nibName: nil, bundle: nil) self.network = network } // .. }
ViewController가 추상타입인 Network를 프로퍼티로 가지고 있게 합니다.
그리고 button을 눌렀을 때에는 network의 requset() 함수를 호출합니다.
그리고 생성자를 통해 ViewController가 외부로부터 필요한 객체를 주입받습니다.(의존성 주입)
class AlamofireNetwork: Network { func request() { let url = "주소" AF.request(url, method: .get, encoding: URLEncoding.default) .validate(statusCode: 200..<300) .responseDecodable(of: Something.self){ response in switch response.result { case .success(let data): print(data) case .failure(let error): print(error.localizedDescription) } } } } class URLSessionNetwork: Network { func request() { let url = URL(string: "주소") guard let url = url else { return } URLSession.shared.dataTask(with: url) { data, response, error in guard error == nil else { print(error?.localizedDescription) return } if let data = data, let response = response as? HTTPURLResponse { if 200..<300 ~= response.statusCode { let data = try? JSONDecoder().decode(Something.self, from: data) print(data) } } }.resume() } }
그리고 하위 타입인 AlamofireNetwork나 URLSessionNetwork가 Network 프로토콜을 준수하도록 합니다.
하위 타입들이 상위 타입인 Network에 의존하게 되었으므로 의존성이 역전되었다고 볼 수 있습니다.
이렇게 DIP를 준수한 코드를 작성하게 되면 상위레벨의 코드를 수정할 필요가 없게 되었기 때문에 OCP 원칙을 준수한다고 말할 수 있습니다.
이렇게 SOLID 원칙에 대해서 알아보았는데요.
꼭 SOLID 원칙을 지켜서 코드를 짜야해!
이렇게 작성하지 않은 코드는 잘못된 코드야!
라고 생각하기 보다는 위의 원칙을 지켜서 코드를 짜니 유지 보수에 좋은 구조가 나오더라라는
선배 개발자 분들의 팁(?) 정도로 보시면 될 것 같습니다.
'Swift' 카테고리의 다른 글
Model-View-Controller(MVC) (0) 2022.04.03 Property Wrapper (0) 2022.03.25 Codable (0) 2021.12.28 NSCoder (0) 2021.12.27 escaping closure(탈출 클로저) (0) 2021.12.23