Patterns - Design Patterns

Doeunnkimm·2023년 8월 2일
3

Software

목록 보기
1/1
post-thumbnail

📌 원본

Introduce

디자인 패턴은 소프트웨어 디자인에서 일반적으로 반복되는 문제에 대해 일반적인 솔루션을 제공하므로 소프트웨어 개발의 기본 부분이다. 디자인 패턴은 특정 소프트웨어를 위해 제공하는 것이 아닌 반복되는 코드를 최적화된 방식으로 처리하는 데 사용할 수 있는 개념이다.

Facebook의 JavaScript 기반 라이브러리 React의 인기로 인해 현재의 현대 웹 개발 생태계에서 가치를 제공하기 위해 디자인 패턴이 수정되고 최적화되었으며 새로운 패턴이 만들어졌다. 최신 버전의 React는 애플리케이션 디자인에서 매우 중요한 역할을 하고 있으며 많은 기존 디자인 패턴을 대체할 수 있는 Hooks라는 새로운 기능도 도입되었다.

최신 웹 개발에는 다양한 종류의 패턴이 포함된다. 이번 글을 통해 ES2015+를 사용한 일반적인 디자인 패턴의 구현, 이점 및 함정, React 관련 디자인 패턴 및 React Hooks를 사용한 가능한 수정 및 구현, 그리고 최신 웹 앱을 개선하는 데 도움이 될 수 있는 더 많은 패턴과 최적화를 다룬다.

Singleton Pattern

애플리케이션 전체에서 단일 글로벌 인스턴스 공유

싱글톤은 한 번 인스턴스화할 수 있고 전역적으로 액세스할 수 있는 클래스이다. 이 단일 인스턴스는 응용 프로그램 전체에서 공유될 수 있으므로 싱글폰은 응용 프로그램의 전역 상태를 관리하는 데 적합하다.

먼저 ES2015 클래스를 사용하여 싱글톤이 어떻게 보이는지 살펴보자. 이 예제에서는 다음을 포함하는 Counter 클래스를 빌드할 것이다.

  • 인스턴스의 값을 반환하는 getInstance 메서드
  • counter 변수의 현재 값을 반환하는 getCount 메서드
  • counter 값을 1씩 증가시키는 increment 메서드
  • counter 값을 1씩 감소시키는 decrement 메서드
let counter = 0;

class Counter {
  getInstance() {
    return this
  }
  
  getCount() {
    return counter
  }
  
  increment() {
    return ++counter
  }
  
  decrement() {
    return --counter
  }
}

그러나 위 클래스는 싱글톤 기준을 충족하지 않는다.

⭐️ 싱글톤은 한번만 인스턴스화 할 수 있어야 한다.

현재 Counter 클래스는 여러 인스턴스를 만들 수 있다.

const counter1 = new Counter()
const counter2 = new Counter()

console.log(counter1.getInstance() === counter2.getInstance()) // false

new 메서드를 2번 호출함으로써 counter1과 counter2는 서로 다른 인스턴스가 생성되었다.

🤔 Counter 클래스의 인스턴스를 하나만 만들 수 있을까?

⭐️ 방법은 인스턴스라는 변수를 만드는 것

→ 인스턴스에 대한 참조를 동일하게 !

Counter의 생성자에서 새 인스턴스가 생성될 때 인스턴스에 대한 참조와 동일하게 인스턴스를 설정할 수 있다. 인스턴스 변수에 이미 값이 있는지 확인하여 새 인스턴스 화를 방지할 수 있다.

let instance
let counter = 0

class Counter {
  constructor() {
    if (instance) {
      throw new Error('You can only create one instance!')
    }
    instance = this
  }
  
  ...
}
  
const counter1 = new Counter()
const counter2 = new Counter()
// Error: You can only create one instance!

더 이상 여러 새로운 인스턴스를 만들 수 없다.

⭐️ 인스턴스를 내보내기 전에 freeze시켜 변경하거나 덮어쓸 수 없도록 해야한다.

Counter 인스턴스를 내보낼 때 인스턴스를 freeze해야 한다. Object.freeze 메서드는 싱글톤을 수정할 수 없도록 한다. freeze된 인스턴스의 속성을 추가하거나 수정할 수 없으므로 싱글톤의 값을 실수로 덮어쓸 위험이 줄어든다.

...
class Counter {
  ...
}

const singletonCounter = Object.freeze(new Counter())
export default singletonCounter

장단점

🟢 장점1: 메모리 공간 절약
인스턴스화를 하나의 인스턴스로 제한하면 잠재적으로 많은 메모리 공간을 절약할 수 있다. 매번 새 인스턴스에 대한 메모리를 설정하는 대신 응용 프로그램 전체에서 참조되는 해당 인스턴스에 대한 메모리만 설정하면 된다.

🔴 단점1: 테스트 하기 까다로움
싱글톤에 의존하는 테스트 코드는 까다로울 수 있다. 매번 새 인스턴스를 만들 수 없기 때문에 모든 테스트는 이전 테스트의 전역 인스턴스 수정에 의존한다. 이 경우 테스트 순서가 중요하며 작은 수정 하나하나가 전체 테스트 실패로 이어질 수 있다. 테스트 후 테스트에 의해 수정된 사항을 재설정하려면 전체 인스턴스를 재설정해야 한다.

🔴 단점2: 글로벌 행동
싱글톤 인스턴스는 전체 앱에서 참조될 수 있어야 한다. 전역 변수는 기본적으로 동일한 동작을 보여준다. 전역 변수는 전역 범위에서 사용할 수 있으므로 응용 프로그램 전체에서 해당 변수에 액세스할 수 있다.

전역 변수를 갖는 것은 일반적으로 잘못된 설계 결정으로 간주된다. 전역 범위 오염은 전역 변수의 값을 실수로 덮어쓰는 결과를 초래할 수 있으며 이로 인해 많은 예기치 않은 동작이 발생할 수 있다.

