SOLID 원칙과 디자인 패턴을 활용한 견고한 TypeScript 백엔드 구축
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
빠르게 발전하는 소프트웨어 개발 환경에서 견고하고 확장 가능하며 유지 관리 가능한 백엔드 애플리케이션을 구축하는 것은 매우 중요합니다. Node.js와 함께 JavaScript가 서버 측에서 계속해서 우위를 점하고 있는 가운데, TypeScript는 타입 안전성을 제공하고 개발자 경험을 향상시키는 필수적인 도구로 부상했습니다. 그러나 단순히 TypeScript를 사용하는 것만으로는 고품질 코드를 보장할 수 없습니다. 백엔드 솔루션을 진정으로 향상시키려면 확립된 아키텍처 지침과 반복되는 문제에 대한 검증된 솔루션을 채택해야 합니다. 여기서 SOLID 원칙과 일반적인 디자인 패턴이 중요해집니다. 이는 단순한 학문적 개념이 아니라 혼란스러운 코드베이스를 우아하고 복원력 있는 시스템으로 변환하는 실용적인 청사진입니다. 이 글에서는 TypeScript 백엔드 애플리케이션 내에서 SOLID 원칙과 핵심 디자인 패턴의 실제 구현을 살펴보고, 이를 통해 적응하고 확장하며 지속될 수 있는 소프트웨어를 구축하는 데 어떻게 기여하는지 설명합니다.
기본 이해
실제 예제로 바로 들어가기 전에 논의의 기초가 되는 핵심 개념을 파악하는 것이 중요합니다.
SOLID 원칙은 소프트웨어 디자인을 더 이해하기 쉽고 유연하며 유지 관리하기 쉽게 만들기 위한 다섯 가지 디자인 원칙입니다. Robert C. Martin (Uncle Bob)에 의해 보급되었습니다.
- 단일 책임 원칙 (SRP): 클래스는 변경될 이유가 하나만 있어야 합니다. 즉, 클래스는 하나의 작업만 수행해야 합니다.
- 개방/폐쇄 원칙 (OCP): 소프트웨어 엔티티 (클래스, 모듈, 함수 등)는 확장에 대해 개방되어야 하지만 수정에 대해서는 폐쇄되어야 합니다. 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있어야 합니다.
- 리스코프 치환 원칙 (LSP): 프로그램의 객체는 프로그램의 올바름을 변경하지 않고도 해당 서브타입의 인스턴스로 대체 가능해야 합니다. 간단히 말해, 하위 클래스는 상위 클래스를 대체할 수 있어야 합니다.
- 인터페이스 분리 원칙 (ISP): 클라이언트는 사용하지 않는 인터페이스에 의존하도록 강요되어서는 안 됩니다. 하나의 큰 인터페이스보다는 작고 목적에 특화된 많은 인터페이스가 더 좋습니다.
- 의존성 역전 원칙 (DIP): 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다. 추상화는 세부 사항에 의존해서는 안 됩니다. 세부 사항은 추상화에 의존해야 합니다.
디자인 패턴은 소프트웨어 설계 중에 발생하는 일반적인 문제에 대한 일반화되고 재사용 가능한 솔루션입니다. 직접적인 해결책이 아니라 특정 문제를 해결하기 위해 사용자 정의할 수 있는 템플릿 또는 청사진입니다. 백엔드 개발과 관련된 몇 가지 일반적인 패턴에 집중할 것입니다.
- 싱글턴 패턴: 클래스가 하나의 인스턴스만 가지도록 보장하고 이에 대한 전역 액세스 지점을 제공합니다.
- 팩토리 메서드 패턴: 객체를 생성하기 위한 인터페이스를 정의하지만, 서브클래스가 어떤 클래스를 인스턴스화할지 결정하도록 합니다. 서브클래스에 인스턴스화를 위임합니다.
- 전략 패턴: 알고리즘 패밀리를 정의하고, 각 알고리즘을 캡슐화하며, 상호 교환 가능하게 만듭니다. 전략은 알고리즘이 이를 사용하는 클라이언트와 독립적으로 변경될 수 있도록 합니다.
- 리포지토리 패턴: 도메인 객체에 액세스하기 위해 컬렉션과 유사한 인터페이스를 사용하여 도메인 및 데이터 매핑 계층 간의 중재자 역할을 합니다.
TypeScript 백엔드에서의 실제 적용
일반적인 전자상거래 주문 처리 시스템과 같은 일반적인 시나리오를 사용하여 이러한 원칙과 패턴이 TypeScript 백엔드 컨텍스트에서 어떻게 실행 가능한 코드로 전환되는지 살펴보겠습니다.
단일 책임 원칙 (SRP)
OrderService
클래스를 고려해 봅시다. SRP가 없다면 주문 생성, 결제 처리, 알림 전송, 로깅을 처리할 수 있습니다. 이렇게 하면 유지 관리 및 테스트가 어렵습니다.
문제 있는 디자인 (SRP 위반):
// services/order.service.ts class OrderService { createOrder(userId: string, itemIds: string[]): Order { // 1. 입력 유효성 검사 // 2. 데이터베이스에 주문 영속 // 3. 결제 처리 // 4. 주문 확인 이메일 발송 // 5. 주문 생성 이벤트 로깅 // ... 많은 책임 return new Order(); // 단순화됨 } }
SRP 준수 디자인:
OrderService
를 각각 단일 책임을 가진 여러 개의 별도 클래스로 분리합니다.
// interfaces/order.interface.ts interface OrderCreationRequest { userId: string; itemIds: string[]; } interface Order { id: string; userId: string; status: string; // ... } // repositories/order.repository.ts class OrderRepository { async create(orderData: OrderCreationRequest): Promise<Order> { console.log("Persisting order data to DB..."); // DB 상호 작용 시뮬레이션 return { id: 'order-123', userId: orderData.userId, status: 'pending' }; } // ... 기타 영속성 메서드 (찾기, 업데이트, 삭제) } // services/payment.service.ts class PaymentService { async processPayment(orderId: string, amount: number): Promise<boolean> { console.log(`Processing payment for order ${orderId}, amount: ${amount}`); // 결제 게이트웨이 상호 작용 시뮬레이션 return true; } } // services/notification.service.ts class NotificationService { async sendOrderConfirmationEmail(order: Order, userEmail: string): Promise<void> { console.log(`Sending confirmation email for order ${order.id} to ${userEmail}`); // 이메일 발송 시뮬레이션 } } // services/logger.service.ts class LoggerService { log(message: string, context?: object): void { console.log(`LOG: ${message}`, context); } } // services/order.service.ts (이제 오케스트레이터) class OrderCreationService { constructor( private orderRepository: OrderRepository, private paymentService: PaymentService, private notificationService: NotificationService, private loggerService: LoggerService ) {} async createOrder(request: OrderCreationRequest, userEmail: string): Promise<Order> { // 입력 유효성 검사는 다른 서비스이거나 미들웨어/데코레이터일 수 있습니다. const newOrder = await this.orderRepository.create(request); await this.paymentService.processPayment(newOrder.id, 100); // 금액 가정 await this.notificationService.sendOrderConfirmationEmail(newOrder, userEmail); this.loggerService.log('Order created successfully', { orderId: newOrder.id, userId: newOrder.userId }); return newOrder; } } // Express 경로 핸들러에서의 사용 // const orderRepo = new OrderRepository(); // const paymentSvc = new PaymentService(); // const notificationSvc = new NotificationService(); // const loggerSvc = new LoggerService(); // const orderCreator = new OrderCreationService(orderRepo, paymentSvc, notificationSvc, loggerSvc); // app.post('/orders', async (req, res) => { // const order = await orderCreator.createOrder(req.body, req.user.email); // res.status(201).json(order); // });
이 리팩토링을 통해 각 클래스를 더 쉽게 이해하고, 테스트하고, 유지 관리할 수 있게 됩니다. 결제 로직이 변경되면 PaymentService
만 수정하면 됩니다.
개방/폐쇄 원칙 (OCP) 및 전략 패턴
결제 처리에 여러 결제 게이트웨이 (Stripe, PayPal 등)를 지원해야 한다고 가정해 봅시다. 새 게이트웨이를 추가할 때마다 PaymentService
를 직접 수정하는 것은 OCP를 위반합니다. OCP와 함께 전략 패턴을 사용할 수 있습니다.
OCP 위반:
// services/payment.service.ts class PaymentService { async processPayment(orderId: string, amount: number, paymentMethod: 'stripe' | 'paypal'): Promise<boolean> { if (paymentMethod === 'stripe') { // Stripe 특정 로직 } else if (paymentMethod === 'paypal') { // PayPal 특정 로직 } else { throw new Error('Unsupported payment method'); } return true; } }
OCP 및 전략 준수 디자인:
// interfaces/payment-gateway.interface.ts interface PaymentGateway { process(amount: number, orderId: string): Promise<boolean>; } // strategies/stripe-gateway.ts class StripeGateway implements PaymentGateway { async process(amount: number, orderId: string): Promise<boolean> { console.log(`Processing ${amount} via Stripe for order ${orderId}`); // Stripe API 호출 return true; } } // strategies/paypal-gateway.ts class PayPalGateway implements PaymentGateway { async process(amount: number, orderId: string): Promise<boolean> { console.log(`Processing ${amount} via PayPal for order ${orderId}`); // PayPal API 호출 return true; } } // services/payment.processor.ts class PaymentProcessor { private gateway: PaymentGateway; setPaymentGateway(gateway: PaymentGateway): void { this.gateway = gateway; } async executePayment(amount: number, orderId: string): Promise<boolean> { if (!this.gateway) { throw new Error("Payment gateway not set."); } return this.gateway.process(amount, orderId); } } // 사용법 // const paymentProcessor = new PaymentProcessor(); // // Stripe 결제의 경우 // paymentProcessor.setPaymentGateway(new StripeGateway()); // await paymentProcessor.executePayment(50, 'order-456'); // // PayPal 결제의 경우 // paymentProcessor.setPaymentGateway(new PayPalGateway()); // await paymentProcessor.executePayment(75, 'order-789');
이제 새 결제 게이트웨이를 추가하려면 PaymentGateway
를 구현하는 새 클래스를 만들고 PaymentProcessor
에 주입하기만 하면 됩니다. PaymentProcessor
수정은 필요 없으며 OCP를 준수합니다.
리스코프 치환 원칙 (LSP)
다양한 배송 유형을 고려해 봅시다. StandardShipping
및 ExpressShipping
클래스가 있다면, ExpressShipping
은 기능 고장 없이 StandardShipping
이 예상되는 곳 어디든 사용될 수 있어야 합니다.
// interfaces/shipping.interface.ts interface ShippingService { calculateCost(weight: number, distance: number): number; deliver(orderId: string, address: string): Promise<boolean>; } // services/standard-shipping.ts class StandardShipping implements ShippingService { calculateCost(weight: number, distance: number): number { return weight * 0.5 + distance * 0.1; // 단순 계산 } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`Delivering order ${orderId} to ${address} via Standard Shipping`); // 표준 배송 시뮬레이션 return true; } } // services/express-shipping.ts class ExpressShipping implements ShippingService { calculateCost(weight: number, distance: number): number { return weight * 1.5 + distance * 0.3 + 10; // 더 높은 비용 } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`Delivering order ${orderId} to ${address} via Express Shipping`); // 특급 배송 시뮬레이션 return true; } // 컨텍스트를 망칠 수 있는 "불필요한" 메서드는 없음 } // 사용법 function shipOrder(shippingService: ShippingService, orderId: string, address: string, weight: number, distance: number) { const cost = shippingService.calculateCost(weight, distance); console.log(`Shipping cost: $${cost}`); shippingService.deliver(orderId, address); } // shipOrder(new StandardShipping(), 'order-1', '123 Main St', 5, 100); // shipOrder(new ExpressShipping(), 'order-2', '456 Oak Ave', 3, 50);
StandardShipping
과 ExpressShipping
모두 문제 없이 ShippingService
로 대체될 수 있어 LSP를 준수합니다.
인터페이스 분리 원칙 (ISP)
단일한 UserRepository
인터페이스 대신 여러 메서드를 사용하는 대신 분리할 수 있습니다.
ISP 위반:
// interfaces/user.repository.ts interface IUserRepository { create(user: User): Promise<User>; findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; update(id: string, user: Partial<User>): Promise<User | null>; delete(id: string): Promise<boolean>; // 프로필 관리, 인증 등을 위한 메서드 포함 updateProfile(userId: string, profileData: Partial<UserProfile>): Promise<UserProfile>; resetPassword(userId: string, newPasswordHash: string): Promise<boolean>; }
ISP 준수 디자인:
// interfaces/user.read.repository.ts interface IUserReadRepository { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; } // interfaces/user.write.repository.ts interface IUserWriteRepository { create(user: User): Promise<User>; update(id: string, user: Partial<User>): Promise<User | null>; delete(id: string): Promise<boolean>; } // interfaces/user.profile.repository.ts interface IUserProfileRepository { updateProfile(userId: string, profileData: Partial<UserProfile>): Promise<UserProfile>; } // 실제 구현 클래스는 여러 특정 인터페이스를 구현할 수 있습니다. class UserMongoRepository implements IUserReadRepository, IUserWriteRepository { async findById(id: string): Promise<User | null> { /* ... */ return null; } async findByEmail(email: string): Promise<User | null> { /* ... */ return null; } async create(user: User): Promise<User> { /* ... */ return user; } async update(id: string, user: Partial<User>): Promise<User | null> { /* ... */ return null; } async delete(id: string): Promise<boolean> { /* ... */ return false; } } // 사용자 데이터만 읽어야 하는 서비스는 `IUserReadRepository`에만 의존하여 // 종속성을 최소화합니다.
이렇게 하면 클라이언트 (서비스 등)가 사용하지 않는 메서드에 의존하는 것을 방지하여 시스템을 더 모듈화하고 리팩터링하기 쉽게 만듭니다.
의존성 역전 원칙 (DIP) 및 리포지토리 패턴
DIP는 상위 수준 모듈이 하위 수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다고 제안합니다. 리포지토리 패턴은 자연스럽게 DIP와 일치합니다. OrderService
가 MongoDB
또는 PostgreSQL
을 직접 아는 대신 OrderRepository
추상화에 의존해야 합니다.
DIP 위반:
// services/order.service.ts import { MongoClient } from 'mongodb'; // 하위 수준 세부 정보에 직접 의존 class OrderService { private db: MongoClient; constructor(mongoClient: MongoClient) { this.db = mongoClient; } async getOrderById(id: string): Promise<Order | null> { const collection = this.db.db('my_db').collection('orders'); return await collection.findOne({ _id: new ObjectId(id) }); } }
DIP 및 리포지토리 패턴 준수 디자인:
// interfaces/order-repository.interface.ts interface IOrderRepository { findById(id: string): Promise<Order | null>; create(orderData: OrderCreationRequest): Promise<Order>; updateStatus(id: string, status: string): Promise<Order | null>; } // repositories/mongo-order.repository.ts (하위 수준 모듈) import { MongoClient, ObjectId, Collection } from 'mongodb'; class MongoOrderRepository implements IOrderRepository { private collection: Collection<Order>; constructor(mongoClient: MongoClient) { this.collection = mongoClient.db('my_db').collection<Order>('orders'); } async findById(id: string): Promise<Order | null> { return await this.collection.findOne({ _id: new ObjectId(id) }); } async create(orderData: OrderCreationRequest): Promise<Order> { const result = await this.collection.insertOne({ ...orderData, status: 'pending', id: new ObjectId().toHexString() }); return { ...orderData, status: 'pending', id: result.insertedId.toHexString() }; } async updateStatus(id: string, status: string): Promise<Order | null> { const result = await this.collection.findOneAndUpdate( { _id: new ObjectId(id) }, { $set: { status: status } }, { returnDocument: 'after' } // returnDocument 옵션 추가 ); // findOneAndUpdate 결과 타입 처리 return result && result.value ? { ...result.value, id: result.value._id.toHexString() } : null; } } // services/order-management.service.ts (상위 수준 모듈) class OrderManagementService { constructor(private orderRepository: IOrderRepository) {} // 추상화에 의존 async fulfillOrder(orderId: string): Promise<Order | null> { const order = await this.orderRepository.findById(orderId); if (!order) { throw new Error(`Order with ID ${orderId} not found.`); } // 주문 이행을 위한 비즈니스 로직 return this.orderRepository.updateStatus(orderId, 'fulfilled'); } } // 간단한 의존성 주입 (수동)을 사용한 메인 애플리케이션 설정에서의 사용 // const mongoClient = await MongoClient.connect('mongodb://localhost:27017'); // const orderRepository: IOrderRepository = new MongoOrderRepository(mongoClient); // const orderManagementService = new OrderManagementService(orderRepository); // // 라우트에서 (예: Express) // app.put('/orders/:id/fulfill', async (req, res) => { // const orderId = req.params.id; // try { // const fulfilledOrder = await orderManagementService.fulfillOrder(orderId); // res.json(fulfilledOrder); // } catch (error: any) { // res.status(404).json({ message: error.message }); // } // });
여기서 OrderManagementService
는 MongoDB
특정 사항을 신경 쓰지 않고 IOrderRepository
인터페이스와만 상호 작용합니다. 이를 통해 MongoOrderRepository
를 PostgresOrderRepository
또는 InMemoryOrderRepository
(테스트용)로 교체할 수 있으며 OrderManagementService
는 변경되지 않습니다.
싱글턴 패턴
종종 종속성을 숨긴다고 비판받지만, 싱글턴 패턴은 데이터베이스 연결 풀이나 전역 구성 서비스와 같이 반드시 하나의 인스턴스만 가져야 하는 리소스에 유용할 수 있습니다.
// util/database.ts import { MongoClient, Db, Collection } from 'mongodb'; // Collection 타입 추가 class Database { private static instance: Database; private client: MongoClient; private db: Db; private constructor() { // 직접적인 인스턴스화 방지 const uri = process.env.MONGO_URI || 'mongodb://localhost:27017'; this.client = new MongoClient(uri); } public static getInstance(): Database { if (!Database.instance) { Database.instance = new Database(); } return Database.instance; } public async connect(): Promise<void> { // isConnected()는 deprecated 되었으므로, 더 이상 사용하지 않음 // 대신, 연결 상태를 직접 확인하거나, connect()가 자동으로 처리하도록 함 // 여기서는 간단하게 connect()가 호출될 때마다 연결을 시도하도록 함 if (!this.db) { // db가 할당되지 않았으면 연결 시도 await this.client.connect(); this.db = this.client.db(process.env.DB_NAME || 'mydatabase'); console.log("Connected to MongoDB"); } } public getClient(): MongoClient { return this.client; } public getDb(): Db { if (!this.db) { throw new Error("Database not connected. Call connect() first."); } return this.db; } public async close(): Promise<void> { if (this.client) { // client가 존재하는지 확인 await this.client.close(); console.log("MongoDB connection closed"); this.db = null; // db 참조 제거 } } } // 사용법: // const dbInstance = Database.getInstance(); // await dbInstance.connect(); // const db = dbInstance.getDb(); // const usersCollection: Collection = db.collection('users'); // Collection 타입 명시 // // ... usersCollection 사용 ...
이렇게 하면 애플리케이션 전체에 걸쳐 데이터베이스에 단 하나의 활성 연결만 존재하도록 보장합니다.
팩토리 메서드 패턴
이 패턴은 런타임까지 정확한 유형을 알 수 없거나 객체 생성 논리를 중앙 집중화해야 하는 경우에 객체를 만드는 데 좋습니다.
// interfaces/product.interface.ts interface Product { id: string; name: string; price: number; getDescription(): string; } // products/digital-product.ts class DigitalProduct implements Product { constructor(public id: string, public name: string, public price: number, private downloadLink: string) {} getDescription(): string { return `${this.name} (${this.id}) - Digital Download. Price: $${this.price}`; } } // products/physical-product.ts class PhysicalProduct implements Product { constructor(public id: string, public name: string, public price: number, private weight: number) {} getDescription(): string { return `${this.name} (${this.id}) - Physical Product. Weight: ${this.weight}kg. Price: $${this.price}`; } } // factories/product-factory.ts class ProductFactory { static createProduct(type: 'digital' | 'physical', id: string, name: string, price: number, details: any): Product { switch (type) { case 'digital': return new DigitalProduct(id, name, price, details.downloadLink); case 'physical': return new PhysicalProduct(id, name, price, details.weight); default: throw new Error('Invalid product type'); } } } // 관리자 서비스 또는 API 엔드포인트에서의 사용 // const digitalGame = ProductFactory.createProduct('digital', 'game-1', 'Cyberpunk 2077', 59.99, { downloadLink: 'http://cdn.game.com/cp2077' }); // console.log(digitalGame.getDescription()); // const physicalBook = ProductFactory.createProduct('physical', 'book-1', 'Clean Code', 35.00, { weight: 0.8 }); // console.log(physicalBook.getDescription());
ProductFactory
는 생성 로직을 추상화하여 팩토리를 사용하는 클라이언트 코드를 수정하지 않고도 새로운 제품 유형을 도입할 수 있게 해줍니다.
결론
TypeScript 백엔드 개발에 SOLID 원칙과 디자인 패턴을 신중하게 적용하면 복잡한 시스템이 잘 구조화되고 이해 가능하며 적응력 있는 애플리케이션으로 변환됩니다. SOLID 원칙은 응집력이 있고 느슨하게 결합된 클래스와 모듈을 설계하는 데 도움을 주며, 디자인 패턴은 일반적인 반복 문제에 대한 검증된 재사용 가능한 솔루션을 제공합니다. 이러한 개념을 채택함으로써 개발자는 오늘날 강력하고 효율적일 뿐만 아니라 미래의 요구 사항과 진화하는 비즈니스 로직에 직면하여 유지 관리 가능하고 확장 가능하며 복원력 있는 백엔드 시스템을 구축할 수 있습니다. 궁극적으로 이는 협업하기 쉽고 철저하게 테스트하며 자신 있게 확장할 수 있는 코드를 작성할 수 있도록 합니다.