SOLID란 로버트 마틴이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯가지 기본 원칙을 소개한 것이다. 프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 함께 적용할 수 있다.
SOLID원칙들은 소프트웨어 작업에서 프로그래머가 소스 코드가 읽기 쉽고 확장하기 쉽게 될 때까지 소프트웨어 소스 코드를 리팩토링하여 코드 smell을 제거하기 우해 적용할 수 있는 지침이다.
출처: https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84)
위키피디아에는 유지 보수와 확장에 강조하고 있네요.
이번에 우테코를 하면서 정적(static)메서드를 가진 클래스를 많이 사용하게 됐습니다. 상태를 가지지 않으니까 좋은 것 같은데 왜 instance가 있고 static이 있을까?
정적 메서드의 단점
1. 상속: 하위 클래스는 정적 메서드를 재정의할 수 없습니다. 하위 클래스에서 정적 메서드를 호출하면 상위 클래스의 정적 메서드를 참조합니다.
2. 인스턴스 데이터 액세스 불가: 정적 메서드는 인스턴스별 데이터에 액세스하거나 수정할 수 없으므로 특정 상황에서의 유용성이 제한될 수 있습니다.
검색을 좀 해보니 static메서드만을 가진 클래스는 solid 원칙에 어긋난다고 하시는 분들이 계셨습니다.
곰곰이 생각해보면 충분히 타당한 말씀이라고 생각이 들었습니다. 제 생각엔 static메서드만을 가진 *유틸리티 클래스는 OCP원칙에 상충된다고 생각했습니다. 확장이 어려운 구조인 것입니다..
코드에 대해 더 잘 이해하고 싶은 이유때문에 solid원칙에 대해서 공부를 해야겠다고 다짐을 하게 되었는데요. 각설하고 SOLID원칙엔 무엇이 있을까요?
SOLID원칙은 개발자와 개발자간의 일종의 소통 인터페이스? 같은 것이라고 저는 생각이 들었습니다. 이 세상엔 다양한 환경과 경험을 가진 수많은 개발자가 있을텐데요. 이처럼 수많은 개발자들 사이에서 코드를 보고 생각을 공유한다는 것은 쉽지 않은 것인데요. SOLID원칙을 사용하면 더욱 자세하게 깊이 있는 대화가 가능할 것이라고 저는 생각하게 됐습니다.
코드를 보며..
상황1(solid를 모를때)
- 주니어: 시니어 선배님 저는 이 코드가 별로에요. 그 이유는 음.. 그냥요.ㅎㅎ
- 시니어: ?? 헛소리하지마 임마
상황2(solid를 알 때)
- 주니어: 선배님 저는 이 코드에서 냄새가 난다고 생각해요. 이 코드는 SOLID원칙에 SRP를 위반하고 있어서 너무 많은 책임을 갖고 있습니다. 그래서 이 코드는 두 개의 별도의 클래스로 나눠주는게 맞다고 생각합니다.
- 시니어: 음 그렇구나. 너가 한 번 나눠볼래?
극단적인 예제이지만 SOLID원칙을 아는 것만으로도 우리는 선배 개발자에게 더욱 논리적으로 설명할 수 있는 기회가 생겼다고 볼 수 있을 것 같네요. 🙆♂️
단일 책임 원칙
객체는 단 하나의 책임만 가져야 한다는 원칙을 말합니다.
여기서 말하는 책임이란 하나의 기능을 담당한다고 보면 됩니다.
메이플스토리 자쿰을 잡아보신 분들이라면 알겠지만, 자쿰의 팔은 일체형(?)이 아닙니다. 각각의 팔은 HP가 있고 그 HP를 모두 소모했을 때 사라지는 걸로 알고 있는데요. 8개의 팔은 따로 움직이며 하나의 책임(상하로 움직이는 행동)만 하고 있습니다.
하나의 클래스에 여러 팔기능(책임)을 채택하냐, 각각의 클래스로 분리하여 기능을 분산시키느냐의 설계는 프로그램의 유지보수와 밀접한 관계가 있습니다.
만약 하나의 클래스에 여러 팔기능을 채택했을 때
왼쪽 하단의 팔을 크림슨발록 왼쪽 팔이랑 바꾸려면 어떻게 될까요?
유지보수에 많은 비용을 사용한다는 것은 불보듯 뻔할 것입니다.
한개의 클래스에서 여러기능을 하고 있으면 결합도가 높아져서 팔 하나를 바꾸는 것에 성공했어도 다른 부분에서 버그가 발생할 가능성이 높을 것입니다.
한 객체에 많은 기능이 주어질수록 시스템의 복잡도와 결합도는 높아질 것입니다. 스파게티 코드가 될 가능성이 높아지는 것이죠.
그렇다면 자쿰의 각각의 팔기능으로 클래스를 나눈다면?
그 부분만 크림슨발록 왼쪽 팔로 수정하면 될 것 같습니다!
만약 버그가 발생한다고 쳐도 왼쪽 하단 클래스만 수정하면 되니까 시간적인 소모도 덜할 것 같습니다.
이처럼 SRP를 적용하는 것은 주제에 맞는 기능만을 모아서
여러 클래스로 기능을 나누고, 클래스의 의미를 좀 더 쉽게 파악이 가능한 상태 그리고 유지보수에 용이하기 위해 사용합니다.
class WOOWACafe {
constructor() {
}
// 제품을 만들기 위해 사용하는 기본적인 가이드라인 함수
makeProduct() {
//...
}
// 제빵팀에서 빵을 만드는 메서드
makeBakery() {
this.makeProduct();
}
// 바리스타팀에서 커피를 만드는 메서드
makeCoffee() {
this.makeProduct()
}
// 제과팀에서 디저트를 만드는 메서드
makeDessert() {
this.makeProduct();
}
}
woowa cafe에서 너무 많은 기능을 담당하고 있습니다. woowa cafe 객체는 제품을 만드는 가이드라인(함수)만 주어지면 됩니다. 굳이 빵을 만들고 커피를 만들고 디저트를 만드는 방법을 개입할 필요가 없습니다. 괜찮은 제품만 나오만 되는 것이지요.
class WOOWACafe {
constructor() {
}
// 제품을 만들기 위해 사용하는 기본적인 가이드라인 함수
makeProduct() {
//...
}
}
class Bakery extends WOOWACafe {
constructor() {
super();
}
// 제품을 만들기 위해 사용하는 기본적인 가이드라인 함수
makeProduct() {
//...
}
// 제빵팀에서 빵을 만드는 메서드
makeBakery() {
this.makeProduct();
}
}
class Barista extends WOOWACafe {
constructor() {
super();
}
// 제품을 만들기 위해 사용하는 기본적인 가이드라인 함수
makeProduct() {
//...
}
// 바리스타팀에서 커피를 만드는 메서드
makeCoffee() {
this.makeProduct()
}
}
class Dessert extends WOOWACafe {
constructor() {
super();
}
// 제품을 만들기 위해 사용하는 기본적인 가이드라인 함수
makeProduct() {
//...
}
// 제과팀에서 디저트를 만드는 메서드
makeDessert() {
this.makeProduct();
}
}
이처럼 코드를 수정함으로써 각 객체가 자신만의 기능을 담당하면서 유지보수에도 용이해졌습니다. 오버라이딩을 통해 팀에 맞는 제품 개발방식을 개발할 수도 있게 됐습니다.
SRP를 지키면서, 즉 객체가 하나의 기능만 갖게 분리하는 능력을 발전시키면서 프로그램의 유지보수성을 높이기 위한 설계 기법이라는 것을 배울 수 있었습니다.
당장에 코드에 적용은 힘들겠지만, 코드를 도메인 별로 나누고 그 도메인에서도 개별 기능을 나눠보는 연습이 도움이 될 것 같습니다.
개방-폐쇄 원칙
소프트웨어 요소는 확장에 열려 있으나 변경에는 닫혀 있어야한다.
- 기능 추가 요청이 오면 클래스를 확장을 통해 손쉽게 구현하면서, 확장에 따른 클래스 수정은 최소화 하도록 프로그램을 작성해야 하는 설계 기법이다.
- 즉 다형성과 확장을 가능케 하는 객체지향의 장점을 극대화하는 기본적인 설계 원칙
class Baseball {
static getResult() {
//...
}
static isStrike() {
//...
}
static isBall() {
//...
}
static isNothing() {
//...
}
}
제가 작성한 코드인데요. 딱 봐도 아시겠지만 이 코드는 정적메서드만을 갖고 있는 클래스로서 다형성과 확장이 아예 막혀 있는 것을 알 수 있겠습니다.
제 생각엔 유틸리티 클래스로 굳이 인스턴스를 갖지 않아도 될 것 같아서 이렇게 만들었는데
SOLID원칙에 대해 알아보고 정리해보니까 얼마나 딱딱하고 결합도가 높은 프로그램인지 몸소 느낄 수 있게 됐습니다..
여러분이 OCP를 지키려고 한다면 이런 딱딱한 프로그램을 만드시는 것은 지양하는게 좋겠죠?
리스코프 치환 원칙
"프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다." 계약에 의한 설계를 참고하라.
- 서브타입은 언제나 부모타입으로 교체할 수 있어야한다.
- 리스코프 치환 원칙이란, 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로 흘러가야 하는 것을 의미하는 것이다.
- 따라서 기본적으로 LSP 원칙은 부모 메서드의 오버라이딩을 조심스럽게 따져가며 해야한다.
왜냐하면 부모 클래스와 동일한 수준의 선행 조건을 기대하고 사용하는 프로그램 코드에서 예상치 못한 문제를 일으킬 수 있기 때문이다.
계층도간의 is-a 관계를 만족한다고 하더라도(새-타조, 직사각형-정사각형) 하위 타입에서 가변성을 가지면서 상위 타입에서 정의한 조건과 일치하지 않는다면 상속을 받지 말아야 합니다.
인터페이스 분리 원칙
"사용자가 필요하지 않은 것들에 의존하게 되지 않도록, 인터페이스를 작게 유지하라."
- SRP원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하는 것으로 보면 된다.
- 다만 ISP 원칙의 주의해야 할점은 한번 인터페이스를 분리하여 구성해놓고 나중에 무언가 수정사항이 생겨서 또 인터페이스들을 분리하는 행위를 가하지 말아야 한다.
// 인터페이스 정의
class Swordsmanship {
attack() {
console.log('검사의 공격!');
}
defense() {
console.log('검사의 방어!');
}
}
class Magic {
fireAttack() {
console.log('마법사의 화염 공격!');
}
iceDefense() {
console.log('마법사의 얼음 방어!');
}
}
class Archery {
rangedAttack() {
console.log('궁수의 원거리 공격!');
}
dodge() {
console.log('궁수의 회피!');
}
}
// 캐릭터 클래스 정의
class Swordsman {
constructor() {
this.skills = new Swordsmanship();
}
}
class Mage {
constructor() {
this.skills = new Magic();
}
}
class Archer {
constructor() {
this.skills = new Archery();
}
}
// 테스트
const swordsman = new Swordsman();
const mage = new Mage();
const archer = new Archer();
swordsman.skills.attack(); // 검사의 공격!
mage.skills.fireAttack(); // 마법사의 화염 공격!
archer.skills.rangedAttack();// 궁수의 원거리 공격!
의존관계 역전 원칙
프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다. 의존성 주입은 이 원칙을 따르는 방법 중 하나다.
- DIP 원칙은 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙
- 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 됩니다.
대신 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 합니다.
고수준 모듈: 어떤 의미 있는 단일 기능을 제공하는 모듈 (interface, 추상 클래스)
저수준 모듈: 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현 (메인클래스, 객체)
// 추상화(인터페이스) 정의
class NotificationService {
sendNotification(message) {
throw new Error("Subclasses must implement sendNotification method");
}
}
class EmailService extends NotificationService {
sendNotification(message) {
console.log(`Email notification: ${message}`);
}
}
class SMSService extends NotificationService {
sendNotification(message) {
console.log(`SMS notification: ${message}`);
}
}
// 주문 처리 클래스
class OrderProcessor {
constructor(notificationService) {
this.notificationService = notificationService;
}
processOrder(order) {
// 주문 처리 로직
console.log(`Processing order for ${order.customerName}`);
// 주문 처리 후 고객에게 알림
this.notificationService.sendNotification(`Your order #${order.orderNumber} has been processed.`);
}
}
// 테스트
const emailService = new EmailService();
const smsService = new SMSService();
const orderWithEmailNotification = new OrderProcessor(emailService);
const orderWithSMSNotification = new OrderProcessor(smsService);
const order1 = {
orderNumber: "12345",
customerName: "John Doe"
};
const order2 = {
orderNumber: "54321",
customerName: "Jane Smith"
};
orderWithEmailNotification.processOrder(order1);
orderWithSMSNotification.processOrder(order2);
SOLID를 공부해보면서 객체지향에 대해 조금 더 알게 됐지만, 코드에 적용하기 위해서 고생을 좀 해야 더 와닿을 것 같습니다.. 앞으로 남은 MVP, JEST 기본 문법을 재밌게 학습하러 가봐야겠습니다. 빠이!