ES2015에서 전역 변수를 생성하는 것은 흔하지 않다. let 그리고 const 키워드로 선언된 변수는 블록 범위로 유지함으로써 개발자가 실수로 글로벌 범위로 오염시키는 것을 방지한다.

그러니 싱글톤의 일반적인 사용 사례는 응용 프로그램 전체에서 일종의 전역 상태를 갖는 것이다. 이는 예기치 않은 동작으로 이어질 수 있다.

React의 상태 관리

React에서는 싱글톤을 사용하는 대신 Redux 또는 React Context와 같은 상태 관리 도구를 통해 전역 상태에 의존하는 경우가 많다. 전역 상태 동작이 싱글톤과 비슷해 보일 수 있지만 이러한 도구는 싱글톤의 가변 상태가 아닌 읽기 전용 상태를 제공한다.

이러한 도구를 사용해도 전역 상태의 단점이 마법처럼 사라지지는 않지만, 구성 요소가 상태를 직접 업데이트할 수 없기 때문에 최소한 전역 상태가 우리가 의도한 대로 변경되도록 할 수 있다.

Proxy Pattern

대상 객체에 대한 상호작용에 대한 중간다리

프록시 개체를 사용하면 특정 개체와의 상호작용을 더 잘 제어할 수 있다. 프록시 개체는 개체와 상호작용을 할 때마다(예: 값을 가져오거나 값을 설정할 때) 동작을 결정할 수 있다.

⭐️ 일반적으로 프록시는 다른 사람을 대신하는 것을 의미

→ 대상 개체와 직접 상호작용하는 대신 프록시 개체와 상호작용

John Doe를 나타내는 person 개체를 만들어 보자.

const person = {
  name: 'John Doe',
  age: 42,
  nationality: 'American'
}

이 개체와 직접 상호 작용하는 대신 프록시 개체와 상호작용하려고 한다. JavaScript에서는 새 Proxy인스턴스를 만들어 새 프록시시를 쉽게 만들 수 있다.

const personProxy = new Proxy(person, {});

Proxy의 두 번째 인수는 핸들러를 나타내는 개체이다. 핸들러 개체에서는 상호 작용의 유형에 따라 특정 동작을 정의할 수 있다. Proxy 핸들러에 추가할 수 있는 여러 가지 방법이 있지만 가장 일반적인 두 가지 방법은 get과 set이다.

  • get: 속성에 액세스할 때 호출
  • set: 속성을 수정할 때 호출

person 개체와 직접 상호 작용하는 대신 프록시와 상호 작용한다.

personProxy핸들러를 추가해보자. 속성을 수정하여 프록시에 설정된 메서드를 호출할 때 프록시가 속성의 이전 값과 새 값을 기록하기를 원한다. 속성에 액세스하여 프록시에서 get 메서드를 호출할 때 프록시가 속성의 키와 값을 포함하는 더 읽기 쉬운 문자을 기록하기를 원한다.

const personProxy = new Proxy(person, {
  get: (obj, prop) => {
    console.log(`The value of ${prop} is ${obj[prop]}`)
  },
  set: (obj, prop, value) => {
    console.log(`Changed ${prop} from ${obj[prop] to ${value}}`)
	obj[prop] = value
  }
})

personProxy.name;
personProxy.age = 43;
⭐️ 프록시는 유효성 검사를 추가하는 데 유용할 수 있다.

person은 나이를 문자열 값을 변경하거나 빈 이름을 지정할 수 없어야 한다. 또는 사용자가 존재하지 않는 개체의 속성에 액세스하려는 경우 사용자에게 알려주어야 한다.

const personProxy = new Proxy(person, {
  get: (obj, prop) => {
    if (!obj[prop]) {
      console.log(`Hmm.. this property doesn't seem to exist on the target object`)
    } else {
      console.log(`The value of ${prop} is ${obj[prop]}`)
    }
  },
  set: (obj, prop, value) => {
    if (prop === 'age' && typeof value !== 'number') {
      console.log(`Sorry, you can only pass numeric values for age`)
    } else if (prop === 'name' && value.length < 2) {
      console.log(`You need to provide a valid name`)
    } else {
      console.log(`Changed ${prop} from ${obj[prop] to ${value}}`)
    }
  }
})

Reflect

JavaScript는 Reflect라는 기본 객체를 제공하므로써 프록시 작업 시 대상 개체를 쉽게 조작할 수 있다.

이전에는 직접 값을 가져오거나 괄호 표기로 설정하여 프록시 내에서 대상 개체의 속성을 수정하고 액세스하려고 했다. 대신 Reflect를 사용할 수 있다. Reflect 개체의 메서드 이름은 핸들러 개체의 메서드 이름과 동일하다. 예를 들어 Reflect.get()Reflect.set()을 통해 대상 개체의 속성을 액세스하거나 수정할 수 있다.

const personProxy = new Proxy(person, {
  get: (obj, prop) => {
    console.log(`The value of ${prop} is ${Reflect.get(obj, prop)}`)
  },
  set: (obj, prop, value) => {
    console.log(`Changed ${prop} from ${obj[prop]} to value`)
    Reflect.set(obj, prop, value)
  }
})

장단점

🟢 장점1: 동작 제어 추가
프록시는 개체의 동작에 대한 제어를 추가하는 강력한 방법이다.

🟢 장점2: 다양한 사용 사례
프록시에는 다양한 사용 사례가 있을 수 있다. 유효성 검사, 서식 지정, 알림 또는 디버깅에 도움이 될 수 있다.

🔴 단점1: 과도한 사용, 성능
프록시 개체를 과도하게 사용하거나 각 핸들러 메서드 호출에 대해 과도하게 작업을 수행하면 응용 프로그램의 성능에 부정적인 영향을 미칠 수 있다. 성능에 중요한 코드에는 프록시를 사용하지 않는 것이 가장 좋다.

