[JS] 좋은 객체 지향 설계를 위해서(/w SOLID)

황준승·2022년 10월 31일
3
post-thumbnail

이전 블로그 내용에서 객체지향 프로그래밍에 무엇인지 이해하였다. 아직 못 보신 분들이 계시다면 객체지향 프로그래밍에 대해 글을 한 번 보세요 ㅎㅎ

목표 : 대규모 소프트웨어 개발을 위해 유연하고 변경이 쉽게 객체를 구현하기 위해 어떤 방법으로 객체를 설계하는지 알아보자.

📌 객체 지향 설계를 위한 5원칙 - SOLID

컴퓨터 프로그래밍에서 로버트 마틴이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 소개한 것입니다. 프로그래머가 시간이 지나도 유지 보수확장이 쉬운 시스템을 만들고자 할 때 이 원칙을 함께 적용할 수 있다.
...
SOLID 원칙들은 소프트웨어 작업에서 프로그래머가 소스코드가 읽기 쉽고 확장하기 쉽게 될 때까지 소프트웨어 소스 코드를 리팩터링하여 코드 냄새를 제거하기 위해 적용할 수 있는 지침이다. ...위키피디아

객체 지향 설계를 위한 5원칙인 SOLID에 대해 알아보자

  • S : SRP(Single Responsibility Principle) - 단일 책임 원칙
  • O : OCP(Open Closed Principle) - 개방 폐쇄 원칙
  • L : LSP(Listov Substitution Principle) - 리스코프 치환 원칙
  • I : ISP(Interface Segregatio Principle) - 인터페이스 분리 원칙
  • D : DIP(Dependency Inversion Principle) - 의존 역전 원칙

📌 SRP(단일 책임 원칙)

어떤 클래스를 변경해야 한느 이유는 오직 하나뿐이어야 한다. - 로버트 C.마틴

위의 그림에서 남자 객체 하나에 엄청나게 많은 역할과 책임이 부여되어있다. 객체지향 원리에서는 각 역할에 맞게 객체의 행동과 역할(책임)을 분리해야한다.

SRP를 잘 지키지 못한 코드 예시

class Dog {
  this._gender = "female" // "female or male"

  pee() {
    if (this._gender === "male") {
      // 한쪽 다리를 들고 소변을 보다.
    } else {
      // 뒷 다리 두 개를 굽혀 앉은 자세로 소변을 본다. 
    }
  }
}
  • 위의 강아지 클래스 코드는 소변 보다 메서드에서 남자와 여자를 모두 구현하려고 해서 단일 책임 원칙(SRP)를 위반하고 있는 것을 볼 수 있다.

SRP를 잘 지킨 코드 예시

class Dog {
  pee() {
    // Overiding
  }
}

class MaleDog extends Dog{
  constructor() {
  	super();
  }
  
  pee() {
    // 한쪽 다리를 들고 소변을 보다.
  }
}

class FemaleDog extends Dog{
  constructor() {
  	super();
  }
  
  pee() {
    // 뒷 다리 두 개를 굽혀 앉은 자세로 소변을 본다. 
  }
}
  • 그래서 위와 같이 강아지라는 추상 클래스를 두고 수컷강아지, 암컷강아지 클래스가 각자 자신의 특징에 맞게 pee() 메서드를 구현해서 리팩토링 할 수 있습니다.

📌 OCP(개방 폐쇄 원칙)

소프트웨어 엔티티(클래스, 모듈, 함수 등)은 확장에 대해서는 열려있어야 하지만 변경에 대해서는 닫혀 있어야 한다. - 로버트 C.마틴

즉, 자신의 확장에는 열려 있고 주변의 변화에 대해서는 닫혀있어야 한다. 객체의 확장은 개방적으로, 객체의 수정은 패쇄적으로...

OCP가 적용이 되지 않은 코드

class DecimalToBinary {
  dec2bin(number) {
  	return parseInt(number, 10).toString(2);
  }
}
  • 십진수에서 이진수로 변환하는 기능의 클래스이다. 만약 2진수를 10진수로 혹은 10진수를 16진수로 변환하는 기능을 추가하기 위해서(객체의 확장이 개방적이지 않다)는 DeciamlToBinary 클래스를 수정해야 하고 있는 OCP(개방 패쇄) 원칙에 위배됩니다.

OCP가 적용된 코드

class NumberConverter {
  convertBase(number, fromBase, toBase) {
    return parseInt(number, fromBase).toString(toBase);
  }
}

class DecimalToBinary extends NumberConverter {
  dec2bin(number) {
    return this.convertBase(number, 10, 2);
  }
}

