NestJS 백엔드에서 SOLID 원칙 구현하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
끊임없이 진화하는 소프트웨어 개발 환경에서 견고하고 유지보수 가능하며 확장 가능한 시스템을 구축하는 것은 매우 중요합니다. JavaScript가 계속해서 강세를 보이고, 특히 TypeScript의 등장으로 정적 타이핑과 향상된 예측 가능성을 제공하면서 백엔드 개발은 상당한 발전을 이루었습니다. NestJS와 같은 프레임워크는 의견이 분명하고 모듈화된 아키텍처로 엔터프라이즈급 애플리케이션 구축을 위한 훌륭한 기반을 제공합니다. 그러나 강력한 프레임워크를 사용하는 것만으로는 충분하지 않으며, 진정한 품질은 기본 설계 원칙을 준수하는 데서 비롯됩니다. 여기서 SOLID 원칙이 등장합니다. 단일 책임, 개방-폐쇄, 리스코프 치환, 인터페이스 분리, 의존성 역전이라는 이 다섯 가지 원칙은 더 깨끗하고 유연하며 복원력 있는 코드를 작성하기 위한 로드맵을 제공합니다. TypeScript 백엔드 프로젝트, 특히 NestJS를 활용하는 프로젝트에서 SOLID 원칙을 이해하고 적용하면 코드 구성이 획기적으로 개선되고 기술 부채가 줄어들며 협업이 용이해집니다. 이 글에서는 각 SOLID 원칙을 자세히 살펴보고 구체적인 코드 예제를 통해 NestJS 환경에서의 실용적인 적용을 시연할 것입니다.
핵심 개념 설명
실질적인 구현에 들어가기 전에 SOLID의 핵심 원칙에 대해 간략하게 정의해 보겠습니다.
- 단일 책임 원칙 (SRP): 클래스 또는 모듈은 변경할 이유가 하나만 있어야 합니다. 이는 단일하고 잘 정의된 기능 조각을 책임져야 함을 의미합니다.
- 개방-폐쇄 원칙 (OCP): 소프트웨어 엔터티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하고 수정에 대해서는 닫혀 있어야 합니다. 기존의 작동하는 코드를 수정하지 않고 새로운 기능을 추가할 수 있어야 합니다.
- 리스코프 치환 원칙 (LSP): 하위 타입은 올바르게 작동하는 프로그램의 기본 타입을 대체할 수 있어야 합니다.
S가T의 하위 타입이라면,T타입의 객체는 애플리케이션을 중단시키지 않고S타입의 객체로 대체될 수 있어야 합니다. - 인터페이스 분리 원칙 (ISP): 클라이언트는 사용하지 않는 인터페이스에 의존하도록 강요받아서는 안 됩니다. 하나의 크고 범용적인 인터페이스 대신, 여러 개의 작고 역할에 특화된 인터페이스를 선호해야 합니다.
- 의존성 역전 원칙 (DIP): 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다. 추상화는 세부 사항에 의존해서는 안 되며, 세부 사항은 추상화에 의존해야 합니다.
NestJS는 모듈, 서비스, 의존성 주입에 대한 강력한 강조를 통해 이러한 원칙을 실천하기 위한 훌륭한 환경을 제공합니다.
NestJS에서 SOLID 원칙 구현하기
일반적인 전자 상거래 시나리오를 고려한 실제 예제를 통해 이러한 원칙을 설명해 보겠습니다.
단일 책임 원칙 (SRP)
NestJS에서 컨트롤러는 종종 요청을 조정하고, 서비스는 비즈니스 로직을 처리하며, 리포지토리는 데이터 액세스를 관리합니다. SRP를 준수한다는 것은 이러한 관심사를 명확하게 분리하는 것을 의미합니다.
잘못된 예 (SRP 위반):
// user.controller.ts (예시 - 너무 많은 일을 함) import { Controller, Get, Post, Body } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) {} @Post() async createUser(@Body() userData: any): Promise<User> { // 사용자 생성, 비밀번호 해싱, 환영 이메일 전송 등의 로직 const newUser = this.usersRepository.create(userData); await this.usersRepository.save(newUser); // 여기서 이메일 전송 로직을 상상해 보세요 console.log('Sending welcome email...'); return newUser; } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.usersRepository.findOne(id); } }
이 UserController는 HTTP 요청 처리, 사용자 생성, 데이터베이스 저장, 잠재적으로 이메일 전송에 대한 책임을 집니다. 이러한 관심사 중 하나라도 변경되면 UserController를 수정해야 합니다.
좋은 예 (SRP 준수):
// user.entity.ts import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column({ unique: true }) email: string; @Column() passwordHash: string; // 해시된 비밀번호 저장 } // user.repository.ts (User에 대한 데이터 액세스 처리) import { EntityRepository, Repository } from 'typeorm'; import { User } from './user.entity'; @EntityRepository(User) export class UserRepository extends Repository<User> { // 사용자 정의 데이터 액세스 메서드는 여기에 넣을 수 있습니다. } // email.service.ts (이메일 전송 관련 처리) import { Injectable } from '@nestjs/common'; @Injectable() export class EmailService { async sendWelcomeEmail(to: string, username: string): Promise<void> { console.log(`Sending welcome email to ${username} at ${to}`); // 실제 이메일 전송 로직 (예: nodemailer 사용) } } // user.service.ts (User에 대한 비즈니스 로직 처리) import { Injectable } from '@nestjs/common'; import { UserRepository } from './user.repository'; import { EmailService } from '../email/email.service'; import * as bcrypt from 'bcrypt'; // 비밀번호 해싱용 @Injectable() export class UserService { constructor( private readonly userRepository: UserRepository, private readonly emailService: EmailService, ) {} async createUser(email: string, passwordPlain: string): Promise<User> { const passwordHash = await bcrypt.hash(passwordPlain, 10); const newUser = this.userRepository.create({ email, passwordHash }); await this.userRepository.save(newUser); await this.emailService.sendWelcomeEmail(email, email); // 단순화를 위해 이메일을 사용자 이름으로 사용 return newUser; } async findUserById(id: number): Promise<User> { return this.userRepository.findOne(id); } } // user.controller.ts (HTTP 요청 처리, 서비스에 위임) import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { UserService } from './user.service'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @Post() async createUser(@Body() userData: { email: string; passwordPlain: string }): Promise<User> { return this.userService.createUser(userData.email, userData.passwordPlain); } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.userService.findUserById(parseInt(id, 10)); } }
이제 UserController는 HTTP 요청만 처리하고, UserService는 사용자 관련 비즈니스 로직을 관리하며, UserRepository는 데이터 지속성을 처리하고, EmailService는 이메일 전송만 담당합니다. 각 클래스는 변경될 이유가 하나뿐입니다.
개방-폐쇄 원칙 (OCP)
OCP를 설명하기 위해 전자 상거래 애플리케이션에서 다양한 유형의 결제 방법을 고려해 보겠습니다.
잘못된 예 (OCP 위반):
// payment.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class PaymentService { processPayment(amount: number, method: 'credit_card' | 'paypal' | 'stripe'): string { if (method === 'credit_card') { // 신용카드 결제 로직 return `Processed credit card payment of $${amount}`; } else if (method === 'paypal') { // PayPal 결제 로직 return `Processed PayPal payment of $${amount}`; } else if (method === 'stripe') { // Stripe 결제 로직 return `Processed Stripe payment of $${amount}`; } else { throw new Error('Unsupported payment method'); } } }
새로운 결제 방법(예: 'Google Pay')을 추가하려면 processPayment 메서드를 수정해야 하므로 OCP를 위반합니다.
좋은 예 (OCP 준수):
// payment-method.interface.ts export interface IPaymentMethod { processPayment(amount: number): string; } // credit-card.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class CreditCardPayment implements IPaymentMethod { processPayment(amount: number): string { // 신용카드별 로직 return `Processed Credit Card payment of $${amount}`; } } // paypal.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PayPalPayment implements IPaymentMethod { processPayment(amount: number): string { // PayPal별 로직 return `Processed PayPal payment of $${amount}`; } } // stripe.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class StripePayment implements IPaymentMethod { processPayment(amount: number): string { // Stripe별 로직 return `Processed Stripe payment of $${amount}`; } } // payment.service.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PaymentService { // NestJS 의존성 주입은 토큰 또는 모듈에서 제공하는 것을 기반으로 구체적인 구현을 주입할 수 있도록 합니다. // 여기서 팩토리 또는 전략 패턴을 사용할 수도 있습니다. processPaymentWithStrategy(paymentMethod: IPaymentMethod, amount: number): string { return paymentMethod.processPayment(amount); } } // payment.module.ts (다양한 전략을 제공하는 방법 예제) import { Module } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { CreditCardPayment } from './credit-card.payment'; import { PayPalPayment } from './paypal.payment'; import { StripePayment } from './stripe.payment'; import { IPaymentMethod } from './payment-method.interface'; const paymentMethodProviders = [ { provide: 'CREDIT_CARD_PAYMENT', useClass: CreditCardPayment, }, { provide: 'PAYPAL_PAYMENT', useClass: PayPalPayment, }, { provide: 'STRIPE_PAYMENT', useClass: StripePayment, }, ]; @Module({ providers: [PaymentService, ...paymentMethodProviders], exports: [PaymentService, ...paymentMethodProviders], }) export class PaymentModule {} // payment.controller.ts import { Controller, Post, Body, Inject } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { IPaymentMethod } from './payment-method.interface'; @Controller('payments') export class PaymentController { constructor( private readonly paymentService: PaymentService, @Inject('CREDIT_CARD_PAYMENT') private readonly creditCardPayment: IPaymentMethod, @Inject('PAYPAL_PAYMENT') private readonly payPalPayment: IPaymentMethod, @Inject('STRIPE_PAYMENT') private readonly stripePayment: IPaymentMethod, ) {} @Post('process') processPayment(@Body() data: { amount: number; method: string }): string { let paymentStrategy: IPaymentMethod; switch (data.method) { case 'credit_card': paymentStrategy = this.creditCardPayment; break; case 'paypal': paymentStrategy = this.payPalPayment; break; case 'stripe': paymentStrategy = this.stripePayment; break; default: throw new Error('Unsupported payment method'); } return this.paymentService.processPaymentWithStrategy(paymentStrategy, data.amount); } }
이제 IPaymentMethod를 구현하는 새로운 클래스를 만들기만 하면 새로운 결제 방법을 추가할 수 있으며, 기존 PaymentService 및 PaymentController는 변경되지 않은 채로 OCP를 준수합니다.
리스코프 치환 원칙 (LSP)
LSP는 새로운 파생 클래스가 기본 클래스의 동작을 변경하지 않고 확장함을 보장합니다. Product 엔티티와 그 하위 타입을 고려해 봅시다.
잘못된 예 (LSP 위반):
// product.interface.ts export interface IProduct { getId(): string; getPrice(): number; // 모든 제품에 할인을 적용하는 메서드 applyDiscount(percentage: number): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (LSP 위반) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // 라이선스 또는 고정 비용으로 인해 디지털 제품에는 할인이 적용될 수 없습니다. applyDiscount(percentage: number): number { throw new Error('Digital products cannot be discounted.'); } } // 주문 처리 로직 어딘가 function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { // 이 함수는 모든 제품에 대해 applyDiscount가 작동한다고 가정합니다. // DigitalProduct가 전달되면 오류가 발생하여 계약이 깨집니다. total += product.applyDiscount(10); // 10% 할인 적용 } return total; }
여기서 DigitalProduct의 applyDiscount 메서드는 IProduct.applyDiscount의 예상 동작을 변경하면서 오류를 발생시킵니다. IProduct가 예상되는 곳에 DigitalProduct가 사용되면 애플리케이션이 중단될 수 있습니다.
좋은 예 (LSP 준수):
이를 해결하기 위해 인터페이스를 재평가하거나 DigitalProduct가 할인을 '정상적으로' 처리하도록 할 수 있습니다(예: 원본 가격 반환). LSP를 위한 더 나은 접근 방식은 관심사를 분리하거나 동작을 더 정확하게 정의하는 것일 수 있습니다.
// discountable-product.interface.ts export interface IDiscountableProduct { applyDiscount(percentage: number): number; } // product.interface.ts (할인 로직이 없는 기본 제품) export interface IProduct { getId(): string; getPrice(): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct, IDiscountableProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (기본적으로 할인 기능 없음) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // applyDiscount 메서드가 없거나 원본 가격을 반환합니다. } // 주문 처리 로직 어딘가 function calculateDiscountedTotal(products: IDiscountableProduct[]): number { let total = 0; for (const product of products) { // 이제 안전합니다. 할인 가능한 제품만 전달되므로 예기치 않은 동작이 발생하지 않습니다. total += product.applyDiscount(10); } return total; } function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { total += product.getPrice(); } return total; }
이제 할인 가능한 제품을 예상하는 함수는 해당 제품만 받게 되어 동작이 예기치 않게 변경되지 않도록 보장합니다. 이는 ISP에도 관련됩니다.
인터페이스 분리 원칙 (ISP)
ISP는 클라이언트가 사용하지 않는 인터페이스에 의존하도록 강요받아서는 안 된다고 제안합니다.
잘못된 예 (ISP 위반):
// user-management.interface.ts (비대 인터페이스) export interface IUserManager { createUser(data: any): Promise<any>; updateUser(id: string, data: any): Promise<any>; deleteUser(id: string): Promise<void>; getUsers(): Promise<any[]>; resetPassword(userId: string): Promise<void>; assignRole(userId: string, role: string): Promise<void>; // ... sendWelcomeEmail, generateReport 등 관련 없는 메서드도... } // 사용자 생성 및 가져오기만 필요한 서비스이지만 전체 인터페이스를 구현합니다. export class UserDisplayService implements IUserManager { createUser(data: any): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { throw new Error('Not implemented'); } // 필요 없음 deleteUser(id: string): Promise<void> { throw new Error('Not implemented'); } // 필요 없음 getUsers(): Promise<any[]> { /* ... */ } resetPassword(userId: string): Promise<void> { throw new Error('Not implemented'); } // 필요 없음 assignRole(userId: string, role: string): Promise<void> { throw new Error('Not implemented'); } // 필요 없음 }
UserDisplayService는 필요하지 않은 메서드를 구현하도록 강요받아 불필요한 복잡성을 초래하고 오류 가능성(예: '구현되지 않음' 오류 발생)을 초래합니다.
좋은 예 (ISP 준수):
// user-creator.interface.ts export interface IUserCreator { createUser(data: any): Promise<any>; } // user-reader.interface.ts export interface IUserReader { getUsers(): Promise<any[]>; getUserById(id: string): Promise<any>; } // user-updater.interface.ts export interface IUserUpdater { updateUser(id: string, data: any): Promise<any>; resetPassword(userId: string): Promise<void>; } // user-deleter.interface.ts export interface IUserDeleter { deleteUser(id: string): Promise<void>; } // 사용자 표시 서비스는 IUserReader만 필요합니다. export class UserDisplayService implements IUserReader { constructor(/* 의존성 */) {} getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } } // 전체 사용자 관리를 위한 서비스는 여러 개의 작은 인터페이스를 구현할 수 있습니다. export class FullUserService implements IUserCreator, IUserReader, IUserUpdater, IUserDeleter { constructor(/* 의존성 */) {} createUser(data: any): Promise<any> { /* ... */ } getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { /* ... */ } resetPassword(userId: string): Promise<void> { /* ... */ } deleteUser(id: string): Promise<void> { /* ... */ } }
이제 UserDisplayService는 IUserReader에만 의존하여 사용하지 않는 메서드를 구현할 필요가 없습니다. 이는 코드를 더 깨끗하고 집중적이며 테스트하기 쉽게 만듭니다.
의존성 역전 원칙 (DIP)
DIP는 구체적인 구현 대신 추상화에 의존해야 함을 강조합니다. NestJS의 의존성 주입 컨테이너는 DIP를 강력하게 지원합니다.
잘못된 예 (DIP 위반):
// email-sender.class.ts (구체적인 하위 수준 모듈) export class SMSEmailSender { send(to: string, subject: string, body: string): void { console.log(`Sending SMS email to ${to}: ${subject} - ${body}`); // 특정 SMS-이메일 게이트웨이를 통한 전송 시뮬레이션 } } // user.service.ts import { Injectable } from '@nestjs/common'; import { SMSEmailSender } from './email-sender.class'; // 구체적인 클래스에 직접 의존 @Injectable() export class UserService { private emailSender: SMSEmailSender; constructor() { this.emailSender = new SMSEmailSender(); // 상위 수준 모듈이 하위 수준 모듈을 생성 } async registerUser(email: string, passwordHash: string): Promise<void> { // ... 사용자 등록 로직 ... this.emailSender.send(email, 'Welcome!', 'Thank you for registering.'); } }
UserService(상위 수준 모듈)는 SMSEmailSender(하위 수준 모듈)에 직접 의존합니다. 다른 이메일 전송 메커니즘(예: SendGrid 또는 Mailgun 사용)으로 전환하려면 UserService를 수정해야 합니다.
좋은 예 (DIP 준수):
// email-sender.interface.ts (추상화) export interface IEmailSender { send(to: string, subject: string, body: string): Promise<void>; } // sms-email-sender.class.ts (구체적인 하위 수준 모듈, 추상화에 의존) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SMSEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Sending SMS email to ${to}: ${subject} - ${body}`); // 실제 SMS-이메일 게이트웨이 통합 } } // sendgrid-email-sender.class.ts (다른 구체적인 하위 수준 모듈) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SendGridEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Sending via SendGrid to ${to}: ${subject} - ${body}`); // 실제 SendGrid API 통합 } } // user.service.ts (상위 수준 모듈, 추상화에 의존) import { Injectable, Inject } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class UserService { constructor( @Inject('EMAIL_SENDER') private readonly emailSender: IEmailSender, // 추상화에 의존 ) {} async registerUser(email: string, passwordHash: string): Promise<void> { // ... 사용자 등록 로직 ... await this.emailSender.send(email, 'Welcome!', 'Thank you for registering.'); } } // user.module.ts (NestJS DI를 사용하여 의존성 연결) import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { SMSEmailSender } from './sms-email-sender.class'; import { SendGridEmailSender } from './sendgrid-email-sender.class'; import { IEmailSender } from './email-sender.interface'; @Module({ providers: [ UserService, // 기능 토글, 환경 등에 따라 사용할 구현을 선택합니다. { provide: 'EMAIL_SENDER', // 의존성의 토큰 useClass: SMSEmailSender, // 사용할 구체적인 구현 // 실제 프로젝트에서는 이를 전환할 수 있습니다. // useClass: process.env.EMAIL_PROVIDER === 'SENDGRID' ? SendGridEmailSender : SMSEmailSender, }, ], exports: [UserService], }) export class UserModule {}
이제 UserService는 IEmailSender 추상화에 의존합니다. 실제 구현(SMSEmailSender 또는 SendGridEmailSender)은 모듈 수준(또는 런타임/구성 시)에서 결정되며 UserService를 수정할 필요가 없습니다. 이는 뛰어난 유연성과 테스트 용이성을 제공합니다.
결론
NestJS와 함께 TypeScript 백엔드 프로젝트에서 SOLID 원칙을 채택하는 것은 단순히 규칙 집합을 따르는 것이 아니라 유연하고 견고하며 유지보수 가능한 소프트웨어를 만드는 사고방식을 함양하는 것입니다. 각 구성 요소가 명확하고 단일한 목적(SRP)을 갖도록 보장하고, 기존 코드를 중단시키지 않고 새로운 기능을 쉽게 통합할 수 있는 시스템(OCP)을 설계하며, 안정적인 상속 계층(LSP)을 구축하고, 민첩하고 집중적인 인터페이스(ISP)를 만들고, 마침내 추상화를 통해 상위 수준 정책을 하위 수준 세부 정보와 분리(DIP)하는 것에 이르기까지 SOLID 원칙은 귀중한 프레임워크를 제공합니다. NestJS의 아키텍처 패턴 – 모듈, 서비스, 리포지토리 및 강력한 의존성 주입 시스템 –은 자연스럽게 이러한 원칙과 일치하며 적용을 촉진하여 개발자가 자신감과 용이함으로 복잡하고 확장 가능한 애플리케이션을 구축할 수 있도록 합니다. 개발 라이프사이클 전반에 걸쳐 SOLID 원칙을 의식적으로 적용하면 코드베이스를 기능적인 것에서 모범적인 것으로 끌어올릴 수 있습니다.