Provider Pattern

여러 하위 구성 요소에서 데이터를 사용 가능

경우에 따라 응용 프로그램의 많은 구성 요소에서 데이터를 사용할 수 있도록 한다. props를 사용하여 구성 요소에 데이터를 전달할 수 있지만, 응용 프로그램의 거의 모든 컴포넌트가 props의 값에 접근해야 하는 경우에는 데이터를 전달하기 어려울 수 있다.

우리는 종종 props drilling이라는 것으로 끝을 보는데, 이것은 우리가 컴포넌트 트리 아래 멀리 props를 전달할 때 그렇다. props에 의존하는 코드를 리팩토링하는 것은 거의 불가능하고, 특정 데이터가 어디에서 왔는지 아는 것은 어렵다.

저 아래에 있는 자식 컴포넌트에게 props를 전달하기 위해 중간 컴포넌트를 타고 타고 전달한다고 생각했을 때, 이 데이터를 사용할 필요가 없는 구성 요소의 계층을 모두 생략할 수 있다면 최적일 것이다.

⭐️ props drilling에 의존하지 않고 직접 액세스할 수 있도록 돕는 것이 provider 패턴

→ provider 패턴을 사용하면 여러 구성 요소에 데이터를 사용할 수 있다.

provider는 context 개체가 제공하는 상위 구성 요소이다. React가 제공하는 createContext 메서드를 사용하여 context 개체를 만들 수 있다. 이 provider 내에서 래핑되는 모든 구성 요소는 props로 넘겨지는 value 값에 접근할 수 있다.

const DataContext = React.createContext()

function App() {
  const data = {...}
                
  return (
    <DataContext.Provider value={data}>
      <MyApp />
    </DataContext.Provider>
  )
}

장단점

🟢 장점
provider 패턴/Context API를 사용하면 데이터를 수동으로 각 구성 요소 계층에 전달할 필요 즉, prop drilling 없이도 많은 컴포넌트에 전달할 수 있다.

코드 리팩터링 시 실수로 버그가 발생할 위험을 줄여준다. 이전에는 나중에 props 이름을 변경하려면 이 값이 사용된 전체 애플리케이션에서 이 props의 이름을 변경해야 했다.

우리는 더이상 props drilling을 다룰 필요가 없다. 이전에는 특정 props 값이 어디에서 발생했는지 하상 명확지 않았기 때문에 응용 프로그램의 데이터 흐름을 이해하는 것이 어려울 수 있었다. provider 패턴을 사용하면 더 이상 이 데이터를 신경 쓰지 않는 컴포넌트에 props를 불필요하게 전달할 필요가 없다.

provider 패턴을 사용하면 컴포넌트가 이 글로벌 상태에 접근할 수 있으므로 일종의 글로벌 상태를 쉽게 유지할 수 있다.

🔴 단점
경우에 따라 provider 패턴을 과도하게 사용하면 성능 문제가 발생할 수 있다. context를 사용하는 모든 컴포넌트는 각 상태 변경 시 렌더링을 다시 수행한다.

자주 업데이트되는 값을 많은 컴포넌트에 전달하면 성능에 부정적인 영향을 미칠 수 있다.

구성 요소가 업데이트할 수 있는 불필요한 값을 포함하는 provider를 소비하지 않도록 하려면 각 개별 사용 사례에 대해 여러 provider를 만들 수 있다.

Prototype Pattern

동일한 type의 여러 object 간에 프로퍼티 공유

프로토타입 패턴은 동일한 type의 여러 object 간에 프로퍼티를 공유할 수 있는 유용한 방법이다. 프로토타입은 JavaScript가 기본인 object이며, 프로토타입 체인을 통해 object가 액세스할 수 있다.

우리의 응용 프로그램에서 우리는 종종 같은 type의 많은 객체를 만들어야 한다. 이를 수행하는 유용한 방법은 ES6 클랫의 여러 인스턴스를 만드는 것이다.

class Dog {
  constructor() {
    this.name = name
  }
  
  bark() {
    return `Woof!`
  }
}

const dog1 = new Dog("Daisy")
const dog2 = new Dog("Max")
const dog3 = new Dog("Spot")

여기서 생성자는 name 속성을 포함하고 클래스 자체는 bark 속성을 포함하는 방법을 주목해보자. ES6 클래스를 사용하는 경우 클래스 자체에 정의된 모든 속성, 이 경우 bark가 자동으로 프로토타입에 추가된다.

우리는 생성자의 프로토타입 속성에 접근하거나 모든 인스턴스의 __proto__ 속성을 통해 프로토타입을 직접 볼 수 있다.

console.log(Dog.prototype)
// constructor : f Dog(name) bark: f bark()

console.log(dog1.__proto__)
// constructor: f Dog(name) bark: f bark()

생성자의 어떤 인스턴스에서든 __proto__의 값은 생성자의 프로토타입을 직접 참조한다.

⭐️ 프로토타입 패턴은 동일한 속성에 접근할 수 있어야 하는 object를 작업할 때 매우 강력

모든 인스턴스는 프로토타입 object에 접근할 수 있으므로 매번 프로퍼티의 복제를 만드는 대신 단순히 프로토타입에 속성을 추가할 수 있다.

아까 만든 Dog 클래스에서 짖을 수 있을 뿐만 아니라 놀 수 있어야 한다고 한다면 프로퍼티를 추가하면 된다.

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

  bark() {
    return `Woof!`;
  }
}

const dog1 = new Dog("Daisy");
const dog2 = new Dog("Max");
const dog3 = new Dog("Spot");

Dog.prototype.play = () => console.log("Playing now!");

dog1.play(); // Playing now!

Object.create

Object.create 메서드를 사용하면 새 object를 생성할 수 있으며 이를 통해 해당 프로토타입의 값을 명시적으로 전달할 수 있다. 즉, 다른 object로부터 직접 프로퍼티를 상속받을 수 있도록 하는 간단한 방법이다.

