티스토리 뷰

객체지향 프로그래밍 설계 5원칙 SOLID란?

SOLID

  - 객체지향 프로그래밍 및 설계의 다섯가지 기본 원칙의 맨 앞단어를 하나씩 가져와 만든것 입니다.

  - 이 원칙을 따르면 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 구축할 수 있습니다.

  - 소프트웨어 작업에서 프로그래머가 소스 코드가 읽기 쉽고 확장하기 쉽게 될 때까지

    소프트웨어 소스 코드를 리팩터링하여 코드 냄새를 제거하기 위해 적용할 수 있는 지침입니다.

 

SOLID종류

S | SRP | Single Responsibility Principle | 단일 책임 원칙

 ■ 한 클래스는 하나의 책임을 가져야 한다, 즉 클래스나 모듈을 변경할 이유는 단 하나 뿐이여야 한다.

 ■ 소프트웨어의 유지보수성과 확장성을 향상시키는 데 도움이 된다.

 ■ 각 클래스나 모듈이 하나의 명확한 책임을 갖게 되면,

    그 책임에 대한 변경이 다른 부분에 영향을 미치지 않도록 보장할 수 있다.

 ■ 코드의 응집성을 높이고 결합도를 낮추어 코드를 이해하고 변경하기 쉽게 만든다.

더보기

단일 책임원칙을 위반하는 예시

주문 클래스가 주문 생성과 동시에 데이터베이스에 저장되는 책임까지 가지고 있는 경우

class Order {
  constructor(orderId, customerName, totalAmount) {
    this.orderId = orderId;
    this.customerName = customerName;
    this.totalAmount = totalAmount;
    this.saveToDatabase(); // 데이터베이스에 저장하는 책임을 갖고 있음
  }
  
  getOrderDetails() {
    return `Order ID: ${this.orderId}, Customer: ${this.customerName}, Total Amount: ${this.totalAmount}`;
  }

  saveToDatabase() {
    // 데이터베이스에 주문 정보를 저장하는 로직
  }
}

위 클래스는 주문정보를 표현하는 것만이 아닌 주문 정보를 저장하는 책임도 가지고 있습니다.

만약 데이터베이스에 저장하는 방식이 변경된다면 주문 클래스 또한 수정되어야 합니다.

즉, 주문 클래스가 다른 책임에 대해서도 변경될 가능성을 높이며, 코드의 유지보수성을 떨어뜨릴 수 있습니다.

 

단일 책임 원칙을 준수하는 예시

주문을 받고 처리하는 기능

class Order { // 주문을 나타내며, 주문에 대한 정보를 포함
  constructor(orderId, customerName, totalAmount) {
    this.orderId = orderId;
    this.customerName = customerName;
    this.totalAmount = totalAmount;
  }

  getOrderDetails() {
    return `Order ID: ${this.orderId}, Customer: ${this.customerName}, Total Amount: ${this.totalAmount}`;
  }
}

class OrderManager { // 주문을 관리하고 처리하는 책임을 담당
  constructor() {
    this.orders = [];
  }

  addOrder(order) {
    this.orders.push(order);
  }

  findOrderById(orderId) {
    return this.orders.find(order => order.orderId === orderId);
  }
}

위 처럼 클래스는 자신만의 책임을 가지고, 주문 관리 클래스는 주문과 관련된 모든 기능을 담당합니다.

주문 클래스는 주문에 대한 정보를 유지하고 반환하는 것에만 집중하며

각 부분이 명확하게 책임을 갖게되어 유지보수가 쉬워집니다.

 

