BackEnd

디자인 패턴

sihanni 2025. 1. 14. 13:25

디자인 패턴

디자인 패턴은 반복적으로 사용되는 설계 문제를 해결하기 위한 일반화된 솔루션이다.

주로 코드의 재사용성, 가독성, 유지보수성을 높이고, 설계의 복잡성을 줄이는 데 사용된다.

Module Pattern

모듈 패턴은 데이터 은닉과 캡슐화를 통해 코드를 구성하고 외부에 필요한 부분만 공개하는 데 사용된다.

  • private 변수와 메서드를 제공
  • 전역 네임 스페이스 오염을 방지
  • 코드 분리가 용이
  • 주로 클로저와 즉시 실행 함수 (IIFE: Immediately Invoked Function Expression)을 사용해 내부 상태를 숨기고, 필요한 부분만 외부로 노출함.
  • 모듈이라는 취지에 맞게 캡슐화(내부 데이터, 메서드를 숨기고 외부에서 접근 못하게 제한)이 목적이다.
  • 그리고 관련있는 데이터와 메서드를 한곳에 묶어 유지보수성을 향상시킨다.
const CounterModule = (() => {
  // Private state and methods
  let counter = 0;

  const increment = () => {
    counter++;
  };

  const decrement = () => {
    counter--;
  };

  // Public API
  return {
    getCounter: () => counter,
    increase: () => increment(),
    decrease: () => decrement(),
  };
})();

// 사용 예시
console.log(CounterModule.getCounter()); // 0
CounterModule.increase();
CounterModule.increase();
console.log(CounterModule.getCounter()); // 2
CounterModule.decrease();
console.log(CounterModule.getCounter()); // 1

counter 변수와 increment(), decrement() 함수는 외부에서 직접 접근할 수 없다.

외부에 노출되는 것은 getCounter(), increase(), decrease() 메서드 뿐이다.

// counter.module.ts
let counter = 0;

// Public API
export const CounterModule = {
  getCounter: () => counter,
  increase: () => {
    counter++;
  },
  decrease: () => {
    counter--;
  },
};

// main.ts
import { CounterModule } from './counter.module';

console.log(CounterModule.getCounter()); // 0
CounterModule.increase();
CounterModule.increase();
console.log(CounterModule.getCounter()); // 2
CounterModule.decrease();
console.log(CounterModule.getCounter()); // 1

이 예제도 counter는 counter.module.ts 파일 내부에만 존재하고, 외부에선 접근이 불가하다.

 

싱글톤을 활용한 모듈 패턴

class CounterSingleton {
  private static instance: CounterSingleton;
  private counter = 0;

  private constructor() {} // Private constructor to prevent direct instantiation

  static getInstance(): CounterSingleton {
    if (!CounterSingleton.instance) {
      CounterSingleton.instance = new CounterSingleton();
    }
    return CounterSingleton.instance;
  }

  getCounter() {
    return this.counter;
  }

  increase() {
    this.counter++;
  }

  decrease() {
    this.counter--;
  }
}

// 사용 예시
const counter1 = CounterSingleton.getInstance();
const counter2 = CounterSingleton.getInstance();

counter1.increase();
counter1.increase();

console.log(counter2.getCounter()); // 2 (counter1과 counter2는 같은 인스턴스)
counter2.decrease();
console.log(counter1.getCounter()); // 1

이렇게 모듈패턴은 코드의 구조적 설계와 "캡슐화"를 제공하는 강력한 도구다.

하지만 간단한 애플리케이션에서는 필요 이상의 복잡성을 유발할 수 있다.

Singleton Pattern

싱글톤 패턴은 애플리케이션 전역에서 단 하나의 인스턴스만 생성되도록 보장하는 디자인패턴이다.

이를 통해 특정 클래스의 상태를 공유하거나, 불필요한 인스턴스 생성을 방지하여 메모리 사용을 최적화 할 수 있다.

 

나의 경우 앱 푸시알림으로 카프카를 사용할때 카프카 클래스를 싱글톤으로 구현하여, 앱 전역적으로 하나의 카프카 인스턴스만 생성되도록 설계해서 사용한 경험이 있다.

하지만 당시에는 하나의 싱글톤 클래스에 프로듀서와 컨슈머를 다 포함시켰는데 지금 생각해보면 프로듀서와 컨슈머를 따로 각각의 싱글톤 클래스로 구현하는게 더 좋아보인다. 추후 마루톡에서 사용할 때는 그렇게 해야겠다.

프로듀서와 컨슈머가 각각의 클래스에서 따로 구현되어야 각자 자신의 역할에 집중 할 수있고, 개별적으로 확장이나 수정이 용이할 것 같다는 생각이다.

 