const dog = {
  bark() {
    return `Woof!`
  },
}

const pet1 = Object.create(dog)

pet1.bark(); // Woof!

프로토타입 패턴을 통해 다른 object로부터 속성을 상속받을 수 있었다. 따라서 프로퍼티의 중복을 방지하고 메모리 사용량을 줄일 수 있다.

Container/Presentational Pattern

애플리케이션 로직에서 View를 분리하여 관심사 분리 시행

우리가 6개의 개 이미지를 가져오고 이 이미지들을 화면에 렌더링하는 애플리케이션을 만들고 싶다고 가정해 보자.

// DogImages.js

export default function DogImages({ dog }) {
  return dogs.map((dog, i) => <img src={dog} key={i} alt="Dog" />)
}
// DogImagesContainer.js

import DogImages from './DogImages'
import { useState, useEffect } from 'react'

export default function DogImagesContainer() {
  const [dogs, useDogs] = useState([])
  
  useEffect(() => {
    fetch('https://dog.ceo/api/breed/labrador/images/random/5')
      .then(res => res.json())
      .then(data => setDogs(data))
  }, [])
  
  return (
    <DogImages dogs={dogs} />
  )
}

이상적으로, 우리는 이 프로세스를 두 부분으로 분리함으로써 문제를 분리하기를 원한다.

  1. Presentational 컴포넌트
    데이터가 사용자가에게 표시되는 방식에 관심이 있는 컴포넌트이다. 위 예제에서는 개 이미지 목록을 렌더링하는 컴포넌트(DogImages.js)

  2. Container 컴포넌트
    사용자에게 표시되는 데이터에 관심을 가지는 컴포넌트. 위 예제에서는 개 이미지를 가져오는 것(DogImagesContainer.js)

Presentational Component

Presentational 컴포넌트는 props를 통해 데이터를 받는다. 주요 기능은 데이터를 수정하지 않고 스타일을 포함하여 원하는 방식으로 데이터를 표시하는 것이다.

개 이미지를 보여주는 예를 통한다면, 개 이미지를 렌더링할 때 API에서 가져온 각 개 이미지를 매핑하여 렌더링하기만 하면 된다. 이를 위해 props를 통해 데이터를 받고 받은 데이터를 렌더링하는 컴포넌트를 만들 수 있다.

Container Components

Container 컴포넌트의 주요 기능은 데이터를 Presentational 컴포넌트에게 전달하는 것이며, 이 컴포넌트에는 데이터가 포함된다. Container 컴포넌트 자체는 일반적으로 데이터에 관심을 갖는 Presentaitional 컴포넌트 외에 다른 컴포넌트를 렌더링하지 않는다. Container 컴포넌트 자체는 어떤 것도 렌더링하지 않기 때문에 일반적으로 어떤 스타일링도 포함하지 않는다.

장단점

🟢 장점1: 관심사 분리를 장려한다
Container/Presentational 패턴은 관심사 분리를 장려한다. Presentational 컴포넌트는 UI를 담당하는 순수한 기능일 수 있는 반면 Container 컴포넌트는 애플리케이션의 상태와 데이터를 담당한다. 이를 통해 관심사 분리를 쉽게 시행할 수 있다.

🟢 장점2: 쉽게 재사용 가능
Presentational 컴포넌트는 데이터를 변경하지 않고 단순히 데이터를 표시하기 때문에 쉽게 재사용할 수 있다. 다양한 목적으로 애플리케이션 전체에서 Presentational 컴포넌트를 재사용할 수 있다.

🟢 장점3: 테스트하기 쉬운 Presentational 컴포넌트
Presentational 컴포넌트를 테스트하는 것은 쉬운데, 이는 일반적인 순수한 함수이기 때문이다. 우리는 데이터 저장소를 모의 실험할 필요 없이 전달하는 데이터를 기반으로 구성 요소가 무엇을 렌더링할 것인지 알고 있다.

Observer Pattern

observables을 사용하여 이벤트 발생 시 subscribers에게 알림

관찰자 패턴을 사용하면 이벤트가 발생할 때마다 observable은 모든 observers에게 알린다.

observable 객체는 일반적으로 3가지 중요한 부분을 포함한다.

  • observers: 특정 이벤트가 발생할 때마다 알림을 받은 관찰자 배열
  • subscribe(): 옵저버 목록에 옵저버를 추가하기 위한 메서드
  • unsubscribe(): 옵저버 목록에서 옵저버를 제거하기 위한 메서드
  • notify(): 특정 이벤트가 발생할 때마다 모든 옵저버에게 알리는 방식

observable을 만들어보자. 만드는 쉬운 방법은 ES6 클래스를 사용하는 것이다.

class Observable {
  constructor() {
    this.observers = []
  }
  
  subscribe(func) {
    this.observers.push(func)
  }
  
  unsubscribe(func) {
    this.observers = this.observers.filter(observer => observer !== func)
  }
  
  notify(data) {
    this.observers.forEach(observer => observer(data))
  }
}

이 Observable로 무언가를 만들어보자. 버튼과 스위치의 두 가지 컴포넌트로 구성된 매우 기본적인 app을 가지고 있다.

export default function App() {
  return (
    <div className="App">
      <Button>Click me!</Button>
      <FormControlLabel control={<Switch />} />
    </div>
  )
}

우리는 애플리케이션과의 상호작용을 추적하려고 한다. 사용자가 버튼을 클릭하거나 스위치를 전환할 때마다 타임 스탬프와 함께 이 이벤트를 기록하려고 한다. 기록하는 것 외에도 이벤트가 발생할 때마다 표시되는 토스트 알림을 만드로 싶다.

기본적으로 우리가 하려는 것은 다음과 같다.

사용자가 handleClick 혹은 handleToggle 함수를 호출할 때마다 함수는 관찰자에서 notify 메서드를 호출한다. notify 메서드는 모든 observers에게 handleClick 혹은 handleToggle 함수가 전달한 데이터를 알려준다.

