9/25 Node.js 심화 1주차 (1)

성준호·2024년 9월 25일
0

1. 객체 지향

1) 객체란?

객체(Object)는 현실 세계의 물체나 개념을 소프트웨어 세계로 옮긴 것입니다. 예를 들면, ‘자동차’나 ‘사람’처럼 생각하면 됩니다. 여기서, 객체는 여러 속성과 행동[메서드]으로 구성됩니다.

  • 객체는 정보[데이터]와 그 정보를 처리하는 행동[함수 또는 메서드]를 가지고 있다.
  • 객체들은 서로 메서드 호출을 통해 메시지를 주고 받아 협력한다.

2 객체 지향이란?

객체 지향은 소프트웨어 개발에서 주요 구성 요소를 기능이 아닌 객체로 삼으며 어떤 객체가 어떤 일을 할 것인가에 초점을 맞춘다
즉, 객체를 도출하고 각각의 역할을 명확하게 정의하는 것에 초점을 맞추는 방법론이다.

  • 객체 지향은 책임과 권한을 가진 객체들이 서로 메시지를 주고 받아 협력하여 필요한 기능을 수행하는 방법론이다.
  • 이 방법은 크고 복잡한 시스템도 효과적으로 분해하고 구성하며, 개발자가 손쉽게 이해하고 효율적으로 관리할 수 있게 도와준다.

3 객체 지향 소프트웨어 구분

아래의 기준을 만족할 경우 객체 지향, 만족하지 않으면 절차 지향적 성격을 가진다.

  1. 캡슐화, 다형성, 상속을 지원하는가?
    --> 클래스는 객체를 생성할 때 사용하는 틀이며 new 키워드를 통해 객체를 생성할 수 있다.

  2. 데이터 접근 제한이 가능한가?
    --> 데이터 접근 제한은 특정 데이터에 대한 외부 접근을 제한하여 데이터의 안정성과 보안을 높여준다.

2. 객체 지향 프로그래밍

1) 프로그래밍 패러다임

가장 대표적인 세 가지의 프로그래밍 패러다임
1. 구조적 프로그래밍 (Structured Programming)
2. 객체 지향 프로그래밍 (Object-Oriented Programming, OOP)
3. 함수형 프로그래밍 (Functional Programming)

구조적 프로그래밍은 기능 중심적인 개발을 진행한다.

  • 구조적 프로그래밍은 가장 처음으로 적용된 패러다임이다.

객체 지향 프로그래밍은 프로그램의 처리 단위가 객체인 프로그래밍 방법입니다.

  • 객체 지향 프로그래밍은 현실 세계를 프로그램으로 모델링하는 가장 대표적인 프로그래밍 패러다임이다.

함수형 프로그래밍은 함수를 중심적으로 개발을 진행한다

  • 가장 초기에 만들어졌지만, 최근들어 주목 받기 시작했다.

2) 객체 지향 프로그래밍

객체지향 프로그래밍은 상태(데이터)와 그 데이터를 조작하는 프로세스(메서드)가 같은 모듈 내부에 배치되는 프로그래밍 방식을 의미한다.

  • 객체 지향 프로그래밍은 코드를 추상화하여 개발자가 더욱 직관적으로 사고할 수 있게 한다.
  • 현실 세계의 객체를 유연하게 표현할 수 있다.
  • 객체는 고유한 특성을 가지고 있으며, 특정 기능을 수행할 수 있다.
  • 예를 들어 자동차라는 객체는 출발, 정지, 운행, 제동과 같은 기능을 수행할 수 있다.

3) 객체 지향 프로그래밍을 사용하는 이유