O | OCP | Open - Closed Principle | 개방-폐쇄원칙

  ■ 소프트웨어 개체(클래스, 모듈 함수등)는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

      - 소프트 웨어 개체의 행위는 확장될 수 있어야 하지만, 개체를 변경해서는 안된다.

      - 기존 코드에 영향을 주지 않고 소프트웨어에 새로운 기능이나 구성 요소를 추가할 수 있어야 한다. 

  ■ 소프트웨어의 유연성을 높이고 유지보수성을 개선하는 데 도움이 된다.

  ■ 새로운 기능이나 요구 사항이 추가될 때 기존의 코드를 변경하지 않고도 이러한 변경 사항을 수용할 수 있도록 한다

 

적용 방법 

  • 추상화개념을 적용, 즉 코드를 인터페이스, 추상 클래스 등으로 추상화하여 구현 세부사항에 의존하지 않도록한다.

  • 다형성을 통해 새로운 기능을 추가할 때 기존 코드를 변경하지 않고도 새로운 클래스나 메서드를 추가한다.

  • 디자인 패턴, 즉  전략 패턴, 템플릿 메서드 패턴 등은 OCP를 지원하는 패턴으로 적용시킬수 있다.

더보기

개방 폐쇄 원칙을 위반하는 예시

동물 소리 출력

class Animal { // 동물을 나타내는 클래스
  constructor(name) {
    this.name = name;
  }

  makeSound() { // 동물에 따른 소리 출력
    switch (this.name) {
      case 'dog':
        console.log('Woof!');
        break;
      case 'cat':
        console.log('Meow!');
        break;
      // 이곳에 새로운 동물의 소리를 추가할 수 있음
      default:
        console.log('Unknown animal');
        break;
    }
  }
}

const dog = new Animal('dog');
dog.makeSound(); // 출력: Woof!
const cat = new Animal('cat');
cat.makeSound(); // 출력: Meow!

위 코드는 작동은 하지만 동물이 추가 될때마다 Animal클래스를 수정해야 합니다.

사람이 적을 수 있고 판단할수 있는 정도면 어떻게든 할수 있지만 10000마리 1000000마리의 동물이 추가 된다면  Animal 클래스의 코드를 변경해야 합니다.

확장에는 열려 있으나 변경에는 닫혀 있어야 한다는 원칙이 위반, 즉 클래스의 변경이 닫혀 있지 않습니다.

 

개방 폐쇄 원칙을 준수하는 예시

// 추상 동물 클래스
class Animal {
  constructor(name) {
    this.name = name;
  }

  // 추상 메서드로 선언하여 하위 클래스에서 재정의하도록 함
  makeSound() {
    throw new Error('This method must be overridden');
  }
}

// 개 클래스
class Dog extends Animal {
  constructor() {
    super('dog');
  }

  makeSound() {
    console.log('Woof!');
  }
}

// 고양이 클래스
class Cat extends Animal {
  constructor() {
    super('cat');
  }

  makeSound() {
    console.log('Meow!');
  }
}

// 새로운 동물 클래스
class Cow extends Animal {
  constructor() {
    super('cow');
  }

  makeSound() {
    console.log('Moo!');
  }
}

// 동물 객체 생성 및 소리 출력
const dog = new Dog();
dog.makeSound(); // 출력: Woof!

const cat = new Cat();
cat.makeSound(); // 출력: Meow!

const cow = new Cow();
cow.makeSound(); // 출력: Moo!

이렇게 다형성과 추상화를 활용하여 새로운 동물이 추가될 때 기존 코드를 변경할 필요가없어졌습니다.

새로운 클래스를 추가하고 해당  메서드만 구현하면서 개방 - 폐쇄의 원칙을 준수 할수 있게 되었습니다.

 

L | LSP | Liskov Substitution Principle | 리스코프 치환원칙

 ■ 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

     - S가 T의 하위 유형이라면, 프로그램의 기능에 변화를 주지 않고서도 T 타입의 객체를 S 객체로 대체할 수 있어야한다.

 ■ 즉, 어떤 클래스의 인스턴스가 있을 때

    그 클래스의 서브클래스의 인스턴스로 대체해도 프로그램은 정상적으로 동작해야 한다는 원칙이다.

 ■ 코드의 유연성과 확장성이 향상되고, 다형성(polymorphism)을 올바르게 사용할 수 있다.