먼저 logger와 toastify functions를 생성해보자.

import { ToastContainer, toast } from 'react-toastify'

function logger(data) {
  console.log(`${Date.now()} ${data}`)
}

function toastify(data) {
  toast(data)
}

export default function App() {
  return (
    <div className="App">
      <Button>Click me!</Button>
      <FormControlLabel control={<Switch />} />
      <ToastContainer />
    </div>
  )
}

현재 logger 및 toastfiy는 observable을 인식하지 못하고 있다. observable은 아직 통지할 수 없다. observable 기능을 사용하려면 observable의 구독 방법을 사용하여 구독해야 한다.

observable.subscribe(logger)
observable.subscribe(toastify)

이벤트가 발생할 때마다 logger 및 toastify 함수에 대한 알림이 표시된다. 이제 실제로 observable을 알려주는 함수를 구현하면 된다. 이 함수들은 observable에 대한 알림 방법을 호출하고 observers가 받아야 할 데이터를 전달해야 한다.

export default function App() {
  function handleClick() {
    observable.notify("User clicked button!")
  }
  
  function handleToggle() {
    observable.nofify("User toggled switch!")
  }
  
  return (
    <div className="App">
      <Button onClick={handleClick}>Click me!</Button>
      <FormControlLabel control={<Switch onChange={handleToggle}/>} />
      <ToastContainer />
    </div>
  );
}
⭐️ 비동기 이벤트 기반 데이터를 사용할 때 매우 유용하다.

특정 데이터가 다운로드를 완료하거나 사용자가 새 메시지를 게시판에 보낼 때마다 특정 구성 요소가 알림을 받기를 원할 수 있으며 다른 모든 구성원이 알림을 받아야 한다.

장단점

🟢 장점1: 단일 책임 원칙을 분리를 강제하는 좋은 방법
관찰자 패턴을 사용하는 것은 관심사와 단일 책임 원칙의 분리를 강제하는 좋은 방법이다. 관찰자 패턴은 observable 객체에 단단히 결합되지 않고 언제든지 결합/해제 할 수 있다. observable 객체는 사건을 감시하는 역할을 하고, observers는 단순히 수신한 데이터를 처리한다.

🔴 단점1: 성능 문제
observers가 너무 복잡해지면 모든 구독자에게 알릴 때 성능 문제가 발생할 수 있다.

Module Pattern

코드를 더 작고 재사용 가능한 조각으로 분할

애플리케이션과 코드베이스가 커짐에 따라 코드를 유지 관리 가능하고 분리된 상태로 유지하는 것이 점점 더 중요해진다. 모듈 패턴을 사용하면 코드를 더 작고 재사용 가능한 조각으로 나눌 수 있다.

코드를 더 작은 재사용 가능한 조각으로 분할할 수 있는 것 외에도 모듈을 사용하면 파일 내의 특정 값을 비공개로 유지할 수 있다. 모듈 내의 선언은 기본적으로 해당 모듈로 범위가 지정(캡슐화) 된다. 특정 값을 명시적으로 내보내지 않으면 해당 값응ㄴ 모듈 외부에서 사용할 수 없다.

React로 애플리케이션을 구축할 때 많은 양의 컴포넌트를 처리해야 하는 경우가 많다. 이러한 모든 컴포넌트를 하나의 파일에 작성하는 대신 자체 파일에서 컴포넌트를 분리하여 기본적으로 각 컴포넌트에 대한 모듈을 만들 수 있다.

Mixin Pattern

상속 없이 객체 또는 클래스에 기능 추가

믹스인은 상속을 사용하지 않고 다른 객체나 클래스에 재사용 가능한 기능을 추가하기 위해 사용할 수 있다.

⭐️ 믹스인을 단독으로 사용할 수 없다
→ 목적이 상속이 없는 객체나 클래스에 기능을 추가하는 것이기 때문에!

응용을 해보자. 여러 마리의 개를 만들어야 하는데, 우리가 만드는 기본 개는 어떤 프로퍼티도 없지만 name 프로퍼티만 있다.

classs Dog {
  constructor(name) {
    this.name = name;
  }
}

개는 단지 이름을 가지는 것 이상을 할 수 있어야 한다. 짖고, 꼬리를 흔들고, 놀 수 있어야 한다. 이것을 Dog class에 직접 추가하는 대신, 이것을 제공하는 믹스인을 만들 수 있다.

const dogFunctionality = {
  bark: () => console.log('Woof!'),
  wagTail: () => console.log('Wagging my tail!'),
  play: () => console.log('Playing!'),
}

Object.assign 메서드를 사용하여 Dog 프로토타입에 dogFunctionality 믹스인을 추가할 수 있다. 이 방법을 사용하면 대상 객체에 프로퍼티를 추가할 수 있다.

Object.assign(Dog.prototype, dogFuncationality)

const pet1 = new Dog('Daisy')

pet1.name // Daisy
pet1.bark() // Woof!
pet1.play() // Playing!
⭐️ 믹스인을 사용하면 객체의 프로토타입에 기능을 주입하여 상속 없이 객체에 기능을 쉽게 추가 가능

Mediator/Middleware Pattern

중앙 중개자 객체를 사용하여 컴포넌트 간의 통신 처리

중개자 패턴을 사용하면 컴포넌트가 중재자라는 중심점을 통해 서로 상호작용할 수 있다. 중재자는 서로 직접 대화하는 대신 요청을 수신하고 전달한다. JavaScript에서 중재자는 종종 객체 리터럴이나 함수에 지나지 않는다.

이 패턴은 항공 교통 관제사와 조종사 간의 관계에 비유할 수 있다. 조종사들이 서로 직접 대화하게 하는 대신 조종사들은 항공 교통 관제사와 대화한다. 항공 교통 관제사는 모든 비행기가 다른 비행기와 충돌하지 않고 안전하게 비행하기 위해 필요한 정보를 받도록 한다.

