디자인 패턴 구현
객체지향 프로그래밍에서 디자인 패턴(Design Patterns) 은 특정 상황에서 반복적으로 발생하는 설계 문제를 해결하기 위한 검증된 솔루션을 의미합니다. GoF(Gang of Four)로 알려진 에릭 감마, 리처드 헬름, 랄프 존슨, 존 블리시디스가 저술한 "디자인 패턴: 재사용 가능한 객체지향 소프트웨어의 요소"라는 책에서 23가지의 고전적인 디자인 패턴을 정리하면서 널리 알려졌습니다.
디자인 패턴은 특정 언어나 기술에 종속되지 않는 일반적인 해결책이며, 타입스크립트와 같은 객체지향 언어에서도 이러한 패턴들을 효과적으로 구현하고 활용할 수 있습니다. 타입스크립트의 강력한 타입 시스템은 디자인 패턴을 구현할 때 더 명확하고 타입 안전한 코드를 작성할 수 있도록 돕습니다.
이 절에서는 가장 널리 사용되는 몇 가지 디자인 패턴을 타입스크립트 예시와 함께 살펴보겠습니다.
생성 패턴
생성 패턴은 객체 생성 메커니즘을 추상화하여, 객체를 생성하는 방식과 시스템의 다른 부분을 분리합니다.
팩토리 메서드 패턴
목적: 객체 생성을 서브클래스에 위임하여, 어떤 클래스의 인스턴스를 생성할지 서브클래스가 결정하도록 합니다.
설명: 슈퍼클래스는 객체 생성을 위한 인터페이스(팩토리 메서드)를 정의하고, 서브클래스들은 이 인터페이스를 구현하여 실제 객체를 생성합니다. 이는 클라이언트 코드와 구체적인 클래스 간의 결합도를 낮춰 OCP를 준수할 수 있게 합니다.
타입스크립트 구현 예시
// 1. 제품(Product) 인터페이스
interface Product {
getName(): string;
}
// 2. 구체적인 제품 클래스
class ConcreteProductA implements Product {
getName(): string {
return "Concrete Product A";
}
}
class ConcreteProductB implements Product {
getName(): string {
return "Concrete Product B";
}
}
// 3. 생성자(Creator) 추상 클래스 (팩토리 메서드 정의)
abstract class Creator {
public abstract factoryMethod(): Product; // 팩토리 메서드
// 생성자는 팩토리 메서드에 의존하는 일부 코드를 포함할 수 있습니다.
public someOperation(): string {
const product = this.factoryMethod(); // 서브클래스가 어떤 제품을 만들지 결정합니다.
return `Creator: The same creator's code has just worked with ${product.getName()}`;
}
}
// 4. 구체적인 생성자 클래스
class ConcreteCreatorA extends Creator {
public factoryMethod(): Product {
return new ConcreteProductA();
}
}
class ConcreteCreatorB extends Creator {
public factoryMethod(): Product {
return new ConcreteProductB();
}
}
// 클라이언트 코드
function clientCode(creator: Creator) {
console.log(creator.someOperation());
}
console.log("App: Launched with ConcreteCreatorA.");
clientCode(new ConcreteCreatorA());
console.log("\nApp: Launched with ConcreteCreatorB.");
clientCode(new ConcreteCreatorB());
Creator
추상 클래스가 factoryMethod
를 정의하고, ConcreteCreatorA
와 ConcreteCreatorB
가 각각 ConcreteProductA
와 ConcreteProductB
를 생성하도록 구현합니다. 클라이언트 코드는 어떤 구체적인 Creator
를 사용하는지에 따라 다른 Product
를 받게 되며, 직접 Product
를 생성하는 로직을 알 필요가 없습니다.
싱글턴 패턴
목적: 클래스의 인스턴스를 오직 하나만 존재하도록 보장하고, 그 인스턴스에 대한 전역적인 접근점을 제공합니다.
설명: 데이터베이스 연결, 로거, 설정 관리자 등 애플리케이션 전체에서 단 하나의 인스턴스만 필요한 경우에 사용됩니다.
타입스크립트 구현 예시
class Singleton {
private static instance: Singleton;
private data: string;
// 생성자를 private으로 만들어 외부에서 new Singleton()을 막습니다.
private constructor(data: string) {
this.data = data;
}
// 인스턴스를 얻는 정적 메서드
public static getInstance(data: string = "Default Singleton Data"): Singleton {
if (!Singleton.instance) {
Singleton.instance = new Singleton(data);
}
return Singleton.instance;
}
public someBusinessLogic(): void {
console.log(`Singleton is doing something with data: ${this.data}`);
}
public getData(): string {
return this.data;
}
public setData(data: string): void {
this.data = data;
}
}
// 사용 예시
const s1 = Singleton.getInstance("First Instance Data");
const s2 = Singleton.getInstance("Second Instance Data - will not be used"); // 이미 인스턴스가 있으므로 무시됨
if (s1 === s2) {
console.log("Singleton works, both variables contain the same instance.");
}
s1.someBusinessLogic(); // Singleton is doing something with data: First Instance Data
s2.setData("Updated Data");
console.log(s1.getData()); // Updated Data (s1과 s2는 같은 인스턴스이기 때문)
private
생성자와 static getInstance()
메서드를 사용하여 클래스의 인스턴스가 하나만 생성되도록 강제합니다. TypeScript의 private
접근 제한자는 이를 컴파일 시점에 강력하게 보장합니다.
구조 패턴
구조 패턴은 객체들이 서로 어떻게 조합되어 더 큰 구조를 형성하는지 다룹니다.
어댑터 패턴
목적: 호환되지 않는 인터페이스를 가진 클래스들이 함께 작동할 수 있도록 해줍니다.
설명: 기존 클래스의 인터페이스를 클라이언트가 기대하는 인터페이스로 변환하는 래퍼(Wrapper) 클래스를 만듭니다.
타입스크립트 구현 예시
// 1. 기존 클래스 (호환되지 않는 인터페이스) - Adaptee
class OldPaymentGateway {
processOldPayment(amount: number, currency: string): boolean {
console.log(`Old Gateway: Processing ${amount} ${currency} payment.`);
return true; // 성공 가정
}
}
// 2. 클라이언트가 기대하는 인터페이스 - Target
interface NewPaymentGateway {
pay(amount: number): boolean; // 클라이언트는 amount만으로 결제하고 싶어함
}
// 3. 어댑터 클래스 (OldPaymentGateway를 NewPaymentGateway 인터페이스에 맞게 변환)
class PaymentGatewayAdapter implements NewPaymentGateway {
private oldGateway: OldPaymentGateway;
constructor(oldGateway: OldPaymentGateway) {
this.oldGateway = oldGateway;
}
pay(amount: number): boolean {
// OldPaymentGateway의 메서드를 클라이언트가 기대하는 형태로 호출
console.log("Adapter: Converting new request to old gateway format.");
const result = this.oldGateway.processOldPayment(amount, "USD"); // 하드코딩된 통화
if (result) {
console.log("Adapter: Payment successful via old gateway.");
}
return result;
}
}
// 클라이언트 코드
class PaymentService {
private gateway: NewPaymentGateway;
constructor(gateway: NewPaymentGateway) {
this.gateway = gateway;
}
makePayment(amount: number): void {
console.log(`Payment Service: Attempting to make payment of ${amount}...`);
if (this.gateway.pay(amount)) {
console.log("Payment Service: Payment completed.");
} else {
console.log("Payment Service: Payment failed.");
}
}
}
// 기존 게이트웨이를 새 인터페이스에 맞게 어댑터를 통해 사용
const oldGatewayInstance = new OldPaymentGateway();
const adapter = new PaymentGatewayAdapter(oldGatewayInstance);
const paymentService = new PaymentService(adapter);
paymentService.makePayment(150);
// 만약 새 게이트웨이가 있다면 직접 연결
// class NewModernGateway implements NewPaymentGateway {
// pay(amount: number): boolean {
// console.log(`New Modern Gateway: Processing ${amount} payment.`);
// return true;
// }
// }
// const modernGateway = new NewModernGateway();
// const modernPaymentService = new PaymentService(modernGateway);
// modernPaymentService.makePayment(200);
OldPaymentGateway
는 processOldPayment
라는 다른 시그니처를 가집니다. PaymentGatewayAdapter
는 NewPaymentGateway
인터페이스를 구현하여 클라이언트가 기대하는 pay
메서드를 제공하고, 내부적으로 OldPaymentGateway
의 메서드를 호출하여 호환되지 않는 인터페이스를 연결합니다.
행위 패턴
행위 패턴은 객체들 사이의 알고리즘과 책임 할당을 다룹니다.
옵저버 패턴
목적: 객체 간의 일대다 의존성을 정의하여, 한 객체의 상태가 변경될 때 그 객체에 의존하는 모든 객체에게 변경 사항을 자동으로 알리고 업데이트하도록 합니다.
설명: 발행-구독(Publish-Subscribe) 모델이라고도 불립니다. Subject(발행자)와 Observer(구독자)로 구성됩니다. Subject는 Observer를 등록, 해지하고, 상태 변경 시 등록된 모든 Observer에게 알림을 보냅니다.
타입스크립트 구현 예시
// 1. 발행자(Subject) 인터페이스
interface Subject {
attach(observer: Observer): void; // 옵저버 추가
detach(observer: Observer): void; // 옵저버 제거
notify(): void; // 옵저버들에게 알림
}
// 2. 구독자(Observer) 인터페이스
interface Observer {
update(subject: Subject): void; // 발행자로부터 상태 변경 알림을 받는 메서드
}
// 3. 구체적인 발행자
class ConcreteSubject implements Subject {
public state: number; // 발행자의 핵심 상태
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.');
}
this.observers.push(observer);
console.log('Subject: Attached an 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.');
}
public notify(): void {
console.log('Subject: Notifying observers...');
for (const observer of this.observers) {
observer.update(this);
}
}
// 발행자의 상태 변경 메서드
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(); // 상태 변경 시 알림
}
}
// 4. 구체적인 구독자
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();
const observer2 = new ConcreteObserverB();
subject.attach(observer1);
subject.attach(observer2);
subject.someBusinessLogic();
subject.someBusinessLogic();
subject.detach(observer1);
subject.someBusinessLogic();
Subject
인터페이스와 Observer
인터페이스를 정의하고, ConcreteSubject
는 상태가 변경될 때 등록된 모든 Observer
에게 update
메서드를 통해 알림을 보냅니다. ConcreteObserverA
와 ConcreteObserverB
는 이 알림을 받아 각각 다른 로직을 수행합니다.
전략 패턴
목적: 알고리즘 군을 정의하고, 각 알고리즘을 별도의 클래스로 캡슐화하여 서로 교환 가능하게 만듭니다.
설명: 클라이언트가 런타임에 특정 알고리즘을 선택하여 사용할 수 있도록 합니다. OCP를 준수하며, 조건문 (if/else if
)으로 가득 찬 코드를 피할 수 있게 해줍니다.
타입스크립트 구현 예시
// 1. 전략(Strategy) 인터페이스
interface PaymentStrategy {
pay(amount: number): void;
}
// 2. 구체적인 전략 클래스
class CreditCardPayment implements PaymentStrategy {
pay(amount: number): void {
console.log(`Paid ${amount} using Credit Card.`);
// 신용카드 결제 로직
}
}
class PayPalPayment implements PaymentStrategy {
pay(amount: number): void {
console.log(`Paid ${amount} using PayPal.`);
// PayPal 결제 로직
}
}
class BankTransferPayment implements PaymentStrategy {
pay(amount: number): void {
console.log(`Paid ${amount} using Bank Transfer.`);
// 계좌 이체 로직
}
}
// 3. 문맥(Context) 클래스 - 전략 객체를 사용하여 비즈니스 로직 실행
class ShoppingCart {
private amount: number;
private paymentStrategy: PaymentStrategy; // Strategy 인터페이스에 의존
constructor(amount: number) {
this.amount = amount;
}
// 런타임에 전략을 설정
public setPaymentStrategy(strategy: PaymentStrategy): void {
this.paymentStrategy = strategy;
}
public checkout(): void {
if (!this.paymentStrategy) {
console.error("No payment strategy selected!");
return;
}
console.log(`Shopping Cart: Processing checkout for ${this.amount}...`);
this.paymentStrategy.pay(this.amount); // 전략 객체의 pay 메서드 호출
console.log("Shopping Cart: Checkout completed.");
}
}
// 사용 예시
const cart = new ShoppingCart(250);
console.log("--- Paying with Credit Card ---");
cart.setPaymentStrategy(new CreditCardPayment());
cart.checkout();
console.log("\n--- Paying with PayPal ---");
cart.setPaymentStrategy(new PayPalPayment());
cart.checkout();
console.log("\n--- Paying with Bank Transfer ---");
cart.setPaymentStrategy(new BankTransferPayment());
cart.checkout();
PaymentStrategy
인터페이스를 정의하여 다양한 결제 방식을 추상화합니다. ShoppingCart
클래스(Context)는 PaymentStrategy
인터페이스에 의존하며, 런타임에 어떤 구체적인 결제 전략을 사용할지 결정하고 pay
메서드를 호출합니다. 새로운 결제 방식이 추가되어도 ShoppingCart
클래스는 수정할 필요가 없습니다.
디자인 패턴은 단순한 코드 조각이 아니라, 특정 문제에 대한 검증된 사고방식과 구조화된 접근 방식입니다. 타입스크립트는 강력한 타입 시스템과 인터페이스, 추상 클래스 등의 기능을 통해 이러한 디자인 패턴을 더욱 명확하고 타입 안전하게 구현할 수 있도록 지원합니다. 패턴을 이해하고 적절히 활용함으로써, 코드의 유지보수성, 확장성, 재사용성을 크게 향상시킬 수 있습니다.
다음 절에서는 객체지향의 중요한 개념인 상속과 합성에 대해 더 깊이 있게 다루겠습니다.