본문 바로가기
Language/Node.js

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

by 노믹 2022. 12. 27.

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

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

클래스나 모듈을 변경할 이유가 단 하나뿐이어야 한다는 원칙입니다.

SRP책임이라는 개념을 정의하며 적절한 클래스의 크기를 제시합니다.

SRP는 객체 지향설계에서 중요한 개념이고 이해하고 따르기 쉬운 개념이지만, 프로그래머가 가장 무시하는 규칙입니다. 일반적인 프로그래머는 "깨끗하고 우아하게 작성된 소프트웨어"보다 "동작하기만 하는 소프트웨어"에 초점을 맞추기 때문입니다.

 

SRP를 이용해서 코드를 개선해보도록 하겠습니다.

아래의 UserSettings 클래스는 하나의 클래스가 가지는 책임이 여러 개가 존재합니다.

1. changeSettings: Settings를 변경한다.

2. verifyCredentials: 인증을 검증한다.

/** SRP Before **/
class UserSettings {
  constructor(user) { // UserSettings 클래스 생성자
    this.user = user;
  }

  changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
    if (this.verifyCredentials()) {
      //...
    }
  }

  verifyCredentials() { // 사용자의 인증을 검증하는 메소드
    //...
  }
}

그렇다면 2가지의 책임을 가지고 있는 UserSettings 클래스를 어떻게 분리할 수 있을까요?

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 객체의 메소드를 사용한다.
      //...
    }
  }
}

이제 책임을 분리하여 개선된 코드는 클래스마다 단 1개의 책임을 가지게 되었습니다.

 

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

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

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

조금 더 쉽게 설명하자면, 기존 코드에 영향을 주지않고 소프트웨어에 새로운 기능이나 구성 요소를 추가할 수 있어야 한다는 것입니다.

만약 요구사항을 조금 확장하는 데 소프트웨어를 엄청나게 수정해야 한다면, 소모되는 개발 코스트 또한 엄청나게 증가할 것입니다.

 

OCP를 이용해서 코드를 개선해보도록 하겠습니다.

calculator 함수는 계산기의 역할을 하는 함수입니다. 현재는 덧셈, 뺄셈 기능만 구현되어 있습니다.

