포시코딩

객체 지향 설계 5원칙 (SOLID) 본문

Node.js

객체 지향 설계 5원칙 (SOLID)

포시 2022. 12. 28. 17:01
728x90

객체 지향 설계 5원칙 (SOLID)

SOLID는 프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 사용한다.

 

SOLID의 종류 5가지

단일 책임의 원칙 (Single Responsibility Principle, SRP)

하나의 객체는 단 하나의 책임을 가져야 한다.

즉, 클래스모듈변경할 이유단 하나 뿐이어야 한다는 원칙이다.

SRP 적용 전

class UserSettings {
  constructor(user) {
    this.user = user;
  }
  
  changeSettings(userSettings) {  // 사용자 설정 변경 메소드
    if (this.verifyCredentials()) {
      // ...생략
    }
  }
  
  verifyCredentials() {  // 사용자 인증 검증 메소드
    // ...생략
  }
}

Usersettings 라는 하나의 클래스에 아래와 같은 여러 책임이 존재하고 있는 모습이다.

  1. changeSettings: Settings를 변경한다.
  2. verifyCredentials: 인증을 검증한다.

SRP 적용 후

class UserAuth {
  constructor(user) {
    this.user = user;
  }
  
  verifyCredentials() {  // 사용자 인증 검증 메소드
    // ...생략
  }
}

class UserSettings {
  constructor(user) {
    this.userAuth = new UserAuth(user);
  }
  
  changeSettings(userSettings) {  // 사용자 설정 변경 메소드
    if (this.userAuth.verifyCredentials()) {  // 생성자에서 선언한 userAuth 객체 메소드
      // ...생략
    }
  }
}
  • 사용자의 설정을 변경하는 책임을 가진 UserSettings 클래스
  • 사용자의 인증을 검증하는 책임을 가진 UserAuth 클래스

이렇게 변경하면 이제 클래스마다 단 1개의 책임을 가지게 된다.

 

개방-폐쇄 원칙 (Open-Closed Principle, OCP)

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

  • 소프트웨어 개체의 행위는 확장될 수 있어야 하지만, 개체를 변경해서는 안된다.
  • 즉, 기존 코드에 영향을 주지않고 소프트웨어에 새로운 기능이나 구성 요소를 추가할 수 있어야 한다.

OCP 적용 전

function calculator(nums, option) {
  let result = 0;
  for (const num of nums) {
    if (option === 'add') result += num;
    else if (option === 'sub') result -= num;
    // 여기에 새로운 연산(기능)을 추가하기 위해서는 함수 내부에서 코드 수정이 필요하다.
  }
  return result;
}

calculator 함수에 곱셈, 나눗셈, 제곱 연산 등 다양한 계산기의 기능이 추가되려면 함수 내부에서 코드 수정이 필요할 것이고, 

OCP의 '확장에는 열려있으나 변경에는 닫혀있어야 한다.'에 해당하는 원칙이 깨지게 된다.

OCP 적용 후

