자바스크립트 공부 일지 21

객체 지향 프로그래밍

객체 지향

개념

  • 객체 지향은 소프트웨어의 핵심을 기능이 아닌 객체로 삼으며 “누가 어떠한 일을 할 것인가?”에 초점을 맞춥니다. 즉, 객체를 도출하고 각각의 역할을 정의하는 것에 초점을 맞춥니다.

책임과 권한을 가진 객체들이 서로 메시지를 주고받으며 협력해서 필요한 기능을 수행하도록 시스템을 개발하는 것을 객체 지향이라고 합니다.

객체 지향적인 소프트웨어의 구분

절차지향적인 소프트웨어와 객체지향적인 소프트웨어를 구분하는 방법은 아래의 기준을 만족할 경우 객체지향, 만족하지 않으면 절차지향적인 성격을 가집니다.

  • 캡슐화, 다형성, 클래스 상속을 지원하는가? → 여기서 **클래스(Class)**는 단순히, 클래스라는 을 만들고, new 생성자 함수를 이용하여 객체를 생성하는 아주 단순한 역할을 가지고 있습니다.

  • 데이터 접근 제한을 걸 수 있는가?

캡슐화 (Encapsulation)

  • 💡 개념적이나 물리적으로 객체 내부의 세부적인 사항감추는 것캡슐화라고 부릅니다. 즉, 캡슐화를 사용하는 가장 큰 이유는 정보은닉을 목적으로 합니다.

정보은닉은 객체에 대한 중요한 정보를 외부로 노출시키지 않도록 하기 위한 기법입니다.
캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것입니다. 캡슐화를 통해 객체 내부의 접근을 제한하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 설계를 좀 더 쉽게 변경할 수 있게 됩니다. -> 단순히 외부에 노출되어 있는 것을 사용

Javascript 클래스는 멤버 변수를 숨길 수 없습니다. 그래서 개발자들은 멤버 변수 앞에 _를 붙여 클래스 내부의 변수를 숨긴 것 “처럼" 표시하겠다는 규칙을 만들었습니다.

하지만 Javascript를 실행했을 때에는 클래스의 멤버 변수가 숨겨지지 않으니, 예제는 Typescript로 확인해보도록 하겠습니다.

ts
/** Encapsulation **/
class User {
  // 접근 제어의 경우 typescript를 사용해 구현할 수 있다. 이후 자바스크립트로 변환하면서 스코프를 이용한다고함.

  private name: string; // 현재 클래스 내에서만 사용 가능
  private age: number; // private는 getter, setter로만 접근 가능하다.
                       // this.name 등으로 접근 불가.

