NestJS로 풀어보는 SOLID 원칙

Jaeyeol Lee @kodingwarrior@hackers.pub
Disclaimer
이 글은 NestJS를 공부하면서 객체지향 프로그래밍 원칙과의 연결점을 스스로 정리한 내용입니다. Spring 같은 프레임워크를 이해할 때 객체지향 개념이 중요한 것처럼, NestJS 역시 객체지향 설계를 염두에 두면 훨씬 더 깊이 이해할 수 있을 것 같다는 관점에서 작성되었습니다.
소프트웨어는 시간이 지날수록 점점 복잡해지고, 원래 의도와 다르게 무너질 위험에 쉽게 노출됩니다. “한 클래스가 너무 많은 일을 한다”, “새로운 기능 하나 추가하려는데 기존 코드를 몽땅 뜯어고쳐야 한다”, “교체 가능한 구현체인데도 특정 코드에 딱 달라붙어버렸다”… 이런 상황을 겪어본 개발자는 많을 겁니다.
이러한 문제를 피하기 위해 정리된 다섯 가지 핵심 규칙이 바로 SOLID 원칙입니다. 이 개념은 소프트웨어 엔지니어 Robert C. Martin(일명 Uncle Bob) 이 다섯 가지 원칙을 하나의 묶음으로 제시하면서 널리 알려졌습니다. 이후로 사실상 “좋은 코드”를 판단하는 표준처럼 자리 잡았습니다.
하지만 SOLID는 객체지향 프로그래밍 전용 규칙이 아닙니다. 함수형 프로그래밍에도 적용할 수 있고, React 같은 UI 라이브러리, 오픈소스 프레임워크 내부 구조를 설계할 때도 그대로 통하는 일반적인 소프트웨어 설계 원칙 입니다. 이번 글에서는 NestJS를 예시로 삼아, 각 원칙이 어떤 문제를 해결하고 실제 코드에 어떻게 녹여낼 수 있는지 살펴보겠습니다.
SRP – 단일 책임 원칙
SRP는 “한 클래스는 오직 하나의 책임만 가져야 한다” 는 원칙으로, 여러 기능이 한곳에 얽히면 서로 다른 이유로 동시에 수정되어야 하므로 유지보수가 어려워집니다. NestJS가 Controller, Service, Repository를 나누는 구조를 제공하는 것도 사실 이 원칙을 실현하기 위함입니다.
❌ 위반 사례
@Controller('users')
export class UserController {
constructor(private readonly repo: Repository<User>) {}
@Get(':id')
async getUser(@Param('id') id: string) {
const user = await this.repo.findOneBy({ id });
if (!user?.isActive) throw new Error('Inactive user');
return { id: user.id, name: user.name };
}
}
✅ 개선 사례
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get(':id')
getUser(@Param('id') id: string) {
return this.userService.findOne(id);
}
}
@Injectable()
export class UserService {
constructor(private readonly repo: Repository<User>) {}
async findOne(id: string) {
const user = await this.repo.findOneBy({ id });
if (!user?.isActive) throw new Error('Inactive user');
return user;
}
}
개선 이유
Controller는 요청과 응답만 담당하고, Service는 비즈니스 로직만 다루며, Repository는 데이터베이스 접근에만 집중하게 나누면 각 계층이 독립적으로 바뀔 수 있고 코드의 응집도와 유지보수성이 높아집니다.
📊 다이어그램
OCP – 개방-폐쇄 원칙
OCP는 “소프트웨어 개체는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다” 는 원칙입니다. 즉, 새로운 기능을 넣더라도 기존 코드를 직접 수정하지 않고 확장 방식으로 처리할 수 있어야 한다는 뜻입니다.
❌ 위반 사례
@Injectable()
export class UserService {
async findOne(id: string) {
console.log(`[LOG] fetching user ${id}`);
return { id };
}
}
✅ 개선 사례
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
console.log(`[LOG] ${req.method} ${req.url}`);
return next.handle();
}
}
@Module({
providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],
})
export class AppModule {}
개선 이유
UserService는 오직 사용자 로직만 담당하도록 두고, 로깅은 Interceptor로 분리하면 로깅 전략을 바꾸거나 새로운 로깅 방식을 추가할 때 기존 코드를 건드리지 않고 확장만으로 대응할 수 있습니다.
📊 다이어그램
LSP – 리스코프 치환 원칙
LSP는 “상위 타입을 사용하는 코드는 하위 타입으로 교체하더라도 정상적으로 동작해야 한다” 는 원칙입니다. MIT의 Barbara Liskov 교수가 1987년 발표한 개념으로, 인터페이스를 구현한 객체라면 언제든 안정적으로 대체 가능해야 한다는 점을 강조합니다.
❌ 위반 사례
export class StripeGateway {
pay(amount: number): string {
return `Paid ${amount} via Stripe`;
}
}
export class TossGateway extends StripeGateway {
pay(amount: number): string {
throw new Error('Toss unavailable ❌');
}
}
✅ 개선 사례
export interface PaymentGateway {
pay(amount: number): string;
}
export class StripeGateway implements PaymentGateway {
pay(amount: number) {
return `Paid ${amount} via Stripe`;
}
}
export class TossGateway implements PaymentGateway {
pay(amount: number) {
return `Paid ${amount} via Toss`;
}
}
개선 이유
PaymentService는 인터페이스인 PaymentGateway에만 의존하고, 실제 구현체는 계약(pay 메서드)만 지키면 언제든 교체 가능하므로, 코드의 일관성과 신뢰성이 보장됩니다.
📊 다이어그램
ISP – 인터페이스 분리 원칙
ISP는 “클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다” 는 원칙으로, 거대한 인터페이스가 불필요한 의존을 강제하는 문제를 막고자 합니다. NestJS에서는 Guard, Pipe, Interceptor, Filter 등이 이 원칙을 잘 반영합니다.
❌ 위반 사례
class UglyRequestHandler {
handle(req: any): any {
try {
this.authenticate(req);
this.validate(req);
this.log(req);
const result = this.execute(req);
return { statusCode: 201, message: '회원가입 성공', data: result };
} catch (err: any) {
return this.catchError(err);
}
}
private authenticate(req: any) { /* ... */ }
private validate(req: any) { /* ... */ }
private log(req: any) { /* ... */ }
private execute(req: any) { /* ... */ }
private catchError(err: any) { /* ... */ }
}
✅ 개선 사례
@Controller('auth')
@UseFilters(HttpExceptionFilter)
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(GuestOnlyGuard)
@UsePipes(new ValidationPipe())
@UseInterceptors(LoggingInterceptor)
@Post('signup')
async signup(@Body() dto: CreateUserDto) {
return this.authService.signup(dto);
}
}
개선 이유
인증은 Guard, 검증은 Pipe, 로깅은 Interceptor, 에러 처리는 Filter가 담당하도록 분리하면 각 기능을 독립적으로 교체하거나 확장할 수 있고, 컨트롤러는 핵심 흐름만 관리할 수 있어 불필요한 의존이 사라집니다.
📊 다이어그램
DIP – 의존 역전 원칙
DIP는 “고수준 모듈은 저수준 모듈에 의존하지 않고, 추상화에 의존해야 한다” 는 원칙입니다. 구체 구현체에 묶여버리면 교체가 어려워지고, 시스템 전체가 쉽게 깨지기 때문에 등장한 개념입니다.
❌ 위반 사례
@Injectable()
export class UserService {
async createUser(name: string) {
console.log('User created:', name);
}
}
✅ 개선 사례
export interface LoggerPort {
log(message: string): void;
}
@Injectable()
export class ConsoleLogger implements LoggerPort {
log(message: string) {
console.log(message);
}
}
@Injectable()
export class UserService {
constructor(private readonly logger: LoggerPort) {}
async createUser(name: string) {
this.logger.log(`User created: ${name}`);
}
}
@Module({
providers: [{ provide: 'LOGGER', useClass: ConsoleLogger }],
})
export class AppModule {}
개선 이유
UserService는 LoggerPort
라는 추상화에만 의존하고, 실제 구현은 DI 컨테이너에서 주입되므로 언제든 다른 로거(ConsoleLogger, WinstonLogger, LogtapeLogger, FileLogger 등)로 교체할 수 있어 코드가 훨씬 유연해집니다.
📊 다이어그램
마치며
SOLID는 오래 살아남는 코드를 위한 다섯 가지 약속입니다. 책임을 분리해 응집도를 높이고(SRP), 기존 코드를 건드리지 않고 확장할 수 있도록 만들며(OCP), 계약을 지켜 일관성을 유지하고(LSP), 불필요한 의존을 줄이고(ISP), 추상화를 통해 교체 가능성을 확보합니다(DIP).
세부적인 구현은 둘째치더라도 NestJS는 Guard, Pipe, Interceptor, DI Container 등 이미 SOLID를 녹여낼 수 있는 구조적 도구들을 제공합니다. SOLID는 특정 프레임워크의 패턴이 아니라, 현대 소프트웨어 전반에 알게 모르게 스며들어 있는 보편적 원칙이라고 할 수 있습니다.
글을 읽으시다가 사실과 다른 부분이 보이거나 설명이 모호해 보이는 지점, 혹은 보완하면 더 나아질 것 같은 아이디어가 떠오르신다면, 사소한 것이라도 편하게 지적해 주세요—빠르게 반영하며 글과 코드를 함께 다듬어 보겠습니다.