객체 지향 프로그래밍은 데이터와 기능이 밀접하게 연결되어 있기 때문에, 코드의 구조와 동작을 직관적으로 파악할 수 있다.
예로 자동차라는 객체가 있다고 할 때, 이 객체는 색상, 속도와 같은 데이터와, 출발, 정지같은 기능(메서드)를 가진다. 따라서 문제가 발생하면 자동차라는 객체의 내부만 살펴보면 된다.
또한 하나의 객체에 정의된 기능이나 데이터 구조는 다른 객체에서도 쉽게 재사용할 수 있다.

3. 객체 지향 프로그래밍의 원칙

1) 캡슐화

객체 내부의 세부적인 사항을 감추는 것, 즉 중요한 정보를 외부로 노출시키지 않도록 만드는 것

JS는 완벽한 캡슐화를 지원하지 않는다. 그러나 개발자들은 변수 앞에 언더바(_)를 붙여 내부의 변수를 숨긴 것처럼 나타내는 규칙을 따르곤 한다.

/** Encapsulation **/
class User {
  private name: string; // name 변수를 외부에서 접근을 할 수 없게 만듭니다.
  private age: number; // age 변수를 외부에서 접근을 할 수 없게 만듭니다.

  setName(name: string) { // Private 속성을 가진 name 변수의 값을 변경합니다.
    this.name = name;
  }
  getName() { // Private 속성을 가진 name 변수의 값을 조회합니다.
    return this.name;
  }
  setAge(age: number) { // Private 속성을 가진 age 변수의 값을 변경합니다.
    this.age = age;
  }
  getAge() { // Private 속성을 가진 age 변수의 값을 조회합니다.
    return this.age;
  }
}

const user = new User(); // user 인스턴스 생성
user.setName('이용우');
user.setAge(30);
console.log(user.getName()); // 이용우
console.log(user.getAge()); // 30
console.log(user.name); // Error: User 클래스의 name 변수는 private로 설정되어 있어 바로 접근할 수 없습니다.

User 클래스를 선언하고, 내부에는 name, age 멤버 변수를 초기화하였다.
여기서는 private라는 접근 제한자를 사용하고 있다. 이는 인스턴스 내부에서만 해당 변수에 접근이 가능하도록 제한하는 문법이다.

따라서 User 클래스의 name, age 멤버 변수는 클래스 외부에서는 어떠한 방법으로도 직접 접근을 할 수 없다. 오로지 setter만 변수를 변경할 수 있고, getter만 변수를 조회할 수 있게 되었다

2) 상속

상속은 하나의 클래스가 가진 특징을 다른 클래스가 그대로 물려받는 것을 의미한다.
이미 정의된 상위 클래스의 특징을 하위 클래스에서 물려받아 코드의 중복을 제거하고 재사용성을 증대시킨다.

  • 개별 클래스를 상속 관계로 묶음으로써 클래스 간의 체계화된 구조를 쉽게 파악할 수 있다.
  • 상위 클래스의 데이터와 메서드를 변경함으로써 전체 코드에 대한 일관성을 유지할 수 있다.
/** Inheritance **/
class Mother { // Mother 부모 클래스
  constructor(name, age, tech) { // 부모 클래스 생성자
    this.name = name;
    this.age = age;
    this.tech = tech;
  }
  getTech(){ return this.tech; } // 부모 클래스 getTech 메서드
}

class Child extends Mother{ // Mother 클래스를 상속받은 Child 자식 클래스
  constructor(name, age, tech) { // 자식 클래스 생성자
    super(name, age, tech); // 부모 클래스의 생성자를 호출
  }
}

const child = new Child("이용우", "28", "Node.js");
console.log(child.name); // 이용우
console.log(child.age); // 28
console.log(child.getTech()); // 부모 클래스의 getTech 메서드 호출: Node.js

Mother 부모 클래스를 상속받은 Child 자식 클래스에서 name, age 멤버 변수를 직접 접근하여 호출하고, Mother 부모 클래스에서 정의된 getTech() 메소드를 호출할 수 있게 되었다.

3) 추상화