  constructor(name: string, age: number) {
    this.name = name;
    this.age = 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('임건', 28); // user 인스턴스 생성
user.setName("임꺽정");
user.setAge(26);
console.log(user.getName()); // 임꺽정
console.log(user.getAge()); // 26
// console.log(user.name); // Error: User클래스의 name 변수는 private로 설정되어 있어 바로 접근할 수 없습니다.

상속 (Inheritance)

  • 💡 상속이란 이미 정의된 상위 클래스의 특징을 하위 클래스에서 물려받아 코드의 중복을 제거하고 코드 재사용성을 증대시킵니다. 즉, 하나의 클래스가 가진 **특징(함수, 변수 및 데이터)**을 다른 클래스가 그대로 물려 받는 것을 말합니다.

개별 클래스를 상속 관계로 묶음으로써 클래스 간의 체계화된 구조를 파악하기 쉬워집니다.
데이터와 메소드를 변경할 때 상위에 있는 것만 수정하여 전체적으로 일관성을 유지할 수 있습니다.

js
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

추상화 (Abstraction)

  • 💡 객체에서 공통된 부분을 모아 상위 개념으로 새롭게 선언하는 것을 추상화라고 합니다. 즉, 불필요한 부분을 생략하고 객체 속성 중 공통적이고 중요한 것에만 중점을 두어 모델화 하는 것입니다. 추상화는 객체들의 공통적인 특성을 파악하여 필요 없는 특성을 제거하는 과정입니다.
    시스템을 구축하기 전에 시스템 구조 및 구성을 가시적으로 볼 수 있고, 해당 시스템과 유사한 모델을 만들어 여러가지 테스트를 할 수 있습니다.
    복잡한 내부 구현에 신경쓰지 않고, 외부에 노출되어 있는 인터페이스만을 이용하여 코드를 작성할 수 있습니다.

  • 클래스를 설계할 때 공통적으로 묶일 수 있는 기능을 추상화 → 추상 클래스 → 인터페이스로 모델링해서 향후 다형성(Polymorphism)으로 확장할 수 있도록 설계합니다.

여기서 인터페이스(Interface)란 클래스 정의할 때 메소드와 속성만 정의하여 인터페이스에 선언된 프로퍼티 또는 메소드의 구현을 강제하여 코드의 일관성을 유지할 수 있도록 만듭니다.

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

// 인터페이스에서 상속받은 프로퍼티와 메소드는 구현하지 않을 경우 에러가 발생합니다.
class Employee implements Human{
  // Human 인터페이스의 멤버 변수 초기화
  // name: string;
  constructor(
    public name: string
  ) {  }
  
  // Human 인터페이스에서 상속받은 메소드
  // 구현부를 작성.
  setName(name : string) { 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 추상 메소드를 강제로 구현하게 되었습니다.

이제부터 동일한 인터페이스를 상속받은 클래스는 해당 인터페이스 내부에 선언되어 있는 프로퍼티메소드가 구현되어 있다는것을 인지할 수 있게 되었습니다.

다형성 (Polymorphism)

  • 💡 다형성이란 **객체(클래스)**가 연산을 수행하게 될 때 하나의 행위에 대해 각 객체가 가지고 있는 고유한 특성으로 다른 여러 형태로 재구성 되는 것을 말한다. 즉, 동일한 메소드의 이름을 사용하지만 메소드에 대해 클래스마다 다르게 구현되는 개념이 다형성입니다.

다형성을 통해 역할(인터페이스)과 구현을 분리해서 **오버라이딩(Overriding)**을 통해 서비스의 구현기능을 유연하게 변경, 확장이 가능합니다.

Java오버로딩(Overloading), **오버라이딩(Overriding)**가 대표적인 다형성의 예시입니다.

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

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

class User {
  constructor(name) { this.name = name; }

  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 polymorphismArray = [employee1, employee2, user1, user2];
// polymorphismArray에 저장되어 있는 Employee, User 인스턴스들의 buy 메소드를 호출합니다.
// 행위는 같지만 내부적으로 작동하는 코드는 다르다.
polymorphismArray.forEach((polymorphism) => polymorphism.buy());

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

의존성 (Dependency)

  • 의존성이란 객체(모듈 및 클래스)들이 협력하는 과정 속에서 해당 객체들이 다른 객체를 의존하게 되는 정도를 나타냅니다.
  • 의존성이라는 말속에는 어떤 객체가 변경될 때 그 객체에 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포되어 있습니다.
  • 만약 하나의 객체가 변경될 경우 의존하고 있는 다른 객체 또한 변경이 이루어지게 됩니다.

결합도 (Coupling)

  • 결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 의존성을 가지고 있는지를 나타냅니다.
  • 객체 사이의 의존성이 과한 경우를 가리켜 결합도가 높다고 말한다.
  • 객체들이 합리적인 수준으로 의존할 경우에는 결합도가 낮다고 말한다.
  • 두 객체 사이의 결합도가 높으면 높을수록 함께 변경될 확률도 높아지기 때문에 변경하기 어려워진다.
  • 따라서 설계의 목표는 객체 사이결합도를 낮춰 변경이 용이한 설계를 만드는 것이어야 한다.

응집도 (Cohesion)

  • 응집도는 모듈에 포함된 내부 요소들이 각각 연관되어 있는 관계의 정도를 나타냅니다.
  • 밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에 위임하는 객체를 가리켜 응집도가 높다고 말한다.
  • 1개의 메소드가 내부에서 변수를 많이 사용할 수록 해당 메소드클래스응집도가 높아지게됩니다.
  • 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을뿐더러 응집도를 높일 수 있다.
  • 객체의 응집도를 높이기 위해서는 객체는 스스로 자신의 데이터를 책임져야 합니다.

프로그래밍 패러다임

  • 프로그래밍 패러다임은 무엇을 해야 할지를 말하기보다는 무엇을 해서는 안 되는지를 말해줍니다.

프로그래밍 패러다임에는 대표적으로 3가지가 존재합니다.

  1. 구조적 프로그래밍 (Structured Programming)
  2. 객체 지향 프로그래밍 (Object-Oriented Programming, OOP)
  3. 함수형 프로그래밍 (Functional Programming)

구조적 프로그래밍제어 흐름직접적인 전환에 대한 규칙을 제시합니다.

  • 구조적 프로그래밍기능을 중심적으로 개발을 진행합니다.
  • 구조적 프로그래밍은 프로그래밍이라는 기술이 시작되면서 가장 처음으로 적용된 패러다임입니다.

객체 지향 프로그래밍제어흐름간접적인 전환에 대한 규칙을 제시합니다.

  • 객체 지향 프로그래밍은 프로그램의 처리단위가 객체인 프로그래밍 방법입니다.
  • 객체 지향 프로그래밍은 “현실 세계를 모델링”하는 대표적인 프로그래밍 패러다임입니다.

함수형 프로그래밍할당문에 대한 규칙을 제시합니다.

  • 함수형 프로그래밍함수를 중심적으로 개발을 진행합니다.
  • 함수형 프로그래밍은 3가지의 패러다임 중 가장 처음 만들어졌지만 최근들어 겨우 도입되기 시작하는 패러다임 입니다.

객체 지향 프로그래밍

  • 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍(Object-Oriented Programming)이라고 부릅니다.
  • 객체 지향 프로그래밍코드를 추상화해 직관적으로 사고할 수 있기 때문에, 대표적인 프로그래밍 방법론으로 적용되고 있습니다.
  • 객체 지향 프로그래밍에서는 자동차, 동물, 사람 등과 같은 현실 세계의 객체를 유연하게 표현할 수 있습니다.
  • 객체는 어떠한 특성을 가지고 있으며 특정 기능을 수행할 수 있습니다.
  • 자동차는 객체이고 출발, 정지, 운행 및 제동과 같은 기능을 수행할 수 있습니다.

API를 만들 때 마다 복사 → 붙여넣기 방식으로 동일한 코드를 여러군데 분산을 시켜놓았을 때, 해당 로직을 수행하는 코드를 수정해야할 때 복사한 모든 코드를 일일히 찾아가며 수정을 해야하는 상황이 발생합니다.

로그래밍을 하면서 효율적으로 시간을 관리할 수 있어야합니다. 그렇게 하기 위해선 코드를 얼마나 깔끔하게 짜느냐도 중요하지만, 코드 변경점이 발생하더라도 최대한 코드를 적게 수정하여 더욱 많은 시간을 만들 수 있어야합니다.

발생한 문제 상황을 빠르게 인지하고, 어떤 코드에서 오류가 발생했는지 빠르게 찾아보며, 오류 사항을 빠르게 고쳐 개발에 사용하는 시간을 최대한으로 줄이는것을 목표로 삼아야합니다.

객체지향 프로그래밍데이터와 프로세스를 하나의 단위로 처리하는 특성을 가지고 있기 때문에 코드를 수정해야할 때 어떤 코드에서 문제가 발생했는지 개발자들이 직관적으로 인지할 수 있으며 여러곳에 분산된 모든 코드를 수정해야하는 것이 아닌 해당 로직을 수행하는 코드만 수정하더라도 문제가 해결될 수 있습니다. 🙂

장점
  • 객체지향 프로그래밍의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공함으로써 요구사항 변경에 좀 더 수월하게 대응할 수 있는 가능성을 높여줍니다.
  • 동작을 기준으로 프로그래밍을 진행하는 것보다 데이터를 중심으로 프로그래밍을 하게되면 코드의 덩치가 커지더라도 일관성을 유지하기 좋습니다.
  • 객체지향 코드는 자신의 문제를 스스로 처리해야 한다는 우리의 예상을 만족시켜주기 때문에 이해하기 쉽고, 객체 내부의 변경이 객체 외부에 파급되지 않도록 제어할 수 있기 때문에 변경하기 수월합니다.
  • 흔히 데이터프로세스하나의 단위로 통합해 놓는 방식으로 표현하기도 합니다.
  • 데이터와 데이터를 사용하는 프로세스동일한 객체 안에 위치한다면 객체지향 프로그래밍 방식을 따르고 있을 확률이 높습니다.
설계
  • 좋은 설계요구하는 기능온전히 수행하면서 추후의 변경을 매끄럽게 수용할 수 있는 설계입니다.
  • 변경 가능한 코드이해하기 쉬운 코드입니다. 만약 코드를 변경해야 하는데 그 코드를 이해할 수 없다면 변경에 유용하더라도 코드를 수정하겠다는 마음이 선뜻 들지는 않을 것입니다.
  • 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계입니다.
  • 훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다.

객체지향 설계의 5원칙 (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를 이용해서 코드를 개선해봅시다

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

  1. changeSettings: Settings를 변경한다.
  2. verifyCredentials: 인증을 검증한다.
js
/** SRP Before **/
class UserSettings {
  constructor(user) { // UserSettings 클래스 생성자
    this.user = user;
  }

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

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

2가지의 책임을 가지고 있는 UserSettings 클래스를 어떻게 분리해 봅시다.

  1. 사용자의 설정변경하는 책임을 가진 UserSettings 클래스
  2. 사용자의 인증검증하는 책임을 가진 UserAuth 클래스
js
/** SRP After **/
class UserAuth {
  constructor(user) { // UserAuth 클래스 생성자
    this.user = user;
  }

  verifyCredentials() { // 사용자의 인증을 검증하는 메소드
    // 인자를 받지 않음. 멤버변수 this.user를 이용할것이므로
    //...
  }
}

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 함수는 계산기의 역할을 하는 함수인데요, 현재는 덧셈, 뺄셈 기능만 구현되어 있습니다.

js
/** 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파라미터를 Callback 함수로 변경하여 다른 조건이 추가되더라도 실제 calculator 함수에서는 어떠한 변화가 발생하지 않도록 구현 할 수 있습니다!

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

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

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

그렇다면 **정사각형(Sqaure)**과 직사각형(Rectagle) 문제를 이용해서 LSP를 적용해보도록 하겠습니다!

**정사각형(Sqaure)**의 특징은 무엇일까요? 높이와 너비가 동일한 특성을 가지고 있습니다.

정사각형과 다르게 **직사각형(Rectagle)**의 경우 높이와 너비가 서로 독립적으로 변경될 수 있는 특성을 가지고 있습니다.

클래스를 구현해봅시다.

js
/** 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클래스에서 동일한 메소드를 호출하였지만, 다른 결과값이 출력된 것을 확인하였습니다. 만약 두 클래스를 서로 교체하였을 때에도 동일한 결과 값도출되지 않는 것을 확인 할 수 있겠죠?

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

SquareRectangle클래스를 수정해야 LSP원칙에 위배되지 않게 구현할 수 있을까요?

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

이럴 경우 두 클래스를 모두 포함하는 인터페이스를 구현해야합니다. 여기서는 Shape라는 부모 클래스를 만들어 인터페이스의 역할을 대체하도록 만들겠습니다.

js
/** LSP After **/
// ts의 경우에는 Interface로 생성
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로 진행하도록 하겠습니다

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

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

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

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

ts
/** 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를 이용해서 코드를 개선해봅시다.

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

js
/** 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원칙인 “높은 계층의 모듈(도메인)이 저수준의 모듈(하부구조)에 의존해서는 안된다.”에 해당하는 원칙을 지킬 수 있습니다.

js
/** 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 원칙을 이용하여 저수준의 모듈을 수정하더라도 고수준의 모듈 코드를 더이상 수정하지 않도록 코드가 개선되었습니다

소켓 프로그래밍 프로젝트 분리

심화 주차 - 2 의 소켓프로그래밍 코드를 분리해봅시다.

  • 코드를 분리하는게 왜 필요할까요?
    1. 가독성 향상: 코드를 분리하면서 추상화가 자연스럽게 되기 때문에 코드를 읽기가 훨씬 수월해집니다.
    2. 관리(유지보수)의 용이: 어떤 함수가 어떤 역할을 갖는지 비교적 쉽게 파악할 수 있습니다.

**추상화(Abstraction)**란, 함수랑 비슷하다고 생각하시면 쉽습니다.

만약 여러분이 "지금 서버와 소켓으로 연결된 모든 클라이언트에 메세지를 보내는 기능"을 구현하려면 어떻게 할까요?

js
// 1. 함수 생성
function sendMessageAll(message) {
	// 2. 메세지를 보내기 위한 로직 작성
	~~~
	~~
	~~~~
}

내가 만든 sendMessageAll 이라는 기능을 사용하는 다른 개발자는 sendMessageAll 함수의 내부가 어떻게 작성됐는지 굳이 알 필요가 없습니다.

socket.io 라이브러리를 이용해서 기능을 구현했든, 직접 웹소켓을 만들어서 기능을 구현했든 신경쓰지 않아도 되고 동작만 하면 되는것이죠.

다른 사람들은 이제 단순히 sendMessageAll 이라는 함수를 호출하면 "지금 서버와 소켓으로 연결된 모든 클라이언트에 메세지를 보내는 기능"을 수행하는것만 유추할 수 있습니다.

코드 분리의 기준

  • 어떤 기준으로 코드를 분리하면 좋을까요?

    위에서 말했던것중 중요한 키워드가 몇개 있었는데요, 무엇일까요?

    • 추상화
    • 기능(함수)의 역할

    위에 적어놓은 두개의 키워드를 꼽을수 있겠습니다.

    그럼, 두개의 키워드를 기준으로 코드를 분리하려면 어떻게 해야할지 고민하면서 바로 코드를 정리해봅시다.

다음 세 가지 소켓을 분리해봅시다.

js
// 4. 소켓 연결 이벤트 핸들링
io.on("connection", (sock) => {
  socketIdMap[sock.id] = null;
  console.log("새로운 소켓이 연결됐어요!");

  sock.on("CHANGED_PAGE", (data) => {
    console.log("페이지가 바뀌었대요", data, sock.id);
    socketIdMap[sock.id] = data; // 소켓 아이디와 페이지 url을 매핑

    emitSamePageViewerCount();
  });

  sock.on("BUY", (data) => {
    const payload = {
      nickname: data.nickname,
      goodsId: data.goodsId,
      goodsName: data.goodsName,
      date: new Date().toISOString(),
    };

    console.log("클라이언트가 구매한 데이터", data, new Date());
    sock.broadcast.emit("BUY_GOODS", payload);
  });

  sock.on("disconnect", () => {
    delete socketIdMap[sock.id];
    console.log(sock.id, "연결이 끊어졌어요!");
    emitSamePageViewerCount();
  });
});

다음과 같이 코드를 변경합니다.

js
io.on("connection", (sock) => {
  const { watchChangePage, watchBuying, watchByeBye } = initSocket(sock);

  watchChangePage();

  watchBuying();

  watchByeBye();
});
  1. 서버에 소켓이 새로 연결되면, (io.on('connection', ~~~~)
  2. 서버에서 소켓 연결할때 필요한 준비를 하고, (~~~ = initSocket(sock);)
  3. 페이지가 변경되었는지 감시하면서 (watchChangePage();)
  4. 구매를 하는지 감시하고 (watchBuying();)
  5. 나가는지 감시한다. (watchByebye();)

나머지 initSocket함수는 다음과 같습니다.

js
// app.js

function initSocket(sock) {
  socketIdMap[sock.id] = null;
  console.log("새로운 소켓이 연결됐어요!");

  // 특정 이벤트가 전달되었는지 감지할 때 사용될 함수
  function watchEvent(event, func) {
    sock.on(event, func);
  }

  // 연결된 모든 클라이언트에 데이터를 전달하는 함수
  function notifyEveryone(event, data) {
    sock.broadcast.emit(event, data);
  }

  // 특정한 클라이언트에 데이터를 전달하는 함수
  function notifyTo(socketId, event, data) {
    io.to(socketId).emit(event, data);
  }

  // 페이지가 변경되었을 때, 같은 페이지를 본 사용자 수를 전달하는 함수
  function emitSamePageViewerCount() {
    const urls = Object.values(socketIdMap);
    const countByUrl = urls.reduce((value, url) => {
      if (!url) return value; // detail 페이지가 아닌 사용자는 제외
      return {
        ...value,
        [url]: value[url] ? value[url] + 1 : 1,
      };
    }, {});

    for (const [socketId, url] of Object.entries(socketIdMap)) {
      const count = countByUrl[url];
      notifyTo(socketId, "SAME_PAGE_VIEWER_COUNT", count);
    }
  }

  return {
    watchChangePage: () => {
      watchEvent("CHANGED_PAGE", (data) => {
        console.log("페이지가 바뀌었대요", data, sock.id);
        socketIdMap[sock.id] = data; // 소켓 아이디와 페이지 url을 매핑

        emitSamePageViewerCount();
      });
    },

    watchBuying: () => {
      watchEvent("BUY", (data) => {
        const payload = {
          nickname: data.nickname,
          goodsId: data.goodsId,
          goodsName: data.goodsName,
          date: new Date().toISOString(),
        };

        console.log("클라이언트가 구매한 데이터", data, new Date());
        notifyEveryone("BUY_GOODS", payload);
      });
    },

    watchByeBye: () => {
      watchEvent("disconnect", () => {
        delete socketIdMap[sock.id];
        console.log(sock.id, "연결이 끊어졌어요!");
        emitSamePageViewerCount();
      });
    },
  };
}

이렇게 코드를 읽기 쉽도록 원래 있던 코드를 분리하면서, 새로운 함수를 만들어 나가는 과정을 "추상화"한다고 표현합니다!

이렇게 추상화 된 코드는 보통 역할에 맞는 파일에 묶여서 관리되는데요, 바로 해보겠습니다

현재는 app.js내 서버가 실행되고, 라우터 및 미들웨어를 설정하고, 소켓을 설정합니다. 분리해봅시다.

app.js

js
// app.js
const express = require("express");
const cookieParser = require("cookie-parser");
const { Server } = require("http"); // 1. 모듈 불러오기

const goodsRouter = require("./routes/goods.js");
const usersRouter = require("./routes/users.js");
const authRouter = require("./routes/auth.js");
const connect = require("./schemas");

const app = express();
const http = Server(app); // 2. express app을 http 서버로 감싸기

connect(); // mongoose를 연결합니다.

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static("assets"));
app.use("/api", [goodsRouter, usersRouter, authRouter]);

app.get("/", (req, res) => {
  res.send("Hello World!");
});

module.exports = http;

server.js

js
// server.js
const http = require("./app.js")
require("./socket.js")

const port = 3000;

http.listen(port, () => {
  console.log(port, "포트로 서버가 열렸어요!");
});

socket.js

js
// socket.js
const socketIo = require("socket.io"); // 1. 모듈 불러오기
const http = require("./app.js") // http 가져오기
const io = socketIo(http); // 3. http 객체를 Socket.io 모듈에 넘겨서 소켓 핸들러 생성

const socketIdMap = {};

// app.js

function initSocket(sock) {
  socketIdMap[sock.id] = null;
  console.log("새로운 소켓이 연결됐어요!");

  // 특정 이벤트가 전달되었는지 감지할 때 사용될 함수
  function watchEvent(event, func) {
    sock.on(event, func);
  }

  // 현재 사용자를 제외한 연결된 모든 클라이언트에 데이터를 전달하는 함수
  function notifyEveryone(event, data) {
    sock.broadcast.emit(event, data);
  }

  // 특정한 클라이언트에 데이터를 전달하는 함수
  function notifyTo(socketId, event, data) {
    io.to(socketId).emit(event, data);
  }

  // 페이지가 변경되었을 때, 같은 페이지를 본 사용자 수를 전달하는 함수
  function emitSamePageViewerCount() {
    const urls = Object.values(socketIdMap);
    const countByUrl = urls.reduce((value, url) => {
      if (!url) return value; // detail 페이지가 아닌 사용자는 제외
      return {
        ...value,
        [url]: value[url] ? value[url] + 1 : 1,
      };
    }, {});

    for (const [socketId, url] of Object.entries(socketIdMap)) {
      const count = countByUrl[url];
      notifyTo(socketId, "SAME_PAGE_VIEWER_COUNT", count); // 추상화된 notifyTo 함수 이용
    }
  }

  return {
    watchChangePage: () => {
      watchEvent("CHANGED_PAGE", (data) => {
        console.log("페이지가 바뀌었대요", data, sock.id);
        socketIdMap[sock.id] = data; // 소켓 아이디와 페이지 url을 매핑

        emitSamePageViewerCount();
      });
    },

    watchBuying: () => {
      watchEvent("BUY", (data) => {
        const payload = {
          nickname: data.nickname,
          goodsId: data.goodsId,
          goodsName: data.goodsName,
          date: new Date().toISOString(),
        };

        console.log("클라이언트가 구매한 데이터", data, new Date());
        notifyEveryone("BUY_GOODS", payload);
      });
    },

    watchByeBye: () => {
      watchEvent("disconnect", () => {
        delete socketIdMap[sock.id];
        console.log(sock.id, "연결이 끊어졌어요!");
        emitSamePageViewerCount();
      });
    },
  };
}

io.on("connection", (sock) => {
  const { watchChangePage, watchBuying, watchByeBye } = initSocket(sock);

  watchChangePage();

  watchBuying();

  watchByeBye();
});

이제 server.js만 실행하면 정상적으로 소켓 및 라우팅이 진행되면서 이들을 포함한 서버도 실행됩니다.