오늘은 객체 지향 프로그래밍 설계 원칙인 S.O.L.I.D 원칙에 대해 알아보자
SOLID란?
SOLID 원칙들은 결국 클래스 내부 응집도는 높이고, 타 클래스들 간 결합도는 낮추는
High Cohesion(응집도) - Loose Coupling(결합도) 원칙을 객체 지향의 관점에서 도입한 것이다.
1) S - 단일 책임원칙 (SRP, Single Responsibility Principle)
하나의 클래스는 단 하나의 책임만 가져야한다를 정의하는 원칙이다.
위의 다이어그램을 한번 살표보자 왼쪽에 Person이라는 클래스 안에 나이, 이름, 혈액형
~ 개발언어, 커리어까지 모두 모아 놨을 때 이 Person의 모든 특징을 만족하는 사람은
환자이면서 개발자인 사람 밖에 없다.
그렇다는 것은 개발자면서 환자인 사람 밖에 저 클래스를 사용할 수 없다는 뜻이므로
재사용성이나 확정성이 굉자히 떨어진다.
그렇다면 저 특징들을 오른쪽과 같이 개발자와 환자로 나눠 놓으면 어떤 변경 사항이 있을 때
해당되는 클래스만 영향이 갈 수 있도록 영향 범위를 좁힐 수 있다.
요약
한 클래스에게 역할과 책임을 너무 많이 주지 말라는 것으로 정리할 수 있다.
2) O - 개방-폐쇄 원칙 (OCP, Open/Closed Principle)
확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
[ 확장에 열려있다. ]
새로운 변경 사항이 발생했을 때 유연하게 코드를 추가함으로써 애플리케이션의 기능을 큰 힘을 들이지 않고 확장할 수 있다.
[ 변경에 닫혀있다. ]
객체를 직접적으로 수정하는건 제한해야 한다는 것을 의미한다.
새로운 변경 사항이 발생했을 때 객체를 직접적으로 수정해야 한다면 새로운 변경사항에 대해
유연하게 대응할 수 없는 애플리케이션이라고 말한다.
이는 유지보수의 비용 증가로 이어지는 매우 안좋은 예시이다.
따라서 객체를 직접 수정하지 않고도 변경사항을 적용할 수 있도록 설계해야 한다. 그래서 변경에 닫혀있다고 표현한 것이다.
정리하면 앞의 객체 지향 프로그래밍 특징 중 추상화를 잘하라는 의미하며
다형성과 확작성을 가능케하는 객체지향의 장점을 극대화하는 설계 원칙이다.
아직 이해가 크게 되지 않는다. 한번 실제 OCP를 지키지 않은 코드와 OCP를 적용했을 때 코드 차이를 봐보자
❌ OCP를 지키지 않은 코드
class Drone {
var name: String
init(name: String) {
self.name = name
}
class DroneController {
func start(drone: Drone) {
let name = drone.name
if name == "A" {
print("A 드론 날다.")
} else if name == "B" {
print("B 드론 날다.")
} else {
print("C 드론 날다.")
}
}
}
만약 드론이 A,B,C,D,E 까지 있다고 했을 때 start의 조건문의 else if가 계속 들어나게 된다.
이것은 위에서 언급한 수정이 객체에 직접적으로 이루워지면 안된다는 조건을 위배하므로
변경에 닫혀있지 않는 것을 의미한다.
✅ OCP를 지킨 코드
protocol Flyable { func fly() }
class Adrone: Flyable {
// 프로토클을 통해 메서드 강제 구현 규칙으로 규격화만 하면 확장에 제한 없다 (opened)
func fly() {
print("A 드론 날다.")
}
}
class Bdrone: Flyable {
// 프로토클을 통해 메서드 강제 구현 규칙으로 규격화만 하면 확장에 제한 없다 (opened)
func fly() {
print("B 드론 날다.")
}
}
class DroneController {
// 기능 확장으로 인한 클래스가 추가되어도, 더이상 수정할 필요가 없어진다 (closed)
func start(drone: Flyable) {
drone.fly()
}
}
이렇게 프로토콜을 중간에 넣어 추상화를 만들면 opened와 closed의 특성을 지키는 장점이 있다.
요약
자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.
3) L - 리스코프 치환 원칙 (LSP, Liskov Substitution Principle)
상위 타입의 객체를 하위 타입의 객체로 치환해도, 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.
자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행이 보장되어야 한다는 의미이다.
즉, 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때
코드가 원래 의도대로 작동해야 한다는 의미이다.
이것을 부모 클래스와 자식 클래스 사이의 행위가 일관성이 있다고 말한다.
LSP는 한마디로 다형성을 지원하기 위한 원칙이다.
❌ LSP를 지키지 않은 코드
class Animal {
var speed = 100;
func go(distance: Int) -> Int {
return speed * distance;
}
}
class Eagle : Animal {
// 부모 클래스의 go() 메소드를 자기 멋대로 코드를 재사용 한답 시고 메소드 타입을 바꾸고 매개변수 갯수도 바꿔 버렸다.
// 어찌보면 부모 클래스의 행동 규약을 어긴 셈이다. 그리고 애초에 이 코드는 다형성 코드가 동작 자체가 되지 않는다.
func go(_ distance: Int,_ flying : Bool) -> String {
if flying {
return "\(distance) 만큼 날아서 갔습니다."
}
else {
return "\(distance) 만큼 걸어서 갔습니다."
}
}
}
var eagle: Animal = Eagle();
eagle.go(10, true) // 사용 불가 ❌: go 함수를 자기 멋대로 바꿈
위 코드와 같이 재대로 오버라이드를 하지 않거나 , 부모 클래스이 의도와 다른식으로 재정의를 하면
LSP원칙이 위배되어 잘못된 동작을 유발할 수 있다.
LSP는 상속과 굉장히 가까운 관계가 있는데 추가로 잘못된 상속 관계와 올바른 상송 관계를 알아보자
❌ 잘못된 상속 관계 : 아버지와 아들 // 아들은 아버지의 한 종류이다??
✅ 올바른 상속 관계 : 포유류와 고래 // 고래는 포유류의 한 종류이다!!
class 고래: Swimable{
// 고래는 수영 할 수 있다.
}
아버지클래스타입 홍길동 = 아들(); // ❌ LSP(리스코프 치환 원칙) 위배.
포유류클래스타입 도커 = 고래(); // ✅ LSP(리스코프 치환 원칙) 준수.
아들이 태어나 홍길동이라는 이름을 짓고 아버지의 행위를 한다??? 뭔가 어색하다..
고래 한마리가 태어나 도커라는 이름을 짓고 포유류의 행위를 한다. 깔끔하지 않은가?
요약
하위클래스가 상위클래스 역할을 대신할 때 논리적으로 맞아 떨어져야 한다.
4) I - 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)
범용 인터페이스 하나보다 클라이언트를 위한 여러 개의 인터페이스로 구성하는 것이 좋다.
인터페이스 분리 원칙은 마치 단일 책임 원칙과 비슷하게 보이는데, SRP 원칙이 클래스의 단일 책임을
강조한다면, ISP는 인터페이스의 단일 책임을 강조한다고 말할 수 있다.
다만 유의할 점은 인터페이스는 클래스와 다르게 추상화이기 때문에 여러개의 역할을 가지는데 있어 제약이 없긴 하다.
요약
상황과 관련 있는 메서드만 제공해라.
5) D - 의존관계 역전 원칙 (DIP), Dependency Inversion Principle)
추상화에 의존해야지 구체화에 의존하면 안된다.
여기서 추상화는 부모클래스 또는 프로토콜이 될 수 있다.
더 자세히 설명하면 클라이언트(사용자)가 상속 관계로 이루어진 모듈을 가져다 사용할때,
하위 모듈을 직접 인스턴스를 가져다 쓰지 말라는 뜻이다.
하위 모듈은 마치 자동차의 타이어와 같다. 굉장히 자주 바뀔 수 있는 가능성이 있는 코드이다.
그렇기 때문에 하위 모듈의 구체적인 내용에 클라이언트가 의존하게 되어 하위 모듈에 변화가
있을 때마다 클라이언트나 상위 모듈의 코드를 자주 수정해야 되기 때문이다.
따라서 한마디로 추상화 타입의 객체로 통신하라는 원칙이다.
❌ DIP를 지키지 않은 코드
여러 무기 정의
class OneHandSword {
let name : String
let damage: Int
init(name: String, damage: Int) {
self.name = name
self.damage = damage
}
func attack() -> Int {
return damage
}
}
class TwoHandSword { ... }
캐릭터 정의
class Character {
let name: String
var health: Int ;
var weapon: OneHandSword; // 의존 저수준 객체, DIP 위배 ❌
init(name: String, health: Int, weapon: OneHandSword) {
self.name = name
self.health = health
self.weapon = weapon
}
func attack() -> Int {
return weapon.attack(); // 의존 객체에서 메서드를 실행
}
func changeWeapone(weapone: OneHandSword) {
self.weapon = weapone
}
}
무기엔 한손검 타입만 있는 게 아니다.
위에서 살펴봤듯이 한손검, 양손검 .. 여러 무기들을 장착하게 하려면, 아예 캐릭터
클래스의 클래스 필드 변수 타입을 교체해줘야 한다.
하지만 만약 위 코드가 의존성 역전 원칙을 잘 지켰다면 고민할 필요가 없는 문제다.
위 코드의 문제는 이미 완전하게 구현된 하위 모듈을 의존하고 있다는 점이다.
즉, 구체 모듈을 의존하는 것이 아닌 추상적인 고수준 모듈을 의존하도록 리팩토링 하면 된다.
✅ DIP를 지킨 코드
protocol Weaponable {
var name: String { get }
var damage: Int { get }
func attack() -> Int
}
class OneHandSword : Weaponable { ... }
class TwoHandSword :Weaponable { ... }
Weaponable 프로토콜을 만들어 각 무기들의 구체 타입을 추상화 시켜준다.
class Character {
let name: String
var health: Int
var weapon: Weaponable // 의존 고수준 객체(추상화), DIP 지킴 ✅
init(name: String, health: Int, weapon: Weaponable) {
self.name = name
self.health = health
self.weapon = weapon
}
func attack() -> Int {
return weapon.attack(); // 의존 객체에서 메서드를 실행
}
func changeWeapone(weapone: OneHandSword) {
self.weapon = weapone
}
}
고수준 모듈 , 즉 추상화 된 프로토콜 타입을 이용하므로써 캐릭터 클래스를 무기별로 만들지않고도
자연스럽게 무기를 교체할 수 있다.
요약
자신보다 변하기 쉬운 것에 의존하지 마라.
참고
'CS > 객체지향' 카테고리의 다른 글
객체 지향 프로그래밍 (1) [ 개념, 특징 ] (0) | 2024.08.27 |
---|