Factory 실전 가이드
Factory의 핵심 개념과 실무 활용
학습 항목
이미지 로딩 중...
Factory Pattern 핵심 개념 완벽 정리
Factory Pattern의 기본 개념부터 실무 적용까지 상세하게 다룹니다. 객체 생성의 유연성을 높이고 코드 유지보수성을 개선하는 방법을 배웁니다. 중급 개발자를 위한 실전 예제와 베스트 프랙티스를 제공합니다.
들어가며
이 글에서는 Factory Pattern 핵심 개념 완벽 정리에 대해 상세히 알아보겠습니다. 총 16가지 주요 개념을 다루며, 각각의 개념에 대한 설명과 실제 코드 예제를 함께 제공합니다.
목차
- Factory_Pattern_기본_개념 - 객체 생성 책임 분리
- Simple_Factory - 단일 팩토리 메서드
- Factory_Method_Pattern - 서브클래스 생성 위임
- Abstract_Factory_Pattern - 관련 객체군 생성
- 실무_활용_사례 - 플러그인 시스템 구현
- Factory_vs_Constructor - 언제 팩토리를 사용할까
- 타입_안전성_확보 - TypeScript 제네릭 활용
- 의존성_주입과_결합 - DI 컨테이너 연계
- Factory_Pattern_기본_개념
- Simple_Factory
- Factory_Method_Pattern
- Abstract_Factory_Pattern
- 실무_활용_사례
- Factory_vs_Constructor
- 타입_안전성_확보
- 의존성_주입과_결합
1. Factory_Pattern_기본_개념 - 객체 생성 책임 분리
개요
2. Simple_Factory - 단일 팩토리 메서드
개요
3. Factory_Method_Pattern - 서브클래스 생성 위임
개요
4. Abstract_Factory_Pattern - 관련 객체군 생성
개요
5. 실무_활용_사례 - 플러그인 시스템 구현
개요
6. Factory_vs_Constructor - 언제 팩토리를 사용할까
개요
7. 타입_안전성_확보 - TypeScript 제네릭 활용
개요
8. 의존성_주입과_결합 - DI 컨테이너 연계
개요
1. Factory_Pattern_기본_개념
개요
간단히 말해서, Factory Pattern은 객체 생성 인터페이스를 정의하되, 실제 인스턴스화는 서브클래스나 별도의 팩토리 클래스가 결정하도록 하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 코드의 결합도를 낮추고 확장성을 높이기 위해서입니다. 예를 들어, 로깅 시스템에서 파일 로거, 데이터베이스 로거, 클라우드 로거 등 다양한 로거를 지원해야 하는 경우, 팩토리 패턴을 사용하면 새로운 로거 추가 시 기존 코드 수정 없이 확장할 수 있습니다. 전통적인 방법에서는 new 키워드로 직접 객체를 생성하고 생성자에 의존했다면, 팩토리 패턴을 사용하면 추상화된 인터페이스를 통해 객체를 요청하고 받을 수 있습니다. 이 패턴의 핵심 특징은 첫째, 객체 생성 로직의 캡슐화입니다. 둘째, Open/Closed Principle(개방-폐쇄 원칙) 준수로 확장에는 열려있고 수정에는 닫혀있습니다. 셋째, 클라이언트 코드와 구체적인 클래스 간의 의존성을 제거합니다. 이러한 특징들이 코드의 유지보수성과 테스트 용이성을 크게 향상시킵니다.
코드 예제
// PaymentMethod 인터페이스 정의
interface PaymentMethod {
processPayment(amount: number): void;
}
// 구체적인 결제 수단 클래스들
class CreditCardPayment implements PaymentMethod {
processPayment(amount: number): void {
console.log(`Processing credit card payment: $${amount}`);
}
}
class PayPalPayment implements PaymentMethod {
processPayment(amount: number): void {
console.log(`Processing PayPal payment: $${amount}`);
}
}
class CryptoPayment implements PaymentMethod {
processPayment(amount: number): void {
console.log(`Processing crypto payment: $${amount}`);
}
}
// Factory 클래스
class PaymentFactory {
static createPayment(type: string): PaymentMethod {
switch (type) {
case 'creditcard':
return new CreditCardPayment();
case 'paypal':
return new PayPalPayment();
case 'crypto':
return new CryptoPayment();
default:
throw new Error(`Unknown payment type: ${type}`);
}
}
}
// 사용 예제
const payment = PaymentFactory.createPayment('paypal');
payment.processPayment(100);
설명
이것이 하는 일: 이 코드는 다양한 결제 수단 객체를 생성하는 책임을 PaymentFactory에 집중시켜, 클라이언트 코드는 구체적인 결제 클래스를 알 필요 없이 PaymentMethod 인터페이스만으로 작업할 수 있게 합니다. 첫 번째로, PaymentMethod 인터페이스를 정의하여 모든 결제 수단이 구현해야 할 공통 계약을 명시합니다. 이렇게 하는 이유는 클라이언트 코드가 구체적인 구현이 아닌 추상화에 의존하도록 하여, 나중에 새로운 결제 수단이 추가되어도 클라이언트 코드를 수정할 필요가 없기 때문입니다. 그 다음으로, CreditCardPayment, PayPalPayment, CryptoPayment 같은 구체적인 클래스들이 PaymentMethod 인터페이스를 구현합니다. 각 클래스는 자신만의 방식으로 processPayment 메서드를 구현하여 실제 결제 로직을 처리합니다. 내부에서는 각 결제 수단의 특성에 맞는 검증, API 호출, 트랜잭션 처리 등이 일어납니다. 세 번째 단계로, PaymentFactory 클래스의 정적 메서드 createPayment가 실행되면서 전달받은 타입 문자열을 기반으로 적절한 결제 객체를 생성합니다. 마지막으로, 클라이언트 코드는 단순히 원하는 결제 타입을 문자열로 전달하고 PaymentMethod 인터페이스를 받아 사용하면 됩니다. 여러분이 이 코드를 사용하면 새로운 결제 수단 추가 시 팩토리의 switch문만 수정하면 되고, 기존 클라이언트 코드는 전혀 건드리지 않아도 됩니다. 또한 테스트 시 Mock 객체를 쉽게 주입할 수 있어 단위 테스트가 용이해지고, 각 결제 수단의 로직이 독립적으로 관리되어 버그 발생 시 영향 범위를 최소화할 수 있습니다. Summary: 핵심 정리: Factory Pattern은 객체 생성 로직을 캡슐화하여 클라이언트 코드와 구체 클래스 간의 결합도를 낮춥니다. 새로운 타입 추가나 생성 로직 변경이 빈번한 경우에 사용하세요. 다만 간단한 객체 생성에는 오버엔지니어링이 될 수 있으니 주의하세요. Tips: 💡 문자열 대신 enum을 사용하면 타입 안정성을 높이고 IDE 자동완성 기능을 활용할 수 있습니다 💡 팩토리 메서드에서 객체 풀(Object Pool)을 구현하면 무거운 객체의 재사용성을 높여 성능을 개선할 수 있습니다 💡 switch문 대신 Map이나 객체를 사용한 전략 패턴과 결합하면 더 유연하고 확장 가능한 구조를 만들 수 있습니다 💡 팩토리가 생성한 객체를 싱글톤으로 관리해야 하는 경우, 팩토리 내부에 캐시를 구현하세요 💡 의존성 주입 컨테이너와 함께 사용하면 팩토리 자체도 주입받아 테스트 용이성을 더욱 높일 수 있습니다
2. Simple_Factory
개요
간단히 말해서, Simple Factory는 정적 메서드 하나로 객체 생성을 담당하는 가장 단순한 형태의 팩토리 패턴입니다. 실무에서 이 패턴이 필요한 이유는 객체 생성 로직이 여러 곳에 중복되는 것을 방지하고, 변경 사항을 한 곳에서 관리할 수 있기 때문입니다. 예를 들어, 설정 파일이나 데이터베이스에서 읽은 값에 따라 다른 객체를 생성해야 하는 경우, Simple Factory를 사용하면 생성 로직을 중앙화할 수 있습니다. 기존에는 클라이언트 코드 곳곳에서 new Enemy('zombie') 같은 코드가 반복되었다면, 이제는 EnemyFactory.create('zombie')로 통일하여 생성 로직을 한 곳에서 제어할 수 있습니다. 이 패턴의 핵심 특징은 첫째, 구현이 매우 간단하고 이해하기 쉽다는 점입니다. 둘째, 객체 생성 로직의 중앙화로 유지보수가 용이합니다. 셋째, 클라이언트 코드가 구체 클래스에 대한 의존성을 줄일 수 있습니다. 다만 Open/Closed Principle을 완벽히 준수하지는 못하므로, 더 복잡한 상황에서는 Factory Method나 Abstract Factory로 발전시켜야 합니다.
코드 예제
// Enemy 인터페이스
interface Enemy {
name: string;
health: number;
attack(): void;
}
// 구체적인 적 클래스들
class Zombie implements Enemy {
name = 'Zombie';
health = 100;
attack(): void {
console.log('Zombie bites!');
}
}
class Skeleton implements Enemy {
name = 'Skeleton';
health = 80;
attack(): void {
console.log('Skeleton shoots arrow!');
}
}
class Dragon implements Enemy {
name = 'Dragon';
health = 500;
attack(): void {
console.log('Dragon breathes fire!');
}
}
// Simple Factory
class EnemyFactory {
static create(type: string, level: number = 1): Enemy {
let enemy: Enemy;
switch (type) {
case 'zombie':
enemy = new Zombie();
break;
case 'skeleton':
enemy = new Skeleton();
break;
case 'dragon':
enemy = new Dragon();
break;
default:
throw new Error(`Unknown enemy type: ${type}`);
}
// 레벨에 따른 스탯 조정
enemy.health *= level;
return enemy;
}
}
// 사용 예제
const boss = EnemyFactory.create('dragon', 5);
console.log(`${boss.name} has ${boss.health} HP`);
boss.attack();
설명
이것이 하는 일: 이 코드는 다양한 적 캐릭터를 생성하는 로직을 EnemyFactory에 집중시키고, 레벨에 따른 스탯 조정 같은 공통 로직도 함께 처리합니다. 첫 번째로, Enemy 인터페이스를 정의하여 모든 적이 가져야 할 공통 속성(name, health)과 행동(attack)을 명시합니다. 이렇게 하는 이유는 게임 엔진이나 전투 시스템이 구체적인 적의 타입을 몰라도 Enemy 인터페이스만으로 작동할 수 있게 하기 위함입니다. 그 다음으로, Zombie, Skeleton, Dragon 같은 구체 클래스들이 각자의 특성을 구현합니다. 각 클래스는 자신만의 기본 스탯과 공격 방식을 정의하며, 실제 게임에서는 여기에 AI 로직, 애니메이션, 사운드 등이 추가될 수 있습니다. 세 번째 단계로, EnemyFactory.create 메서드가 실행되면서 타입 문자열을 확인하고 적절한 적 객체를 생성합니다. 중요한 점은 여기서 레벨 매개변수를 받아 체력을 조정하는 공통 로직을 처리한다는 것입니다. 이렇게 하면 객체 생성과 동시에 초기화 로직도 함께 관리할 수 있습니다. 마지막으로, 클라이언트 코드는 단순히 create('dragon', 5)처럼 호출하여 레벨 5 드래곤을 받습니다. 최종적으로 반환된 객체는 Enemy 타입이므로, 어떤 적이든 동일한 방식으로 처리할 수 있습니다. 여러분이 이 코드를 사용하면 새로운 적을 추가할 때 팩토리만 수정하면 되고, 레벨 스케일링 공식을 변경할 때도 한 곳만 수정하면 됩니다. 또한 테스트 시 Mock Enemy를 쉽게 만들 수 있어 전투 시스템을 독립적으로 테스트할 수 있으며, 난이도 밸런싱 작업 시 팩토리의 로직만 조정하면 전체 게임에 즉시 반영됩니다. Summary: 핵심 정리: Simple Factory는 정적 메서드로 객체 생성을 중앙화하는 가장 기본적인 팩토리 패턴입니다. 생성 로직이 간단하고 자주 변경되지 않는 경우에 사용하세요. 팩토리 메서드 수정이 빈번하다면 더 유연한 패턴으로 발전시키세요. Tips: 💡 타입 문자열 대신 enum을 사용하고, 팩토리 메서드의 반환 타입을 명확히 지정하면 컴파일 타임 안정성이 높아집니다 💡 복잡한 초기화 로직이 필요한 경우, Builder Pattern과 결합하여 팩토리가 빌더를 반환하도록 할 수 있습니다 💡 생성된 객체를 캐싱해야 한다면, WeakMap을 사용해 메모리 누수를 방지하면서 재사용할 수 있습니다 💡 switch문 대신 Map<string, () => Enemy>를 사용하면 런타임에 새로운 타입을 등록할 수 있어 플러그인 시스템 구현이 가능합니다 💡 팩토리 메서드에서 로깅이나 모니터링을 추가하면 어떤 객체가 언제 생성되는지 추적하여 성능 분석에 활용할 수 있습니다
3. Factory_Method_Pattern
개요
간단히 말해서, Factory Method Pattern은 부모 클래스에서 객체 생성 인터페이스를 정의하고, 자식 클래스에서 실제 생성될 객체의 타입을 결정하는 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, 시스템이 어떤 객체를 생성해야 할지 미리 알 수 없거나, 생성 로직을 서브클래스에 위임하고 싶을 때 유용합니다. 예를 들어, 문서 편집기에서 Word 문서, PDF 문서, HTML 문서 등 다양한 포맷을 지원하는 경우, 각 문서 타입별로 Factory Method를 구현한 서브클래스를 만들 수 있습니다. 기존 Simple Factory에서는 팩토리 내부의 조건문을 수정해야 했다면, Factory Method Pattern에서는 새로운 서브클래스를 추가하기만 하면 되므로 기존 코드를 전혀 수정하지 않아도 됩니다. 이 패턴의 핵심 특징은 첫째, Open/Closed Principle을 완벽히 준수한다는 점입니다. 둘째, 각 생성 로직이 독립적인 클래스로 분리되어 Single Responsibility Principle도 만족합니다. 셋째, 클라이언트 코드가 구체 클래스가 아닌 추상 팩토리와 추상 제품에만 의존합니다. 이러한 특징들이 코드의 확장성과 유지보수성을 극대화합니다.
코드 예제
// 추상 Button 인터페이스
interface Button {
render(): void;
onClick(callback: () => void): void;
}
// 플랫폼별 구체적인 Button 클래스들
class WindowsButton implements Button {
render(): void {
console.log('Rendering Windows-style button');
}
onClick(callback: () => void): void {
console.log('Windows button clicked');
callback();
}
}
class MacOSButton implements Button {
render(): void {
console.log('Rendering macOS-style button');
}
onClick(callback: () => void): void {
console.log('macOS button clicked');
callback();
}
}
// 추상 Dialog 클래스 (Creator)
abstract class Dialog {
// Factory Method - 서브클래스에서 구현
abstract createButton(): Button;
// 템플릿 메서드 - 팩토리 메서드를 사용
render(): void {
const button = this.createButton();
button.render();
button.onClick(() => console.log('Dialog closed'));
}
}
// 구체적인 Creator 클래스들
class WindowsDialog extends Dialog {
createButton(): Button {
return new WindowsButton();
}
}
class MacOSDialog extends Dialog {
createButton(): Button {
return new MacOSButton();
}
}
// 클라이언트 코드
function initializeApp(os: string): void {
let dialog: Dialog;
if (os === 'Windows') {
dialog = new WindowsDialog();
} else {
dialog = new MacOSDialog();
}
dialog.render();
}
initializeApp('Windows');
설명
이것이 하는 일: 이 코드는 Dialog라는 추상 클래스가 createButton이라는 팩토리 메서드를 정의하고, WindowsDialog와 MacOSDialog 같은 서브클래스가 각자의 플랫폼에 맞는 버튼을 생성하도록 위임합니다. 첫 번째로, Button 인터페이스와 이를 구현한 WindowsButton, MacOSButton 클래스를 정의합니다. 이렇게 하는 이유는 각 플랫폼의 버튼이 동일한 인터페이스를 제공하되, 렌더링과 이벤트 처리는 플랫폼 특성에 맞게 다르게 구현하기 위해서입니다. 그 다음으로, Dialog 추상 클래스가 createButton이라는 추상 메서드(팩토리 메서드)를 선언합니다. 이 메서드는 구현을 강제하지만 어떤 버튼을 만들지는 서브클래스에게 맡깁니다. render 메서드는 템플릿 메서드 역할을 하며, 내부에서 createButton을 호출하여 생성된 버튼을 사용합니다. 세 번째 단계로, WindowsDialog와 MacOSDialog가 createButton 메서드를 각자의 방식으로 구현합니다. WindowsDialog는 WindowsButton을 반환하고, MacOSDialog는 MacOSButton을 반환합니다. 이렇게 하면 Dialog의 render 메서드는 수정 없이 재사용되면서도, 실제 생성되는 버튼은 서브클래스가 결정합니다. 마지막으로, 클라이언트 코드는 운영체제에 따라 적절한 Dialog 서브클래스를 선택하고 render를 호출합니다. 최종적으로 각 플랫폼에 맞는 버튼이 생성되고 렌더링됩니다. 여러분이 이 코드를 사용하면 새로운 플랫폼(예: Linux)을 추가할 때 LinuxButton과 LinuxDialog 클래스만 추가하면 되고, 기존 코드는 전혀 수정할 필요가 없습니다. 또한 각 플랫폼의 UI 로직이 독립적인 클래스로 분리되어 있어 버그 발생 시 영향 범위가 명확하며, 단위 테스트도 각 Dialog 서브클래스별로 독립적으로 작성할 수 있습니다. 템플릿 메서드 패턴과 결합되어 공통 로직(render의 흐름)은 재사용하면서 가변적인 부분(버튼 생성)만 서브클래스가 커스터마이징할 수 있습니다. Summary: 핵심 정리: Factory Method Pattern은 객체 생성을 서브클래스에 위임하여 Open/Closed Principle을 준수합니다. 생성할 객체의 타입이 런타임에 결정되거나, 시스템이 여러 제품군을 지원해야 할 때 사용하세요. 클래스 계층이 복잡해질 수 있으니 Simple Factory로 충분한지 먼저 검토하세요. Tips: 💡 팩토리 메서드를 protected로 선언하면 외부에서 직접 호출을 방지하고 템플릿 메서드 패턴과의 결합을 명확히 할 수 있습니다 💡 팩토리 메서드에 매개변수를 전달하여 생성할 객체를 더 세밀하게 제어할 수 있지만, 너무 많은 매개변수는 Builder Pattern으로 대체하세요 💡 서브클래스가 많아지면 Registry Pattern을 함께 사용하여 팩토리를 동적으로 등록하고 조회할 수 있습니다 💡 팩토리 메서드에서 싱글톤 객체를 반환해야 한다면, Lazy Initialization을 사용해 첫 호출 시에만 생성하고 이후에는 캐시된 인스턴스를 반환하세요 💡 TypeScript에서는 제네릭을 활용하여 팩토리 메서드의 반환 타입을 더 명확히 지정하면 타입 안정성이 향상됩니다
4. Abstract_Factory_Pattern
개요
간단히 말해서, Abstract Factory Pattern은 구체적인 클래스를 지정하지 않고 관련되거나 의존적인 객체들의 집합을 생성하는 인터페이스를 제공하는 패턴입니다. 실무에서 이 패턴이 필요한 이유는 제품군(Product Family) 전체의 일관성을 보장해야 하기 때문입니다. 예를 들어, 데이터베이스 추상화 레이어에서 MySQL용 Connection, Command, DataReader와 PostgreSQL용 Connection, Command, DataReader를 각각 세트로 생성해야 하는 경우, Abstract Factory를 사용하면 MySQL 제품군과 PostgreSQL 제품군이 섞이는 것을 방지할 수 있습니다. 기존 Factory Method에서는 단일 제품 타입만 생성했다면, Abstract Factory는 여러 관련 제품을 함께 생성하며, 각 제품군이 상호 호환되도록 보장합니다. 이 패턴의 핵심 특징은 첫째, 제품군의 일관성을 강제한다는 점입니다. 둘째, 구체적인 클래스로부터 클라이언트를 격리시켜 결합도를 낮춥니다. 셋째, 새로운 제품군 추가가 용이하지만, 새로운 제품 타입 추가는 인터페이스 변경이 필요합니다. 이러한 특징들이 대규모 시스템에서 복잡한 객체 생성을 체계적으로 관리할 수 있게 합니다.
코드 예제
// 추상 제품 인터페이스들
interface Button {
render(): void;
}
interface Checkbox {
render(): void;
toggle(): void;
}
// 다크 모드 제품군
class DarkButton implements Button {
render(): void {
console.log('Rendering dark button with #333 background');
}
}
class DarkCheckbox implements Checkbox {
render(): void {
console.log('Rendering dark checkbox with white border');
}
toggle(): void {
console.log('Dark checkbox toggled');
}
}
// 라이트 모드 제품군
class LightButton implements Button {
render(): void {
console.log('Rendering light button with #FFF background');
}
}
class LightCheckbox implements Checkbox {
render(): void {
console.log('Rendering light checkbox with gray border');
}
toggle(): void {
console.log('Light checkbox toggled');
}
}
// 추상 팩토리 인터페이스
interface UIFactory {
createButton(): Button;
createCheckbox(): Checkbox;
}
// 구체적인 팩토리들
class DarkThemeFactory implements UIFactory {
createButton(): Button {
return new DarkButton();
}
createCheckbox(): Checkbox {
return new DarkCheckbox();
}
}
class LightThemeFactory implements UIFactory {
createButton(): Button {
return new LightButton();
}
createCheckbox(): Checkbox {
return new LightCheckbox();
}
}
// 클라이언트 코드
class Application {
private button: Button;
private checkbox: Checkbox;
constructor(factory: UIFactory) {
this.button = factory.createButton();
this.checkbox = factory.createCheckbox();
}
render(): void {
this.button.render();
this.checkbox.render();
}
}
// 사용 예제
const isDarkMode = true;
const factory: UIFactory = isDarkMode
? new DarkThemeFactory()
: new LightThemeFactory();
const app = new Application(factory);
app.render();
설명
이것이 하는 일: 이 코드는 UIFactory 인터페이스를 통해 관련된 UI 컴포넌트들(버튼, 체크박스)을 일관된 테마로 생성하도록 보장하며, Application 클라이언트는 구체적인 테마를 몰라도 됩니다. 첫 번째로, Button과 Checkbox 같은 추상 제품 인터페이스를 정의합니다. 이렇게 하는 이유는 각 제품 타입이 제공해야 할 공통 기능을 명시하고, 클라이언트가 구체적인 구현이 아닌 인터페이스에 의존하도록 하기 위해서입니다. 그 다음으로, DarkButton/DarkCheckbox와 LightButton/LightCheckbox 같은 구체적인 제품 클래스들이 각 테마에 맞게 구현됩니다. 중요한 점은 Dark 제품들끼리, Light 제품들끼리 시각적 일관성을 유지한다는 것입니다. 실제 프로젝트에서는 여기에 색상, 폰트, 애니메이션 등의 스타일이 포함됩니다. 세 번째 단계로, UIFactory 인터페이스가 제품 생성 메서드들(createButton, createCheckbox)을 선언하고, DarkThemeFactory와 LightThemeFactory가 각각 자신의 제품군을 생성하도록 구현합니다. 이렇게 하면 팩토리 하나가 관련된 모든 제품을 책임지므로, 클라이언트는 단일 팩토리만 주입받으면 됩니다. 마지막으로, Application 클래스는 생성자에서 UIFactory를 주입받아 필요한 UI 컴포넌트들을 생성합니다. 최종적으로 isDarkMode 같은 설정에 따라 적절한 팩토리를 선택하면, 모든 UI 컴포넌트가 자동으로 일관된 테마로 생성됩니다. 여러분이 이 코드를 사용하면 새로운 테마(예: HighContrast)를 추가할 때 새로운 제품 클래스들과 팩토리만 추가하면 되고, Application 코드는 전혀 수정할 필요가 없습니다. 또한 테마 전환 시 팩토리만 교체하면 모든 컴포넌트가 일관되게 변경되며, 각 테마의 컴포넌트들이 독립적으로 관리되어 A/B 테스트나 사용자 커스터마이징이 용이합니다. 의존성 주입 패턴과 결합하여 팩토리를 앱 전역에서 관리하면 테마 일관성을 시스템 레벨에서 보장할 수 있습니다. Summary: 핵심 정리: Abstract Factory Pattern은 관련된 객체들의 집합을 일관성 있게 생성하는 인터페이스를 제공합니다. 제품군 전체의 호환성을 보장해야 하거나, 여러 플랫폼/테마를 지원해야 할 때 사용하세요. 새로운 제품 타입 추가 시 모든 팩토리를 수정해야 하는 단점을 고려하세요. Tips: 💡 팩토리를 싱글톤으로 관리하면 앱 전역에서 일관된 제품군 사용을 보장할 수 있지만, 테스트 시에는 DI를 통해 Mock 팩토리를 주입하세요 💡 제품 타입이 자주 추가되는 경우, Prototype Pattern과 결합하여 팩토리가 프로토타입을 복제하도록 하면 인터페이스 변경을 줄일 수 있습니다 💡 TypeScript에서 제네릭을 활용하면 타입 안전성을 유지하면서 팩토리를 더 유연하게 만들 수 있습니다 💡 팩토리 메서드에 설정 객체를 전달하여 생성되는 제품을 세밀하게 커스터마이징할 수 있지만, 복잡도가 높아지면 Builder Pattern으로 전환하세요 💡 런타임에 팩토리를 교체해야 하는 경우, Strategy Pattern과 결합하여 동적으로 팩토리를 전환할 수 있습니다
5. 실무_활용_사례
개요
간단히 말해서, 이 실무 활용 사례는 Factory Pattern을 플러그인 시스템, 서비스 로케이터, 의존성 주입 컨테이너 같은 실제 아키텍처 패턴과 결합하는 방법을 보여줍니다. 실무에서 이 접근법이 필요한 이유는 시스템의 확장성과 유지보수성을 극대화하기 위해서입니다. 예를 들어, 마이크로서비스 아키텍처에서 각 서비스의 클라이언트를 동적으로 생성하거나, 멀티 테넌트 SaaS에서 고객별로 다른 기능 세트를 제공하는 경우, 팩토리 기반 플러그인 시스템이 매우 유용합니다. 기존에는 모든 기능을 정적으로 import하고 하드코딩했다면, 팩토리와 레지스트리를 사용하면 설정 파일이나 데이터베이스를 기반으로 런타임에 기능을 동적으로 구성할 수 있습니다. 이 접근법의 핵심 특징은 첫째, 코드 수정 없이 새로운 플러그인을 추가할 수 있는 진정한 플러그인 아키텍처를 구현한다는 점입니다. 둘째, 플러그인 간의 의존성을 팩토리가 관리하여 초기화 순서 문제를 해결합니다. 셋째, Lazy Loading을 통해 필요한 플러그인만 로드하여 성능을 최적화할 수 있습니다. 이러한 특징들이 대규모 확장 가능한 시스템 구축의 기반이 됩니다.
코드 예제
// 플러그인 인터페이스
interface Plugin {
name: string;
initialize(): void;
execute(context: any): void;
}
// 구체적인 플러그인들
class SyntaxHighlighter implements Plugin {
name = 'SyntaxHighlighter';
initialize(): void {
console.log('Syntax highlighter initialized');
}
execute(context: any): void {
console.log(`Highlighting code: ${context.language}`);
}
}
class AutoComplete implements Plugin {
name = 'AutoComplete';
initialize(): void {
console.log('AutoComplete initialized');
}
execute(context: any): void {
console.log(`Suggesting: ${context.partial}`);
}
}
// 플러그인 팩토리 타입
type PluginFactory = () => Plugin;
// 플러그인 레지스트리 (싱글톤)
class PluginRegistry {
private static instance: PluginRegistry;
private factories = new Map<string, PluginFactory>();
private constructor() {}
static getInstance(): PluginRegistry {
if (!this.instance) {
this.instance = new PluginRegistry();
}
return this.instance;
}
register(name: string, factory: PluginFactory): void {
this.factories.set(name, factory);
}
create(name: string): Plugin {
const factory = this.factories.get(name);
if (!factory) {
throw new Error(`Plugin not found: ${name}`);
}
return factory();
}
getAllPluginNames(): string[] {
return Array.from(this.factories.keys());
}
}
// 플러그인 매니저
class PluginManager {
private plugins = new Map<string, Plugin>();
private registry = PluginRegistry.getInstance();
loadPlugin(name: string): void {
if (!this.plugins.has(name)) {
const plugin = this.registry.create(name);
plugin.initialize();
this.plugins.set(name, plugin);
console.log(`Plugin loaded: ${name}`);
}
}
executePlugin(name: string, context: any): void {
const plugin = this.plugins.get(name);
if (!plugin) {
throw new Error(`Plugin not loaded: ${name}`);
}
plugin.execute(context);
}
}
// 플러그인 등록 (앱 시작 시 또는 플러그인 설치 시)
const registry = PluginRegistry.getInstance();
registry.register('syntax', () => new SyntaxHighlighter());
registry.register('autocomplete', () => new AutoComplete());
// 사용 예제
const manager = new PluginManager();
manager.loadPlugin('syntax');
manager.loadPlugin('autocomplete');
manager.executePlugin('syntax', { language: 'TypeScript' });
manager.executePlugin('autocomplete', { partial: 'cons' });
설명
이것이 하는 일: 이 코드는 플러그인 레지스트리에 팩토리 함수를 등록하고, 플러그인 매니저가 필요한 시점에 팩토리를 호출하여 플러그인을 생성하고 관리합니다. 첫 번째로, Plugin 인터페이스를 정의하여 모든 플러그인이 구현해야 할 계약을 명시합니다. 이렇게 하는 이유는 에디터가 플러그인의 구체적인 타입을 몰라도 일관된 방식으로 플러그인을 사용할 수 있게 하기 위해서입니다. initialize는 플러그인 초기화를, execute는 실제 기능 실행을 담당합니다. 그 다음으로, PluginRegistry가 싱글톤 패턴으로 구현되어 앱 전역에서 단일 레지스트리를 공유합니다. register 메서드는 플러그인 이름과 팩토리 함수를 Map에 저장하고, create 메서드는 저장된 팩토리를 호출하여 플러그인 인스턴스를 생성합니다. 팩토리 함수를 저장하는 이유는 실제 객체 생성을 필요한 시점까지 지연시키기 위해서입니다. 세 번째 단계로, PluginManager가 플러그인의 라이프사이클을 관리합니다. loadPlugin 메서드는 플러그인이 아직 로드되지 않았다면 레지스트리에서 생성하고 초기화한 후 Map에 캐싱합니다. executePlugin은 로드된 플러그인을 찾아 execute 메서드를 호출합니다. 이렇게 하면 플러그인은 한 번만 생성되고, 필요할 때마다 재사용됩니다. 마지막으로, 앱 시작 시 또는 플러그인 설치 시 registry.register를 호출하여 플러그인을 등록합니다. 최종적으로 PluginManager를 통해 필요한 플러그인을 동적으로 로드하고 실행할 수 있습니다. 여러분이 이 코드를 사용하면 새로운 플러그인 추가 시 단순히 레지스트리에 등록만 하면 되고, 에디터 코어 코드는 전혀 수정할 필요가 없습니다. 또한 설정 파일(JSON, YAML)을 읽어 동적으로 플러그인을 등록할 수 있어 사용자가 플러그인을 직접 관리할 수 있으며, Lazy Loading으로 사용하지 않는 플러그인은 메모리에 로드되지 않아 성능이 향상됩니다. 플러그인 간 의존성이 있는 경우 팩토리 함수 내부에서 다른 플러그인을 주입받도록 구현하여 복잡한 의존성 그래프도 관리할 수 있습니다. Summary: 핵심 정리: 팩토리 패턴과 레지스트리를 결합하면 런타임에 동적으로 확장 가능한 플러그인 시스템을 구축할 수 있습니다. 마이크로서비스, SaaS, 확장 가능한 앱에서 사용하세요. 플러그인 버전 관리와 의존성 해결이 필요하면 DI 컨테이너로 발전시키세요. Tips: 💡 플러그인 메타데이터(버전, 의존성, 권한)를 함께 등록하면 호환성 검사와 보안 검증을 수행할 수 있습니다 💡 비동기 플러그인 로딩을 지원하려면 팩토리를 async 함수로 만들고 dynamic import()를 사용하여 코드 스플리팅을 구현하세요 💡 플러그인 간 통신이 필요한 경우 Event Bus 패턴과 결합하여 느슨한 결합을 유지하세요 💡 플러그인 에러가 전체 시스템을 다운시키지 않도록 try-catch로 감싸고 Circuit Breaker 패턴을 적용하세요 💡 플러그인 성능 모니터링을 위해 팩토리에서 Proxy Pattern을 사용하여 실행 시간, 메모리 사용량 등을 추적할 수 있습니다
6. Factory_vs_Constructor
개요
간단히 말해서, 생성자는 간단하고 직관적인 객체 생성에 적합하고, 팩토리는 복잡한 생성 로직, 조건부 생성, 추상화가 필요한 경우에 적합합니다. 실무에서 이 선택이 중요한 이유는 시스템의 복잡도와 유지보수성에 직접적인 영향을 미치기 때문입니다. 예를 들어, 간단한 DTO(Data Transfer Object)나 Value Object는 생성자로 충분하지만, 외부 API 응답을 파싱하여 여러 타입의 객체로 변환하는 경우에는 팩토리가 더 적합합니다. 기존에는 "항상 팩토리를 사용하라" 또는 "생성자만으로 충분하다" 같은 극단적인 접근을 했다면, 이제는 상황에 따라 적절한 도구를 선택하는 균형 잡힌 접근이 필요합니다. 선택 기준의 핵심 포인트는 첫째, 생성 로직의 복잡도입니다. 둘째, 반환 타입의 다형성 필요 여부입니다. 셋째, 객체 생성의 캡슐화 필요성입니다. 넷째, 테스트 용이성과 Mock 주입 필요 여부입니다. 이러한 기준들을 체계적으로 평가하면 올바른 선택을 할 수 있습니다.
코드 예제
// 1. 생성자가 적합한 경우: 간단한 Value Object
class Point {
constructor(public x: number, public y: number) {}
distance(other: Point): number {
return Math.sqrt(
Math.pow(this.x - other.x, 2) +
Math.pow(this.y - other.y, 2)
);
}
}
const p1 = new Point(0, 0);
const p2 = new Point(3, 4);
console.log(p1.distance(p2)); // 5
// 2. 팩토리가 적합한 경우: 복잡한 생성 로직
interface DatabaseConnection {
connect(): void;
query(sql: string): any;
}
class MySQLConnection implements DatabaseConnection {
constructor(private config: any) {}
connect(): void { console.log('Connected to MySQL'); }
query(sql: string): any { return []; }
}
class PostgreSQLConnection implements DatabaseConnection {
constructor(private config: any) {}
connect(): void { console.log('Connected to PostgreSQL'); }
query(sql: string): any { return []; }
}
class DatabaseConnectionFactory {
static create(type: string, config: any): DatabaseConnection {
// 환경 변수, 설정 파일 등을 기반으로 복잡한 설정 구성
const enrichedConfig = {
...config,
pool: { min: 2, max: 10 },
timeout: 30000
};
switch (type) {
case 'mysql':
return new MySQLConnection(enrichedConfig);
case 'postgresql':
return new PostgreSQLConnection(enrichedConfig);
default:
throw new Error(`Unsupported database: ${type}`);
}
}
}
// 클라이언트 코드는 구체적인 클래스를 몰라도 됨
const dbType = process.env.DB_TYPE || 'mysql';
const db = DatabaseConnectionFactory.create(dbType, { host: 'localhost' });
db.connect();
// 3. 정적 팩토리 메서드: 생성자의 의미를 명확히
class User {
private constructor(
public id: string,
public email: string,
public createdAt: Date
) {}
static create(email: string): User {
return new User(crypto.randomUUID(), email, new Date());
}
static fromDatabase(data: any): User {
return new User(data.id, data.email, new Date(data.created_at));
}
}
const newUser = User.create('user@example.com');
const existingUser = User.fromDatabase({
id: '123',
email: 'existing@example.com',
created_at: '2024-01-01'
});
설명
이것이 하는 일: 이 코드는 세 가지 시나리오를 통해 생성자와 팩토리의 적절한 사용 시점을 비교합니다. 첫 번째로, Point 클래스는 간단한 Value Object로 생성자만으로 충분합니다. 이렇게 하는 이유는 생성 로직이 단순하고, 항상 같은 타입을 반환하며, 추가적인 초기화가 필요 없기 때문입니다. new Point(0, 0)는 직관적이고 명확하며, 팩토리를 추가하면 오히려 불필요한 복잡성만 증가합니다. 그 다음으로, DatabaseConnectionFactory는 복잡한 생성 로직을 캡슐화합니다. 환경 변수를 읽고, 기본 설정을 추가하며, 타입에 따라 다른 클래스를 반환합니다. 내부에서는 연결 풀 설정, 타임아웃 구성, 재시도 로직 등 복잡한 초기화가 일어납니다. 클라이언트 코드는 이런 복잡성을 몰라도 되며, 단순히 원하는 데이터베이스 타입만 지정하면 됩니다. 세 번째 단계로, User 클래스는 정적 팩토리 메서드를 사용합니다. create와 fromDatabase는 같은 클래스의 인스턴스를 생성하지만, 각 메서드 이름이 생성 의도를 명확히 나타냅니다. create는 새로운 사용자를 만들 때 자동으로 ID와 타임스탬프를 생성하고, fromDatabase는 기존 데이터를 역직렬화합니다. 생성자를 private으로 만들어 클라이언트가 잘못된 방식으로 객체를 생성하는 것을 방지합니다. 마지막으로, 이 세 가지 접근법을 비교하면 각각의 적절한 사용 시점을 알 수 있습니다. 최종적으로 여러분의 코드에서 객체 생성 방법을 선택할 때 이 기준들을 적용할 수 있습니다. 여러분이 이 기준을 사용하면 코드 리뷰에서 "왜 이렇게 했나요?"라는 질문에 명확하게 답할 수 있고, 일관된 코드베이스를 유지할 수 있습니다. 또한 간단한 경우에는 생성자로 빠르게 개발하고, 복잡해지면 팩토리로 리팩토링하는 진화적 설계가 가능하며, 팀원들과 객체 생성 패턴에 대한 공통된 이해를 가질 수 있습니다. Summary: 핵심 정리: 간단한 객체는 생성자, 복잡한 생성 로직이나 다형성이 필요한 경우는 팩토리를 사용하세요. 정적 팩토리 메서드는 생성자의 의미를 명확히 하는 좋은 절충안입니다. 항상 가장 간단한 방법부터 시작하고 필요할 때 복잡성을 추가하세요. Tips: 💡 생성자가 3개 이상의 매개변수를 받거나 복잡한 검증 로직이 필요하면 Builder Pattern 사용을 고려하세요 💡 TypeScript에서는 생성자 오버로딩 대신 정적 팩토리 메서드를 여러 개 만들면 더 명확한 API를 제공할 수 있습니다 💡 팩토리 메서드 이름을 from*, create*, of* 같은 규칙으로 통일하면 코드 가독성이 높아집니다 💡 의존성 주입이 필요한 경우 생성자 대신 팩토리를 인터페이스로 정의하여 Mock 주입을 쉽게 할 수 있습니다 💡 성능이 중요한 경우 팩토리의 오버헤드를 측정하고, 필요하다면 Object Pool 패턴과 결합하세요
7. 타입_안전성_확보
개요
간단히 말해서, 타입 안전 팩토리는 TypeScript의 제네릭과 타입 추론을 활용하여 팩토리가 반환하는 객체의 정확한 타입을 컴파일 타임에 보장하는 기법입니다. 실무에서 이 기법이 필요한 이유는 대규모 팀 프로젝트에서 타입 오류로 인한 런타임 버그를 사전에 방지하기 위해서입니다. 예를 들어, API 클라이언트 팩토리가 여러 엔드포인트 클라이언트를 생성하는 경우, 각 클라이언트의 메서드와 반환 타입이 정확히 추론되어야 IDE 자동완성과 타입 체크가 제대로 작동합니다. 기존 문자열 기반 팩토리에서는 반환 타입이 불명확하거나 Union 타입으로 넓게 지정되어 타입 가드를 수동으로 작성해야 했다면, 제네릭을 활용하면 팩토리 호출 시점에 정확한 타입이 자동으로 추론됩니다. 이 기법의 핵심 특징은 첫째, 제네릭을 통한 타입 매개변수화로 재사용 가능한 타입 안전 팩토리를 만들 수 있습니다. 둘째, 조건부 타입(Conditional Types)으로 입력에 따라 다른 반환 타입을 추론할 수 있습니다. 셋째, const assertion과 타입 리터럴을 결합하여 문자열 기반 API도 타입 안전하게 만들 수 있습니다. 이러한 특징들이 대규모 TypeScript 프로젝트의 타입 안정성을 크게 향상시킵니다.
코드 예제
// 1. 제네릭을 활용한 타입 안전 팩토리
interface Product {
id: string;
name: string;
}
interface User {
id: string;
email: string;
}
class Repository<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: string): T | undefined {
return this.items.find((item: any) => item.id === id);
}
}
class RepositoryFactory {
static create<T>(type: new () => T): Repository<T> {
return new Repository<T>();
}
}
// 타입이 정확히 추론됨
const productRepo = RepositoryFactory.create<Product>(Product as any);
const userRepo = RepositoryFactory.create<User>(User as any);
// productRepo.add({ id: '1', name: 'Product 1' }); // OK
// productRepo.add({ id: '1', email: 'test@test.com' }); // 컴파일 에러!
// 2. 조건부 타입을 활용한 고급 팩토리
type ServiceType = 'payment' | 'notification' | 'logging';
interface PaymentService { processPayment(amount: number): void; }
interface NotificationService { sendEmail(to: string): void; }
interface LoggingService { log(message: string): void; }
type ServiceMap = {
payment: PaymentService;
notification: NotificationService;
logging: LoggingService;
};
class ServiceFactory {
private static services = new Map<string, any>();
static create<T extends ServiceType>(type: T): ServiceMap[T] {
if (!this.services.has(type)) {
let service: any;
switch (type) {
case 'payment':
service = { processPayment: (amount: number) => console.log(`Payment: ${amount}`) };
break;
case 'notification':
service = { sendEmail: (to: string) => console.log(`Email to: ${to}`) };
break;
case 'logging':
service = { log: (message: string) => console.log(message) };
break;
}
this.services.set(type, service);
}
return this.services.get(type);
}
}
// 타입이 자동으로 추론됨
const paymentService = ServiceFactory.create('payment'); // PaymentService
paymentService.processPayment(100); // OK
// paymentService.sendEmail('test@test.com'); // 컴파일 에러!
const notificationService = ServiceFactory.create('notification'); // NotificationService
notificationService.sendEmail('user@example.com'); // OK
설명
이것이 하는 일: 이 코드는 TypeScript의 제네릭과 조건부 타입을 활용하여 팩토리가 반환하는 객체의 정확한 타입을 컴파일 타임에 추론하고 보장합니다. 첫 번째로, RepositoryFactory는 제네릭 타입 매개변수 T를 사용하여 어떤 타입의 Repository든 생성할 수 있으면서도 타입 안전성을 유지합니다. 이렇게 하는 이유는 팩토리를 재사용 가능하게 만들면서도, 생성된 Repository가 다루는 데이터 타입을 정확히 추론하기 위해서입니다. create<Product>로 호출하면 Repository<Product>가 반환되므로, add 메서드에 User 객체를 전달하면 컴파일 에러가 발생합니다. 그 다음으로, ServiceFactory는 더 고급 기법인 Mapped Type과 조건부 타입을 활용합니다. ServiceMap 타입은 서비스 이름 문자열을 실제 서비스 인터페이스로 매핑하고, create 메서드의 반환 타입을 ServiceMap[T]로 지정하여 전달된 타입 리터럴에 따라 정확한 서비스 타입이 추론됩니다. 내부적으로는 switch문을 사용하지만, 타입 시스템은 이를 이해하고 올바른 타입을 반환합니다. 세 번째 단계로, 클라이언트 코드에서 ServiceFactory.create('payment')를 호출하면, TypeScript는 'payment'라는 문자열 리터럴 타입을 인식하고 ServiceMap['payment'], 즉 PaymentService를 반환 타입으로 추론합니다. 이렇게 하면 IDE가 자동완성을 제공하고, 잘못된 메서드 호출을 컴파일 타임에 잡아낼 수 있습니다. 마지막으로, 이 패턴은 싱글톤 캐싱과 결합되어 성능도 최적화합니다. 최종적으로 런타임 유연성과 컴파일 타임 안전성을 모두 확보한 팩토리를 얻게 됩니다. 여러분이 이 기법을 사용하면 리팩토링 시 타입 오류를 즉시 발견하여 런타임 버그를 예방할 수 있고, IDE의 자동완성과 타입 힌트가 정확히 작동하여 개발 생산성이 향상되며, 코드 리뷰 시 타입 관련 실수를 쉽게 발견할 수 있습니다. 또한 새로운 팀원이 팩토리 API를 사용할 때 타입 시스템이 가이드 역할을 하여 학습 곡선이 낮아집니다. Summary: 핵심 정리: 제네릭과 조건부 타입을 활용하면 팩토리의 유연성을 유지하면서도 타입 안전성을 확보할 수 있습니다. 대규모 TypeScript 프로젝트에서는 필수적으로 사용하세요. 타입이 너무 복잡해지면 유틸리티 타입으로 분리하여 가독성을 유지하세요. Tips: 💡 const assertion(as const)을 사용하여 객체 리터럴의 타입을 좁히면 더 정확한 타입 추론이 가능합니다 💡 타입 가드 함수를 팩토리와 함께 제공하면 런타임 타입 체크도 안전하게 수행할 수 있습니다 💡 Branded Types를 사용하면 같은 기본 타입(string, number)이라도 의미론적으로 다른 타입을 구분할 수 있습니다 💡 infer 키워드를 활용한 조건부 타입으로 복잡한 타입 추출 로직을 구현할 수 있습니다 💡 타입 추론이 실패하는 경우 명시적 타입 매개변수를 전달하도록 강제하면 실수를 방지할 수 있습니다
8. 의존성_주입과_결합
개요
간단히 말해서, 팩토리와 DI를 결합하면 팩토리 자체를 주입 가능한 의존성으로 만들어, 프로덕션 코드와 테스트 코드에서 다른 팩토리 구현을 사용할 수 있게 합니다. 실무에서 이 접근법이 필요한 이유는 진정한 단위 테스트와 통합 테스트 분리를 위해서입니다. 예를 들어, 결제 서비스를 테스트할 때 실제 결제 게이트웨이를 호출하지 않고 Mock 객체를 사용하려면, 결제 객체를 생성하는 팩토리도 주입받아야 합니다. 기존에는 팩토리의 정적 메서드를 직접 호출하여 테스트 격리가 어려웠다면, DI와 결합하면 팩토리를 인터페이스로 정의하고 런타임에 적절한 구현을 주입할 수 있습니다. 이 접근법의 핵심 특징은 첫째, 팩토리를 인터페이스로 추상화하여 구현을 교체 가능하게 만듭니다. 둘째, DI 컨테이너가 팩토리의 라이프사이클(싱글톤, 스코프드, 트랜지언트)을 관리합니다. 셋째, 테스트에서 Mock 팩토리를 쉽게 주입하여 외부 의존성을 격리할 수 있습니다. 이러한 특징들이 테스트 주도 개발(TDD)과 지속적 통합(CI)을 가능하게 합니다.
코드 예제
// 1. 팩토리 인터페이스 정의
interface IEmailServiceFactory {
create(): IEmailService;
}
interface IEmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
// 2. 프로덕션 구현
class SendGridEmailService implements IEmailService {
async send(to: string, subject: string, body: string): Promise<void> {
console.log(`Sending via SendGrid to ${to}: ${subject}`);
// 실제 SendGrid API 호출
}
}
class EmailServiceFactory implements IEmailServiceFactory {
create(): IEmailService {
return new SendGridEmailService();
}
}
// 3. 테스트용 Mock 구현
class MockEmailService implements IEmailService {
public sentEmails: Array<{ to: string; subject: string; body: string }> = [];
async send(to: string, subject: string, body: string): Promise<void> {
this.sentEmails.push({ to, subject, body });
console.log(`Mock: Email recorded for ${to}`);
}
}
class MockEmailServiceFactory implements IEmailServiceFactory {
private mockService = new MockEmailService();
create(): IEmailService {
return this.mockService;
}
getSentEmails() {
return this.mockService.sentEmails;
}
}
// 4. 의존성 주입 컨테이너 (간단한 구현)
class DIContainer {
private services = new Map<string, any>();
register<T>(key: string, factory: () => T): void {
this.services.set(key, factory);
}
resolve<T>(key: string): T {
const factory = this.services.get(key);
if (!factory) {
throw new Error(`Service not found: ${key}`);
}
return factory();
}
}
// 5. 사용 예제
class OrderService {
constructor(private emailFactory: IEmailServiceFactory) {}
async completeOrder(orderId: string, customerEmail: string): Promise<void> {
console.log(`Processing order ${orderId}`);
const emailService = this.emailFactory.create();
await emailService.send(
customerEmail,
'Order Confirmation',
`Your order ${orderId} has been confirmed`
);
}
}
// 프로덕션 설정
const productionContainer = new DIContainer();
productionContainer.register<IEmailServiceFactory>(
'EmailServiceFactory',
() => new EmailServiceFactory()
);
const orderService = new OrderService(
productionContainer.resolve<IEmailServiceFactory>('EmailServiceFactory')
);
orderService.completeOrder('12345', 'customer@example.com');
// 테스트 설정
const testContainer = new DIContainer();
const mockFactory = new MockEmailServiceFactory();
testContainer.register<IEmailServiceFactory>(
'EmailServiceFactory',
() => mockFactory
);
const testOrderService = new OrderService(
testContainer.resolve<IEmailServiceFactory>('EmailServiceFactory')
);
testOrderService.completeOrder('TEST-001', 'test@example.com');
console.log('Sent emails:', mockFactory.getSentEmails());
설명
이것이 하는 일: 이 코드는 팩토리를 인터페이스로 추상화하고 DI 컨테이너를 통해 주입함으로써, 프로덕션에서는 실제 이메일 서비스를, 테스트에서는 Mock 서비스를 사용할 수 있게 합니다. 첫 번째로, IEmailServiceFactory와 IEmailService 인터페이스를 정의하여 계약을 명시합니다. 이렇게 하는 이유는 구체적인 구현(SendGrid, AWS SES)에 의존하지 않고, 인터페이스에만 의존하도록 하여 구현을 자유롭게 교체할 수 있게 하기 위해서입니다. 그 다음으로, EmailServiceFactory는 프로덕션 환경에서 실제 SendGridEmailService를 생성하고, MockEmailServiceFactory는 테스트 환경에서 MockEmailService를 생성합니다. MockEmailService는 실제 API를 호출하는 대신 배열에 이메일 정보를 저장하여, 테스트에서 검증할 수 있게 합니다. 이렇게 하면 단위 테스트가 외부 서비스에 의존하지 않아 빠르고 안정적으로 실행됩니다. 세 번째 단계로, DIContainer가 팩토리 인스턴스를 관리합니다. register 메서드로 팩토리 생성 함수를 등록하고, resolve 메서드로 필요한 시점에 팩토리를 가져옵니다. 실제 프로젝트에서는 InversifyJS, TSyringe 같은 성숙한 DI 라이브러리를 사용하지만, 여기서는 개념을 명확히 하기 위해 간단히 구현했습니다. 마지막으로, OrderService는 생성자에서 IEmailServiceFactory를 주입받습니다. 최종적으로 프로덕션 컨테이너는 EmailServiceFactory를, 테스트 컨테이너는 MockEmailServiceFactory를 주입하여, 같은 OrderService 코드가 다른 환경에서 다르게 동작합니다. 여러분이 이 패턴을 사용하면 단위 테스트에서 외부 API 호출 없이 빠르게 테스트할 수 있고, Mock 객체를 통해 이메일 발송 여부, 내용, 횟수 등을 정확히 검증할 수 있습니다. 또한 환경(개발, 스테이징, 프로덕션)별로 다른 팩토리를 주입하여 유연하게 대응할 수 있으며, 새로운 이메일 서비스 제공자로 전환할 때 팩토리만 교체하면 되므로 변경 영향 범위를 최소화할 수 있습니다. Summary: 핵심 정리: 팩토리를 인터페이스로 추상화하고 DI 컨테이너에 등록하면 테스트 용이성과 유연성이 극대화됩니다. 단위 테스트가 중요한 프로젝트에서는 필수적으로 사용하세요. DI 컨테이너 설정이 복잡해지면 환경별 설정 파일로 분리하세요. Tips: 💡 팩토리의 라이프사이클을 싱글톤으로 설정하되, 생성되는 객체는 트랜지언트로 하여 요청마다 새 인스턴스를 받을 수 있습니다 💡 테스트에서 Spy 패턴을 사용하여 실제 서비스를 호출하되 호출 횟수와 매개변수를 기록하면 통합 테스트에 유용합니다 💡 환경 변수를 기반으로 DI 컨테이너가 자동으로 팩토리를 선택하도록 하면 배포 시 코드 변경 없이 환경을 전환할 수 있습니다 💡 Decorator Pattern과 결합하여 팩토리가 생성한 객체를 자동으로 로깅, 캐싱, 재시도 로직으로 감쌀 수 있습니다 💡 TypeScript의 Reflect Metadata와 데코레이터를 활용하면 @Injectable() 같은 어노테이션으로 자동 DI 등록이 가능합니다
마치며
이번 글에서는 Factory Pattern 핵심 개념 완벽 정리에 대해 알아보았습니다. 총 16가지 개념을 다루었으며, 각각의 사용법과 예제를 살펴보았습니다.
관련 태그
#TypeScript #FactoryPattern #DesignPatterns #ObjectCreation #SOLID