/** 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")); // 2

만약 곱셈, 나눗셈, 제곱 연산 등 다양한 계산기의 기능을 추가하려면 calculator 함수 내부에서 코드 수정이 필요할 것이고, OCP 원칙 중에서 "확장에는 열려 있으나 변경에는 닫혀 있어야 한다."에 해당하는 원칙이 깨지게 됩니다.

그렇다면 어떻게 calculator 함수를 수정해야 OCP 원칙에 위배되지 않고 기능을 추가할 수 있을까요?

calculator 함수에서 전달받은 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 함수로 전달합니다.

계산기에 어떠한 기능이 추가되더라도 더 이상 calculator 함수 내부의 코드를 수정하지 않을 수 있게 되었습니다.

 

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

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

하위 타입이란 S 타입의 객체 o1, 각각에 대응하는 T 타입 객체 o2가 있고, T타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입입니다.

즉, S가 T의 하위 유형이면 해당 프로그램의 속성을 변경하지 않고 T 객체를 S 객체로 대체할 수 있습니다.

 

만약 부모 클래스와 자식 클래스가 있는 경우 서로를 바꾸더라도 해당 프로그램에서 잘못된 결과를 도출하지 않는 것입니다.

그렇다면 정사각형(Square)과 직사각형(Rectagle) 문제를 이용해서 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 클래스에서 동일한 메서드를 호출하였지만, 다른 결괏값이 출력된 것을 확인하였습니다. 만약 두 클래스를 서로 교체하였을 때에도 동일한 값이 도출되지 않는 것을 확인할 수 있습니다.

위에서 확인한 결과로 LSP 원칙 중에서 "부모 클래스와 자식 클래스가 있는 경우 서로를 바꾸더라도 해당 프로그램에서 잘못된 결과를 도출하지 않는 것"에 해당하는 원칙이 깨지게 된 것을 확인 할 수 있습니다.

 

언뜻 보면 Rectangle이 Square를 포함하고 있는 것처럼 보이지만 setWidth, setHeigth 메서드처럼 다르게 동작해야 하는 경우가 존재하기 때문에 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

Rectangle 클래스와 Square 클래스에서 상위 타입의 getArea 메서드를 호출하더라도 문제없이 원하는 결괏값을 도출할 수 있게 되었습니다.

 

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

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

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

조금 더 쉽게 설명하자면, 사용자가 필요하지 않은 것들에 의존하지 않도록, 인터페이스를 작게 유지해야 한다는 것입니다.

 

Javascript에서는 interface 기능을 제공하지 않으므로 이번 예제는 Typescript로 진행하도록 하겠습니다.

/** 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 기능을 지원하지 않습니다.');
  }
}

가장 처음 선언된 SmartPrint 인터페이스는 print(), fax(), scan() 3가지의 기능을 가지고 있습니다.

AllInOnePrinter 클래스는 print, fax, scan 3가지의 기능이 모두 필요하지만, EconomicPrinter 클래스의 경우 print기능만 지원하는 클래스입니다.

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

이후에도 fax 기능을 추가로 사용하는 FacsimilePrinter 클래스가 SmartPrinter 인터페이스를 상속받을 경우 scan 기능을 예외 처리 해주는 상황이 발생하게 됩니다.

 

SmartPrinter 인터페이스에 정의된 기능들을 Printer, Fax, Scanner 인터페이스로 분리하여 ISP 원칙에서 “클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 되고, 최대한 인터페이스를 작게 유지해야 한다.”에 해당하는 원칙을 수행하는 코드로 개선할 수 있습니다.

/** 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)

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

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

조금 더 자세하게 정리해 보자면

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

만약 추상화를 하지 않고 고수준 계층의 모듈이 저수준 계층의 모듈을 의존하고 있다면 사소한 코드 변경에도 고수준 계층의 코드를 변경해야 할 것이고, 소모되는 개발 코스트 또한 엄청나게 증가할 것입니다.

 

ReportReader클래스는 파일을 입력받아 확장자별로 파싱 하여 String 형식으로 변환하는 클래스입니다.

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

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

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

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

해당 상황을 구체화에 의존되어 있는 상황이라고 부르는데요, 그렇다면 어떻게 수정해야 DIP 원칙에 맞게끔 코드를 수정할 수 있을까요?

XmlFormatter, JsonFormatter 클래스를 동일한 인터페이스, Formatter 인터페이스를 상속받도록 하여 파싱을 위한 parse 메서드를 선언하도록 합니다.

그리고 ReportReader 클래스에서 Formatter 인터페이스의 parse 메소드를 사용하도록 코드를 수정하도록 합니다.

그렇게 한다면 DIP원칙인 “높은 계층의 모듈(도메인)이 저수준의 모듈(하부구조)에 의존해서는 안된다.”에 해당하는 원칙을 지킬 수 있습니다.

/** DIP After **/
const readFile = require('fs').readFile;

class Formatter { // 인터페이스지만, Javascript로 구현하기 위해 클래스로 선언합니다.
  parse() {  }
}

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

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

class ReportReader {
  constructor(formatter) { // 생성자에서 Formatter 인터페이스를 상속받은 XmlFormatter, JsonFormatter를 전달받습니다.
    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');

DIP 원칙을 이용하여 저수준의 모듈을 수정하더라도 고수준의 모듈 코드를 더 이상 수정하지 않도록 코드가 개선되었습니다.

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

아키텍처 패턴 (Architecture Pattern)  (0) 2022.12.27
도메인 (Domain)  (0) 2022.12.27
객체 지향 프로그래밍 (Object-Oriented Programming, OOP)  (0) 2022.12.26
객체 지향 (Object-Oriented)  (0) 2022.12.26
[OpenAPI] Swagger  (1) 2022.12.25