객체에서 공통된 부분을 모아 상위 개념으로 새롭게 정의하는 것을 추상화라고 한다. 즉, 불필요한 세부 사항을 생략하고 중요한 특징만을 강조하여 코드를 더욱 간결하고 관리하기 쉽게 만드는 원칙이다.

  • 추상화를 통해 객체들의 불필요한 특성을 제거함으로써, 공통적인 특성을 더욱 명확하게 파악할 수 있게 된다.
  • 이를 통해 전체 시스템의 구조를 명확하게 이해하게 되고, 테스트를 더욱 쉽게 작성할 수 있게 된다.

클래스를 설계할 때 공통적으로 묶일 수 있는 기능을 추상화 -> 추상 클래스 -> 인터페이스 순으로 정리하면 여러 클래스 간의 일관성을 유지하면서, 다양한 형태로 확장될 수 있는 코드, 즉 다형성이 가능해진다.
-> 여기서 인터페이스란 클래스를 정의할 때 메소드와 속성만 정의하여 인터페이스에 선언된 프로퍼티 또는 메소드의 구현을 강제하여 코드의 일관성을 유지하게 한다.

/** Abstraction **/
interface Human {
  name: string;
  setName(name);
  getName();
}

// 인터페이스에서 상속받은 프로퍼티와 메소드는 구현하지 않을 경우 에러가 발생합니다.
class Employee implements Human {
  constructor(public name: string) {  }
  
  // Human 인터페이스에서 상속받은 메소드
  setName(name) { this.name = name; }
  
  // Human 인터페이스에서 상속받은 메소드
  getName() { return this.name; }
}

const employee = new Employee("");
employee.setName("이용우"); // Employee 클래스의 name을 변경하는 setter
console.log(employee.getName()); // Employee 클래스의 name을 조회하는 getter

Employee 클래스는 Human 인터페이스에서 정의한 name 프로퍼티와 setName, getName 메서드를 강제로 구현하게 되었다.
따라서, 동일한 인터페이스Human 인터페이스를 구현하는 모든 클래스는 해당 인터페이스에 선언된 프로퍼티메서드를 구현해야 함을 보장하게 되었다. 이로 인해 코드의 일관성을 유지할 수 있게 되었다.

4) 다형성

다형성(Polymorphism)은 하나의 객체(클래스)가 다양한 형태로 동작하는것을 의미한다. 이는 객체가 가진 특성에 따라 같은 기능이 다르게 재구성되는 것을 의미한다.
즉, 동일한 메서드나 함수 명을 사용하더라도, 클래스마다 그 메서드가 다르게 동작하는 것이 다형성의 핵심입니다.

다형성은 역할(인터페이스)과 구현을 분리하게 해준다. 따라서, 오버라이딩을 통해 특정 서비스의 기능을 유연하게 변경하거나 확장할 수 있다.

/** Polymorphism **/
class Person {
  constructor(name) { this.name = name; }

  buy() {}
}

class Employee extends Person {
  buy() { console.log(`${this.constructor.name} 클래스의 ${this.name}님이 물건을 구매하였습니다.`); }
}

class User extends Person {
  buy() { console.log(`${this.constructor.name} 클래스의 ${this.name}님이 물건을 구매하였습니다.`); }
}

const employee1 = new Employee("이용우");
const employee2 = new Employee("김창환");
const user1 = new User("이태강");
const user2 = new User("김민수");

const personsArray = [employee1, employee2, user1, user2];
// personsArray에 저장되어 있는 Employee, User 인스턴스들의 buy 메소드를 호출합니다.
personsArray.forEach((person) => person.buy());

// Employee 클래스의 이용우님이 물건을 구매하였습니다.
// Employee 클래스의 김창환님이 물건을 구매하였습니다.
// User 클래스의 이태강님이 물건을 구매하였습니다.
// User 클래스의 김민수님이 물건을 구매하였습니다.

위의 personsArray.forEach() 예제에서 person 변수는 Person 클래스를 상속받은 Employee 또는 User 클래스의 인스턴스를 참조한다.

