[JavaScript] 자바스크립트 Node.js로 알아보는 디자인 패턴들(Design Patterns in JavaScript)
2024.03.08- -
디자인 패턴들은 소프트웨어 디자인 과정에서 자주 발생하는 문제들에 대한 일반적인 해결책들입니다. 이는 우리의 코드에서 반복적으로 되풀이되는 디자인 문제들을 해결하기 위하여 맞춤화할 수 있는 청사진(blueprint)들을 미리 만들어 놓은 것이라고 보면 됩니다.
우리는 JavaScript로 백엔드 애플리케이션을 구축할 때 Node.js를 많이 사용하곤 하는데요. 최근에 이를 지원하는 프레임워크인 NestJS는 객체지향 프로그래밍을 기반으로 구성하기 때문에 객체지향에서 사용되는 많은 아키텍처와 디자인 패턴들을 차용합니다. 하지만 아직 JavaScript을 사용하여 디자인 패턴을 설명하는 글은 많지 않았는데 최근에 좋은 글을 발견하여 이에 대해 공부한 내용을 바탕으로 작성한 포스팅이 되겠습니다.
Node.js는 특히 서버 측 개발 영역에서 확장 가능하고 성능이 뛰어난 애플리케이션을 구축하는 데 널리 사용되는 플랫폼입니다. 다른 프로그래밍 언어나 프레임워크들과 마찬가지로 코드를 체계적이고 유지 관리 가능하며 쉽게 확장할 수 있도록 하기 위해 사용할 수 있는 다양한 디자인 패턴과 모범 사례가 존재합니다.
이번 포스팅에서는 Node.js에서 사용되는 가장 일반적인 디자인 패턴 몇 가지를 살펴보고 이를 구현하여 강력하고 확장 가능한 코드를 만드는 방법을 배워보도록 할 것입니다. 초보자이든 숙련된 개발자이든 이러한 디자인 패턴을 이해하는 것은 고품질의 유지 관리가 가능한 Node.js 애플리케이션을 구축하는 데 필수적이기 때문에 알아두면 반드시 도움이 될 것입니다.
1. Module 디자인 패턴은 체계적이고 모듈화된 코드를 만든다.
Module 디자인 패턴은 Node.js 개발에 매우 적합하고 널리 사용되는 디자인 패턴입니다. 이를 통해 개발자들은 코드를 쉽게 유지보수하고 테스트할 수 있는 논리적인 모듈적 구성 요소로 조직할 수 있게됩니다.
다음 단계들을 통해 모듈 디자인 패턴을 구현할 수 있습니다.
- 새 모듈 파일을 만들기: 새 모듈을 만들려면 확장자가 .js인 새 파일을 만들면 됩니다. 이 파일에는 모듈 내에서 캡슐화하려는 모든 코드가 포함되어야 합니다. (타입스크립트 사용 시 .ts)
- 모듈을 정의: 모듈을 정의하기 위해 module.exports 속성을 사용할 수 있습니다. 이 속성을 사용하면 애플리케이션의 다른 부분에서 사용할 수 있도록 모듈의 기능을 내보낼 수 있습니다.
- 모듈 가져오기: 애플리케이션에서 모듈을 사용하려면 require() 함수를 사용하여 모듈을 가져올 수 있습니다. 이 함수는 지정한 모듈에서 내보낸 기능을 반환하여 가져옵니다.
- 모듈 사용하기: 모듈을 가져온 후에는 애플리케이션 내에서 모듈의 기능을 사용할 수 있게 됩니다. 함수를 호출하거나 모듈에서 내보낸 속성에 액세스하여 사용할 수 있습니다.
다음 코드는 Node.js에서 Module 디자인 패턴을 어떻게 구현하는지를 보여주는 코드 예시입니다.
// exampleModule.js
const exampleFunction = () => {
console.log("Hello, World!");
};
module.exports = { exampleFunction };
위 코드에서는 exampleFunction이라는 단일 함수를 내보내는 간단한 모듈을 정의하였습니다. 이 함수는 "Hello, World!"라는 문자열을 콘솔에 기록하는 동작을 합니다.
// app.js
const exampleModule = require("./exampleModule");
exampleModule.exampleFunction();
위 코드에서 require() 함수를 사용하여 위에서 생성한 exampleModule 모듈을 가져옵니다. 그러면 모듈에서 내보낸 exampleFunction 함수를 호출할 수 있습니다.
이러한 방식으로 Module 디자인 패턴을 사용하면 애플리케이션의 다른 부분에서 쉽게 가져와서 사용할 수 있는 재사용 가능한 모듈을 만들 수 있습니다. 또한 코드를 깔끔하고 체계적으로 관리할 수 있는 방법도 제공합니다.
2. Singleton(싱글톤) 디자인 패턴은 객체의 단일 인스턴스를 보장한다.
Singleton 디자인 패턴은 특정 객체의 인스턴스가 애플리케이션 내에 단 하나만 존재할 수 있도록 하는 생성 패턴(creation pattern)입니다.
이 패턴은 객체의 인스턴스 수를 제한하거나 애플리케이션 내에서 해당 객체가 전역 상태를 유지해야 하는 경우에 유용합니다.
다음 단계를 통해 Node.js에서 Singleton 디자인 패턴을 구현해 보도록 하겠습니다.
- 새 Singleton 클래스를 생성: Singleton 클래스를 생성하려면 클래스의 새 인스턴스 생성을 담당하는 생성자 함수를 정의할 수 있습니다. 이 생성자 함수 내에서 클래스의 인스턴스가 이미 존재하는지 확인하고 존재하는 경우 해당 인스턴스를 반환합니다.
- Singleton 인스턴스에 액세스하기 위한 정적 메서드 생성: 애플리케이션 내에서 Singleton 인스턴스에 액세스하려면 싱글톤 클래스의 인스턴스를 반환하는 정적 메서드를 만들 수 있습니다. 이렇게 하면 애플리케이션 내에 클래스의 인스턴스가 하나만 존재할 수 있습니다.
- Singleton 클래스를 사용: Singleton 클래스와 Singleton 인스턴스에 액세스하기 위한 정적 메서드를 정의한 후에는 애플리케이션 내에서 이를 사용하여 클래스의 인스턴스가 하나만 생성되도록 할 수 있습니다.
이 단계를 따르면 Node.js에서 Singleton 클래스를 쉽게 생성하고 애플리케이션 내에 객체의 인스턴스가 하나만 존재할 수 있음을 보장할 수 있습니다.
구조
다음은 Node.js에서 Singleton 디자인 패턴을 구현한 예시입니다:
class Singleton {
constructor() {
if (!Singleton.instance) {
Singleton.instance = this;
}
return Singleton.instance;
}
static getInstance() {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
doSomething() {
console.log("Doing something...");
}
}
const singletonInstance1 = new Singleton();
const singletonInstance2 = Singleton.getInstance();
console.log(singletonInstance1 === singletonInstance2); // true
singletonInstance1.doSomething(); // "Doing something..."
singletonInstance2.doSomething(); // "Doing something..."
위 코드에서는 클래스의 인스턴스가 이미 존재하는지 검사하고 존재하는 경우 반환하는 생성자 함수가 있는 Singleton 클래스를 정의합니다.
또한 클래스의 인스턴스가 없는 경우 새 인스턴스를 생성하고 이를 반환하는 정적 메서드 getInstance()도 정의합니다. 마지막으로 싱글톤 클래스에 메시지를 콘솔에 로깅하는 doSomething() 메서드를 정의합니다.
- 정적 메서드 내에서는 '지연된 초기화'가 이루어지고 있습니다. 지연된 초기화란 생성자를 통해 인스턴스의 초기화가 이루어지는 것이 아니라 해당 정적 메서드가 호출되었을 때가 되어서야 초기화가 뒤늦게 이루어지는 것을 말합니다.
그런 다음 Singleton 클래스의 두 인스턴스, singletonInstance1과 singletonInstance2를 생성합니다. 이때 Singleton 클래스의 인스턴스는 하나만 존재할 수 있으므로 '===' 연산자를 사용하여 두 인스턴스가 동일한 인스턴스인지 확인해 보면 true가 반환되는 것을 확인할 수 있습니다 . 마지막으로 두 인스턴스 모두에서 doSomething() 메서드를 호출하여 실제로 동일한 객체임을 증명합니다.
적용
- 싱글턴 패턴은 프로그램의 클래스에 모든 클라이언트가 사용할 수 있는 단일 인스턴스만 있어야 할 때 사용합니다. 예를 들자면 프로그램의 다른 부분들에서 공유되는 단일 데이터베이스 객체처럼 말입니다.
- 싱글턴 패턴은 특별 생성 메서드를 제외하고는 클래스의 객체들을 생성할 수 있는 모든 다른 수단들을 비활성화(private)합니다. 이 메서드에서는 새 객체를 생성하거나 객체가 이미 생성되었으면 기존 객체를 반환합니다.
- 싱글턴 패턴은 전역 변수들을 더 엄격하게 제어해야 할 때 사용합니다.
- 전역 변수들과 달리 싱글턴 패턴은 클래스의 인스턴스가 하나만 있도록 보장해 줍니다. 캐시 된 인스턴스는 싱글턴 클래스 자체를 제외하고는 그 어떤 것과도 대체될 수 없습니다.
참고로 이 제한은 언제든 조정할 수 있고 원하는 수만큼의 싱글턴 인스턴스 생성을 허용할 수도 있습니다. 그러기 위해서 변경해야 하는 코드의 유일한 부분은 getInstance() 메서드의 본문 부분입니다.
장단점
- 장점
- 클래스가 하나의 인스턴트만 갖는다는 것이 보장됩니다.
- 이 인스턴스에 대한 전역 접근 지점을 얻습니다.
- 싱글턴 객체는 처음 요청될 때만 초기화됩니다.
- 단점
- 단일 책임 원칙을 위반합니다. 하지만 이 패턴은 한 번에 두 가지의 문제를 동시에 해결합니다. (인스턴스가 하나만 있음이 보장됨, 전역 접근 지점을 제공)
- 또 싱글턴 패턴은 잘못된 디자인(예를 들어 프로그램의 컴포넌트들이 서로에 대해 너무 많이 알고 있는 경우)이 있는데도 이를 보이지 않게 가릴 수 있습니다.
- 그리고 이 패턴은 다중 스레드 환경에서 여러 스레드가 싱글턴 객체를 여러 번 생성하지 않도록 하는 복잡한 처리가 필요합니다.
- 싱글턴의 클라이언트 코드를 유닛 테스트하기 어려울 수 있습니다. 그 이유는 많은 테스트 프레임워크들이 모의 객체들을 생성할 때 상속에 의존하기 때문입니다. 싱글턴 클래스의 생성자는 비공개이고 대부분 언어에서 정적 메서드를 오버라이딩하는 것이 불가능하므로 싱글턴의 한계를 극복할 수 있는 창의적인 방법을 생각해야 합니다. 아니면 그냥 테스트를 작성하지 말거나 싱글턴 패턴을 사용하지 않으면 됩니다.
TypeScript 예시 코드
/**
* Singleton 클래스는 클라이언트가 고유한 Signleton 인스턴스에
* 액세스할 수 있도록 하는 'getInstance' 메서드를 정의합니다.
*/
class Singleton {
private static instance: Singleton;
/**
* singleton의 생성자는 'new' 연산자를 통한 직접적인
* 생성자의 호출을 방지하기 위해 항상 private으로 해야 합니다.
*/
private constructor() { }
/**
* singleton 인스턴스에 대한 액세스를 제어하는 정적 메서드입니다.
*
* 이 구현을 통해 각 하위 클래스의 인스턴스를 하나만 유지하면서
* Singleton 클래스를 하위 클래스로 분류할 수 있습니다.
*/
public static getInstance(): Singleton {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
/**
* 마지막으로, 모든 singleton은 해당 인스턴스에서 실행할 수 있는
* 몇 가지 비지니스 로직을 정의해야 합니다.
*/
public someBusinessLogic() {
// ...
}
}
/**
* The client code.
*/
function clientCode() {
const s1 = Singleton.getInstance();
const s2 = Singleton.getInstance();
if (s1 === s2) {
console.log('Singleton works, both variables contain the same instance.');
} else {
console.log('Singleton failed, variables contain different instances.');
}
}
clientCode(); // Singleton works, both variables contain the same instance.
3. Factory(팩토리) 디자인 패턴은 공통 인터페이스를 가진 객체를 생성한다.
Factory 디자인 패턴은 생성할 객체의 정확한 클래스를 지정하지 않고도 객체를 생성할 수 있는 방법을 제공하는 생성 패턴입니다.
즉, 부모 클래스에서 객체들을 생성할 수 있는 인터페이스를 제공하지만, 자식 클래스들이 생성될 객체들의 유형을 변경할 수 있도록 하는 생성 패턴입니다.
이 패턴은 공통 인터페이스(common interface)를 공유하는 관련 클래스가 여러 개 있고 런타임에서 이러한 클래스의 객체를 동적으로 생성하려는 경우에 유용합니다.
Node.js에서 팩토리 디자인 패턴을 구현하기 위해 다음 단계를 따릅니다.
- 생성할 객체에 대한 인터페이스(Product)를 정의: 공통 인터페이스를 가진 객체를 만들려면 객체가 가질 공통 메서드나 속성을 지정하는 인터페이스 또는 추상 클래스를 정의해야 합니다.
- 팩토리 클래스(Creator)를 생성: 팩토리 클래스는 객체 생성을 담당합니다. 이 클래스에는 몇 가지 매개변수를 받아들이고 적절한 클래스의 새 인스턴스를 반환하는 메서드가 있어야 합니다.
- 인터페이스를 구현하는 구체적인 클래스(Concrete Product)를 생성: 구체적 클래스는 인터페이스를 구현하는 실제 클래스입니다. 각 구체적 클래스에는 인터페이스에 정의된 메서드의 자체 구현이 있어야 합니다.
- 팩토리 클래스를 사용하여 객체를 생성: 객체를 생성하려면 팩토리 클래스를 사용하여 인터페이스를 구현하는 구체적인 클래스의 인스턴스를 동적으로 생성할 수 있습니다.
이 단계에 따라 Node.js의 팩토리 디자인 패턴을 사용하여 공통 인터페이스를 가진 객체를 쉽게 만들 수 있습니다.
구조
다음은 Node.js에서 팩토리 디자인 패턴을 구현하는 방법의 예시입니다:
// 생성될 객체의 인터페이스를 정의한다.
class Animal {
makeSound() {}
}
// 인터페이스를 구현하는 구체적인 클래스를 정의한다.
class Dog extends Animal {
makeSound() {
console.log("Woof!");
}
}
class Cat extends Animal {
makeSound() {
console.log("Meow!");
}
}
// factory 클래스를 생성한다.
class AnimalFactory {
createAnimal(type) {
switch (type) {
case "dog":
return new Dog();
case "cat":
return new Cat();
default:
throw new Error(`Animal type '${type}' not supported.`);
}
}
}
// 객체 생성을 위해 factory 클래스를 사용한다.
const animalFactory = new AnimalFactory();
const dog = animalFactory.createAnimal("dog");
const cat = animalFactory.createAnimal("cat");
dog.makeSound(); // "Woof!"
cat.makeSound(); // "Meow!"
위 코드에서는 makeSound() 메서드 하나를 가진 Animal 인터페이스를 정의합니다. 또한 Animal 인터페이스를 구현하는 두 개의 구체적인 클래스, Dog와 Cat을 정의하고 각자의 makeSound() 메서드 구현을 통해 Animal 인터페이스를 구현합니다.
그런 다음 'type' 매개변수를 받아들이고 적절한 구체적 클래스의 새 인스턴스를 반환하는 createAnimal() 메서드가 있는 AnimalFactory 클래스를 정의합니다.
마지막으로 AnimalFactory 클래스의 인스턴스를 생성하고 해당 인스턴스의 메서드를 사용하여 Dog 및 Cat 클래스의 인스턴스를 생성합니다. 그런 다음 각 인스턴스에서 makeSound() 메서드를 호출하여 이들이 실제로 공통 인터페이스를 가진 객체임을 증명합니다.
적용
팩토리 메서드는 코드가 함께 작동해야 하는 객체들의 정확한 유형들과 의존 관계들을 미리 모르는 경우 사용합니다.
- 팩토리 메서드는 Product 생성 코드를 Product을 실제로 사용하는 코드와 분리합니다. 그러면 Product 생성자 코드를 나머지 코드와는 독립적으로 확장하기 쉬워집니다. 예를 들어, 앱에 새로운 product을 추가하려면 새로운 creator 자식 클래스를 생성한 후 해당 클래스 내부의 팩토리 메서드를 오버라이딩(재정의)하기만 하면 됩니다.
팩토리 메서드는 라이브러리 또는 프레임워크의 사용자들에게 내부 컴포넌트들을 확장하는 방법을 제공하고자 할 때 사용합니다.
- 상속은 아마 라이브러리나 프레임워크의 default 행등을 확장하는 가장 쉬운 방법일 것입니다. 그런데 프레임워크는 표준 컴포넌트 대신 자식 클래스를 사용해야 한다는 것을 어떻게 인식할까요?
- 해결책은 일단 프레임워크 전체에서 컴포넌트들을 생성하는 코드를 단일 팩토리 메서드로 줄인 후, 누구나 이 팩토리 메서드를 오바라이드 할 수 있도록 하는 것입니다.
팩토리 메서드는 기존 객체들을 매번 재구축하는 대신 이들을 재사용하여 시스템 리소스를 절약하고 싶을 때 사용합니다.
- 이러한 요구 사항은 데이터베이스 연결, 파일 시스템 및 네트워크처럼 시스템 자원을 많이 사용하는 대규모 객체들을 처리할 때 자주 발생합니다.
- 기존 객체를 사용하려면 다음과 같은 작업들을 해야 합니다.
- 먼저 생성된 모든 객체를 추적하기 위해 일부 저장소를 생성해야 합니다.
- 누군가 객체를 요청하면 프로그램은 해당 풀 내에서 유휴(free) 객체를 찾습니다.
- 이후 이 객체를 클라이언트 코드에 반환합니다.
- 유휴(free) 객체가 없으면, 프로그램은 새로운 객체를 생성해야 합니다.(풀에도 추가해주어야 함)
- 위처럼 많은 양의 중복코드로 프로그램을 오염시키지 않도록 모두 한 곳에 넣어주어야 합니다. 아마도 이 코드를 배치할 수 있는 가장 확실하고 편리한 위치는 우리가 재사용하려는 객체들의 클래스의 생성자일 것입니다. 그러나 생성자는 특성상 항상 새로운 객체들을 반환해야 하며, 기존 인스턴스를 반환할 수는 없습니다.
장단점
- 장점
- creator와 concrete product들이 단단하게 결합되지 않도록 할 수 있습니다.
- 단일 책임 원칙. 제품 생성 코드를 프로그램의 한 위치로 이동하여 코드를 더 쉽게 유지보수 할 수 있습니다.
- 개방/폐쇄 원칙. 우리는 기존 클라이언트 코드를 훼손하지 않고 새로운 유형의 제품들을 프로그램에 도입할 수 있습니다.
- 단점
- 패턴을 구현하기 위해 많은 새로운 자식 클래스들을 도입해야 하므로 코드가 더 복잡해질 수 있습니다. 가장 좋은 방법은 creator 클래스들의 기존 계층구조에 패턴을 도입하는 것입니다.
타입스크립트 팩토리 패턴 코드 예시
/**
* Creator 클래스는 Product 클래스의 객체를 반환해야 하는 Factory 메서드를
* 선언합니다. Creator의 하위 클래스는 일반적으로 이 메서드의 구현을
* 제공합니다.
*/
abstract class Creator {
/**
* Creator는 factory 메서드의 기본 구현을 제공할 수도 있습니다.
*/
public abstract factoryMethod(): Product;
/**
* 또한, 그 이름과는 달리 Creator의 주된 업무는 Product를 만드는 것이 아닙니다.
* 보통 Product 객체에 의존하는 몇 가지 핵심 비지니즈 로직을 포함하고 있으며,
* 이는 factory 메서드를 통해 반환됩니다. 하위 클래스는 factory 메서드를 무시하고
* 다른 유형의 Product을 반환함으로써 비지니스 로직을 간접적으로 변경할 수 있습니다.
*/
public someOperation(): string {
// Product 객체를 생성하기 위해 factory 메서드를 호출
const product = this.factoryMethod();
// 이제, product를 사용합니다.
return `Creator: The same creator's code has just worked with ${product.operation()}`;
}
}
/**
* concrete creators는 최종 product의 타입을 변경하기 위해 factory method를 오버라이드합니다.
*/
class ConcreteCreator1 extends Creator {
/**
* concrete product가 실제로 메서드에서 반환되더라도 메서드의 시그니처는 여전히
* 추상적인 product 타입을 사용합니다. 이렇게 하면 Creator는 concrete product 클래스와
* 독립적으로 유지될 수 있습니다.
*/
public factoryMethod(): Product {
return new ConcreteProduct1();
}
}
class ConcreteCreator2 extends Creator {
public factoryMethod(): Product {
return new ConcreteProduct2();
}
}
/**
* Product 인터페이스는 모든 특정 product이 구현해야 하는 연산들을 선언합니다.
*/
interface Product {
operation(): string;
}
/**
* Concrete Products는 Product 인터페이스의 다양한 구현들을 제공합니다.
*/
class ConcreteProduct1 implements Product {
public operation(): string {
return '{Result of the ConcreteProduct1}';
}
}
class ConcreteProduct2 implements Product {
public operation(): string {
return '{Result of the ConcreteProduct2}';
}
}
/**
* client code는 비록 기본 인터페이스를 통해 작동하지만, concrete creator의 인스턴스를
* 사용하여 작동합니다. 클라이언트가 기본 인터페이스를 통해 creator와 계속 작업하는 한,
* 모든 creator의 서브 클래스를 전달할 수 있습니다.
*/
function clientCode(creator: Creator) {
// ...
console.log('Client: I\'m not aware of the creator\'s class, but it still works.');
console.log(creator.someOperation());
// ...
}
/**
* 애플리케이션은 구성 또는 환경에 따라 creator의 타입을 선택합니다.
*/
console.log('App: Launched with the ConcreteCreator1.');
clientCode(new ConcreteCreator1());
console.log('');
console.log('App: Launched with the ConcreteCreator2.');
clientCode(new ConcreteCreator2());
4. event-driven programming을 위한 Observer(옵저버) 디자인 패턴
Observer 디자인 패턴은 다른 객체에서 특정 이벤트나 상태 변경이 발생하면 하나 이상의 객체에 알림을 보내는 동작 패턴(behaviour pattern)입니다. 이 패턴은 미리 정의된 작업 순서가 아닌 이벤트 발생에 따라 프로그램의 흐름이 결정되는 event-driven programming(이벤트 중심 프로그래밍)에서 널리 사용됩니다.
이 패턴은 다수의 객체에게 그들이 관찰 중인 객체에게 일어나는 모든 이벤트에 대하여 알리는 구독 메커니즘을 정의하도록 합니다.
Problem
Customer(손님) 및 Store(가게)라는 두 가지 유형의 객체들이 있다고 가정합니다. 손님은 곧 매장에 출시될 특정 브랜드의 제품(예: 새 아이폰 모델)에 매우 관심이 있습니다.
손님은 매일 매장을 방문하여 제품 재고를 확인할 수 있으나, 제품이 매장에 여전히 운송되는 동안 이러한 방문 대부분은 무의미하게 됩니다.
반면, 매장에서는 새로운 제품이 출시될 때마다 모든 고객에게 스팸으로 간주할 수 있는 수많은 이메일을 보낼 수 있는 경우를 생각해 봅시다. 이 수많은 이메일은 일부 고객들을 신제품 출시 확인을 위한 잦은 매장 방문으로부터 구출해낼 수 있으나, 동시에 신제품 출시에 관심이 없는 다른 고객들을 화나게 할 수도 있을 것입니다.
여기서 충돌이 발생합니다. 손님들이 신제품 출시 확인을 위해 시간을 낭비하든지, 매장들이 알림을 원하지 않는 고객들에게 신제품 출시를 알리며 자원을 낭비해야 합니다.
해결책
시간이 지나면 변경될 수 있는 중요한 상태를 가진 객체가 있다고 가정해봅시다. 이 객체는 종종 subject라고 불립니다. 그러나 위 예시의 경우 이 객체는 자신의 상태에 대한 변경에 대해 다른 객체들에 알림을 보내는 역할도 맡을 것이니 해당 객체를 publisher라고 부르겠습니다.
옵서버 패턴은 publisher 클래스에 개별 객체들이 그 publisher로부터 오는 이벤트들의 알림들을 구독 또는 구독 취소할 수 있도록 구독 메커니즘을 추가할 것을 제안합니다.
실제로 이 메커니즘은
1) subscriber 객체들에 대한 참조의 리스트를 저장하기 위한 배열 필드와
2) 그 리스트에 subscriber들을 추가하거나 제거할 수 있도록 하는 여러 공개된(public) 메서드들로 구성됩니다.
이제 publisher에 중요한 이벤트가 발생할 때마다 subscriber 리스트를 참조한 후 그들의 객체들에 있는 특정 알림 메서드를 호출합니다.
실제 앱에는 같은 publisher 클래스의 이벤트들을 추적하는 데 관심이 있는 수십 개의 서로 다른 subscriber 클래스들이 있을 수 있습니다. 하지만 우리는 publisher를 이러한 모든 클래스에 결합하고 싶지 않습니다. 게다가 우리의 publisher 클래스가 다른 사람들에 의해 사용되어야 한다면 이러한 subscriber 클래스 중 일부는 미리 알지 못할 수도 있습니다.
그러므로 모든 subscriber가 같은 인터페이스를 구현하고 publisher는 오직 그 인터페이스를 통해서만 subscriber들과 통신하도록 하는 것이 매우 중요합니다. 이 인터페이스는 publisher가 알림과 어떤 context 데이터를 전달하는 데 사용할 수 있는 매개변수들의 집합과 알림 메서드를 선언해야 합니다.
앱에 여러 유형의 publisher가 있고 이들을 subscriber들과 호환되도록 하려면 더 나아가 모든 publisher가 같은 인터페이스를 따르도록 할 수 있습니다. 이 인터페이스는 몇 가지 구독 메서드들만 설명하면 됩니다. 이 인터페이스를 통해 subscriber들은 publisher들의 상태들을 그들의 concrete 클래스들과 결합하지 않고 관찰할 수 있습니다.
구조
Node.js에서 Observer 디자인 패턴을 구현하기 위해 다음 단계를 따릅니다:
- 주제 클래스(subject class)를 정의: subject 클래스는 변경 사항을 관찰할 대상입니다. observer를 등록하고 변경 사항이 발생하면 observer에게 알리는 방법이 있어야 합니다.
- observer 인터페이스를 정의: observer 인터페이스에서는 observer 객체가 subject의 알림을 받기 위해 구현할 메서드를 지정합니다.
- observer 클래스를 정의: observer 클래스는 observer 인터페이스를 구현하고 observer가 subject로부터 알림을 받을 때 발생하는 동작을 정의합니다.
- subject에 observer를 등록: 변경 사항이 발생할 때 알림을 받을 수 있도록 observer를 subject에 등록해야 합니다.
- 변경 사항이 발생하면 observer에게 알림을 보냄: subject에게 변경 사항이 발생하면 각 observer에게 적절한 방법을 호출하여 등록된 모든 observer에게 알려야 합니다.
이 단계를 따르면 Node.js에서 Observer 디자인 패턴을 쉽게 구현하고 상태 변화에 반응하는 이벤트 중심 애플리케이션을 만들 수 있습니다.
다음은 Node.js에서 Observer 디자인 패턴을 구현하는 방법의 예시입니다:
// observer 인터페이스를 정의한다.
class Observer {
update() {}
}
// subject 클래스를 정의한다.
class Subject {
constructor() {
this.observers = [];
}
registerObserver(observer) {
this.observers.push(observer);
}
unregisterObserver(observer) {
this.observers = this.observers.filter((obs) => obs !== observer);
}
notifyObservers() {
this.observers.forEach((observer) => observer.update());
}
}
// observer 클래스를 정의한다.
class ExampleObserver extends Observer {
update() {
console.log("ExampleObserver notified of update!");
}
}
// event-driven programming을 위한 observer 디자인 패턴을 사용한다.
const subject = new Subject();
const observer1 = new ExampleObserver();
const observer2 = new ExampleObserver();
subject.registerObserver(observer1);
subject.registerObserver(observer2);
subject.notifyObservers(); // "ExampleObserver notified of update!" (x2)
subject.unregisterObserver(observer1);
subject.notifyObservers(); // "ExampleObserver notified of update!" (x1)
위 코드에서는 update() 메서드 하나가 있는 observer 인터페이스 / observer 등록, 등록 취소 및 알림 메서드가 있는 Subject 클래스 / 자체적으로 update() 메서드를 구현하여 observer 인터페이스를 구현하는 ExampleObserver 클래스를 정의합니다.
그런 다음 Subject 클래스의 인스턴스와 ExampleObserver 클래스의 인스턴스 두 개를 생성합니다. 두 observer를 모두 subject에 등록하고 notifyObservers() 메서드를 사용하여 업데이트를 알립니다. 그런 다음 observer 중 한 명의 등록을 취소하고 나머지 관찰자에게 다른 업데이트를 알립니다.
이러한 방식으로 observer 디자인 패턴을 사용하면 상태 변화에 반응하고 여러 객체를 서로 동기화하는 이벤트 중심(event-driven) 애플리케이션을 만들 수 있습니다.
적용
옵저버 패턴은 한 객체의 상태가 변경되어 다른 객체들을 변경해야 할 필요가 생겼는데 실제 객체 집합들을 미리 알 수 없거나 이러한 집합들이 동적으로 변경될 때 사용합니다.
- 이런 문제는 GUI 클래스와 작업할 때 자주 경험할 수 있습니다. 예를 들어 커스텀 버튼 클래스들을 생성했고, 이제 클라이언트들이 커스텀 코드를 버튼에 연결하여 사용자가 버튼을 누를 때마다 실행되도록 하고 싶다고 가정합시다.
- 옵저버 패턴은 subscriber 인터페이스를 구현하는 모든 객체가 publisher 객체의 이벤트 알림들에 구독할 수 있도록 합니다. 또한 버튼에 구독 메커니즘을 추가할 수 있으며, 클라이언트들이 커스텀 subscriber 클래스들을 통해 커스텀 코드를 연결하도록 할 수 있습니다.
이 패턴은 앱의 일부 객체들이 제한된 시간 동안 혹은 특정 경우에만 다른 객체들을 관찰해야 할 때 사용합니다.
- 구독 리스트는 동적이기 때문에 subscriber들은 필요에 따라 리스트에 가입하거나 탈퇴할 수 있습니다.
구현 방법
- 앱의 비즈니스 로직을 살펴보고 두 부분으로 나눕니다.
- 핵심 기능들은 다른 코드와 독립적이며 publisher 역할을 합니다. 나머지는 subscriber 클래스들의 집합으로 바뀝니다.
- subscriber 인터페이스를 선언합니다.
- 이 인터페이스는 최소한 하나의 update 메서드를 선언해야 합니다.
- publisher 인터페이스를 선언하고 subscriber 객체를 subscriber 리스트에 추가 및 제거하는 한 쌍의 메서드에 대해 기술합니다.
- publisher들은 subscriber 인터페이스를 통해서만 subscriber들과 작업해야 합니다.
- 구독 메서드들의 구현과 실제 구독 리스트를 어디에 배치할지 결정합니다.
- 일반적으로 모든 유형의 publisher에서 이 코드는 실질적으로 유사하므로 publisher 인터페이스에서 직접 파생된 추상 클래스에 코드를 넣는 것이 가장 적합합니다. concrete publisher들은 이 클래스를 확장하여 해당 클래스의 구독 행위을 상속합니다.
- 그러나 기존 클래스 계층구조에 패턴을 적용하는 경우 합성에 기반한 접근 방식을 고려하세요. 구독 로직을 별도의 객체에 넣고 모든 실제 publisher들이 이를 사용하도록 하세요.
- concrete publisher 클래스들을 만듭니다.
- publisher 내부에서 중요한 일이 발생할 때마다 모든 subscriber에게 알림을 전달해야 합니다.
- concrete subscriber 클래스들에서 update notify 메서드들을 구현합니다.
- 대부분의 subscriber는 이벤트에 대한 일부 콘텍스트 데이터가 필요하며, 이 데이터는 알림 메서드의 인수로 전달될 수 있습니다.
- 그러나 다른 옵션이 있습니다.
- 알림을 받으면 subscriber들이 알림에서 직접 모든 데이터를 가져오도록 하는 것입니다. 이 경우 publisher는 update 메서드를 통해 자신을 전달해야 합니다. 유연성이 보다 떨어지는 옵션은 생성자를 통해 publisher를 subscriber에 영구적으로 연결하는 것입니다.
- 클라이언트는 필요한 모든 subscriber를 생성하고 적절한 publisher들과 등록시켜야 합니다.
- Subject(publisher)는 다른 객체들에 관심 이벤트들을 발행합니다. 이러한 이벤트들은 publisher가 상태를 전환하거나 어떤 행동들을 실행할 때 발생합니다. publisher들에는 구독 인프라가 포함되어 있으며, 이 인프라는 현재 subscriber들이 리스트를 떠나고 새 subscriber들이 리스트에 가입할 수 있도록 합니다.
- 새 이벤트가 발생하면 publisher는 subscriber 리스트를 살펴본 후 각 subscriber 객체의 subscriber 인터페이스에 선언된 notify 메서드를 호출합니다.
- 이 subscirber 인터페이스는 알림 인터페이스를 선언하며 대부분의 경우 단일 update 메서드로 구성됩니다. 이 메서드에는 publisher가 업데이트와 함께 어떤 이벤트의 세부 정보들을 전달할 수 있도록 하는 여러 매개변수가 있을 수 있습니다.
- concrete subscriber들은 publisher가 보낸 알림들에 대한 응답으로 몇 가지 작업을 수행합니다. 이러한 모든 클래스는 출판사가 concrete 클래스들과 결합하지 않도록 같은 인터페이스를 구현해야 합니다.
- 일반적으로 subscriber들은 update를 올바르게 처리하기 위해 콘텍스트 정보가 어느 정도 필요로 합니다. 그러므로 publisher들은 종종 콘텍스트 데이터를 notify 메서드의 인수들로 전달합니다. 출판사는 자신을 인수로 전달할 수 있으며, subscriber가 필요한 데이터를 직접 가져오도록 합니다.
- 클라이언트는 publisher 및 subscriber 객체들을 별도로 생성한 후 subscriber들을 publisher update에 등록합니다.
장단점
- 장점
- 개방/폐쇄 원칙. publisher의 코드를 변경하지 않고도 새 subscriber 클래스들을 도입할 수 있습니다.(출판사 인터페이스가 있는 경우 그 반대로 구독자의 클래스들을 변경하지 않고 새 출판사 클래스들을 도입하는 것 역시 가능합니다.)
- 런타임에 객체 간의 관계들을 형성할 수 있습니다.
- 단점
- subscriber들이 무작위로 알림을 받게됩니다.
TypeScript 코드 예시
/**
* Subject interface는 subscriber를 관리하기 위한 일련의 메서드들을 선언합니다.
*/
interface Subject {
// Attach an observer to the subject.
attach(observer: Observer): void;
// Detach an observer from the subject.
detach(observer: Observer): void;
// Notify all observers about an event.
notify(): void;
}
/**
* Subject는 일부 중욯나 상태를 소유하고 상태가 변경되면 observer에게 알립니다.
*/
class ConcreteSubject implements Subject {
/**
* @type {number} 단순화를 위해 모든 subscriber들에게 필수적인 Subject의 상태가
* 이 변수에 저장됩니다.
*/
public state: number;
/**
* @type {Observer[]} subscriber 목록. 실생활에서 subscriber 목록을 보다
* 포괄적으로 저장할 수 있습니다. (이벤트 타입별 등으로 분류)
*/
private observers: Observer[] = [];
/**
* 구독 관리 메서드
*/
public attach(observer: Observer): void {
const isExist = this.observers.includes(observer);
if (isExist) {
return console.log('Subject: Observer has been attached already.');
}
console.log('Subject: Attached an observer.');
this.observers.push(observer);
}
public detach(observer: Observer): void {
const observerIndex = this.observers.indexOf(observer);
if (observerIndex === -1) {
return console.log('Subject: Nonexistent observer.');
}
this.observers.splice(observerIndex, 1);
console.log('Subject: Detached an observer.');
}
/**
* 각 subscriber에서 update를 유발시키는 메서드
*/
public notify(): void {
console.log('Subject: Notifying observers...');
for (const observer of this.observers) {
observer.update(this);
}
}
/**
* 일반적으로 구독 로직은 Subject가 실제로 할 수 있는 일 중 일부에 불과합니다.
* Subject는 일반적으로 중요한 일이 발생하려고 할 때(혹은 발생 후에)마다
* notify 메서드를 트리거하는 중요한 비지니스 로직을 가지고 있습니다.
*/
public someBusinessLogic(): void {
console.log('\nSubject: I\'m doing something important.');
this.state = Math.floor(Math.random() * (10 + 1));
console.log(`Subject: My state has just changed to: ${this.state}`);
this.notify();
}
}
/**
* Observer interface는 Subject가 사용하는 update 메서드를 선언합니다.
*/
interface Observer {
// Receive update from subject.
update(subject: Subject): void;
}
/**
* Concrete Observers는 자신이 관찰하던 Subject가 발행한 업데이트에 반응합니다.
*/
class ConcreteObserverA implements Observer {
public update(subject: Subject): void {
if (subject instanceof ConcreteSubject && subject.state < 3) {
console.log('ConcreteObserverA: Reacted to the event.');
}
}
}
class ConcreteObserverB implements Observer {
public update(subject: Subject): void {
if (subject instanceof ConcreteSubject && (subject.state === 0 || subject.state >= 2)) {
console.log('ConcreteObserverB: Reacted to the event.');
}
}
}
/**
* 클라이언트 코드
*/
const subject = new ConcreteSubject();
const observer1 = new ConcreteObserverA();
subject.attach(observer1);
const observer2 = new ConcreteObserverB();
subject.attach(observer2);
subject.someBusinessLogic();
subject.someBusinessLogic();
subject.detach(observer2);
subject.someBusinessLogic();
실행결과
Subject: Attached an observer.
Subject: Attached an observer.
Subject: I'm doing something important.
Subject: My state has just changed to: 6
Subject: Notifying observers...
ConcreteObserverB: Reacted to the event.
Subject: I'm doing something important.
Subject: My state has just changed to: 1
Subject: Notifying observers...
ConcreteObserverA: Reacted to the event.
Subject: Detached an observer.
Subject: I'm doing something important.
Subject: My state has just changed to: 5
Subject: Notifying observers...
5. Middleware(미들웨어) 디자인 패턴은 유연하고 확장가능한 코드를 만든다.
middleware 디자인 패턴은 Node.js에서 개발자가 복잡한 작업을 더 작고 관리하기 쉬운 단계로 나누어 각 단계를 개별적으로 실행할 수 있게 해주는 행동 패턴입니다.
이 패턴은 각 미들웨어 함수가 요청 또는 응답 객체를 다음 미들웨어 함수로 전달하기 전에 요청 또는 응답 객체를 수정할 수 있는 HTTP 요청 처리를 위해 Node.js 개발에서 널리 사용됩니다.
Node.js에서 미들웨어 디자인 패턴을 구현하려면 다음 단계를 따릅니다:
- 미들웨어 함수를 정의: 각 미들웨어 함수는 req 객체, res 객체, 미들웨어가 chain의 다음 미들웨어 함수로 제어권을 넘길 수 있는 next 함수와 같은 3가지 매개 변수를 받아들여야 합니다.
- 미들웨어 함수를 사용: 미들웨어 함수는 Express.js의 app.use() 메서드를 사용하여 요청 처리 chain에 추가해야 합니다. 미들웨어 함수가 추가되는 순서에 따라 실행되는 순서가 결정됩니다.
- 다음 미들웨어 함수로 제어를 전달: chain의 다음 미들웨어 함수로 제어를 전달하려면 각 미들웨어 함수가 next() 함수를 호출해야 합니다.
이 단계를 따르면 Node.js에서 미들웨어 디자인 패턴을 쉽게 구현하고 복잡한 작업을 처리할 수 있는 유연하고 확장 가능한 애플리케이션을 만들 수 있습니다.
다음은 Express.js를 사용하여 Node.js에서 미들웨어 디자인 패턴을 구현하는 방법의 예시입니다:
const express = require("express");
const app = express();
// 미들웨어 함수 정의
const middleware1 = (req, res, next) => {
console.log("Middleware 1 executing...");
req.customProperty = "Hello from middleware 1!";
next();
};
const middleware2 = (req, res, next) => {
console.log("Middleware 2 executing...");
res.send(req.customProperty);
};
// 미들웨어 함수 사용
app.use(middleware1);
app.use(middleware2);
// 서버 시작
app.listen(3000, () => {
console.log("Server listening on port 3000...");
});
위 코드에서는 middleware1과 middleware2라는 두 개의 미들웨어 함수를 정의하고 있습니다. middleware1은 요청 객체에 사용자 정의 속성을 추가하여 객체를 수정하고 middleware2는 이 속성 값을 응답으로 전송합니다.
그런 다음 Express.js의 app.use() 메서드를 사용하여 두 미들웨어 함수를 요청 처리 chain에 추가합니다. 요청이 수신되면 middleware1이 먼저 실행되고 그 다음 middleware2가 실행됩니다.
마지막으로 서버를 시작하고 포트 3000에서 들어오는 요청을 수신 대기합니다.
6. Dependency Injection(의존성 주입) 디자인 패턴은 느슨한 결합과 테스트 가능성을 만든다.
의존성 주입(dependency injection) 디자인 패턴은 객체나 함수 내에서 직접 종속성을 만드는 대신, 객체나 함수에 종속성을 전달하는 구조 패턴(structural pattern)입니다. 이 패턴은 느슨하게 결합되고 쉽게 테스트할 수 있으며 재사용 가능한 코드를 만드는 데에 유용합니다.
Node.js에서 의존성 주입 디자인 패턴을 구현하려면 다음 단계를 따릅니다:
- 의존성을 파악: 의존성 주입 디자인 패턴을 구현하는 첫 번째 단계는 객체 또는 함수에 필요한 의존성을 식별하는 것입니다. 이러한 의존성은 객체나 함수가 의존하는 다른 객체, 모듈 혹은 서비스일 수 있습니다.
- 의존성을 매개변수로 정의: 의존성을 객체나 함수 안에서 만드는 대신, 해당 객체나 함수가 호출될 때 전달되는 매개변수로 정의합니다. 이렇게 하면 의존성을 명시적으로 만들 수 있고 테스트를 위해 의존성을 쉽게 바꾸거나 모킹(mocking)할 수 있습니다.
- 의존성 주입 컨테이너를 사용: 의존성 주입 컨테이너는 의존성의 생성 및 해결을 관리하는 유틸리티입니다. 의존성을 자동으로 등록하고 해결(resolve)할 수 있는 방법을 제공하여 애플리케이션에 필요한 boilerplate 코드의 양을 줄여줍니다.
다음은 간단한 함수를 사용하여 Node.js에서 의존성 주입 디자인 패턴을 구현하는 방법의 예시입니다:
// 매개변수로 의존성을 정의
function exampleFunction(dependency1, dependency2) {
// 함수 내에서 의존성 사용
console.log(dependency1.doSomething());
console.log(dependency2.doSomething());
}
// 의존성을 다루기 위해 의존성 주입 컨테이너를 사용
const dependency1 = require("./dependency1");
const dependency2 = require("./dependency2");
exampleFunction(dependency1, dependency2);
위 코드에서는 두 개의 종속성인 dependency1과 dependency2를 매개변수로 사용하는 exampleFunction 함수를 정의합니다. 그런 다음 함수 내에서 이러한 종속성을 사용하여 몇 가지 작업을 수행합니다.
그런 다음 종속성 주입 컨테이너를 사용하여 종속성을 관리하고 이를 애플리케이션으로 가져와 exampleFunction 함수에 전달합니다.
7. Command 디자인 패턴은 요청을 객체로 캡슐화한다.
당신은 도시를 한참 걷다가 멋진 레스토랑에 도착하여 창가 테이블에 앉습니다. 친절한 웨이터가 다가와 신속하게 당신의 주문을 받아 종이에 적습니다. 웨이터는 부엌으로 가서 주문을 벽에 붙입니다. 잠시 후 요리사에게 주문이 전달되고 요리사는 주문을 읽고 그에 따라 음식을 요리합니다. 요리사는 주문에 맞는 식사를 트레이에 올려 놓습니다. 웨이터는 트레이를 발견한 후 당신이 주문한 대로 식사가 요리되었는지 확인하고 완성된 주문을 당신의 테이블로 가져옵니다.
종이에 적힌 주문은 커맨드 역할을 합니다. 이 주문은 요리사가 요리할 준비가 될 때까지 대기열에 남아 있습니다. 주문에는 식사를 요리하는 데 필요한 모든 관련 정보가 포함되어 있습니다. 이를 통해 요리사는 당신에게서 주문 세부 사항을 직접 전달받지 않고도 바로 요리를 시작할 수 있습니다.
Command(명령) 디자인 패턴은 요청을 객체로 캡슐화하여 요청을 매개변수로 전달하거나, 자료 구조에 저장하거나, 유연하고 확장 가능한 방식으로 조작할 수 있도록 하는 행동 패턴입니다.
이 패턴은 모듈식, 분리형, 재사용 가능한 코드를 만드는 데 유용합니다.
다시 말해 커맨드 패턴은 요청에 대한 모든 정보가 포함된 독립실행형 객체로 변환하는 패턴으로 이러한 변환은 다양한 요청들이 있는 메서드들을 인수화할 수 있도록 하며, 요청의 실행을 지연 또는 대기열에 넣을 수 있도록 하고, 또 실행 취소할 수 있는 작업을 지원할 수 있도록 합니다.
Node.js에서 command 디자인 패턴을 구현하려면 다음 단계를 따릅니다:
- command 인터페이스를 정의: command 인터페이스는 command 객체가 구현할 메서드를 명시합니다. 이러한 메서드에는 일반적으로 요청된 작업을 수행하는 execute() 메서드와 작업을 취소하는 undo() 메서드가 포함됩니다.
- 구체적인 command 클래스를 정의: 구체적인 command 클래스는 command 인터페이스를 구현하고 실제로 실행될 특정 작업을 정의합니다.
- 각 클래스에는 실제 receiver 객체에 대한 참조와 함께 요청 인수들을 저장하기 위한 필드들의 집합이 있어야 합니다. 이러한 모든 값은 커맨드의 생성자를 통해 초기화 됩니다.
- invoker 클래스를 생성: invoker 클래스는 command 객체를 호출하고 실행을 관리하는 역할을 담당합니다. command 객체를 저장하고 검색하는 방법과 command 객체를 실행하는 메서드를 갖고 있어야 합니다.
- invoker 클래스는 일종의 발송자 역할로 이는 커맨드 인터페이스를 통해서만 커맨드들과 통신해야 합니다. 발송자들은 보통 자체적으로 커맨드 객체들을 생성하지 않고 클라이언트 코드에서 가져옵니다.
- command 객체를 사용: command 객체는 invoker 클래스에서 생성하고 실행해야 합니다.
다음 단계를 따르면 Node.js에서 command 디자인 패턴을 쉽게 구현하고 모듈식, 분리형, 재사용 가능한 코드를 만들 수 있습니다.
다음은 Node.js에서 Command 디자인 패턴을 구현하는 방법의 예시입니다:
// command 인터페이스 정의
class Command {
execute() {}
undo() {}
}
// 구체적인 command 클래스를 정의
class AddCommand extends Command {
constructor(receiver, value) {
super();
this.receiver = receiver;
this.value = value;
}
execute() {
this.receiver.value += this.value;
}
undo() {
this.receiver.value -= this.value;
}
}
class SubtractCommand extends Command {
constructor(receiver, value) {
super();
this.receiver = receiver;
this.value = value;
}
execute() {
this.receiver.value -= this.value;
}
undo() {
this.receiver.value += this.value;
}
}
// invoker 클래스를 생성
class Invoker {
constructor() {
this.commands = [];
}
addCommand(command) {
this.commands.push(command);
}
executeCommands() {
this.commands.forEach((command) => command.execute());
}
undoCommands() {
this.commands.slice().reverse().forEach((command) => command.undo());
}
}
// command 객체를 사용
const receiver = { value: 0 };
const addCommand = new AddCommand(receiver, 5);
const subtractCommand = new SubtractCommand(receiver, 3);
const invoker = new Invoker();
invoker.addCommand(addCommand);
invoker.addCommand(subtractCommand);
invoker.executeCommands();
console.log(receiver.value); // 2 (+5-3)
invoker.undoCommands();
console.log(receiver.value); // 0
클라이언트는 다음 순서로 객체들을 초기화해야 합니다.
- 수신자를 만든다.
- 커맨드들을 만들고 필요한 경우 수신자들과 연관시킨다
- 발송자들을 만들고 앞선 특정 커맨드들과 연관시킨다.
위 코드에서는 2개의 메서드 execute() 및 undo()가 있는 Command 인터페이스와 execute() 및 undo() 메서드의 자체 구현을 통해 Command 인터페이스를 구현한 2개의 구체적인 command 클래스인 AddCommand 및 SubtractCommand를 정의합니다.
그런 다음 command 객체의 실행 및 실행 취소를 관리하는 Invoker 클래스를 정의합니다. 이 클래스에는 command 객체를 목록에 추가하고 목록에 있는 모든 command들을 실행하거나 실행 취소하는 메서드를 갖습니다.
마지막으로 invoker 클래스의 인스턴스와 구체적인 command 클래스의 인스턴스 두 개를 생성합니다. 두 command를 모두 호출자의 command 목록에 추가하고 executeCommands() 메서드를 사용하여 실행합니다. 그런 다음 receiver 객체의 값을 콘솔로 출력하여 명령이 올바르게 실행되었음을 증명합니다. 그런 다음 undoCommands()메서드를 사용하여 명령을 실행 취소하고 receiver 객체의 값을 다시 기록하여 명령이 올바르게 실행 취소되었음을 증명합니다.
적용
작업들로 객체를 매개변수화하려는 경우 커맨드 패턴을 사용합니다.
- 커맨드 패턴은 특정 메서드 호출을 독립실행형 객체로 전환할 수 있습니다. 이 변경을 통해 당신은 이제 커맨드들을 메서드 인수들로 전달하고, 이들을 다른 객체들의 내부에 저장하고, 런타임에 연결된 커맨드를 전환하는 등의 여러 흥미로운 작업을 진행할 수 있습니다.
- 예를 들어, 콘텍스트 메뉴(상황에 맞는 메뉴)와 같은 GUI 컴포넌트를 개발 중이고, 앱의 사용자들이 최종 사용자가 하나의 항목을 클릭했을 때 작업이 시작되는 메뉴 항목들을 설정할 수 있도록 만들고 싶어하는 경우가 있을 수 있습니다.
커맨드 패턴은 작업들의 실행을 예약하거나, 작업들을 대기열에 넣거나 작업들을 원격으로 실행하려는 경우에 사용합니다.
- 다른 여느 객체와 마찬가지로 커맨드는 직렬화될 수 있습니다. 직렬화는 파일이나 데이터베이스에 쉽게 쓸 수 있는 문자열로 변환하는 행위입니다. 나중에 이 문자열은 다시 또 초기 커맨드 객체로 복원될 수 있습니다. 따라서 커맨드의 실행을 지연하고 예약할 수 있고, 같은 방식으로 네트워크를 통해 커맨드를 대기열에 추가하거나 로그(기록) 하거나 전송할 수도 있습니다.
커맨드 패턴은 되돌릴 수 있는 작업을 구현하려고 할 때 사용합니다.
- 실행 취소/다시 실행을 구현하는 방법에는 여러 가지가 있지만, 커맨드 패턴이 아마도 가장 많이 사용되는 패턴일 것입니다.이 메서드에는 두 가지 단점이 있습니다. 첫째, 앱 일부가 비공개일 수 있으므로 앱의 상태를 저장하는 것이 쉽지 않습니다. 이 문제는 메멘토 패턴으로 완화할 수 있습니다.
- 둘째, 상태 백업들은 상당히 많은 RAM 리소스를 소모할 수 있습니다. 따라서 때로는 대안적 구현에 의존할 수 있습니다. 예를 들어 과거 상태를 복원하는 대신 커맨드가 작업을 역으로 수행하는 것입니다. 하지만 역으로 작업을 수행하는데에도 대가가 따르기 마련입니다. 이는 구현하기 어렵거나 심지어 불가능할 수도 있습니다.
- 작업을 되돌리려면 수행한 작업의 기록을 구현해야 합니다. 커맨드 기록은 앱 상태의 관련 백업들과 함께 실행된 모든 커맨드 객체들을 포함하는 스택입니다.
장단점
- 장점
- 단일 책임 원칙. 작업을 호출하는 클래스들을 이러한 작업을 수행하는 클래스들로부터 분리할 수 있습니다.
- 개방/폐쇄 원칙. 기존 클라이언트 코드를 손상하지 않고 앱에 새 커맨드들을 도입할 수 있습니다.
- 실행 취소/다시 실행을 구현할 수 있습니다.
- 작업들의 지연된 실행을 구현할 수 있습니다.
- 간단한 커맨드들의 집합을 복잡한 커맨드로 조합할 수 있습니다.
- 단점
- 발송자와 수신자 사이에 완전히 새로운 레이어를 도입하기 때문에 코드가 더 복잡해질 수 있습니다.
TypeScript 코드 예시
/**
* Command interface는 커맨드를 실행하는 메서드 execute()를 선언합니다.
*/
interface Command {
execute(): void;
}
/**
* 몇몇 commands는 자체적인 기본 작업을 구현할 수 있습니다.
*/
class SimpleCommand implements Command {
private payload: string;
constructor(payload: string) {
this.payload = payload;
}
public execute(): void {
console.log(`SimpleCommand: See, I can do simple things like printing (${this.payload})`);
}
}
/**
* 하지만, 몇몇 commands는 더 복잡한 작업을 receiver라는 다른 객체에게 위임할 수 있습니다.
*/
class ComplexCommand implements Command {
private receiver: Receiver;
/**
* receiver의 메서드를 런칭하는데 필요한 context 데이터
*/
private a: string;
private b: string;
/**
* Complex commands는 생성자를 통해 모든 context data에 따른
* 하나 이상의 객체를 받을 수 있습니다.
*/
constructor(receiver: Receiver, a: string, b: string) {
this.receiver = receiver;
this.a = a;
this.b = b;
}
/**
* Commands는 receiver의 모든 메서드에게 데이터를 위임할 수 있습니다.
*/
public execute(): void {
console.log('ComplexCommand: Complex stuff should be done by a receiver object.');
this.receiver.doSomething(this.a);
this.receiver.doSomethingElse(this.b);
}
}
/**
* recevier 클래스는 몇 가지 중요한 비지니스 로직을 포함합니다. 그들은 요청을 수행하는 것과
* 관련된 모든 종류의 작업을 수행하는 방법을 알고 있습니다. 사실, 어떤 클래스도
* receiver 역할을 할 수 있습니다.
*/
class Receiver {
public doSomething(a: string): void {
console.log(`Receiver: Working on (${a}.)`);
}
public doSomethingElse(b: string): void {
console.log(`Receiver: Also working on (${b}.)`);
}
}
/**
* Invoker는 하나 이상의 commands와 연관되어 command에게 요청을 보냅니다.
*/
class Invoker {
private onStart: Command;
private onFinish: Command;
/**
* commands 초기화
*/
public setOnStart(command: Command): void {
this.onStart = command;
}
public setOnFinish(command: Command): void {
this.onFinish = command;
}
/**
* Invoker는 구체적인 command나 receiver 클래스에 의존하지 않습니다. 명령을
* 실행함으로써 간접적으로 receiver에게 요청을 전달합니다.
*/
public doSomethingImportant(): void {
console.log('Invoker: Does anybody want something done before I begin?');
if (this.isCommand(this.onStart)) {
this.onStart.execute();
}
console.log('Invoker: ...doing something really important...');
console.log('Invoker: Does anybody want something done after I finish?');
if (this.isCommand(this.onFinish)) {
this.onFinish.execute();
}
}
private isCommand(object): object is Command {
return object.execute !== undefined;
}
}
/**
* 클라이언트 코드는 모든 명령으로 호출자를 매개 변수화할 수 있습니다.
*/
const invoker = new Invoker();
invoker.setOnStart(new SimpleCommand('Say Hi!'));
const receiver = new Receiver();
invoker.setOnFinish(new ComplexCommand(receiver, 'Send email', 'Save report'));
invoker.doSomethingImportant();
실행결과
Invoker: Does anybody want something done before I begin?
SimpleCommand: See, I can do simple things like printing (Say Hi!)
Invoker: ...doing something really important...
Invoker: Does anybody want something done after I finish?
ComplexCommand: Complex stuff should be done by a receiver object.
Receiver: Working on (Send email.)
Receiver: Also working on (Save report.)
8. Proxy(프록시) 디자인 패턴은 객체에 대한 액세스(접근)를 제어한다.
신용 카드(credit card)는 은행 계좌(bank account)의 프록시이며, 은행 계좌는 현금(cash)의 프록시입니다. 둘 다 같은 인터페이스를 구현하며 둘 다 결제에 사용될 수 있습니다. 신용 카드를 사용하는 소비자는 많은 현금을 가지고 다닐 필요가 없어서 기분이 좋습니다. 또한 상점 주인은 거래 수입을 은행에 가는 길에 강도를 당하거나 잃어버릴 위험 없이 계좌에 전자적으로 입금이 되기 때문에 기분이 좋습니다.
Proxy 디자인 패턴은 '대리인'이라는 뜻을 가진 proxy 단어의 의미 그대로 다른 객체에 대한 액세스를 제어하기 위해 대리자(surrogate) 또는 자리 표시자(placeholder)를 제공하는 구조 패턴입니다. 이 패턴은 안전하고 확장 가능하며 효율적인 코드를 만드는 데 유용합니다.
프록시는 원래의 객체에 대한 접근을 제어하기 때문에, 요청이 원래 객체에 전달되기 전 또는 후에 무언가를 수행할 수 있도록 합니다.
Problem
객체에 대한 접근을 제한하는 이유는 무엇일까요? 이 질문에 답하기 위하여 방대한 양의 시스템 자원을 소비하는 거대한 객체가 있다고 가정합시다. 이 객체는 필요할 때가 있기는 하지만, 항상 필요한 것은 아닙니다.
우리는 실제로 필요할 때만 이 객체를 만들어서 지연된 초기화를 구현할 수 있습니다. 그러면 객체의 모든 클라이언트들은 어떤 지연된 초기화 코드를 실행해야 하는데, 불행히도 이것은 아마 많은 코드 중복을 초래할 것입니다.
이상적인 상황에서는 이 코드를 객체의 클래스에 직접 넣을 수 있겠지만, 그게 항상 가능한 것은 아닙니다. 예를 들어 그 클래스가 폐쇄된 타사 라이브러리의 일부일 수 있습니다.
Solution
그래서 프록시 패턴은 원래 서비스 객체와 같은 인터페이스로 새 프록시 클래스를 생성하라고 제안합니다. 그러면 프록시 객체를 원래 객체의 모든 클라이언트들에 전달하도록 앱을 업데이트할 수 있습니다. 클라이언트로부터 요청을 받으면 이 프록시는 실제 서비스 객체를 생성하고 모든 작업을 이 객체에 위임합니다.
이때 프록시는 데이터베이스 객체로 자신을 변장합니다. 프록시는 지연된 초기화 및 결과값 캐싱을 클라이언트와 실제 데이터베이스 객체가 알지 못하는 상태에서 처리할 수 있습니다.
구조
Node.js에서 프록시 디자인 패턴을 구현하려면 다음 단계를 따릅니다:
- Proxy 인터페이스를 정의: Proxy 인터페이스는 대체되는 객체의 인터페이스와 동일해야 합니다. 이렇게 하면 어떠한 호출 코드의 변경 없이도 Proxy를 대신하는 객체를 대신 사용할 수 있습니다.
- Proxy 클래스를 구현: Proxy 클래스는 Proxy 인터페이스를 구현하고 실제 객체에 작업을 위임해야 합니다. Proxy 클래스는 캐싱, 로깅 또는 보안 검사와 같은 추가 기능을 추가할 수도 있습니다.
- Proxy 객체를 사용: 애플리케이션 코드에서 실제 객체 대신 Proxy 객체를 사용해야 합니다.
이 단계를 따르면 Node.js에서 프록시 디자인 패턴을 쉽게 구현하고 안전하고 확장 가능하며 효율적인 코드를 만들 수 있습니다.
다음은 Node.js에서 프록시 디자인 패턴을 구현하는 방법의 예시입니다:
// proxy 인터페이스 정의
class Subject {
request() {}
}
// 실제 subject 클래스를 정의
class RealSubject extends Subject {
request() {
console.log("RealSubject handling request...");
}
}
// proxy 클래스를 정의
class Proxy extends Subject {
constructor(realSubject) {
super();
this.realSubject = realSubject;
}
request() {
if (this.checkAccess()) {
this.realSubject.request();
this.logRequest();
} else {
console.log("Access denied.");
}
}
checkAccess() {
// Check access rights here
return true;
}
logRequest() {
console.log("Request logged.");
}
}
// proxy 객체를 사용
const realSubject = new RealSubject();
const proxy = new Proxy(realSubject);
proxy.request(); // "RealSubject handling request..." followed by "Request logged."
위 코드에서는 request() 메서드가 있는 Subject 인터페이스, request() 메서드의 자체 구현으로 Subject 인터페이스를 구현하는 RealSubject 클래스, 그리고 역시 Subject 인터페이스를 구현하고 실제 Subject 객체에 작업을 위임하는 Proxy 클래스를 정의하고 있습니다.
Proxy 클래스는 request() 메서드에서 액세스 권한 확인 및 요청 로깅을 비롯한 추가 기능을 추가합니다.
그런 다음 RealSubject 클래스의 인스턴스를 생성하고 이를 Proxy 클래스 생성자에게 전달하여 proxy 객체를 생성합니다. 그런 다음 proxy 객체에서 request() 메서드를 호출하면 실제 Subject 객체에 작업을 위임하고 부가 기능을 추가합니다.
적용
지연된 초기화(가상 프록시). 이건 어쩌다 필요한 무거운 서비스 객체가 항상 가동되어 있어 시스템 자원들을 낭비하는 경우에 사용합니다.
- 앱이 시작될 때, 객체를 생성하는 대신 객체 초기화를 실제로 초기화가 필요한 시점까지 지연할 수 있습니다.
접근 제어 (보호 프록시). 특정 클라이언트들만 서비스 객체를 사용할 수 있도록 하려는 경우에 사용할 수 있습니다. 예를 들어 객체들이 운영 체제의 중요한 부분이고 클라이언트들이 다양한 실행된 응용 프로그램인 경우입니다.
- 이 프록시는 클라이언트의 자격 증명이 어떤 정해진 기준과 일치하는 경우에만 서비스 객체에 요청을 전달할 수 있습니다.
원격 서비스의 로컬 실행(원격 프록시). 서비스 객체가 원격 서버에 있는 경우입니다.
- 이 경우 프록시는 네트워크를 통해 클라이언트 요청을 전달하여 네트워크와의 작업의 모든 복잡한 세부 사항을 처리합니다.
요청들의 로깅(로딩 프록시). 서비스 객체에 대한 요청들의 기록을 유지하려는 경우입니다.
- 프록시는 각 요청을 서비스에 전달하기 전에 로깅(기록)할 수 있습니다.
요청 결과들의 캐싱(캐싱 프록시). 이것은 클라이언트 요청들의 결과들을 캐시하고 이 캐시들의 수명 주기를 관리해야 할 떄, 특히 결과들이 상당히 큰 경우에 사용됩니다.
- 프로시는 항상 같은 결과를 생성하는 반복 요청들에 대해 캐싱을 구현할 수 있습니다. 프록시는 요청들의 매개변수들을 캐시 키들로 사용할 수 있습니다.
스마트 참조. 이것은 사용하는 클라이언트들이 없어 거대한 객체를 해제할 수 있어야 할 때 사용됩니다.
- 프록시는 서비스 객체 또는 그 결과에 대한 참조를 얻은 클라이언트들을 추적할 수 있습니다. 때때로 프록시는 클라이언트들을 점검하여 클라이언트들이 여전히 활성 상태인지를 확인할 수 있습니다. 클라이언트 리스트가 비어있으면 프록시는 해당 서비스 객체를 닫고 그에 해당하는 시스템 자원을 확보할 수 있습니다.
- 또, 프록시는 클라이언트가 서비스 객체를 수정했는지도 추적할 수 있으며, 변경되지 않은 객체는 다른 클라이언트들이 재사용할 수 있습니다.
구현 방법
- 기존 서비스 인터페이스가 없는 경우, 서비스 인터페이스를 하나 생성하여 프록시와 서비스 객체 간의 상호 교환을 가능하게 만듭니다.
- 서비스 클래스에서 인터페이스를 추출하는 것이 항상 가능한 것은 아닙니다. 왜냐하면 그 인터페이스를 사용하려면 서비스의 모든 클라이언트를 변경해야 하기 때문입니다. 대신 프록시를 서비스 클래스의 자식 클래스로 만들 수 있으며, 이렇게 하면 서비스의 인터페이스를 상속하게 할 수 있습니다.
- 프록시 클래스를 만듭니다.
- 이 클래스에는 서비스에 대한 참조를 저장하기 위한 필드가 있어야 합니다. 일반적으로 프록시들은 서비스들의 전체 수명 주기를 생성하고 관리합니다. 또 드물지만, 클라이언트가 서비스를 프록시의 생성자에 전달하는 방식으로 서비스가 프록시에 전달되기도 합니다.
- 목적에 따라 프록시 메서드들을 구현합니다.
- 대부분의 경우 프록시는 일부 작업을 수행한 후에 그 작업을 서비스 객체에 위임해야 합니다.
- 클라이언트가 프록시를 받을지 실제 서비스를 받을지를 결정하는 생성 메서드를 도입하는 것을 고려합니다.
- 이 메서드는 프록시 클래스의 간단한 정적 메서드이거나 완전한 팩토리 메서드일 수도 있습니다.
- 서비스 객체에 대해 지연된 초기화 구현을 고려합니다.
장단점
- 장점
- 클라이언트들이 알지 못하는 상태에서 서비스 객체를 제어할 수 있습니다.
- 클라이언트들이 신경 쓰지 않을 때 서비스 객체의 수명 주기를 관리할 수 있습니다.
- 프록시는 서비스 객체가 준비되지 않았거나 사용할 수 없는 경우에도 작동합니다.
- 개방/폐쇄 원칙. 서비스나 클라이언트들을 변경하지 않고도 새 프록시들을 도입할 수 있습니다.
- 단점
- 새로운 클래스들을 많이 도입해야 하므로 코드가 복잡해질 수 있습니다.
- 서비스의 응답이 늦어질 수 있습니다.
TypeScript 코드 예시
/**
* Subject interface는 RealSubject와 Proxy의 공통 작업을 선언합니다.
* 클라이언트가 이 인터페이스를 사용하여 RealSubject와 함께 작업하는 한, 실제
* Subject 대신 Proxy를 전달할 수 있습니다.
*/
interface Subject {
request(): void;
}
/**
* RealSubject에는 몇 가지 핵심 비지니스 로직이 포함되어 있습니다.
* 일반적으로 RealSubject는 입력 데이터 수정과 같이 매우 느리거나 민감할 수 있는 유용한
* 작업을 수행할 수 있습니다. Proxy는 RealSubject의 코드를 변경하지 않고도 이러한
* 문제를 해결할 수 있습니다.
*/
class RealSubject implements Subject {
public request(): void {
console.log('RealSubject: Handling request.');
}
}
/**
* Proxy는 RealSubject와 동이한 인터페이스를 갖습니다.
*/
class Proxy implements Subject {
private realSubject: RealSubject;
/**
* Proxy는 RealSubject 클래스의 개체에 대한 참조를 유지합니다.
* 해당 개체는 lazy-loaded 이거나 클라이언트에 의해 Proxy로 전달될 수 있습니다.
*/
constructor(realSubject: RealSubject) {
this.realSubject = realSubject;
}
/**
* Proxy 패턴의 가장 일반적인 응용 프로그램은 레이지 로드, 캐싱, 액세스 제어, 로깅 등입니다.
* Proxy는 이러한 작업 중 하나를 수행한 다음 결과에 따라 연결된
* RealSubject 개체에서 동일한 메서드로 실행을 전달할 수 있습니다.
*/
public request(): void {
if (this.checkAccess()) {
this.realSubject.request();
this.logAccess();
}
}
private checkAccess(): boolean {
// Some real checks should go here.
console.log('Proxy: Checking access prior to firing a real request.');
return true;
}
private logAccess(): void {
console.log('Proxy: Logging the time of request.');
}
}
/**
* 클라이언트 코드는 RealSubject와 Proxy를 모두 지원하기 위해 Subject 인터페이스를 통해
* 모든 객체(Subject와 Proxy 모두)와 함께 작동하도록 되어 있습니다. 그러나 실제에서
* 클라이언트는 대부분 RealSubject와 직접 작업합니다.
* 이 경우 패턴을 더 쉽게 구현하기 위해 RealSubject의 클래스에서 Proxy를 확장할 수 있습니다.
*/
function clientCode(subject: Subject) {
// ...
subject.request();
// ...
}
console.log('Client: Executing the client code with a real subject:');
const realSubject = new RealSubject();
clientCode(realSubject);
console.log('');
console.log('Client: Executing the same client code with a proxy:');
const proxy = new Proxy(realSubject);
clientCode(proxy);
실행 결과
Client: Executing the client code with a real subject:
RealSubject: Handling request.
Client: Executing the same client code with a proxy:
Proxy: Checking access prior to firing a real request.
RealSubject: Handling request.
Proxy: Logging the time of request.
9. Decorator(데코레이터) 디자인 패턴은 동적으로 객체에 기능을 추가한다.
옷을 입는 것은 데코레이터 패턴을 사용하는 예입니다. 우리는 추울 때 스웨터로 몸을 감쌉니다. 스웨터를 입어도 춥다면 위에 재킷을 입고, 또 비가 오면 비옷을 입습니다. 이 모든 옷은 기초 행동을 '확장'하지만, 이 옷들은 우리의 일부가 아니기에 필요하지 않을 때마다 옷을 쉽게 벗을 수 있습니다.
Decorator 디자인 패턴은 같은 클래스, 다른 객체의 동작에 영향을 주지 않고 개별 객체에 정적 또는 동적으로 동작을 추가할 수 있는 구조 패턴입니다. 이 패턴은 유연하고 재사용 가능하며 확장 가능한 코드를 만드는 데 유용합니다.
다시 말해 객체들을 새로운 행동들을 포함한 특수 Wrapper 객체들 내에 넣어서 위 행동들을 해당 객체들에 연결시키는 구조 디자인 패턴인 것입니다.
Node.js에서 Decorator 디자인 패턴을 구현하려면 다음 단계를 따릅니다:
- component 인터페이스를 정의: 컴포넌트 인터페이스는 구체적인 component와 Decorator 클래스가 구현할 메서드를 정의해야 합니다. 이렇게 하면 호출 코드를 변경하지 않고도 구체적인 component 대신 Decorator 클래스를 사용할 수 있습니다.
- 구체적인 component 클래스를 정의: 구체적인 component 클래스는 decorator 클래스에 의해 기능이 추가될 객체입니다. 이 클래스는 component 인터페이스를 구현해야 합니다.
- Decorator 클래스를 정의: decorator 클래스는 component 인터페이스를 구현하고 component 객체에 대한 참조를 가져야 합니다. decorator 클래스는 component객체에 작업을 위임하기 전이나 후에 부가 기능을 추가할 수 있습니다.
- Decorator 객체를 사용: decorator 객체는 애플리케이션 코드에서 구체적인 component 객체 대신 사용해야 합니다.
이 단계를 수행하면 Node.js에서 데코레이터 디자인 패턴을 쉽게 구현하고 유연하고 재사용 가능하며 확장 가능한 코드를 만들 수 있습니다.
다음은 Node.js에서 데코레이터 디자인 패턴을 구현하는 방법의 예시입니다:
// component 인터페이스를 정의
class Component {
operation() {}
}
// 구체적인 component 클래스를 정의
class ConcreteComponent extends Component {
operation() {
console.log("ConcreteComponent operation.");
}
}
// decorator 클래스를 정의
class Decorator extends Component {
constructor(component) {
super();
this.component = component;
}
operation() {
this.component.operation();
this.additionalOperation();
}
additionalOperation() {
console.log("Decorator additional operation.");
}
}
// decorator 객체 사용
const component = new ConcreteComponent();
const decorator = new Decorator(component);
decorator.operation(); // "ConcreteComponent operation." followed by "Decorator additional operation."
위 코드에서는 operation() 메서드가 있는 Component 인터페이스, 자체 구현된 operation() 메서드로 Component 인터페이스를 구현하는 ConcreteComponent 클래스, 그리고 역시 Component 인터페이스를 구현하고 Component 객체에 대한 참조가 있는 Decorator 클래스를 정의합니다.
Decorator 클래스는 operation() 메서드를 통해 Component 객체에서 동일한 메서드를 호출한 다음 그 뒤에 이따라 추가 작업을 수행하는 함수를 호출합니다.
그런 다음 ConcreteComponent 클래스의 인스턴스를 생성하고 이를 Decorator 클래스 생성자에게 전달하여 decorator 객체를 생성합니다. 그런 다음 decorator 객체에서 operation() 메서드를 호출하면 component 객체에서 동일한 메서드를 호출하고 추가된 기능을 바로 뒤에 호출합니다.
적용
데코레이터 패턴은 이 객체들을 사용하는 코드를 훼손하지 않으면서 런타임에 추가 행동들을 객체들에 할당할 수 있어야 할 때 사용합니다.
- 데코레이터는 비즈니스 로직을 계층으로 구성하고, 각 계층에 데코레이터를 생성하고 런타임에 이 로직의 다양한 조합들로 객체들을 구성할 수 있도록 합니다. 이러한 모든 객체가 공통 인터페이스를 따르기 때문에 클라이언트 코드는 해당 모든 객체를 같은 방식으로 다룰 수 있습니다.
이 패턴은 상속을 사용하여 객체의 행동을 확장하는 것이 어색하거나 불가능할 때 사용합니다.
- 많은 프로그래밍 언어에는 클래스의 추가 확장을 방지하는 데 사용할 수 있는 final 키워드가 있습니다. Final 클래스의 경우 기존 행동들을 재사용할 수 있는 유일한 방법은 데코레이터 패턴을 사용하여 클래스를 자체 래퍼로 래핑하는 것입니다.
구현 방법
- 당신의 비즈니스 도메인이 여러 선택적 계층으로 감싸진 기본 컴포넌트로 표시될 수 있는지 확인합니다 .
- 기본 컴포넌트와 선택적 계층들 양쪽에 공통적인 메서드들이 무엇인지 파악합니다.
- 그곳에 컴포넌트 인터페이스를 만들고 해당 메서드들을 선언합니다.
- concrete 컴포넌트 클래스를 만든 후 그 안에 기초 행동들을 정의합니다.
- 기초 데코레이터 클래스를 만듭니다.
- 이 클래스에는 래핑된 객체에 대한 참조를 저장하기 위한 필드가 있어야 합니다. 이 필드는 데코레이터들 및 구상 컴포넌트들과의 연결을 허용하기 위하여 컴포넌트 인터페이스 유형으로 선언하셔야 합니다. 기초 데코레이터는 모든 작업을 래핑된 객체에 위임해야 합니다.
- 모든 클래스들이 컴포넌트 인터페이스를 구현하도록 합니다.
- 기초 데코레이터를 확장하여 구상 데코레이터들을 생성합니다.
- 구상 데코레이터는 항상 부모 메서드 호출 전 또는 후에 행동들을 실행해야 합니다. (부모 메서드는 항상 래핑된 객체에 작업을 위임합니다).
- 데코레이터들을 만들고 이러한 데코레이터들을 클라이언트가 필요로 하는 방식으로 구성하는 일은 반드시 클라이언트 코드가 맡아야 합니다.
장단점
- 장점
- 새 자식 클래스를 만들지 않고도 객체의 행동을 확장할 수 있습니다.
- 런타임에 객체들에서부터 책임들을 추가하거나 제거할 수 있습니다.
- 객체를 여러 데코레이터로 래핑하여 여러 행동들을 합성할 수 있습니다.
- 단일 책임 원칙. 다양한 행동들의 여러 변형들을 구현하는 모놀리식 클래스를 여러 개의 작은 클래스들로 나눌 수 있습니다.
- 단점
- Wrapper들의 스택에서 특정 wrapper를 제거하기가 어렵습니다.
- 데코레이터의 행동이 데코레이터 스택 내의 순서에 의존하지 않는 방식으로 데코레이터를 구현하기가 어렵습니다.
- 계층들의 초기 설정 코드가 보기 흉할 수 있습니다.
TypeScript 코드 예시
/**
* 기본 Component 인터페이스는 데코레이터에 의해 변경될 수 있는 작업을 정의합니다.
*/
interface Component {
operation(): string;
}
/**
* Concrete Components는 작업의 기본 구현을 제공합니다. 이 클래스에는 여러 가지 변형이
* 있을 수 있습니다.
*/
class ConcreteComponent implements Component {
public operation(): string {
return 'ConcreteComponent';
}
}
/**
* 기본 데코레이터 클래스는 다른 component와 동일한 인터페이스를 따릅니다.
* 이 클래스의 주요 목적은 모든 concrete 데코레이터에 대한 wrapping 인터페이스를 정의하는 것입니다.
* wrapping 코드의 기본 구현에는 wrapping된 component를 저장하기 위한 필드와 초기화하기 위한
* 수단이 포함될 수 있습니다.
*/
class Decorator implements Component {
protected component: Component;
constructor(component: Component) {
this.component = component;
}
/**
* Decorator는 모든 작업을 wrapping된 component에게 위임합니다.
*/
public operation(): string {
return this.component.operation();
}
}
/**
* Concrete Decorators는 wrapping 된 객체를 호출하고 특정 방식으로 결과를 바꿉니다.
*/
class ConcreteDecoratorA extends Decorator {
/**
* 데코레이터들은 래핑된 오브젝트를 직접 호출하는 대신, 작업의 부모 구현을 호출할 수 있습니다.
* 이 접근법은 데코레이터 클래스의 확장을 단순화합니다.
*/
public operation(): string {
return `ConcreteDecoratorA(${super.operation()})`;
}
}
/**
* 데코레이터는 랩핑된 오브젝트에 대한 호출 전 또는 후에 동작을 실행할 수 있습니다.
*/
class ConcreteDecoratorB extends Decorator {
public operation(): string {
return `ConcreteDecoratorB(${super.operation()})`;
}
}
/**
* 클라이언트 코드는 Component 인터페이스를 사용하여 모든 객체와 함께 작동합니다.
* 이렇게 하면 사용하는 component의 구체적인 클래스와 독립적으로 유지할 수 있습니다.
*/
function lientCode(component: Component) {
// ...
console.log(`RESULT: ${component.operation()}`);
// ...
}
/**
* 이렇게 하면 클라이언트 코드가 두 가지 단순한 components를 모두 지원할 수 있습니다...
*/
const simple = new ConcreteComponent();
console.log('Client: I\'ve got a simple component:');
clientCode(simple);
console.log('');
/**
* ...데코레이트 된 것처럼
*
* decorators가 단순한 구성품뿐만 아니라 다른 데코레이터들도 어떻게 포장하는지 알아야 합니다.
*/
const decorator1 = new ConcreteDecoratorA(simple);
const decorator2 = new ConcreteDecoratorB(decorator1);
console.log('Client: Now I\'ve got a decorated component:');
clientCode(decorator2);
실행결과
Client: I've got a simple component:
RESULT: ConcreteComponent
Client: Now I've got a decorated component:
RESULT: ConcreteDecoratorB(ConcreteDecoratorA(ConcreteComponent))
10. Adapter(어댑터) 디자인 패턴은 호환되지 않는 인터페이스를 호환가능하게 만든다.
미국에서 유럽으로 처음 여행을 가서 미국에서 유럽으로 처음 여행을 가서 노트북을 충전하려고 하면 깜짝 놀랄지도 모릅니다. 전원 플러그와 소켓은 국가마다 표준이 달라 미국 플러그가 독일 소켓에 맞지 않을 수 있기 때문입니다. 이 문제는 미국식 소켓과 유럽식 플러그가 있는 전원 플러그 어댑터를 사용하면 해결할 수 있습니다.
Adapter 디자인 패턴은 호환되지 않는 인터페이스를 가진 객체들이 협업할 수 있도록 하는 구조 패턴입니다. 이 패턴은 유연하고 재사용 가능하며 확장 가능한 코드를 만드는 데 유용합니다.
Node.js에서 어댑터 디자인 패턴을 구현하려면 다음 단계를 따르세요:
- 호환되지 않는 인터페이스를 파악: adapter 디자인 패턴을 구현하는 첫 번째 단계는 함께 작동해야 하는 객체의 인터페이스를 구분하는 것입니다. 이러한 인터페이스들은 서로 다른 메서드나 매개변수를 가지고 있으므로 호환되지 않아야 합니다.
- target 인터페이스를 정의: target 인터페이스는 클라이언트 코드가 기대하는 인터페이스여야 합니다. 이 인터페이스는 클라이언트 코드와 호환되어야 하며 클라이언트 코드가 기대하는 메서드나 매개 변수를 포함해야 합니다.
- Adapter 클래스를 구현: Adapter 클래스는 target 인터페이스를 구현하고 호환되지 않는 인터페이스를 가진 객체에 대한 참조를 가져야 합니다. 그런 다음 Adapter 클래스는 target 인터페이스의 메서드 또는 매개 변수를 호환되지 않는 인터페이스의 메서드 또는 매개 변수에 매핑해야 합니다.
- adapter 객체를 사용: 애플리케이션 코드에서 호환되지 않는 인터페이스가 있는 객체 대신 adapter 객체를 사용해야 합니다.
이 단계를 수행하면 Node.js에서 어댑터 디자인 패턴을 쉽게 구현하고 유연하고 재사용 가능하며 확장 가능한 코드를 만들 수 있습니다.
다음은 Node.js에서 어댑터 디자인 패턴을 구현하는 방법의 예시입니다:
// incompatible 인터페이스를 정의
class IncompatibleObject {
differentOperation() {}
}
// target 인터페이스를 정의
class TargetInterface {
sameOperation() {}
}
// adapter 클래스를 구현
class Adapter extends TargetInterface {
constructor(incompatibleObject) {
super();
this.incompatibleObject = incompatibleObject;
}
sameOperation() {
this.incompatibleObject.differentOperation();
}
}
// adapter 객체 사용
const incompatibleObject = new IncompatibleObject();
const adapter = new Adapter(incompatibleObject);
adapter.sameOperation(); // calls incompatibleObject.differentOperation()
위 코드에서는 differentOperation() 메서드가 있는 IncompatibleObject 클래스와 sameOperation() 메서드가 있는 TargetInterface 클래스를 정의하고 있습니다. 이러한 인터페이스는 메서드 이름이 다르기 때문에 호환되지 않습니다.
그런 다음 TargetInterface 클래스를 확장하고 호환되지 않는 객체에 대한 참조가 있는 Adapter 클래스를 정의합니다. Adapter 클래스는 TargetInterface의 sameOperation() 메서드를 IncompatibleObject의 differentOperation() 메서드에 매핑합니다. 단순히 sameOperation() 메서드에서 differentOperiton() 메서드를 호출하는 것뿐입니다.
그런 다음 IncompatibleObject 클래스의 인스턴스를 생성하고 이를 Adapter 클래스 생성자에게 전달하여 어댑터 객체를 생성합니다. 그런 다음 어댑터 객체에서 동일한 작업() 메서드를 호출하고, 이 메서드는 IncompatibleObject의 다른 작업() 메서드를 호출합니다.
적용
어댑터 클래스는 기존 클래스를 사용하고 싶지만 그 인터페이스가 나머지 코드와 호환되지 않을 때 사용합니다.
- 어댑터 패턴은 당신의 코드와 레거시 클래스, 타사 클래스 또는 특이한 인터페이스가 있는 다른 클래스 간의 변환기 역할을 하는 중간 레이어 클래스를 만들 수 있도록 합니다.
이 패턴은 부모 클래스에 추가할 수 없는 어떤 공통 기능들이 없는 여러 기존 자식 클래스들을 재사용하려는 경우에 사용합니다.
- 각 자식 클래스를 확장한 후 누락된 기능들을 새 자식 클래스들에 넣을 수 있습니다. 하지만 해당 코드를 모든 새 클래스들에 복제해야 하며, 그건 정말 나쁜 냄새가 나는 코드일 것입니다.
- 이보다 훨씬 더 깔끔한 해결책은 누락된 기능을 어댑터 클래스에 넣는 것입니다. 그 후 어댑터 내부에 누락된 기능이 있는 객체들을 래핑하면 필요한 기능들을 동적으로 얻을 것입니다. 이 해결책이 작동하려면 대상 클래스들에는 반드시 공통 인터페이스가 있어야 하며 어댑터의 필드는 해당 인터페이스를 따라야 합니다. 위 접근 방식은 데코레이터 패턴과 매우 유사합니다.
구현 방법
- 호환되지 않는 인터페이스가 있는 클래스가 최소 두 개 이상 있는지 확인합니다:
- 당신이 변경할 수 없는 유용한 서비스 클래스가 있습니다. (종종 타사 코드, 레거시 코드 또는 기존 의존성이 많은 코드).
- 위 서비스 클래스를 사용하여 이득을 얻을 수 있는 하나 또는 여러 개의 클라이언트 클래스들이 있습니다.
- 클라이언트 인터페이스를 선언하고 클라이언트들이 서비스와 통신하는 방법을 기술합니다.
- 어댑터 클래스를 생성한 후 클라이언트 인터페이스를 따르게 하세요. 일단은 모든 메서드들을 비워 둡니다.
- 서비스 객체에 참조를 저장하기 위하여 어댑터 클래스에 필드를 추가합니다.
- 일반적으로 사용되는 방법은 생성자를 통해 이 필드를 초기화하는 것이지만, 때때로 어댑터의 메서드들을 호출할 때는 이 필드를 어댑터에 전달하는 것이 더 편리하기도 합니다.
- 클라이언트 인터페이스의 모든 메서드를 어댑터 클래스에서 하나씩 구현합니다.
- 어댑터는 인터페이스 또는 데이터 형식 변환만 처리해야 하며, 실제 작업의 대부분을 서비스 객체에 위임해야 합니다.
- 클라이언트들은 클라이언트 인터페이스를 통해 어댑터를 사용해야 합니다.
- 이렇게 하면 클라이언트 코드에 영향을 주지 않고 어댑터들을 변경하거나 확장할 수 있습니다.
장단점
- 장점
- 단일 책임 원칙. 프로그램의 기본 비즈니스 로직에서 인터페이스 또는 데이터 변환 코드를 분리할 수 있습니다.
- 개방/폐쇄 원칙. 클라이언트 코드가 클라이언트 인터페이스를 통해 어댑터와 작동하는 한, 기존의 클라이언트 코드를 손상시키지 않고 새로운 유형의 어댑터들을 프로그램에 도입할 수 있습니다.
- 다수의 새로운 인터페이스와 클래스들을 도입해야 하므로 코드의 전반적인 복잡성이 증가합니다. 때로는 코드의 나머지 부분과 작동하도록 서비스 클래스를 변경하는 것이 더 간단합니다.
TypeScript 코드 예시
/**
* Target은 클라이언트 코드에서 사용하는 도메인별 인터페이스를 정의합니다.
*/
class Target {
public request(): string {
return 'Target: The default target\'s behavior.';
}
}
/**
* The Adaptee에는 몇 가지 유용한 동작이 포함되어 있지만 인터페이스가 기존 클라이언트 코드와
* 호환되지 않습니다. Adaptee는 클라이언트 코드가 사용하기 전에 약간의 적응이 필요합니다.
*/
class Adaptee {
public specificRequest(): string {
return '.eetpadA eht fo roivaheb laicepS';
}
}
/**
* 어댑터를 사용하면 어댑터의 인터페이스가 대상의 인터페이스와 호환됩니다.
*/
class Adapter extends Target {
private adaptee: Adaptee;
constructor(adaptee: Adaptee) {
super();
this.adaptee = adaptee;
}
public request(): string {
const result = this.adaptee.specificRequest().split('').reverse().join('');
return `Adapter: (TRANSLATED) ${result}`;
}
}
/**
* 클라이언트 코드는 Target 인터페이스를 따르는 모든 클래스를 지원합니다.
*/
function clientCode(target: Target) {
console.log(target.request());
}
console.log('Client: I can work just fine with the Target objects:');
const target = new Target();
clientCode(target);
console.log('');
const adaptee = new Adaptee();
console.log('Client: The Adaptee class has a weird interface. See, I don\'t understand it:');
console.log(`Adaptee: ${adaptee.specificRequest()}`);
console.log('');
console.log('Client: But I can work with it via the Adapter:');
const adapter = new Adapter(adaptee);
clientCode(adapter);
실행 결과
Client: I can work just fine with the Target objects:
Target: The default target's behavior.
Client: The Adaptee class has a weird interface. See, I don't understand it:
Adaptee: .eetpadA eht fo roivaheb laicepS
Client: But I can work with it via the Adapter:
Adapter: (TRANSLATED) Special behavior of the Adaptee.
'언어 > 자바스크립트(JavaScript)' 카테고리의 다른 글
npm과 yarn을 비교한 글 (1) | 2024.11.20 |
---|---|
[JavaScript] JavaScript에서 우아하게 에러 핸들링(Error Handling)하기 (try-catch) (2) | 2024.11.18 |
[TypeScript] 타입스크립트 interface vs. type (0) | 2024.05.08 |
[JavaScript] 함수 스코프(Function Scope)와 클로저(Closure) (0) | 2024.02.07 |
[JavaScript] 데코레이터 (Decorator) (0) | 2024.02.04 |
소중한 공감 감사합니다