더보기

사각형과 사각형

사각형의 특징 - 높이와 너비가 서로 독립적으로 변경될 수 있다.

정사각형의 특징 - 높이와 너비가 동일하다

 

리스코프 치환 원칙 위반하는 예시

class Rectangle {// 사각형 클래스
  constructor(width = 0, height = 0) { // 직사각형의 생성자
    this.width = width;
    this.height = height;
  }

  setWidth(width) { // 사각형은 높이와 너비를 독립적으로 정의한다.
    this.width = width;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }

  setHeight(height) { // 사각형은 높이와 너비를 독립적으로 정의한다.
    this.height = height;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }

  getArea() { // 사각형의 높이와 너비의 결과값을 조회하는 메소드
    return this.width * this.height;
  }
}

class Square extends Rectangle { // 정사각형은 사각형을 상속받습니다.
  setWidth(width) { // 정사각형은 높이와 너비가 동일하게 정의된다.
    this.width = width;
    this.height = width;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }

  setHeight(height) { // 정사각형은 높이와 너비가 동일하게 정의된다.
    this.width = height;
    this.height = height;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }
}

const rectangleArea = new Rectangle() // 35
  .setWidth(5) // 너비 5
  .setHeight(7) // 높이 7
  .getArea(); // 5 * 7 = 35
const squareArea = new Square() // 49
  .setWidth(5) // 너비 5
  .setHeight(7) // 높이를 7로 정의하였지만, 정사각형은 높이와 너비를 동일하게 정의합니다.
  .getArea(); // 7 * 7 = 49

리스코프 치환 원칙상속을 사용할 때 서브클래스가 기반 클래스의 기대사항을 깨뜨리지 않도록 보장하여

소프트웨어 시스템의 안정성을 유지하는 데 중요한 역할을 하는것인데

정사각형 클래스는 사각형의 클래스의 기대사항을 깨트렸습니다.

 

사각형클래스에서는 가로와 세로를 설정할 수 있는 setWidthsetHeight 메서드가 있습니다.

이러한 메서드가 있기 때문에 사용자는 사각형의 가로와 세로를 개별적으로 설정할 수 있다고 예상합니다.

그러나 정사각형클래스에서는 한 변의 길이만을 설정하는 setWidthsetHeight 메서드를

오버라이드하여 사용하고 있습니다.

이로 인해 사용자가 사각형 클래스와 정사각형 클래스를 동일한 방식으로 사용할 수 없게 되어

리스코프 치환 원칙을 위반하게 됩니다.

 

리스코프 치환 원칙 준수하는 예시

class Shape { // Rectangle과 Square의 부모 클래스를 정의합니다.
  getArea() { // 각 도형마다 계산 방법이 다를 수 있으므로 빈 메소드로 정의합니다.
  }
}

class Rectangle extends Shape { // Rectangle은 Shape를 상속받습니다.
  constructor(width = 0, height = 0) { // 직사각형의 생성자
    super();
    this.width = width;
    this.height = height;
  }

  getArea() { // 직사각형의 높이와 너비의 결과값을 조회하는 메소드
    return this.width * this.height;
  }
}

class Square extends Shape { // Square는 Shape를 상속받습니다.
  constructor(length = 0) { // 정사각형의 생성자
    super();
    this.length = length; // 정사각형은 너비와 높이가 같이 때문에 width와 height 대신 length를 사용합니다.
  }

  getArea() { // 정사각형의 높이와 너비의 결과값을 조회하는 메소드
    return this.length * this.length;
  }
}

const rectangleArea = new Rectangle(7, 7) // 49
  .getArea(); // 7 * 7 = 49
const squareArea = new Square(7) // 49
  .getArea(); // 7 * 7 = 49