JavaScript에서 객체 간의 다방향 데이터를 처리해야 하는 경우가 많다. 컴포넌트 수가 많은 경우 컴포넌트 간의 통신이 다소 혼란스러울 수 있다.

모든 객체가 다른 객체와 직접 통신하도록 하는 대신 N:N 관계를 생성하는 대신 중재자가 객체의 요청을 처리한다.

⭐️ 중재나는 이 요청을 처리하고 필요한 곳으로 전달한다.

중재자 패턴의 좋은 사용 사례는 채팅방이다. 채팅방 내의 사용자는 서로 직접 대화하지 않고 대신 채팅방은 사용자 간의 중재자 역할을 한다.

class ChatRoom {
  logMessage(user, message) {
    const time = new Date();
    const sender = user.getName()
    
    console.log(`${time} [${sender}]: ${message}`)
  }
}

class User {
  constructor(name, chatroom) {
    this.name = name
    this.chatroom = chatroom
  }
  
  getName() {
    return this.name
  }
  
  send(message) {
    this.chatroom.logMessage(this, message)
  }
}
⭐️ 중개자/미들웨어 패턴을 사용하면 모든 통신이 하나의 중심점을 통해 흐르도록 하여 
   객체 간의 N:N 관계를 쉽게 단순화할 수 있다.

⭐️ HOC Pattern

재사용 가능한 로직을 애플리케이션 전체의 컴포넌트에 props로 전달

응용 프로그램 내에서, 종종 여러 컴포넌트에서 동일한 로직를 사용하기를 원한다. 이 로직는 컴포넌트에 특정 스타일을 적용하거나, 승인을 요구하거나, 전역 상태를 추가하는 것을 포함할 수 있다.

⭐️ 여러 컴포넌트에서 동일한 로직을 재사용할 수 있는 한 가지 방법 
→ 고차 컴포넌트 패턴을 사용하는 것

HOC(Higher Order Component)다른 컴포넌트를 받는 컴포넌트이다. HOC는 매개변수로 전달하는 컴포넌트에 적용하려는 특정 로직이 포함된다. 해당 로직을 적용한 후 HOC는 추가 로직과 함께 요소를 반환한다.

우리는 항상 응용 프로그램의 여러 컴포넌트에 특정 스타일을 추가하기를 원하는데, 매번 스타일 객체를 파일마다 만드는 대신 전달하는 컴포넌트에 스타일 객체를 추가하는 HOC를 간단히 만들 수 있다.

function withStyles(Component) {
  return props => {
    const style = { padding: '0.2rem', margin: '1rem' }
    return <Component style={style} {...props} />
  }
}

const Button = () => <button>Click me!</button>
const Text = () => <p>Hello World!</p>

const StyledButton = withStyles(Button)
const StyledText = withStyles(Text)

위에서 StyledButtonStyledText는 스타일 HOC와 함께 추가된 스타일을 포함한다.

이전에 했던 DogImages 예제를 살펴보자. 데이터를 가져올 때 사용자에게 "로딩..." 화면을 보여주고 싶다. DogImage 컴포넌트에 데이터를 직접 추가하는 대신 이 로직을 추가하는 고차 컴포넌트를 사용할 수 있다.

Loader와 함께 HOC를 생성한다.

⭐️ HOC는 컴포넌트를 수신하고 해당 컴포넌트를 리턴해야 한다.

이 경우, WithLoader HOC는 데이터를 가져올 때까지 Loading... 이라고 표시되는 요소를 수신해야 한다.

사용할 Loader HOC를 최소 버전으로 생성해 보자.

function withLoader(Element) {
  return (props) => <Element />
}

여기서 데이터가 여전히 로딩되고 있는지 아닌지를 열려주는 로직을 추가해야 한다.

withLoader HOC를 재사용할 수 있도록 하기 위해 해당 컴포넌트의 Dog API URL을 하드코딩하지 않는 대신, withLoader HOC의 인수로 URL을 전달할 수 있으므로 이 Loader는 다른 API에서 데이터를 가져오는 동안 로딩 화면이 필요한 다른 컴포넌트에서도 사용핧 수 있다.

functon withLoader(Element, url) {
  return (props) => {}
}

이제 위 HOC는 데이터를 가져오는 중일 때는 Loading...를 표시할 수 있는 로직을 추가하고자 하는 요소인 컴포넌트를 반환하고, 데이터를 가져온 후에는 가져온 데이터 요소를 전달해야 한다.

// withLoader.js
import { useEffect, useState } from 'react'

export default function withLoader(Element, url) {
  return (props) => {
    const [data, setData] = useState(null)
    
    useEffect(() => {
      const getData = async () => {
        const res = await fetch(url)
        const data = await res.json()
        setData(data)
      }
      
      getData()
    }, [])
    
    if (!data) {
      return <div>Loading...</div>
    }
    
    return <Element {...props} data={data} />
  }
}
// DogImages.js
import withLoader from "./withLoader"

function DogImages(props) {
  return props.data.message.map((dog, index) => (
    <img src={dog} alt="Dog" key={index} />
  ));
}

export default withLoader(
  DogImages,
  "https://dog.ceo/api/breed/labrador/images/random/6"
)

Composing

여러 개의 HOC를 구성할 수도 있다. 사용자가 DogImages 목록 위를 이동할 때 Hovering! 텍스트 상자를 표시하는 기능을 추가해보자.

우리는 우리가 hover하는 hovering props를 제공하는 HOC를 만들어야 합니다. 그 props를 기반으로 사용자가 DogImages 목록 위를 hovering 하고 있는지 여부에 따라 텍스트 상자를 조건부로 렌더링할 수 있도록 해보자.

// withHover.js
import { useState } from "react";

