상세 컨텐츠

본문 제목

[Node.js] 심화 이론 (4) (SOLID: SRP / OCP / LSP / ISP / DIP)

notes

by 서울의볼 2024. 2. 19. 12:24

본문

세 줄 요약:

SOLID 원칙은 객체지향 프로그래밍과 설계의 다섯 가지 핵심 원칙이며, 이를 통해 코드의 유연성과 확장성을 높일 수 있음. SRP는 단일 책임 원칙으로, 객체가 하나의 책임만을 가져야 함을 강조함. OCP는 개방-폐쇄 원칙으로, 확장에는 열려있고 변경에는 닫혀있어야 함을 명시함. LSP는 리스코프 치환 원칙으로, 하위 타입의 객체가 상위 타입의 객체로 대체되어도 프로그램의 의도가 바뀌지 않아야 함을 강조함. 그리고 ISP는 인터페이스 분리 원칙으로, 특정 클라이언트를 위한 여러 인터페이스가 범용 인터페이스보다 나은 설계를 제시함. DIP는 의존성 역전 원칙으로, 추상화를 구체화 대비 강조함.

 

  • 객체 지향 프로그래밍 및 설계의 다섯 가지 핵심 원칙을 SOLID라고 부름. 보다 보니, 어떻게 활용하게 될 지는 몰라도 확실히 왜 써야 하는지는 알 것 같음.
  • 단일 책임의 원칙 (Single Responsibility Principle, SRP): 하나의 객체는 단 하나의 책임을 가져야 함. 즉, 클래스나 모듈을 변경할 이유가 단 하나 뿐이어야 한다는 원칙.
    • 두 가지의 책임을 가지고 있는 클래스 분리:
    • /** SRP Before **/
      class UserSettings {
        constructor(user) { // UserSettings 클래스 생성자
          this.user = user;
        }
      
        changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
          if (this.verifyCredentials()) {
            //...
          }
        }
      
        verifyCredentials() { // 사용자의 인증을 검증하는 메소드
          //...
        }
      }​
    • 수정하여 클래스를 나눔:
    • /** SRP After **/
      class UserAuth {
        constructor(user) { // UserAuth 클래스 생성자
          this.user = user;
        }
      
        verifyCredentials() { // 사용자의 인증을 검증하는 메소드
          //...
        }
      }
      
      class UserSettings {
        constructor(user, userAuth) { // UserSettings 클래스 생성자
          this.user = user;
          this.userAuth = userAuth; // UserAuth를 생성자를 통해 주입받는다.
        }
      
        changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
          if (this.userAuth.verifyCredentials()) { // 생성자에서 주입 받은 userAuth 객체의 메소드를 사용한다.
            //...
          }
        }
      }
  • 개방-폐쇄 원칙 (Open-Closed Principle, OCP): 소프트웨어 엔티티 또는 개체(클래스, 모듈, 함수 등)는 확장에는 열려 있으나 변경에는 닫혀 있어야 함. 즉, 소프트웨어 개체의 행위는 확장될 수 있어야 하지만, 개체를 변경해서는 안됨. 기존 코드에 영향을 주지않고 소프트웨어에 새로운 기능이나 구성 요소를 추가할 수 있어야 한다는 것.
    • /** 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
      만약 곱셈, 나눗셈, 제곱 연산 등 다양한 계산기의 기능을 추가하려면 calculator 함수 자체를 수정해야함. 이런 접근 방식은 개방-폐쇄 원칙(OCP)인 “확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”를 위반하게 됨.
    • calculator 함수에서 전달받은 option 매개변수를 콜백 함수로 변경하여 새로운 계산 조건이 추가되더라도 실제 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 함수로 전달합니다.
    • 리스코프 치환 원칙 (Liskov substitution principle, LSP): 어플리케이션에서 객체는 프로그램의 동작에 영향을 주지 않으면서, 하위 타입의 객체로 바꿀 수 있어야 함. 부모 클래스(Parents)와 자식 클래스(Child) 를 가지고 있다면, 이 두가지의 클래스의 객체를 서로를 바꾸더라도 해당 프로그램에서 잘못된 결과를 도출하지 않아야하는 원칙.
      • 직사각형 vs 정사각형 예시:
      • /** 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클래스에서 같은 메서드를 호출하더라도 다른 결과값이 반환되는 것을 확인할 수 있음. 예제에서 높이를 7로 설정하려 하였지만, Square 클래스에서는 너비와 높이가 동일해야 하므로 결과적으로 너비가 7로 설정됨 = LSP 위반.
      • /** LSP After **/
        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
        수정된 코드에서는 Rectangle 과 Square 객체를 생성하고, 각각의 getArea 메서드를 호출하면, 둘 다 49라는 동일한 넓이가 반환되는 것을 확인할 수 있음. 따라서, 이 코드는 리스코프 치환 원칙(LSP)을 만족하도록 구성된 것.
    • 인터페이스 분리 원칙 (Interface segregation principle, ISP): 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 나음.
      • /** 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 기능을 지원한다.
            // ...
          }
        }
        인터페이스 분리 원칙(ISP)을 적용하면 어플리케이션의 복잡성을 줄이고, 각 클래스가 필요한 기능에만 집중할 수 있게 됨!
    • 의존성 역전 원칙 (Dependency Inversion Principle, DIP): 프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안됨. 즉, 높은 계층의 모듈(도메인)이 저수준의 모듈(하부구조)에 직접 의존해서는 안됨.
      • 예시 코드:
      • /** DIP Before **/
        import { readFile } from 'node:fs/promises';
        
        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');
         
      • 위와 같이 각 파일 확장자에 따라 다른 클래스와 다른 메서드를 사용하면, 이는 구체적인 구현에 의존하고 있는 상황임. 이를 해결하려면 XmlFormatter와 JsonFormatter 클래스가 동일한 인터페이스인 Formatter 를 상속받도록 수정해야함.
      • /** DIP After **/
        import { readFile } from 'node:fs/promises';
        
        class Formatter { // 인터페이스지만, Javascript로 구현하기 위해 클래스로 선언합니다.
          parse() {  }
        }
        
        class XmlFormatter extends Formatter {
          parse(content) {
            // Xml 파일을 String 형식으로 변환합니다.
          }
        }
        
        class JsonFormatter extends Formatter {
          parse(content) {
            // JSON 파일을 String 형식으로 변환합니다.
          }
        }
        
        class ReportReader {
          constructor(formatter) { // DI 패턴을 적용하여, Formatter를 생성자를 통해 주입받습니다.
            this.formatter = formatter;
          }
        
          async read(path) {
            const text = await readFile(path, (err, data) => data);
            return this.formatter.parse(text); // 추상화된 formatter로 데이터를 파싱합니다.
          }
        }
        
        const reader = new ReportReader(new XmlFormatter());
        const report = await reader.read('report.xml');
        // or
        // const reader = new ReportReader(new JsonFormatter());
        // const report = await reader.read('report.json');

 

관련글 더보기