Shape 클래스를 부모 클래스로 정의하고, 이를 상속받는 RectangleSquare 클래스를 구현하였습니다.

각 도형 클래스는 getArea() 메소드를 구현하여 도형의 넓이를 계산하였으며

모든 도형 클래스는 Shape 클래스의 서브타입으로 사용될 수 있습니다.

 

I | ISP | Interface Segregation Principle | 인터페이스 분리 원칙

 ■ 특정 클라이언트를 위한 인터페이스 여러개가 범용 인터페이스 하나보다 낫다.

    - 클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 되고, 최대한 인터페이스를 작게 유지해야한다.

    - 사용자가 필요하지 않은 것들에 의존하지 않도록, 인터페이스는 작고 구체적으로 유지해야 한다

 ■ 인터페이스는 특정 클래스가 반드시 구현해야 할 메서드와 속성을 정의하는 일종의 템플릿이다. 

    - 이를 통해 서로 다른 클래스가 동일한 동작을 하는것을 유추할 수 있게 되는것

더보기

인터페이스 분리 원칙 위반하는 예시

interface SmartPrinter { // SmartPrinter가 사용할 수 있는 기능들을 정의한 인터페이스 
  print();

  fax();

  scan();
}

// SmartPrinter 인터페이스를 구현한 AllInOnePrinter 클래스
class AllInOnePrinter implements SmartPrinter {
  print() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
    // ...
  }

  fax() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
    // ...
  }

  scan() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
    // ...
  }
}

// SmartPrinter 인터페이스를 구현한 EconomicPrinter 클래스
class EconomicPrinter implements SmartPrinter {
  print() { // EconomicPrinter 클래스는 print 기능만 지원한다.
    // ...
  }

  fax() { // EconomicPrinter 클래스는 fax 기능을 지원하지 않는다.
    throw new Error('팩스 기능을 지원하지 않습니다.');
  }

  scan() { // EconomicPrinter 클래스는 scan 기능을 지원하지 않는다.
    throw new Error('Scan 기능을 지원하지 않습니다.');
  }
}

사용하지 않는 기능까지 구현을 하면 그만큼 예외 처리를 해주어야 하는 상황이 발생하고 

"필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 된다" 라는 원칙을 위반하게 됩니다.

 

인터페이스 분리 원칙 준수하는 예시

/** ISP After **/
interface Printer { // print 기능을 하는 Printer 인터페이스
  print();
}

interface Fax { // fax 기능을 하는 Fax 인터페이스
  fax();
}

interface Scanner { // scan 기능을 하는 Scanner 인터페이스
  scan();
}


// AllInOnePrinter클래스는 print, fax, scan 기능을 지원하는 Printer, Fax, Scanner 인터페이스를 상속받았다.
class AllInOnePrinter implements Printer, Fax, Scanner {
  print() { // Printer 인터페이스를 상속받아 print 기능을 지원한다.
    // ...
  }

  fax() { // Fax 인터페이스를 상속받아 fax 기능을 지원한다.
    // ...
  }

  scan() { // Scanner 인터페이스를 상속받아 scan 기능을 지원한다.
    // ...
  }
}

// EconomicPrinter클래스는 print 기능을 지원하는 Printer 인터페이스를 상속받았다.
class EconomicPrinter implements Printer {
  print() { // EconomicPrinter 클래스는 print 기능만 지원한다.
    // ...
  }
}

// FacsimilePrinter클래스는 print, fax 기능을 지원하는 Printer, Fax 인터페이스를 상속받았다.
class FacsimilePrinter implements Printer, Fax {
  print() { // FacsimilePrinter 클래스는 print, fax 기능을 지원한다.
    // ...
  }

  fax() { // FacsimilePrinter 클래스는 print, fax 기능을 지원한다.
    // ...
  }
}

인터페이스에 정의된 기능을 Printer, Fax, Scanner 인터페이스로 분리하여