export default function withHover(Element) {
  return props => {
    const [hovering, setHover] = useState(false);

    return (
      <Element
        {...props}
        hovering={hovering}
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
      />
    );
  };
}
// DogImages.js
import withLoader from "./withLoader";
import withHover from "./withHover";

function DogImages(props) {
  return (
    <div {...props}>
      {props.hovering && <div id="hover">Hovering!</div>}
      <div id="list">
        {props.data.message.map((dog, index) => (
          <img src={dog} alt="Dog" key={index} />
        ))}
      </div>
    </div>
  );
}

export default withHover(
  withLoader(DogImages, "https://dog.ceo/api/breed/labrador/images/random/6")
);

Hooks

⭐️ 경우에 따라 HOC 패턴을 React Hooks로 대체할 수 있다.

Hover HOC를 useHover hook으로 대체해보자. 요소를 HOC에 전달하는 방식이 아닌, mouseOver 및 mouseLeave 이벤트를 얻을 수 있는 hooks에서 참조를 반환할 것이다.

// useHover.js
import { useState, useRef, useEffect } from "react";

export default function useHover() {
  const [hovering, setHover] = useState(false);
  const ref = useRef(null);

  const handleMouseOver = () => setHover(true);
  const handleMouseOut = () => setHover(false);

  useEffect(() => {
    const node = ref.current;
    if (node) {
      node.addEventListener("mouseover", handleMouseOver);
      node.addEventListener("mouseout", handleMouseOut);

      return () => {
        node.removeEventListener("mouseover", handleMouseOver);
        node.removeEventListener("mouseout", handleMouseOut);
      };
    }
  }, [ref.current]);

  return [ref, hovering];
}
// DogImages.js
import withLoader from "./withLoader";
import useHover from "./useHover";

function DogImages(props) {
  const [hoverRef, hovering] = useHover();

  return (
    <div ref={hoverRef} {...props}>
      {hovering && <div id="hover">Hovering!</div>}
      <div id="list">
        {props.data.message.map((dog, index) => (
          <img src={dog} alt="Dog" key={index} />
        ))}
      </div>
    </div>
  );
}

export default withLoader(
  DogImages,
  "https://dog.ceo/api/breed/labrador/images/random/6"
);

이처럼 컴포넌트에 직접 hook을 추가하면 더 이상 컴포넌트를 HOC로 래핑할 필요가 없다.

HOC를 사용하면 많은 컴포넌트에 동일한 로직을 제공하면서 해당 로직을 모두 한 곳에 유지할 수 있다. hook을 사용하면 컴포넌트 내에서 사용자 지정 동작을 추가할 수 있으므로 여러 컴포넌트가 이 동작에 의존하는 경우 HOC 패턴에 비해 잠재적으로 버그가 발생할 위험이 높아질 수 있다.

HOC vs Hook

✅ HOC를 위한 최적의 사용 사례

  • 사용자 지정되지 않은 동일한 동작을 응용 프로그램 전반에 걸쳐 많은 컴포넌트에서 사용해야 할 때
  • 추가된 사용자 지정 로직 없이 컴포넌트가 독립 실행형으로 작동할 수 있을 때

✅ Hook을 위한 최적의 사용 사례

  • 동작은 해당 동작을 사용하는 각 컴포넌트에 맞게 사용자 지정되어야 할 때
  • 동작은 애플리케이션 전체에 퍼지지 않으며, 하나 또는 몇 개의 컴포넌트만 동작을 사용할 때
  • 이 동작은 컴포넌트에 많은 프로퍼티를 추가할 때

장단점

🟢 장점
HOC 패턴을 사용하면 모든 것을 한 곳에서 재사용하고자 하는 로직을 유지할 수 있다. 아는 코드를 반복적으로 복제하여 매번 새로운 버그를 잠재적으로 도입함으로써 응용 프로그램에 실수로 버그가 확산될 위험을 줄인다. 로직을 한 곳에 유지함으로써 코드를 DRY로 유지하고 쉽게 문제를 분리할 수 있다.

🔴 단점
HOC가 요소에 전달할 수 있는 props의 이름은 명명 충돌을 일으킬 수 있다.

function withStyles(Component) {
  return props => {
    const style = { padding: '0.2rem', margin: '1rem' }
    return <Component style={style} {...props} />
  }
}
 
const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)

위 경우 WithStyles HOC는 전달하는 요소에 style이라는 props를 추가한다. 그러나 Button 컴포넌트에는 style이라는 props가 이미 있으므로 이 props는 덮어쓴다. props 이름을 바꾸거나 props를 병합하여 HOC가 실수로 발생한 이름 충돌을 처리할 수 있는지 확인해야 한다.

⭐️ Hooks Pattern

함수를 사용하여 app 전체의 여러 컴포넌트 간의 stateful한 로직을 재사용

React 16.8은 Hooks라는 새로운 기능을 도입했다. Hook은 ES2015 클래스 구성 요소를 사용할 필요 없이 리액트 상태 및 라이프사이클 메서드를 사용할 수 있도록 한다.

Hooks가 반드시 디자인 패턴인 것은 아니지만, Hooks를 우리의 응용 디자인에서 매우 중요한 역할을 한다. 많은 전통적인 디자인 패턴은 Hooks로 대체될 수 있다.

Restructuring

여러 컴포넌트 간에 코드를 공유하는 일반적인 방법을 상위 컴포넌트를 사용하는 것이다. 컴포넌트가 클수록 까다롭기 때문에 app을 재구성해야 하는 것 외에도 더 깊은 중첩 컴포넌트 간에 코드를 공유하기 위해 많은 래핑 컴포넌트를 갖는 것을 wrapper hell이라고 불리는 것으로 이어질 수 있다. 개발 도구를 열고 다음과 유사한 구조를 보는 것은 드물지 않다.

<WrapperOne>
  <WrapperTwo>
    <WrapperThree>
      <WrapperFour>
        <WrapperFive>
          <Component>
            <h1>Finally in the component!</h1>
          </Component>
        </WrapperFive>
      </WrapperFour>
    </WrapperThree>
  </WrapperTwo>