어쨌든 다시 돌아와서, 싱글톤 패턴은 

  • 단일 인스턴스 보장
    특정 클래스의 인스턴스가 하나만 존재하도록 보장
  • 전역 접근 제공
    애플리케이션 전역에서 동일한 객체에 접근 가능
  • 리소스 절약
    여러 개의 동일한 객체를 생성하는 대신 하나의 인스턴스를 재사용하여 메모리와 리소스 절약
class Singleton {
  private static instance: Singleton; // 정적 변수로 단일 인스턴스를 저장
  private constructor() {
    // Private 생성자: 외부에서 직접 인스턴스 생성 불가
    console.log('Singleton instance created!');
  }

  // 정적 메서드를 통해 인스턴스에 접근
  public static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton(); // 인스턴스가 없으면 새로 생성
    }
    return Singleton.instance; // 기존 인스턴스 반환
  }

  public sayHello(): void {
    console.log('Hello from Singleton!');
  }
}

// 사용 예시
const singleton1 = Singleton.getInstance();
const singleton2 = Singleton.getInstance();

singleton1.sayHello(); // Hello from Singleton!

console.log(singleton1 === singleton2); // true (같은 인스턴스)

 

싱글톤 패턴은 전역 상태 관리가 용이하고, 리소스 절약에 이점은 있지만, 
전역 상태를 가지다보니 테스트 시 격리가 힘들고, 싱글톤 이느턴스에 대해 의존성이 높아질 수 있고, 멀티스레드환경에서는 레이스 컨디션이 발생할 위험이 있다.

 

실용적인 예시로 싱글톤 설명은 마친다.
데이터 베이스 연결 관리

class Database {
  private static instance: Database;

  private constructor() {
    console.log('Database connected!');
  }

  public static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }

  public query(sql: string): void {
    console.log(`Executing query: ${sql}`);
  }
}

// 사용 예시
const db1 = Database.getInstance();
db1.query('SELECT * FROM users');

const db2 = Database.getInstance();
db2.query('SELECT * FROM orders');

console.log(db1 === db2); // true

 

Factory Pattern

팩토리패턴은 객체 생성 로직을 캡슐화하여 클라이언트 코드에서 객체 생성 방식을 숨기는 디자인 패턴이다.

이를 통해 객체 생성과정을 단순화하고, 다양한 객체를 동적으로 생성할 수 있다.

  • 객체 생성 로직이 복잡하거나 반복될 때
  • 구체적인 클래스 이름을 클라이언트 코드에서 숨기고 싶을 때
  • 런타임에 객체의 타입을 결정해야 할 때

구성 요소

  1. 팩토리 (Factory)
    • 객체를 생성하는 역할을 담당하며, 생성 로직을 캡슐화.
  2. 클라이언트 (Client)
    • 객체를 직접 생성하지 않고 팩토리를 통해 생성된 객체를 사용.
  3. 제품 (Product)
    • 팩토리가 생성하는 객체(인스턴스).

예시

// 제품 인터페이스 정의
interface Button {
  render(): void;
}

interface Checkbox {
  check(): void;
}

// 구체적인 제품 클래스 1
class WindowsButton implements Button {
  render(): void {
    console.log("Rendering Windows Button");
  }
}

class WindowsCheckbox implements Checkbox {
  check(): void {
    console.log("Checking Windows Checkbox");
  }
}

// 구체적인 제품 클래스 2
class MacOSButton implements Button {
  render(): void {
    console.log("Rendering MacOS Button");
  }
}

class MacOSCheckbox implements Checkbox {
  check(): void {
    console.log("Checking MacOS Checkbox");
  }
}

// 추상 팩토리 인터페이스
interface GUIFactory {
  createButton(): Button;
  createCheckbox(): Checkbox;
}

// 구체적인 팩토리 클래스 1
class WindowsFactory implements GUIFactory {
  createButton(): Button {
    return new WindowsButton();
  }
  createCheckbox(): Checkbox {
    return new WindowsCheckbox();
  }
}

// 구체적인 팩토리 클래스 2
class MacOSFactory implements GUIFactory {
  createButton(): Button {
    return new MacOSButton();
  }
  createCheckbox(): Checkbox {
    return new MacOSCheckbox();
  }
}

// 클라이언트 코드
function createUI(factory: GUIFactory): void {
  const button = factory.createButton();
  const checkbox = factory.createCheckbox();

  button.render();
  checkbox.check();
}

// 사용 예시
const windowsFactory = new WindowsFactory();
createUI(windowsFactory);
// Rendering Windows Button
// Checking Windows Checkbox