ISP 원칙

"필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 되고, 최대한 인터페이스를 작게 유지해야한다. " 라는

원칙을 지키며 필요한 기능의 인터페이스만 구현하여 어플리케이션의 복잡성을 줄이고

각 클래스의 필요한 기능에만 집중할수 있게 됩니다.

 

D | DIP | Dependency Inversion Principle | 의존성 역전 원칙 

 ■ 프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다.

    즉 구체적인 것에 의존하기보다는 추상적인 것에 의존 해야한다.

 ■ 고수준 모듈(도메인)은 저수준의 모듈(하부구조)에 직접 의존해서는 안된다.

 ■ 소스 코드의 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다

 ■ 추상화는 세부 사항에 의존하지 않아야 하며, 세부 사항이 추상화에 의존해야 한다.

 ■ 고수준 모듈

    - 전체 시스템이나 주요 기능을 담당

    - 여러 저수준 모듈을 조합하여 그 기능을 수행

    - 일반적으로 사용자와의 상호작용이나 비즈니스 로직을 다룬다.

 ■ 저수준 모듈

    - 고수준 모듈에서 사용되는 세부적인 기능을 담당

    - 커피를 만든다고 가정하면, 뜨거운 물을 추출하고 커피원두를 가는 등의 세부기능이 이에 해당합니다.

더보기

커피숍에서 커피를 만드는 과정

커피숍에는 바리스타가 있고, 바리스타는 아래의 순서로 커피를 만듭니다.

1. 원두를 분쇄한다.

2. 분쇄된 원두를 추출한다.

3. 추출된 커피를 서빙한다.

 

의존성 역전 원칙 위반하는 예시

class Barista { // 고수준 모듈
  grindCoffeeBeans() { // 저수준 모듈
    console.log("원두를 분쇄합니다.");
  }

  extractCoffee() { // 저수준 모듈
    console.log("원두에서 커피를 추출합니다.");
  }

  serveCoffee() { // 저수준 모듈
    console.log("커피를 고객에게 제공합니다.");
  }

  makeCoffee() {
    this.grindCoffeeBeans();
    this.extractCoffee();
    this.serveCoffee();
  }
}

const barista = new Barista();
barista.makeCoffee();

위 코드에서는 고수준 모듈이 저수준 모듈에 직접 의존하고 있습니다.

 

 

의존성 역전 원칙 준수하는 예시

class CoffeeMaker {// 저수준 모듈
  makeCoffee() {
    this.grind();
    this.extract();
    this.serve();
  }

  grind() { 
    throw new Error("이 메서드는 서브클래스에서 구현되어야 합니다.");
  }

  extract() { 
    throw new Error("이 메서드는 서브클래스에서 구현되어야 합니다.");
  }

  serve() { 
    throw new Error("이 메서드는 서브클래스에서 구현되어야 합니다.");
  }
}

class Barista extends CoffeeMaker { // 고수준 모듈
  grind() {
    console.log("원두를 분쇄합니다.");
  }

  extract() {
    console.log("원두에서 커피를 추출합니다.");
  }

  serve() {
    console.log("커피를 고객에게 제공합니다.");
  }
}

const barista = new Barista();
barista.makeCoffee();

CoffeeMaker 클래스는 추상화된 인터페이스 역할을 합니다.

Barista 클래스는 CoffeeMaker  인터페이스를 구현함으로써 고수준 모듈이 되고

커피 제조과정의 세부적인 부분은 추상화된 인터페이스에 의존하게 됩니다.

 

 

'프로그래밍 기초 > CS' 카테고리의 다른 글

AOP에 대하여  (0) 2024.04.17
IoC 와 DI에 대하여  (0) 2024.03.05
동시성문제와 격리수준에 대해서  (0) 2024.02.20
Access Token 과 Refresh Token  (0) 2024.02.16
JWT란?  (0) 2024.02.14
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함