여기서, 각 인스턴스의 buy 메서드를 호출하는 것은 동일하지만, EmployeeUser 클래스의 buy 메서드는 서로 다른 행위를 수행하고 있는 것을 확인할 수 있다.

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

1) 객체 지향 설계 5원칙

SOLID의 종류

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

2) 단일 책임의 원칙 (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() { // 사용자의 인증을 검증하는 메소드
    //...
  }
}

두 가지의 책임을 가지고 있는 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 객체의 메소드를 사용한다.
      //...
    }
  }
}

3) 개방-폐쇄 원칙

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

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

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 함수 자체를 수정해야 한다. 이런 접근 방식은 개방-폐쇄 원칙인 확장에는 열려있으나, 변경에는 닫혀 있어야 한다.를 위반하게 된다.

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

4) 리스코프 치환 원칙

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

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

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

정사각형과 직사각형 예제를 이용해서 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

SquareRectangle 클래스에서 같은 메서드를 호출하더라도 다른 결과값이 반환되는 것을 확인할 수 있다. 예제에서 높이를 7로 설정하려 하였지만, Square 클래스에서는 너비와 높이가 동일해야 하므로 결과적으로 너비가 7로 설정되었다.

언뜻 보면 RectangleSquare를 포함하고 있는 것처럼 보이지만, setWidth, setHeight 메서드처럼 다르게 동작해야 하는 경우가 존재하기 때문에 Square 클래스는 Rectangle을 상속받는 것은 옳은 방법이 아니다.
이럴 경우 두 클래스를 모두 포함하는 인터페이스를 구현해야 한다. 여기선 Shape라는 인터페이스 역할을 수행하는 새로운 부모 클래스를 생성하고, RectangleSquare가 이를 상속받도록 코드를 수정하였다.

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

수정된 코드에선 RectangleSquare 객체를 생성하고, 각각의 getArea 메서드를 호출하면 둘 다 49라는 동일한 넓이가 반환되는 것을 확인할 수 있다. 따라서 이 코드는 리스코프 치환 원칙을 만족하도록 구성되었다.

5) 인터페이스 분리 원칙

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

  • 클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해선 안 되고, 최대한 인터페이스를 작게 유지해야 한다.
/** 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 세 가지의 기능이 모두 필요하지만, EconomicPrinter 클래스의 경우 print 기능만 지원하는 클래스이다.
만약 EconomicPrinter 클래스가 SmartPrinter 인터페이스를 상속받는다면, 필요하지 않은 fax, 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 기능을 지원한다.
    // ...
  }
}

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

6) 의존성 역전 원칙

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

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

추상화 없이 고수준 계층의 모듈이 저수준 계층의 모듈을 의존하게 되면 어떤 상황이 발생할까? 사소한 변경사항에도 고수준 계층의 코드를 변경해야 하고, 소모되는 개발 코스트또한 증가한다.

ReportReader라는 클래스에서 파일을 입력받아 확장자별로 파싱하여 String 형식으로 반환하는 예시이다.

/** 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 메소드를 호출한다.
이렇게 각 파일 확장자에 따라 다른 클래스와 다른 메서드를 사용하면, 이는 구체적인 구현에 의존하고 있는 상황이다.

이 문제를 해결하려면 XmlFormatterJsonFormatter 클래스가 동일한 인터페이스인 Formatter를 상속받도록 수정해야 한다. 이렇게 하면 ReportReader 클래스는 Formatter 인터페이스의 parse 메서드만 의존하게 된다.
또한 ReportReader 클래스가 Formatter를 직접 생성하는 것이 아니라, 생성자를 통해 Formatter 인스턴스를 주입받도록 수정해야 한다. 이는 의존성 주입 패턴(Dependency Injection, DI)을 사용한 것으로 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');
profile
안녕하세요

0개의 댓글