</WrapperOne>

wrapper hell은 응용 프로그램을 통해 데이터가 어떻게 흘러가고 있는지 이해하기 어렵게 만들 수 있으며, 예상치 못한 동작이 발생하는 이유를 파악하기 어렵게 만들 수 있다.

Custom Hooks
React가 제공하는 기본 제공 Hook(useState, useEffect, useReducer, useRef, ...) 외에도 커스텀 훅을 쉽게 만들 수 있다.

예를 들어, 사용자가 입력을 작성할 때 누를 수 있는 특정 키를 추적하고 싶다고 가정해보자. 우리의 커스텀 훅은 우리가 목표로 하는 키를 인수로 받을 수 있어야 한다. 또한 사용자가 인수로 전달한 키에 keyDown 및 keyUp 이벤트 리스너를 추가하려고 한다. 사용자가 keyDown 이벤트가 트리거되어 키가 눌렸음을 listen하게 되면 hook의 상태가 true로 전환되어야 한다.

function useKeyPress(targetKey) {
  const [keyPressed, setKeyPressed] = useState(false);
 
  function handleDown({ key }) {
    if (key === targetKey) {
      setKeyPressed(true);
    }
  }
 
  function handleUp({ key }) {
    if (key === targetKey) {
      setKeyPressed(false);
    }
  }
 
  React.useEffect(() => {
    window.addEventListener("keydown", handleDown);
    window.addEventListener("keyup", handleUp);
 
    return () => {
      window.removeEventListener("keydown", handleDown);
      window.removeEventListener("keyup", handleUp);
    };
  }, []);
 
  return keyPressed;
}

장단점

🟢 장점1: 더 적은 코드 라인
Hook을 사용하면 라이프사이클이 아닌 관심사와 기능별로 코드를 그룹화할 수 있다. 이를 통해 코드가 더 깨끗하고 간결할 뿐만 아니라 더 짧다.

🟢 장점2: 복잡한 컴포넌트 단순화
JavaScript 클래스는 관리하기 어렵고 사용하기 어려우며 축소되지 않을 수도 있다. React Hooks는 이러한 문제를 해결하고 함수형 프로그래밍을 쉽게 만든다. Hooks를 구현하면 클래스 컴포넌트가 필요하지 않다.

🔴 단점1: 린터 플러그인이 없으면
린터 플러그인이 없으면 규칙을 준수해야 하며 어떤 규칙이 위반되었는지 알기 어렵다.

🔴 단점2: 상당한 연습 시간
제대로 사용하려면 상당한 시간의 연습이 필요하다.

Flyweight Pattern

동일한 객체로 작업할 때 기존 인스턴스 재사용

⭐️ 플라이급 패턴은 유사한 객체를 많이 만들 때 메모리를 절약하는 유용한 방법

애플리케이션에서 우리는 사용자들이 책을 추가할 수 있기를 바란다. 모든 책은 제목, 저자, 그리고 isbn 번호를 가지고 있다. 하지만, 도서관은 보통 책의 한 권만 가지고 있지 않다. 보통 같은 책을 여러 권 가지고 있다.

정확히 동일한 책의 복사본이 여러 개인 경우 매번 새로운 책 인스턴스를 만드는 것은 매우 비효율적일 것이다. 대신, 우리는 하나의 책을 나타내는 책 construcotr의 인스턴스를 여러 개 만드는 것을 원한다.

class Book {
  constructor(title, author, isbn) {
    this.title = title
    this.author = author
    this.isbn = isbn
  }
}

목록에 새 책을 추가하는 기능을 만들자. 책을 ISBN 번호가 동일하여 완전히 동일한 책 유형이라면 완전히 새로운 책 인스턴스를 만들고 싶지 않다. 대신 이 책이 이미 존재하는지 먼저 확인해야 한다.

const books = new Map()

const createBook = (title, author, isbn) => {
  const existingBook = books.has(isbn)
  
  if (existingBook) {
    return books.get(isbn)
  }
}

책의 ISBN 번호가 아직 포함되지 않은 경우 새 책을 만들고 ISBN 번호를 isbn 번호 집합에 추가한다.

const createBook = (title, author, isbn) => {
  const existingBook = books.has(isbn)
  
  if (existingBook) {
    return books.get(isbn)
  }
  
  const book = new Book(title, author, isbn)
  books.set(isbn, book)
  
  return book
}

createBook 함수는 한 종류의 책에 대한 새로운 인스턴스를 만드는 것을 도와준다. 그러나 도서관은 보통 같은 책의 여러 복사본을 포함한다. 같은 책의 여러 복사본을 추가할 수 있는 addBook을 만들자. 그것은 새로 만든 책 인스턴스를 반환하거나 이미 존재하는 인스턴스를 반환하는 createBook 함수를 호출해야 한다.

const bookList = [];
 
const addBook = (title, author, isbn, availability, sales) => {
  const book = {
    ...createBook(title, author, isbn),
    sales,
    availability,
    isbn,
  };
 
  bookList.push(book);
  return book;
};

복사본을 추가할 때마다 새 인스턴스를 만드는 대신, 우리는 해당 특정 복사본에 대해 이미 존재하는 책 인스턴스를 효과적으로 사용할 수 있다.

addBook("Harry Potter", "JK Rowling", "AB123", false, 100);
addBook("Harry Potter", "JK Rowling", "AB123", true, 50);
addBook("To Kill a Mockingbird", "Harper Lee", "CD345", true, 10);
addBook("To Kill a Mockingbird", "Harper Lee", "CD345", false, 20);
addBook("The Great Gatsby", "F. Scott Fitzgerald", "EF567", false, 20);
profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

2개의 댓글

comment-user-thumbnail
2023년 8월 2일

좋은 글 감사합니다. 자주 올게요 :)

1개의 답글