객체 지향 프로그래밍 및 설계의 다섯 가지 핵심 원칙을 SOLID라고 부른다.

  • SOLID는 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙의 맨 앞단어를 하나씩 가져와 만든 것이다.
  • SOLID 원칙을 따르면 프로그래머는 시간이 지나도 유지 보수와 확장이 쉬운 시스템 구축할 수 있다.

 

SOLID의 종류

  • 단일 책임의 원칙 ( Single Responsibility Principle, SRP )
  • 개방-폐쇄 원칙 ( Open - Closed Principle, OCP )
  • 리스코프 치환 원칙 ( Liskov Substitution Principle, LSP )
  • 인터페이스 분리 원칙 ( Interface Segregation Principle, ISP )
  • 의존성 역전 원칙 ( Dependency Inversion Principle, DIP )

 

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

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

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

  • SRP는 책임이라는 개념을 정의하며 적절한 클래스의 크기를 제시한다.
  • SRP는 객체 지향설계에서 중요한 개념이고 이해하고 따르기 쉽지만, 프로그래머가 가장 무시하는 규칙 중 하나다.
  • 일반적인 프로그래머는 "깨끗하고 우아하게 작성된 소프트웨어" 보다 "동작하기만 하는 소프트웨어"에 초점을 맞추기 때문이다.

SRP를 이용해 코드를 한번 개선해보자.

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

 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, userAuth) { // UserSettings 클래스 생성자
    this.user = user;
    this.userAuth = userAuth; // UserAuth를 생성자를 통해 주입받는다.
  }

  changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
    if (this.userAuth.verifyCredentials()) { // 생성자에서 주입 받은 userAuth 객체의 메소드를 사용한다.
      //...
    }
  }
}

 

위 처럼 2개의 클래스로 분리해 클래스마다 단 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")); // -8

 

만약 곱셈, 나눗셈, 제곱 연산 등 다양한 계산기의 기능을 추가하려면 calculator 함수 자체를 수정해야 한다.

이런 접근 방식은 개방 - 폐쇄 원칙 ( OCP )인 "확장에는 열려 있지만 변경에는 닫혀 있어야 한다."를 위반하게 된다.

 

그렇다면 calculator 함수를 어떻게 수정해야 개방 - 폐쇄 원칙에 위배되지 않고 새로운 기능을 추가할 수 있을까?

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 함수로 전달합니다.

 

위 처럼 계산기에 어떠한 기능을 추가 하더라도 더이상 calculator 함수 내부의 코드를 수정하지 않을 수 있게 만들 수 있다.

 

리스코프 치환 원칙 ( Liskov Substitution Principle, LSP )

어플리케이션에서 객체는 프로그램의 동작에 영향을 주지 않으면서, 하위 타입의 객체로 바꿀 수 있어야 한다.

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

부모 클래스 ( Parents )와 자식 클래스 ( Child )를 가지고 있다면, 이 두가지의 클래스의 객체를 서로를 바꾸더라도 해당 프로그램에서 잘못된 결과를 도출하지 않아야하는 원칙이다.

 

아래의 정사각형과 직사각형 예쩨를 이용해 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

 

위에 구현한 Rectangle과 Square 클래스에서는 어떠한 문제가 있을까?

Square와 Rectangle 클래스에서 같은 메서드를 호출하더라도 다른 결과값이 반환되는 것을 확인할 수 있다.

예제에서 높이를 7로 설정하려고 했지만, Square 클래스에서는 너비와 높이가 동일해야 하므로 결과적으로 너비가 7로 설정되었다.

만약 두 클래스를 서로 교체했을 때에도 동일한 결과 값이 도출되지 않는 것을 확인 할 수 있다.

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

 

그렇다면, 어떻게 Square과 Rectangle 클래스를 수정해야 LSP원칙을 위반하지 않게 구현할 수 있을까?

언뜻 보면 Rectangle이 Square를 포함하고 있는 것처럼 보이지만 setWidth, setHeight 메서드처럼 다르게 동작해야하는 경우가 존재하기 때문에 Square 클래스는 Rectangle을 상속받는 것은 옳은 방법이 아니다.

이럴 경우 두 클래스를 모두 포함하는 인터페이스를 구현해야한다. 여기서는, Shape 라는 인터페이스 ( Interface ) 역할을 수행하는 새로운 부모 클래스를 생성하고, Rectangle과 Square가 이를 상속받도록 코드를 수정해보자.

/** 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 )을 만족한다는 것을 확인할 수 있다.

 

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

 

인터페이스 분리 원칙 ( Interface Segregation Principle, ISP )

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

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

여기서 설명하는 인터페이스 ( Interface )는 대표적으로 Java, C++ 그리고 Typescript에서 사용하는 문법이다.

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

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

 

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() 세 가지의 기능을 정의하고 있다.

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

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

만약 EconomicPrinter 클래스가 SmartPrinter 인터페이스를 상속받는다면, 필요하지 않은 fax, scan 2가지의 기능을 예외 처리해야 하는 문제가 발생하게 되는 것이다.

 

그렇다면, 어떻게 SmartPrinter 인터페이스를 분리해야 ISP 원칙에 위배되지 않고 코드를 구현할 수 있을까?

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

 

이제 EconomicPrinter는 Printer 인터페이스만 상속 받아, 필요한 print 기능만을 구현하면 된다.

이렇게 인터페이스 분리 원칙 ( ISP )을 적용하면 어플리케이션의 복잡성을 줄이고, 각 클래스가 필요한 기능에만 집중할 수 있게 된다.

 

불필요한 인터페이스를 분리해 ISP원칙을 수행하는 코드를 구현할 수 있게 되었다.

결국 불필요한 기능을 포함한 인터페이스에 의존하게 되면 예상치 못한 문제에 빠질 수 있다는 것을 확인했다.

 

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

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

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

조금 더 자세하게 정리하면,

  • 프로그래머는 구체적인 것에 의존하기보다는 추상적인 것에 의존해야 한다.
  • 고수준 계층의 모듈( 도메인 )은 저수준 계층의 모듈 ( 하부구조 )에 의존해서는 안된다. 둘 다 추상화에 의존해야한다.
  • 추상화는 세부 사항에 의존하지 않아야 하고, 세부 사항이 추상화에 의존해야 한다.

 

만약 이런 추상화 없이 고수준 계층의 모듈이 저수준 계층의 모듈을 의존하게 되면 어떤 상황이 발생할까?

사소한 변경 사항에도 고수준 계층의 코드를 변경해야하고, 소모되는 개발 코스트또한 증가한다.

 

ReportReader라는 클래스에서 파일을 입력받아 확장자별로 파싱해 String 형식으로 반환하는 예시를 통해 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');

 

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

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

이렇게, 각 파일 확장자에 따라 다른 클래스와 다른 메서드를 사용하면, 이는 구체적인 구현에 의존하고 있는 상황이다.

이러한 상황을 어떻게 해결해야 DIP 원칙에 맞게 코드를 개선할 수 있을까?

 

이 문제를 해결하려면 XmlFormatterJsonFormatter 클래스가 동일한 인터페이스인 Formatter를 상속받도록 수정해야한다. 이렇게 하면 ReportReader 클래스는 Formatter 인터페이스의 parse 메서드만 의존하게 된다.

또한, ReportReader 클래스가 Formatter를 직접 생성하는 것이 아니라, 생성자를 통해 Formatter 인스턴스를 주입받도록 수정해야한다. 이는 의존성 주입 ( Dependency Injection, DI ) 패턴을 사용한 것으로, DIP 원칙을 구현하는 방법 중 하나다. 

 

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

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

 

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

+ Recent posts