디자인 패턴이 필요없다고 생각한 적 있다. 'JavaScript인데 뭐 그냥 짜면 되지.'라고. 그 코드가 1만 줄을 넘어가면서 후회가 시작됐다. if-else가 200줄짜리 함수, 콜백 지옥, 어디서 뭐가 호출되는지 아무도 모르는 이벤트 시스템. TypeScript로 전환하면서 디자인 패턴을 적용했고, 그제서야 '아, 이래서 패턴이 필요하구나'를 깨달았다.
TypeScript에서 디자인 패턴이 중요한 이유
JavaScript에서는 동적 타이핑으로 인해 디자인 패턴의 구조적 제약을 컴파일 타임에 보장하기 어렵습니다. TypeScript는 인터페이스, 제네릭, 유니온 타입, 조건부 타입 등의 기능으로 패턴의 계약(contract)을 타입 수준에서 강제할 수 있습니다.
- 컴파일 타임 안전성: 인터페이스를 통해 패턴의 구조를 강제하고, 구현 누락을 컴파일 시점에 감지합니다.
- 자기 문서화: 타입 정의 자체가 패턴의 의도를 명확하게 전달합니다.
- 리팩토링 안전성: 패턴 구조를 변경할 때 타입 체커가 영향 범위를 정확히 알려줍니다.
- IDE 지원: 자동완성, 타입 추론, 빠른 탐색 등으로 패턴 활용 생산성이 높아집니다.
Strategy 패턴: 결제 시스템
Strategy 패턴은 알고리즘을 캡슐화하여 런타임에 교체할 수 있게 하는 패턴입니다. 결제 시스템처럼 여러 처리 방식을 지원해야 하는 경우에 적합합니다.
// 결제 전략 인터페이스
interface PaymentStrategy {
readonly name: string;
validate(amount: number): boolean;
pay(amount: number, metadata: PaymentMetadata): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<RefundResult>;
}
interface PaymentMetadata {
orderId: string;
customerId: string;
description?: string;
}
interface PaymentResult {
success: boolean;
transactionId: string;
paidAt: Date;
}
interface RefundResult {
success: boolean;
refundId: string;
refundedAt: Date;
}
// 신용카드 결제 전략
class CreditCardStrategy implements PaymentStrategy {
readonly name = 'credit-card';
validate(amount: number): boolean {
return amount > 0 && amount <= 10_000_000;
}
async pay(amount: number, metadata: PaymentMetadata): Promise<PaymentResult> {
// PG사 API 호출
const response = await pgClient.approve({
amount,
orderId: metadata.orderId,
method: 'card',
});
return {
success: response.status === 'approved',
transactionId: response.txnId,
paidAt: new Date(),
};
}
async refund(transactionId: string, amount: number): Promise<RefundResult> {
const response = await pgClient.cancel({ transactionId, amount });
return {
success: response.status === 'refunded',
refundId: response.refundId,
refundedAt: new Date(),
};
}
}
// 가상계좌 결제 전략
class VirtualAccountStrategy implements PaymentStrategy {
readonly name = 'virtual-account';
validate(amount: number): boolean {
return amount >= 1000; // 최소 1,000원
}
async pay(amount: number, metadata: PaymentMetadata): Promise<PaymentResult> {
const response = await pgClient.createVirtualAccount({
amount,
orderId: metadata.orderId,
bankCode: '004', // KB국민은행
expireAt: addHours(new Date(), 24),
});
return {
success: true,
transactionId: response.txnId,
paidAt: new Date(),
};
}
async refund(transactionId: string, amount: number): Promise<RefundResult> {
// 가상계좌는 입금 전 취소만 가능
const response = await pgClient.cancelVirtualAccount({ transactionId });
return {
success: response.status === 'cancelled',
refundId: response.refundId,
refundedAt: new Date(),
};
}
}
// 결제 컨텍스트
class PaymentService {
private strategies: Map<string, PaymentStrategy> = new Map();
registerStrategy(strategy: PaymentStrategy): void {
this.strategies.set(strategy.name, strategy);
}
async processPayment(
method: string,
amount: number,
metadata: PaymentMetadata
): Promise<PaymentResult> {
const strategy = this.strategies.get(method);
if (!strategy) {
throw new Error(`지원하지 않는 결제 방식: ${method}`);
}
if (!strategy.validate(amount)) {
throw new Error(`유효하지 않은 결제 금액: ${amount}`);
}
return strategy.pay(amount, metadata);
}
}
// 사용 예시
const paymentService = new PaymentService();
paymentService.registerStrategy(new CreditCardStrategy());
paymentService.registerStrategy(new VirtualAccountStrategy());
Observer 패턴: 이벤트 시스템
Observer 패턴은 객체의 상태 변화를 다른 객체들에게 자동으로 알리는 패턴입니다. TypeScript의 제네릭을 활용하면 타입 안전한 이벤트 시스템을 만들 수 있습니다.
// 타입 안전한 이벤트 맵 정의
interface EventMap {
'order:created': { orderId: string; amount: number; customerId: string };
'order:paid': { orderId: string; transactionId: string; paidAt: Date };
'order:cancelled': { orderId: string; reason: string };
'user:registered': { userId: string; email: string };
'user:login': { userId: string; ip: string; userAgent: string };
}
type EventHandler<T> = (payload: T) => void | Promise<void>;
class TypedEventEmitter<TEvents extends Record<string, any>> {
private handlers = new Map<keyof TEvents, Set<EventHandler<any>>>();
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
// 구독 해제 함수 반환
return () => {
this.handlers.get(event)?.delete(handler);
};
}
async emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): Promise<void> {
const eventHandlers = this.handlers.get(event);
if (!eventHandlers) return;
const promises = [...eventHandlers].map((handler) =>
Promise.resolve(handler(payload)).catch((error) => {
console.error(`이벤트 핸들러 오류 [${String(event)}]:`, error);
})
);
await Promise.all(promises);
}
once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): () => void {
const unsubscribe = this.on(event, (payload) => {
unsubscribe();
return handler(payload);
});
return unsubscribe;
}
}
// 사용 예시
const eventBus = new TypedEventEmitter<EventMap>();
// 타입 안전한 이벤트 구독
eventBus.on('order:created', async (payload) => {
// payload의 타입이 자동 추론됨
console.log(`새 주문: ${payload.orderId}, 금액: ${payload.amount}`);
await notificationService.sendOrderConfirmation(payload.customerId);
});
eventBus.on('order:paid', async (payload) => {
await inventoryService.reserveStock(payload.orderId);
await emailService.sendReceipt(payload.transactionId);
});
// 타입 안전한 이벤트 발행
await eventBus.emit('order:created', {
orderId: 'ORD-001',
amount: 50000,
customerId: 'USR-123',
});
Repository 패턴: 데이터 액세스 추상화
Repository 패턴은 데이터 저장소에 대한 접근을 추상화하여, 비즈니스 로직이 특정 데이터베이스 기술에 의존하지 않게 합니다.
// 기본 엔티티 타입
interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
// 제네릭 Repository 인터페이스
interface Repository<T extends BaseEntity> {
findById(id: string): Promise<T | null>;
findAll(options?: FindAllOptions): Promise<PaginatedResult<T>>;
create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>;
update(id: string, data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>): Promise<T>;
delete(id: string): Promise<void>;
}
interface FindAllOptions {
page?: number;
limit?: number;
sort?: { field: string; order: 'asc' | 'desc' };
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
totalPages: number;
}
// User 엔티티
interface User extends BaseEntity {
email: string;
name: string;
role: 'admin' | 'user' | 'guest';
}
// User 전용 Repository (확장 메서드 포함)
interface UserRepository extends Repository<User> {
findByEmail(email: string): Promise<User | null>;
findByRole(role: User['role']): Promise<User[]>;
}
// Prisma 구현체
class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
async findAll(options: FindAllOptions = {}): Promise<PaginatedResult<User>> {
const { page = 1, limit = 20, sort } = options;
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.user.findMany({
skip,
take: limit,
orderBy: sort ? { [sort.field]: sort.order } : { createdAt: 'desc' },
}),
this.prisma.user.count(),
]);
return {
data,
total,
page,
totalPages: Math.ceil(total / limit),
};
}
async create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
return this.prisma.user.create({ data });
}
async update(id: string, data: Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>): Promise<User> {
return this.prisma.user.update({ where: { id }, data });
}
async delete(id: string): Promise<void> {
await this.prisma.user.delete({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
async findByRole(role: User['role']): Promise<User[]> {
return this.prisma.user.findMany({ where: { role } });
}
}
Builder 패턴: 복잡한 객체 생성
Builder 패턴은 복잡한 객체를 단계적으로 구성할 수 있게 하며, TypeScript의 타입 시스템으로 필수 필드 누락을 컴파일 타임에 감지할 수 있습니다.
// HTTP 요청 빌더
interface RequestConfig {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers: Record<string, string>;
body?: unknown;
timeout: number;
retryCount: number;
retryDelay: number;
}
class RequestBuilder {
private config: Partial<RequestConfig> = {
method: 'GET',
headers: {},
timeout: 30000,
retryCount: 0,
retryDelay: 1000,
};
url(url: string): this {
this.config.url = url;
return this;
}
method(method: RequestConfig['method']): this {
this.config.method = method;
return this;
}
header(key: string, value: string): this {
this.config.headers = { ...this.config.headers, [key]: value };
return this;
}
bearerToken(token: string): this {
return this.header('Authorization', `Bearer ${token}`);
}
contentType(type: string): this {
return this.header('Content-Type', type);
}
json(data: unknown): this {
this.config.body = data;
this.config.method = this.config.method === 'GET' ? 'POST' : this.config.method;
return this.contentType('application/json');
}
timeout(ms: number): this {
this.config.timeout = ms;
return this;
}
retry(count: number, delayMs: number = 1000): this {
this.config.retryCount = count;
this.config.retryDelay = delayMs;
return this;
}
build(): RequestConfig {
if (!this.config.url) {
throw new Error('URL은 필수입니다.');
}
return this.config as RequestConfig;
}
}
// 사용 예시
const request = new RequestBuilder()
.url('https://api.example.com/orders')
.method('POST')
.bearerToken('eyJhbGciOiJIUzI1NiIs...')
.json({ customerId: 'USR-001', items: [{ productId: 'P-100', qty: 2 }] })
.timeout(5000)
.retry(3, 2000)
.build();
Decorator 패턴: NestJS에서의 활용
NestJS는 TypeScript의 데코레이터를 적극적으로 활용하는 프레임워크입니다. 커스텀 데코레이터를 만들어 횡단 관심사(Cross-Cutting Concerns)를 깔끔하게 처리할 수 있습니다.
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
import { CallHandler, ExecutionContext as NestContext, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
// 1. 현재 사용자 추출 데코레이터
export const CurrentUser = createParamDecorator(
(data: keyof UserPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as UserPayload;
return data ? user?.[data] : user;
},
);
// 사용: @CurrentUser() user: UserPayload
// 사용: @CurrentUser('id') userId: string
// 2. 역할 기반 접근 제어 데코레이터
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// Guard에서 활용
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
// 3. 실행 시간 로깅 데코레이터
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: NestContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const startTime = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - startTime;
this.logger.log(`${method} ${url} - ${duration}ms`);
}),
);
}
}
// 컨트롤러에서 활용
@Controller('orders')
@UseGuards(RolesGuard)
export class OrderController {
@Post()
@Roles('admin', 'user')
async createOrder(
@CurrentUser() user: UserPayload,
@Body() dto: CreateOrderDto,
) {
return this.orderService.create(user.id, dto);
}
}
Factory 패턴: 객체 생성 캡슐화
Factory 패턴은 객체 생성 로직을 캡슐화하여 클라이언트 코드가 구체적인 클래스를 알 필요 없게 합니다. TypeScript의 판별 유니온(Discriminated Union)과 결합하면 매우 강력합니다.
// 알림 타입별 판별 유니온
type NotificationConfig =
| { type: 'email'; to: string; subject: string; body: string }
| { type: 'sms'; phoneNumber: string; message: string }
| { type: 'push'; deviceToken: string; title: string; body: string }
| { type: 'slack'; channel: string; text: string; blocks?: SlackBlock[] };
interface NotificationSender {
send(config: any): Promise<{ success: boolean; messageId: string }>;
}
class EmailSender implements NotificationSender {
async send(config: Extract<NotificationConfig, { type: 'email' }>) {
const result = await mailer.send({
to: config.to,
subject: config.subject,
html: config.body,
});
return { success: true, messageId: result.id };
}
}
class SmsSender implements NotificationSender {
async send(config: Extract<NotificationConfig, { type: 'sms' }>) {
const result = await smsClient.send({
to: config.phoneNumber,
content: config.message,
});
return { success: true, messageId: result.id };
}
}
class PushSender implements NotificationSender {
async send(config: Extract<NotificationConfig, { type: 'push' }>) {
const result = await fcm.send({
token: config.deviceToken,
notification: { title: config.title, body: config.body },
});
return { success: true, messageId: result.id };
}
}
// Factory
class NotificationFactory {
private static senders: Record<string, NotificationSender> = {
email: new EmailSender(),
sms: new SmsSender(),
push: new PushSender(),
};
static getSender(type: NotificationConfig['type']): NotificationSender {
const sender = this.senders[type];
if (!sender) throw new Error(`지원하지 않는 알림 타입: ${type}`);
return sender;
}
static async send(config: NotificationConfig) {
const sender = this.getSender(config.type);
return sender.send(config);
}
}
// 사용 예시 - 타입에 따라 자동으로 올바른 Sender 선택
await NotificationFactory.send({
type: 'email',
to: 'user@example.com',
subject: '주문 확인',
body: '<h1>주문이 완료되었습니다.</h1>',
});
타입 시스템을 활용한 패턴 강화
TypeScript의 고급 타입 기능을 활용하면 디자인 패턴을 더욱 안전하게 만들 수 있습니다.
조건부 타입으로 상태 머신 구현
// 주문 상태에 따라 허용되는 액션을 타입으로 제한
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
type AllowedTransitions = {
pending: 'paid' | 'cancelled';
paid: 'shipped' | 'cancelled';
shipped: 'delivered';
delivered: never;
cancelled: never;
};
class StateMachine<S extends string, T extends Record<S, string>> {
constructor(private state: S) {}
getState(): S {
return this.state;
}
transition<Current extends S>(
from: Current,
to: T[Current] extends string ? T[Current] : never,
): void {
if (this.state !== from) {
throw new Error(`현재 상태(${this.state})에서 전환할 수 없습니다.`);
}
this.state = to as unknown as S;
}
}
// 사용 예시
const orderState = new StateMachine<OrderStatus, AllowedTransitions>('pending');
orderState.transition('pending', 'paid'); // OK
orderState.transition('paid', 'shipped'); // OK
// orderState.transition('delivered', 'pending'); // 컴파일 에러!
Branded Types로 안전한 값 구분
// 같은 string이지만 서로 다른 의미의 ID를 구분
type Brand<T, B extends string> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;
// 생성 함수
const createUserId = (id: string): UserId => id as UserId;
const createOrderId = (id: string): OrderId => id as OrderId;
// 함수에서 타입 안전하게 사용
function getOrder(orderId: OrderId): Promise<Order> {
return orderRepository.findById(orderId);
}
const userId = createUserId('usr-001');
const orderId = createOrderId('ord-001');
getOrder(orderId); // OK
// getOrder(userId); // 컴파일 에러! UserId를 OrderId로 사용 불가
안티패턴과 주의사항
디자인 패턴을 잘못 적용하면 오히려 코드 품질이 저하됩니다. 자주 발생하는 안티패턴을 살펴보겠습니다.
1. 과도한 추상화 (Over-Engineering)
// 나쁜 예: 구현체가 하나뿐인데 인터페이스 + 팩토리 + DI
interface IUserNameFormatter {
format(user: User): string;
}
class UserNameFormatter implements IUserNameFormatter { ... }
class UserNameFormatterFactory { ... }
// 좋은 예: 단순한 함수로 충분
function formatUserName(user: User): string {
return `${user.firstName} ${user.lastName}`;
}
2. God Object 패턴
// 나쁜 예: 너무 많은 책임을 가진 서비스
class OrderService {
createOrder() { ... }
processPayment() { ... }
sendNotification() { ... }
updateInventory() { ... }
generateInvoice() { ... }
calculateShipping() { ... }
// 수백 줄의 메서드들...
}
// 좋은 예: 단일 책임 원칙 적용
class OrderService {
constructor(
private paymentService: PaymentService,
private notificationService: NotificationService,
private inventoryService: InventoryService,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.saveOrder(dto);
await this.paymentService.process(order);
await this.inventoryService.reserve(order.items);
await this.notificationService.sendOrderConfirmation(order);
return order;
}
}
3. 타입 단언(as) 남용
// 나쁜 예: 타입 단언으로 타입 안전성 무력화
const data = fetchData() as unknown as User;
// 좋은 예: 런타임 검증과 타입 가드 사용
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'email' in data &&
typeof (data as User).email === 'string'
);
}
const data = fetchData();
if (isUser(data)) {
// data는 User 타입으로 안전하게 사용 가능
console.log(data.email);
}
마무리: 실전 적용 가이드
디자인 패턴은 도구일 뿐, 목적이 아닙니다. 각 패턴이 해결하는 문제를 이해하고, 실제로 그 문제가 존재할 때 적용하는 것이 중요합니다.
- Strategy: 여러 알고리즘을 교체 가능하게 해야 할 때 (결제, 인증, 정렬 등)
- Observer: 이벤트 기반 느슨한 결합이 필요할 때 (알림, 로깅, 캐시 무효화)
- Repository: 데이터 접근 계층을 추상화해야 할 때 (테스트 용이성, DB 교체 가능성)
- Builder: 복잡한 객체를 단계적으로 생성해야 할 때 (설정, 쿼리, HTTP 요청)
- Decorator: 기존 객체에 동적으로 책임을 추가해야 할 때 (로깅, 캐싱, 인증)
- Factory: 객체 생성 로직을 캡슐화해야 할 때 (다형성, 조건부 생성)
TypeScript의 타입 시스템을 적극 활용하면, 패턴의 구조적 제약을 컴파일 타임에 보장받을 수 있어 런타임 오류를 크게 줄일 수 있습니다. 코드 리뷰에서 팀원들과 함께 패턴의 적용 여부를 논의하며 점진적으로 도입해 나가시기 바랍니다.
10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.
GitHub →
💬 댓글