class BinaryToDecimal extends NumberConverter {
  bin2dec(number) {
    return this.convertBase(number, 2, 10);
  }
}
  • 위의 코드에서 상위 클래스에서 선언된 함수와 관련된 함수를 하위 클래스에서 작성하여 객체의 확장을 용이하게 만들었다. 뿐만 아니라 NumberConverter의 별도의 수정이 필요없어 OCP원칙이 적용되었다고 볼 수 있다.

📌 LSP(리스코프 치환 원칙)

서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있다. - 로버트 C.마틴

  • 하위클래스 is a kind of 상위 클래스 (하위 분류는 상위 분류의 한 종류이다.)
  • 구현클래스 is able to 인터페이스 (구현 분류는 인터페이스 할 수 있어야 한다.)

📌 ISP(인터페이스 분리 원칙)

클라이언트는 자신이 사용하지 않는 메소드에 의존 관계를 맞으면 안된다. - 로버트 C.마틴

즉, "사용하지 않는 인터페이스의 메서드는 작성하지도 말라"입니다.

ISP가 위반된 코드 예시

// 인터페이스 클래스
class Phone {
    call(number) {}

    takePhoto() {}

}

class IPhone extends Phone {
    call(number) {}

    takePhoto() {}
}

class Lollipop extends Phone {
    call(number) {}

    // 인터페이스 클래스에서 선언한 takePhoto함수 어디감??
}

javascript에서는 별도의 Interface를 제공하지 않아 이러한 개발자의 실수를 잡아낼 수 없습니다. 하지만 Interface객체에 다음과 같은 코드를 작성한다면 overiding 에러를 잡아내고 ISP(인터페이스 분리 원칙)을 지킬 수 있습니다.

// 인터페이스 클래스
class Phone {
    call(number) {
      throw new Error("Overiding Error");
    }

    takePhoto() {
      throw new Error("Overiding Error");
    }

}

// 하위 클래스에서 해당 메서드를 선언하지 않을 시 오버라이딩 에러가 발생합니다. 

📌 DIP(의존 역전 원칙)

고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다. 추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야한다. 자주 변경되는 클래스에 의존하지 마라. - 로버트 C.마틴

글로는 정확히 이해가 되지 않으니 예를 들어서 설명을 하도록 하겠습니다.

DIP를 잘 지키지 못한 예시

class Dog {
  speak() {
    console.log("Bark");
  }
}

class Cat {
  speak() {
    console.log("Meow");
  }
}

class Zoo {
  constructor() {
    this.cat = new Cat();
    this.dog = new Dog();
  }
  
  speakAll() {
    this.cat.speak();
    this.dog.speak();
  }
}

위 코드는 Dog, Cat 클래스가 있고 Zoo는 Cat과 Dog를 가지고 있다. High level 클래스인 Zoo가 Low level 클래스인 Cat, Dog를 가지며 의존성을 띈다.

dependency를 그림으로 나타내면 다음과 같다.

이러한 의존성을 없애기 위해 나온 것이 DI(Dependency Inversion)으로 가운데 추상화 클래스를 두고 High Level 클래스인 Zoo와 Low level 클래스인 Dog, Cat를 둘다 의존하게 하는 것이다.

// Interface
class Animal {
  speak() {
    // ...
  }
}

class Dog extends Animal { 
  speak() {
    console.log("Bark");
  }
}

class Zoo {
  constructor() {
    this.animals = [];
  }
  
  addAnimal(animal) {
    this.animals.push(animal);
  }
  
  speakAll() {
    for (const animal of this.animals) {
      animal.speak();
    }
  }
}

class Cat extends Animal {
  speak() {
    console.log("Meow");
  }
}


// 실행
const zoo = new Zoo();
const cat = new Cat();

zoo.addAnimal(cat);

이처럼 DIP(의존 역전 원칙)을 지킬 경우 만약 Cat이라는 새로운 객체가 생성이 되어도 Zoo에 대한 수정이 하나도 없다. 뿐만 아니라 High Level 클래스인 Zoo에서 Low level class인 Animal 클래스에 대한 직접적인 의존을 하지 않았다.

즉, Animal이라는 추상화 클래스를 만들고 그곳에 Cat, Dog를 의존토록 한 뒤 구현하였다.

low level class의 구현은 계속 해나가더라도 high level class는 독립적이며, 추가적인 수정이 필요하지 않다.

참고 자료
DIP(의존관계 역전 원칙에 대해서) in python
DIP(의존관계 역전 원칙에 대해서) in typescript
OCP(개방 패쇄 원칙에 대해서) in javascript
SOLID에 대해서

profile
다른 사람들이 이해하기 쉽게 기록하고 공유하자!!

0개의 댓글