ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 2023 - 07 - 10 객체지향 설계 5가지 원칙 SOLID
    Today I Learned/TIL 07 2023. 7. 8. 01:19

    객체지향은 소프트웨어의 핵심을 기능이 아닌 객체로 삼으며, 각각의 역할을 정의하는 것에 초점을 맞춘다. 

    객체지향 소프트웨어의 특징

    1. 캡슐화, 다형성, 클래스상속을 지원한다.

    2. 데이터 접근 제한을 걸 수 있다.

     

    객체지향 프로그래밍의 장점

    1. 의존성을 효율정으로 통제할 수 있는 다양한 방법을 제공함으로써  요구사항 변경에 좀 더 수월하게 대응할 수 있다.

    2. 데이터 중심으로 프로그래밍 함으로써, 코드의 덩치가 커지더라도 일관성을 유지하기 좋다.

    3. 객체지향 코드는 자신의 문제를 스스로 처리해야 한다는 우리의 예상을 만족시켜주기 때문에 이해하기 쉽고, 객체 내부의 변경이 객체 외부에 파급되지 않을 수 있도록 제어할 수 있기 때문에 변경하기 수월하다.

    4. 데이터와 프로세스를 하나의 단위로 통합한 방식으로 표현한다.

     

     

    객체지향설계 5가지 원칙 (SOLID)
    
    
    1. 단일책임의 원칙 (SRP, Single Responsibility Principle)
    	: 하나의 객체는 하나의 책임을 진다. 즉 클래스나 모듈을 변경할 이유가 하나뿐이어야 한다.
        
        
    2. 개방-폐쇄의 원칙 (OCP, Open-Closed Principle)
    	: 소프트웨어 엔티티 또는 개체 (클래스, 모듈, 함수 등)에는 열려있어야 하지만,
          변경에는 닫혀 있어야 한다. 즉, 기존 코드에 영향을 주지 않으면서, 소프트웨어에 새로운 기능이나
          구성 요소를 추가할 수 있어야 한다.
    
    
    3, 리스코프 치환의 원칙 (LSP, Liskov substitution principle)
    	: 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서, 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
        S타입의 객체1. T타입의 객체 2가 있다고 가정했을 때, T타입의 객체2를 객체1로 변경시켜도 전체 프로그램
        의 행위가 변하지 않는다면, S는 T의 하위타입이다.
        즉S가 T의 하위유형이면 프로그램의 속성을 변경핮 않고 T객체를 S객체로 대체할 수 있다.
    
    
    4. 인터페이스 분리 원칙 (ISP, Interface segregation principle)
    	: 특정 클라이언트를 위한 인터페이스 열러개가, 범용 클라이언트 하나보다 더 낫다.
        클라이언트가 필요하지 않은 것들에 의존하지 않도록, 인터페이슥을 작게 유지해야 한다.
    
    5. 의존성 역전 원칙 (DIPDependency Inversion Principle)
    	: 프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다. 즉, 높은 계층의 모듈(도메인)이
        낮은 계층의 모듈 (하부구조)에 의지해서는 안된다. 추상화가 세부사항에 의존하는 것이 아니라,
        세부 사항이 추상화에 의존해야 한다.

     

    코드 예시

     

    1. 단일책임의 원칙 SRP

     

    UserSettings 클래스는 하나의 클래스가 두개의 책임을 가진다.

    changeSettings는 셋팅을 변경하고, VerifyCredentials은 인증을 검증한다.

    /** SRP Before **/
    class UserSettings {
      constructor(user) { // UserSettings 클래스 생성자
        this.user = user;
      }
    
      changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
        if (this.verifyCredentials()) {
          //...
        }
      }
    
      verifyCredentials() { // 사용자의 인증을 검증하는 메소드
        //...
      }
    }

    이것을 아래와 같이 바꿀수 있다.

    1. UserSettings 클래스 (사용자의 설정을 변경하는 책임)

    2. UserAuth 클래스 (사용자의 인증을 검증하는 책임)

    /** SRP After **/
    class UserAuth {
      constructor(user) { // UserAuth 클래스 생성자
        this.user = user;
      }
    
      verifyCredentials() { // 사용자의 인증을 검증하는 메소드
        //...
      }
    }
    
    class UserSettings {
      constructor(user) { // UserSettings 클래스 생성자
        this.userAuth = new UserAuth(user); // UserAuth를 새로운 객체로 정의한다.
      }
    
      changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
        if (this.userAuth.verifyCredentials()) { // 생성자에서 선언한 userAuth 객체의 메소드를 사용한다.
          //...
        }
      }
    }

     

     

     

    2. 개방 - 책임의 원칙 OCP

     

    calcularator 함수는 계산기 역할을 하며, 덧셈, 뺄셈 기능만 구현되어있다. 

    /** OCP Before **/
    function calculator(nums, option) {
      let result = 0;
      for (const num of nums) {
        if (option === "add") result += num; // option이 add일 경우 덧셈 연산을 합니다.
        else if (option === "sub") result -= num; // option이 sub일 경우 뺄셈 연산을 합니다.
        // 새로운 연산(기능)을 추가 하기 위해서는 함수 내부에서 코드 수정이 필요합니다.
      }
      return result;
    }
    
    console.log(calculator([2, 3, 5], "add")); // 10
    console.log(calculator([5, 2, 1], "sub")); // -8

     

    여기에, 곱셈, 나눗셈, 제곱연산 등 다양한 기능을 추가하려면 "확장에는 열려있으나 변경에는 닫혀있어야 한다"는 OCP원칙을 따라서 callback함수에서 전달받은 option 파라미터를 callback함수로 변경하여, 다른 조건이 추가되더라도 실제 calculator함수에는 변화가 발생하지 않도록 구현할 수 있다.

    /** OCP After **/
    function calculator(nums, callBackFunc) {
      // option을 CallbackFunc로 변경
      let result = 0;
      for (const num of nums) {
        result = callBackFunc(result, num); // option으로 분기하지 않고, Callback함수를 실행하도록 변경
      }
      return result;
    }
    
    const add = (a, b) => a + b; // 함수 변수를 정의합니다.
    const sub = (a, b) => a - b;
    const mul = (a, b) => a * b;
    const div = (a, b) => a / b;
    console.log(calculator([2, 3, 5], add)); // add 함수 변수를 Callback 함수로 전달합니다.
    console.log(calculator([5, 2, 1], sub)); // sub 함수 변수를 Callback 함수로 전달합니다.

     

     

    3. 리스코프 치환 원칙 (LSP)

    정사각형은 높이와 너비가 동일하지만, 직사각형은 높이와 너비가 서로 독립적이다.

    /** LSP Before **/
    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

     

    위에서 정사각형(Square)과 직사각형(Rectangle)의 클래스에서는 동일한 매서드를 호출하지만, 다른 결과를 출력한다.

    두 클래스를 서로 교체했을때도 동일한 결과값이 도출되지 않음을 확인할 수 있다. 이 경우 두 클래스를 모두 포함하는 shape이라는 부모 클래스를 만들어 인터페이스의 역할을 대체할 수 있다.

     

    /** LSP After **/
    class Shape { // Rectangle과 Square의 부모 클래스를 정의합니다.
      getArea() { // 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

     

     

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

     

    처음 선언된 SmartPrint 인터페이스는 print(), fax(), scan() 세가지 기능을 갖고있다.

    AllInOnePrinter 클래스는 세가지 기능이 모두 필요하지만, EconomicPrinter는 print기능만 지원하는 클래스이다.

    만약 EconomicPrinter 클래스에서 SmartPrinter 인터페이스를 상속받아 사용할 경우, fax, scan 두가지 기능을 예외처리 해줘야 하는 상황이 발생한다.

    이 경우 클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안되고, 최대한 인터페이스를 작게 유지해야 한다는 원칙으로 코드를 개선할 수 있다.

     

    변경 전

    /** ISP Before **/
    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 기능을 지원한다.
        // ...
      }
    }

     

     

    5. 의존성 역전 원칙 (DIP)

    추상화에 의존하되, 구체화에 의존하지 말라. 

    즉, 높은 계층의 모듈(도메인)이 저수준 모듈(하부구조)에 의존해서는 안된다.

     

    Xml파일을 파싱하기 위해 XmlFormatter 클래스를 불러와 parseXml 메소드를 호출하고,

    Json파일을 파싱하기 위해 JsonFormatter 클래스를 불러와 parseJson 메소드를 호출한다.

     

    서로 다른 파일 확장자별로 파싱하는 방법이 달라 다른 클래스, 다른 메소드를 호출하게 되어 있다.

    이를, 두 클래스를 동일한 Formatter 인터페이스를 상속받도록 해서 파싱을 위한 parse 메소드를 선언하도록 한다.

    그리고 ReportReader클래스에서 Formatter인터페이스와 parse메소드를 사용해서 코드를 수정한다.

     

    /** DIP Before **/
    const readFile = require('fs').readFile;
    
    class XmlFormatter {
      parseXml(content) {
        // Xml 파일을 String 형식으로 변환합니다.
      }
    }
    
    class JsonFormatter {
      parseJson(content) {
        // JSON 파일을 String 형식으로 변환합니다.
      }
    }
    
    class ReportReader {
    
      async read(path) {
        const fileExtension = path.split('.').pop(); // 파일 확장자
    
        if (fileExtension === 'xml') {
          const formatter = new XmlFormatter(); // xml 파일 확장자일 경우 XmlFormatter를 사용한다.
    
          const text = await readFile(path, (err, data) => data);
          return formatter.parseXml(text); // xmlFormatter클래스로 파싱을 할 때 parseXml 메소드를 사용한다.
    
        } else if (fileExtension === 'json') {
          const formatter = new JsonFormatter(); // json 파일 확장자일 경우 JsonFormatter를 사용한다.
    
          const text = await readFile(path, (err, data) => data);
          return formatter.parseJson(text); // JsonFormatter클래스로 파싱을 할 때 parseJson 메소드를 사용한다.
        }
      }
    }
    
    const reader = new ReportReader();
    const report = await reader.read('report.xml');
    // or
    // const report = await reader.read('report.json');

    댓글

Designed by Tistory.