const macFactory = new MacOSFactory();
createUI(macFactory);
// Rendering MacOS Button
// Checking MacOS Checkbox

 

  • GUIFactory 인터페이스는 createButton과 createCheckbox 메서드를 정의하여 관련 객체군 생성.
  • WindowsFactory와 MacOSFactory는 구체적인 팩토리 구현체로, 특정 플랫폼에 맞는 객체를 생성.
  • 클라이언트는 구체적인 클래스가 아닌 팩토리 인터페이스를 사용하여 객체를 생성.

팩토리 패턴은 복잡한 애플리케이션 구조에서 객체 생성 로직을 분리하는 데 효과적이며, 클라이언트 코드가 구체적인 클래스에 의존하지 않도록 설계할 수 있다.

 

Observer Pattern

옵저버 패턴은 객체 간의 일대다 관계를 정의하여 한 객체의 상태가 변경되었을 때, 그 객체에 의존하는 다른 객체들에게 자동으로 통지하고 업데이트하는 디자인 패턴이다. 주로 이벤트 처리 시스템이나 모니터링 시스템에서 사용된다고 한다.

  • 구성요소
    • Subject
      상태를 보유하며, 상태 변경 시 옵저버들에게 알리는 역할
      옵저버들을 등록, 제거, 알림을 관리
    • Obeserver
      주제의 상태를 감시하고, 상태 변경 시 통지를 받아 동작을 수행
import { EventEmitter } from "events";

// 주제 역할
class Subject extends EventEmitter {
  changeState(newState: string): void {
    console.log(`State changed to: ${newState}`);
    this.emit("stateChange", newState);
  }
}

// 옵저버 등록
const subject = new Subject();

subject.on("stateChange", (state: string) => {
  console.log(`Observer 1 received state: ${state}`);
});

subject.on("stateChange", (state: string) => {
  console.log(`Observer 2 received state: ${state}`);
});

// 상태 변경
subject.changeState("New State");
// State changed to: New State
// Observer 1 received state: New State
// Observer 2 received state: New State

 

Node.js의 EventEmitter는 옵저버 패턴의 실용적인 구현 사례.

 

  • 옵저버 패턴
    • 주제(Subject)가 옵저버를 직접 관리하며, 상태 변경 시 즉시 통지.
    • Subject와 Observer가 서로 알고 있어야 함.
  • Pub/Sub 패턴
    • 중간에 메시지 브로커(이벤트 채널)가 관여하여 발행자와 구독자가 서로 모른 채로 메시지를 교환.
    • 더 느슨한 결합을 제공하며, 확장성이 높음.

옵저버 패턴은 상태 변경을 실시간으로 반영해야 하는 시스템에서 매우 유용하다.

TypeScript와 같은 언어에서는 인터페이스와 클래스를 활용해 명확하고 타입 안정성이 높은 구현을 제공할 수 있다.

이를 활용하면 복잡한 이벤트 처리 시스템이나 실시간 데이터 흐름을 효과적으로 관리할 수 있다.

 

NestJS와 Repository Pattern

NestJS에서 리포지토리 패턴은 "데이터 접근 로직을 캡슐화" 하여 비즈니스 로직과 데이터 액세스 로직을 분리하는 데 사용한다.

코드의 유지보수성과 테스트 가능성을 높이는 데 큰 역할을 하며, NestJS에서 TypeORM 같은 ORM 도구와 함께 자주 사용된다.

  • Repository
    데이터베이스 작업을 담당하는 계층
    데이터 모델과 직접 상호작용하며, 데이터 생성, 읽기, 업데이트, 삭제  (CRUD) 를 처리
  • Entity
    데이터베이스 테이블 구조를 정의하는 클래스
    ORM에서 데이터를 매핑하는 데 사용됌
  • Service
    Repository를 호출하여 비즈니스 로직을 처리하는 계층
    데이터베이스 액세스 로직이 아닌 도메인 로직에 집중
  • 장점
    • 코드 재사용성
      데이터베이스 로직을 중앙 집중화하여 여러 서비스에서 재사용 가능
    • 유지 보수성
      비즈니스 로직과 데이터 액세스 로직이 분리되어 변경 사항 관리가 용이
    • 테스트 용이성
      Mock Repository를 주입하여 독립적인 테스트 가능
  • 결론
    NestJS의 리포지토리 패턴은 데이터 접근과 비즈니스 로직을 명확히 분리하여 코드 품질을 높이고 유지보수성을 강화하는데 매우 유용하다. TypeORM과 같은 ORM과 함께 사용하면 객체지 향 프로그래밍의 장점을 살리면서도 효율적인 데이터 관리가 가능하다.