function calculator(nums, callBackFunc) {
  let result = 0;
  for (const num of nums) {
    result = callBackFunc(result, num);
  }
  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;  // 추가된 기능

 

리스코프 치환 원칙 (Liskov substitution principle, LSP)

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

만약 부모 클래스와 자식 클래스가 있는 경우 서로를 바꾸더라도

해당 프로그램에서 잘못된 결과를 도출하지 않아야 한다는 원칙이다.

LSP 적용 전

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()
  .setWidth(5)	// 너비 5
  .setHeight(7)	// 높이 7
  .getArea();	// 5 * 7 = 35
const squareArea = new Square()
  .setWidth(5)	// 너비 5
  .setHeight(7)	// 높이 7 but, 정사각형이라 높이 7, 너비 7
  .getArea();	// 7 * 7 = 49

Rectangle 클래스와 Square 클래스에서 동일한 메소드를 호출했지만, 다른 결과 값이 출력되고 있다. 

두 클래스를 서로 교체하였을 때도 결과 값이 동일하지 않다는 걸 알 수 있다.

LSP 적용 후

class Shape {
  getArea() {}  // 빈 메소드
}

class Rectangle extends Shape {
  constructor(width = 0, height = 0) {
    super();
    this.width = width;
    this.height = height;
  }
  
  getArea() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(length = 0) {
    super();
    this.length = length;  // 정사각형이기 때문에 width, heigth 대신 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라는 클래스를 만들어 인터페이스의 역할을 대체했다.

Rectangle 클래스와 Square 클래스에서 상위 타입의 getArea 메소드를 호출하더라도 문제없이 원하는 결과값을

도출하는 것을 확인할 수 있다.

 

인터페이스 분리 원칙 (Interface segregation principle, ISP)

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

  • 클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 되고, 
    최대한 인터페이스를 작게 유지해야 한다.
  • 즉, 사용자가 필요하지 않은 것들에 의존하지 않도록, 인터페이스를 작게 유지해야 한다.

* Javascript에서는 interface 기능을 제공하지 않으므로 이번 예시는 Typescript로 진행된다.

ISP 적용 전

interface SmartPrinter {
  print();
  fax();
  scan();
}

class AllInOnePrinter implements SmartPrinter {
  print() {
    // ...생략
  }
  fax() {
    // ...생략
  }
  scan() {
    // ...생략
  }
}

class EconomcPrinter implements SmartPrinter {
  print() {
    // ...생략
  }
  fax() {
    throw new Error('팩스 기능을 지원하지 않습니다.');
  }
  scan() {
    throw new Error('Scan 기능을 지원하지 않습니다.');
  }
}

AllInOnePrinter 클래스는 print, fax, scan 3가지의 기능이 모두 필요하지만, 

EconomicPrinter 클래스의 경우 print 기능만 지원하는 클래스이다.

 

EconomicPrinter 클래스에서 SmartPrinter 인터페이스를 상속받아 사용할 경우

fax, scan 2가지의 기능을 예외처리 해줘야 하는 상황이 발생한다.

이 경우 위에서 설명한 ISP의 원칙을 위배하게 되는데 아래 코드를 통해 어떻게 개선해야 할지 알아보자

ISP 적용 후

interface Printer {
  print();
}
interface Fax {
  fax();
}
interface Scanner {
  scan();
}

class AllInOnePrinter implements Printer, Fax, Scanner {
  print() {
    // ...생략
  }
  fax() {
    // ...생략
  }
  scan() {
    // ...생략
  }
}

class EconomicPrinter implements Printer {
  print() {
    // ...생략
  }
}

 

의존성 역전 원칙 (Dependency Inversion Principle, DIP)

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

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

  • 고수준 계층의 모듈(도메인)저수준 계층의 모듈(하부구조)에 의존해서는 안된다.
    둘 다 추상화에 의존해야 한다.
  • 추상화는 세부 사항에 의존해서는 안된다. 세부 사항은 추상화에 의존해야 한다.

DIP 적용 전

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();
      
      const text = await readFile(path, (err, data) => data);
      return formatter.parseXml(text);
      
    } else if (fileExtension === 'json') {
      const formatter = new JsonFormatter();
      
      const text = await readFile(path, (err, data) => data);
      return formatter.parseJson(text);
    }
  }
}

const reader = new ReportReader();
const report = await reader.read('report.xml');
// or
// const report = await reader.read('reader.json');

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

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

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

 

해당 상황을 구체화에 의존되어 있는 상황이라고 부르는데, DIP를 적용해보자

DIP 적용 후

const readFile = require('fs').readFile;

class Formatter {
  parse() {  }
}

class XmlFormatter extends Formatter {
  parse(content) {
    // Xml 파일을 String 형식으로 변환
  }
}

class JsonFormatter extends Formatter {
  parse(content) {
    // JSON 파일을 String 형식으로 변환
  }
}

class ReportReader {
  constructor(formatter) {
    this.formatter = formatter;
  }
  
  async read(path) {
    const text = await readFile(path, (err, data) => data);
    return this.formatter.parse(text);
  }
}

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');

DIP 원칙을 이용하여 저수준의 모듈을 수정하더라도

고수준의 모듈 코드를 더 이상 수정하지 않도록 코드가 개선된 것을 볼 수 있다.

728x90

'Node.js' 카테고리의 다른 글

도메인(Domain)  (0) 2022.12.28
추상화(Abstraction)  (0) 2022.12.28
객체 지향 프로그래밍 (Object-Oriented Programming, OOP)  (0) 2022.12.26
객체지향(Object-Oriented)  (0) 2022.12.26
Redis  (0) 2022.12.24