🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

SOLID 원칙 실전 프로젝트 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 4. · 22 Views

SOLID 원칙 실전 프로젝트 완벽 가이드

객체 지향 설계의 핵심인 SOLID 원칙을 실전 프로젝트에 적용하는 완벽 가이드입니다. 각 원칙의 개념부터 실무 코드 예제, 그리고 바로 적용 가능한 팁까지 초급 개발자도 쉽게 이해할 수 있도록 구성했습니다.


목차

  1. SRP_단일_책임_원칙
  2. OCP_개방_폐쇄_원칙
  3. LSP_리스코프_치환_원칙
  4. ISP_인터페이스_분리_원칙
  5. DIP_의존성_역전_원칙
  6. 실전_적용_전략
  7. 테스트와_SOLID
  8. 일반적인_안티패턴

1. SRP 단일 책임 원칙

시작하며

여러분이 코드를 작성하다가 하나의 클래스가 점점 커지고, 수정할 때마다 다른 기능까지 영향을 받는 상황을 겪어본 적 있나요? 예를 들어, 사용자 정보를 관리하는 클래스가 데이터 저장, 이메일 발송, 로그 기록까지 모두 처리하는 경우입니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 하나의 클래스에 너무 많은 책임이 집중되면 코드 수정이 어려워지고, 버그가 발생할 확률도 높아집니다.

또한 팀원들과 협업할 때 충돌이 자주 일어나게 됩니다. 바로 이럴 때 필요한 것이 단일 책임 원칙(Single Responsibility Principle, SRP)입니다.

이 원칙을 따르면 각 클래스가 하나의 명확한 책임만 가지게 되어 코드의 유지보수성과 테스트 용이성이 크게 향상됩니다.

개요

간단히 말해서, 단일 책임 원칙은 "하나의 클래스는 하나의 책임만 가져야 한다"는 원칙입니다. 여기서 책임이란 '변경의 이유'를 의미합니다.

왜 이 원칙이 필요한지 실무 관점에서 설명하자면, 클래스가 여러 책임을 가질 경우 한 가지 기능을 수정할 때 다른 기능에도 영향을 미칠 수 있기 때문입니다. 예를 들어, 사용자 데이터 형식을 변경했는데 갑자기 이메일 발송 기능에 버그가 생기는 경우가 발생할 수 있습니다.

이는 개발 속도를 느리게 하고 예상치 못한 사이드 이펙트를 만들어냅니다. 기존에는 모든 기능을 하나의 큰 클래스에 몰아넣었다면, 이제는 각 책임별로 클래스를 분리하여 관리할 수 있습니다.

SRP의 핵심 특징은 첫째, 각 클래스가 명확한 하나의 목적을 가진다는 것입니다. 둘째, 변경이 필요할 때 해당 책임을 가진 클래스만 수정하면 됩니다.

셋째, 테스트가 쉬워지고 코드 재사용성이 높아집니다. 이러한 특징들이 결국 전체 프로젝트의 품질과 개발 생산성을 향상시킵니다.

코드 예제

// ❌ SRP 위반: 여러 책임을 가진 클래스
class UserManager {
  createUser(name: string, email: string) {
    // 사용자 생성
    const user = { name, email };

    // 데이터베이스 저장
    this.saveToDatabase(user);

    // 이메일 발송
    this.sendWelcomeEmail(email);

    // 로그 기록
    console.log(`User created: ${name}`);
  }

  private saveToDatabase(user: any) { /* ... */ }
  private sendWelcomeEmail(email: string) { /* ... */ }
}

// ✅ SRP 준수: 책임을 분리한 클래스들
class User {
  constructor(public name: string, public email: string) {}
}

class UserRepository {
  save(user: User): void {
    // 데이터베이스 저장 책임만 담당
    console.log('Saving to database...');
  }
}

class EmailService {
  sendWelcomeEmail(email: string): void {
    // 이메일 발송 책임만 담당
    console.log(`Sending welcome email to ${email}`);
  }
}

class Logger {
  log(message: string): void {
    // 로깅 책임만 담당
    console.log(`[LOG] ${message}`);
  }
}

class UserService {
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService,
    private logger: Logger
  ) {}

  createUser(name: string, email: string): void {
    const user = new User(name, email);
    this.userRepo.save(user);
    this.emailService.sendWelcomeEmail(email);
    this.logger.log(`User created: ${name}`);
  }
}

설명

이것이 하는 일: 위 코드는 SRP를 위반한 예시와 이를 준수하도록 개선한 예시를 보여줍니다. 첫 번째 UserManager 클래스는 사용자 생성, 데이터 저장, 이메일 발송, 로깅까지 모든 것을 처리하는 반면, 개선된 버전은 각 책임을 별도의 클래스로 분리했습니다.

첫 번째로, User 클래스는 사용자 데이터만 표현하는 책임을 가집니다. 이는 순수한 데이터 모델로서, 비즈니스 로직이나 외부 의존성 없이 사용자의 속성만 관리합니다.

이렇게 하면 사용자 데이터 구조가 변경되어도 다른 클래스에 영향을 주지 않습니다. 그 다음으로, UserRepository는 데이터베이스 저장만, EmailService는 이메일 발송만, Logger는 로깅만 담당합니다.

각 클래스가 하나의 명확한 책임을 가지므로, 예를 들어 데이터베이스를 MySQL에서 PostgreSQL로 변경하더라도 UserRepository만 수정하면 됩니다. 이메일 서비스나 로거는 전혀 건드릴 필요가 없습니다.

마지막으로, UserService는 이러한 개별 서비스들을 조합하여 사용자 생성이라는 비즈니스 로직을 구현합니다. 이를 통해 각 컴포넌트를 독립적으로 테스트할 수 있고, 필요에 따라 EmailService를 다른 구현체로 교체하는 것도 쉽게 가능합니다.

여러분이 이 코드를 사용하면 코드 변경 시 영향 범위를 최소화할 수 있고, 각 클래스를 독립적으로 단위 테스트할 수 있으며, 새로운 기능 추가나 기존 기능 수정이 훨씬 안전해집니다. 또한 팀원들과 협업할 때 각자 다른 클래스를 작업하여 충돌을 줄일 수 있습니다.

실전 팁

💡 클래스 이름에 'Manager', 'Handler', 'Util' 같은 모호한 단어가 들어간다면 SRP를 위반하고 있을 가능성이 높습니다. 대신 구체적인 책임을 나타내는 이름을 사용하세요.

💡 한 클래스를 수정할 때 다른 기능의 테스트가 깨진다면 SRP 위반 신호입니다. 각 클래스의 테스트는 독립적이어야 합니다.

💡 "이 클래스가 변경되는 이유가 몇 가지인가?"라고 질문해보세요. 2가지 이상이라면 클래스를 분리해야 합니다.

💡 의존성 주입(Dependency Injection)을 활용하면 SRP를 지키면서도 클래스 간 협력을 쉽게 구현할 수 있습니다.

💡 작은 클래스가 많아지는 것을 두려워하지 마세요. 10개의 작은 클래스가 1개의 큰 클래스보다 유지보수하기 훨씬 쉽습니다.


2. OCP 개방 폐쇄 원칙

시작하며

여러분이 기존 코드에 새로운 기능을 추가할 때마다 기존 코드를 수정해야 하고, 그로 인해 예상치 못한 버그가 발생하는 경험을 해본 적 있나요? 예를 들어, 결제 시스템에 새로운 결제 방식을 추가할 때마다 기존 결제 로직을 수정해야 하는 경우입니다.

이런 문제는 특히 레거시 코드를 다룰 때 심각해집니다. 기존 코드를 수정하면 이미 잘 작동하던 기능이 망가질 수 있고, 회귀 테스트에 많은 시간을 소비하게 됩니다.

또한 코드가 점점 복잡해지고 if-else 문이 끝없이 늘어나는 스파게티 코드가 됩니다. 바로 이럴 때 필요한 것이 개방-폐쇄 원칙(Open-Closed Principle, OCP)입니다.

이 원칙을 따르면 기존 코드를 수정하지 않고도 새로운 기능을 확장할 수 있어 안전하고 유연한 설계가 가능합니다.

개요

간단히 말해서, 개방-폐쇄 원칙은 "소프트웨어 엔티티는 확장에는 열려있고, 수정에는 닫혀있어야 한다"는 원칙입니다. 즉, 새로운 기능을 추가할 때 기존 코드를 변경하지 않아야 합니다.

왜 이 원칙이 필요한지 실무 관점에서 설명하자면, 잘 작동하는 코드를 수정하는 것은 위험하기 때문입니다. 예를 들어, 신용카드 결제가 잘 작동하는 상태에서 카카오페이를 추가하기 위해 기존 결제 코드를 수정한다면, 신용카드 결제에 버그가 생길 수 있습니다.

이는 실제 매출에 직접적인 영향을 미치는 심각한 문제입니다. 기존에는 새 기능을 추가할 때마다 switch 문이나 if-else 문을 계속 추가했다면, 이제는 인터페이스와 다형성을 활용하여 기존 코드를 건드리지 않고 새 클래스만 추가할 수 있습니다.

OCP의 핵심 특징은 첫째, 추상화를 통해 변하지 않는 부분과 변하는 부분을 분리한다는 것입니다. 둘째, 새로운 기능은 새로운 클래스로 구현하여 추가합니다.

셋째, 기존 코드의 안정성을 유지하면서 확장 가능성을 보장합니다. 이러한 특징들이 장기적으로 프로젝트의 유지보수 비용을 크게 절감시킵니다.

코드 예제

// ❌ OCP 위반: 새 결제 수단 추가 시 기존 코드 수정 필요
class PaymentProcessor {
  processPayment(amount: number, method: string) {
    if (method === 'credit-card') {
      console.log(`Processing ${amount} via Credit Card`);
    } else if (method === 'paypal') {
      console.log(`Processing ${amount} via PayPal`);
    } else if (method === 'kakao-pay') {
      // 새 결제 수단 추가를 위해 기존 코드 수정
      console.log(`Processing ${amount} via Kakao Pay`);
    }
  }
}

// ✅ OCP 준수: 확장에는 열려있고 수정에는 닫혀있음
interface PaymentMethod {
  process(amount: number): void;
}

class CreditCardPayment implements PaymentMethod {
  process(amount: number): void {
    console.log(`Processing ${amount} via Credit Card`);
  }
}

class PayPalPayment implements PaymentMethod {
  process(amount: number): void {
    console.log(`Processing ${amount} via PayPal`);
  }
}

class KakaoPayPayment implements PaymentMethod {
  process(amount: number): void {
    // 새 클래스만 추가, 기존 코드는 수정하지 않음
    console.log(`Processing ${amount} via Kakao Pay`);
  }
}

class PaymentService {
  processPayment(amount: number, method: PaymentMethod): void {
    // 결제 수단이 추가되어도 이 코드는 변경되지 않음
    method.process(amount);
  }
}

// 사용 예시
const paymentService = new PaymentService();
paymentService.processPayment(10000, new CreditCardPayment());
paymentService.processPayment(20000, new KakaoPayPayment());

설명

이것이 하는 일: 위 코드는 OCP를 위반한 if-else 방식과 이를 준수하는 인터페이스 기반 설계를 비교합니다. 첫 번째 방식은 새 결제 수단을 추가할 때마다 processPayment 메서드를 수정해야 하지만, 두 번째 방식은 새 클래스만 추가하면 됩니다.

첫 번째로, PaymentMethod 인터페이스를 정의하여 모든 결제 수단이 따라야 할 계약을 명시합니다. 이 인터페이스는 process 메서드를 가지며, 모든 결제 수단은 이를 구현해야 합니다.

이렇게 추상화된 인터페이스는 변하지 않는 부분으로, PaymentService는 이 인터페이스에만 의존합니다. 그 다음으로, 각 결제 수단은 PaymentMethod 인터페이스를 구현한 별도의 클래스로 만들어집니다.

CreditCardPayment, PayPalPayment, KakaoPayPayment는 각각 독립적인 클래스로 존재하며, 서로에게 영향을 주지 않습니다. 네이버페이나 토스페이를 추가하고 싶다면?

단순히 PaymentMethod를 구현하는 새 클래스만 만들면 됩니다. 마지막으로, PaymentService는 구체적인 결제 수단이 아닌 PaymentMethod 인터페이스에 의존합니다.

이것이 핵심입니다. PaymentService의 processPayment 메서드는 어떤 결제 수단이 들어오든 상관없이 동일하게 동작하며, 새로운 결제 수단이 100개가 추가되어도 한 글자도 수정할 필요가 없습니다.

여러분이 이 코드를 사용하면 새 기능 추가 시 기존 코드를 테스트할 필요가 없고, 각 결제 수단을 독립적으로 개발하고 배포할 수 있으며, 런타임에 동적으로 결제 수단을 교체하는 것도 가능합니다. 또한 플러그인 아키텍처를 구현하기도 쉬워집니다.

실전 팁

💡 if-else나 switch 문으로 타입을 분기하고 있다면 OCP 위반 신호입니다. 인터페이스와 다형성으로 리팩토링을 고려하세요.

💡 전략 패턴(Strategy Pattern)은 OCP를 구현하는 대표적인 디자인 패턴입니다. 알고리즘을 캡슐화하여 교체 가능하게 만듭니다.

💡 모든 곳에 OCP를 적용하려 하지 마세요. 변경 가능성이 높은 부분에만 적용하는 것이 효율적입니다.

💡 추상 클래스와 인터페이스 중 선택할 때는 다중 상속이 필요한지, 공통 구현이 필요한지를 고려하세요. TypeScript에서는 주로 인터페이스를 권장합니다.

💡 Factory 패턴과 함께 사용하면 객체 생성 로직도 OCP를 준수하도록 만들 수 있습니다.


3. LSP 리스코프 치환 원칙

시작하며

여러분이 상속을 사용하여 코드를 재사용하려다가 오히려 더 복잡하고 버그가 많은 코드를 만들어낸 경험이 있나요? 예를 들어, 정사각형(Square)을 직사각형(Rectangle)의 하위 클래스로 만들었는데, 너비와 높이를 설정하는 과정에서 이상한 동작이 발생하는 경우입니다.

이런 문제는 "is-a" 관계를 잘못 이해했을 때 발생합니다. 현실 세계에서는 정사각형이 직사각형의 일종이지만, 프로그래밍에서는 행동(behavior)이 중요합니다.

부모 클래스를 사용하는 코드가 자식 클래스로 교체했을 때 정상 동작하지 않는다면, 그것은 잘못된 상속 관계입니다. 바로 이럴 때 필요한 것이 리스코프 치환 원칙(Liskov Substitution Principle, LSP)입니다.

이 원칙을 따르면 상속 관계를 올바르게 설계하여 예측 가능하고 안전한 코드를 만들 수 있습니다.

개요

간단히 말해서, 리스코프 치환 원칙은 "부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대체해도 프로그램의 정확성이 깨지지 않아야 한다"는 원칙입니다. 즉, 자식 클래스는 부모 클래스의 행동 규약을 반드시 지켜야 합니다.

왜 이 원칙이 필요한지 실무 관점에서 설명하자면, 잘못된 상속은 런타임 에러나 예상치 못한 동작을 유발하기 때문입니다. 예를 들어, 새(Bird) 클래스를 상속받은 펭귄(Penguin) 클래스에서 fly() 메서드를 호출하면 에러가 발생하거나 이상한 동작을 하게 됩니다.

이는 코드의 신뢰성을 크게 떨어뜨립니다. 기존에는 현실 세계의 분류를 그대로 코드에 적용했다면, 이제는 객체의 행동을 중심으로 상속 관계를 설계할 수 있습니다.

LSP의 핵심 특징은 첫째, 자식 클래스는 부모 클래스의 선행 조건을 강화할 수 없다는 것입니다. 둘째, 자식 클래스는 부모 클래스의 후행 조건을 약화할 수 없습니다.

셋째, 부모 클래스의 불변 조건은 자식 클래스에서도 유지되어야 합니다. 이러한 특징들이 다형성을 안전하게 사용할 수 있게 해줍니다.

코드 예제

// ❌ LSP 위반: Square는 Rectangle의 행동 규약을 깨뜨림
class Rectangle {
  constructor(protected width: number, protected height: number) {}

  setWidth(width: number) {
    this.width = width;
  }

  setHeight(height: number) {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  setWidth(width: number) {
    // 정사각형은 너비와 높이가 같아야 하므로 둘 다 변경
    this.width = width;
    this.height = width; // Rectangle의 행동과 다름!
  }

  setHeight(height: number) {
    this.width = height;
    this.height = height; // Rectangle의 행동과 다름!
  }
}

// 문제 발생: Rectangle을 기대하는 코드가 Square로 대체되면 망가짐
function testRectangle(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(4);
  console.log(`Expected area: 20, Actual: ${rect.getArea()}`); // Square일 경우 16 출력!
}

// ✅ LSP 준수: 공통 인터페이스로 분리
interface Shape {
  getArea(): number;
}

class RectangleShape implements Shape {
  constructor(private width: number, private height: number) {}

  setWidth(width: number) {
    this.width = width;
  }

  setHeight(height: number) {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class SquareShape implements Shape {
  constructor(private side: number) {}

  setSide(side: number) {
    this.side = side;
  }

  getArea(): number {
    return this.side * this.side;
  }
}

// 안전한 사용: 각 도형을 독립적으로 사용
const rect = new RectangleShape(5, 4);
const square = new SquareShape(4);
console.log(rect.getArea()); // 20
console.log(square.getArea()); // 16

설명

이것이 하는 일: 위 코드는 LSP를 위반하는 잘못된 상속 관계와 이를 해결한 올바른 설계를 보여줍니다. Rectangle-Square 예제는 LSP 위반의 고전적인 사례로, 현실 세계의 관계가 프로그래밍 세계에서는 맞지 않을 수 있음을 보여줍니다.

첫 번째로, Rectangle 클래스는 너비와 높이를 독립적으로 설정할 수 있다는 행동 규약을 가집니다. setWidth를 호출하면 너비만 변경되고 높이는 그대로여야 합니다.

이것이 Rectangle을 사용하는 모든 코드가 기대하는 동작입니다. 그 다음으로, Square 클래스는 이 규약을 깨뜨립니다.

setWidth를 호출하면 높이까지 함께 변경되므로, Rectangle을 기대하는 코드에 Square를 넣으면 예상과 다른 결과가 나옵니다. testRectangle 함수는 너비 5, 높이 4를 설정하여 면적 20을 기대하지만, Square를 넣으면 마지막에 호출된 setHeight(4) 때문에 4x4=16이 나옵니다.

마지막으로, 올바른 해결책은 상속을 사용하지 않고 각각을 독립적인 클래스로 만드는 것입니다. RectangleShape와 SquareShape는 Shape 인터페이스를 구현하지만 서로 대체될 수 없습니다.

각자의 고유한 메서드(setWidth/setHeight vs setSide)를 가지며, 잘못된 사용을 컴파일 타임에 방지할 수 있습니다. 여러분이 이 원칙을 따르면 다형성을 안전하게 사용할 수 있고, 예상치 못한 런타임 에러를 예방할 수 있으며, 코드를 읽는 사람이 동작을 쉽게 예측할 수 있습니다.

또한 단위 테스트 작성이 훨씬 명확해집니다.

실전 팁

💡 "현실에서 A는 B의 일종이다"가 "코드에서 A가 B를 상속해야 한다"를 의미하지 않습니다. 행동을 중심으로 생각하세요.

💡 자식 클래스에서 부모 클래스의 메서드를 빈 구현으로 남기거나 예외를 던진다면 LSP 위반입니다. 상속 대신 컴포지션을 고려하세요.

💡 "계약에 의한 설계(Design by Contract)"를 활용하여 선행 조건, 후행 조건, 불변 조건을 명확히 문서화하세요.

💡 부모 클래스의 protected 멤버를 자식 클래스에서 잘못 사용하면 LSP를 위반하기 쉽습니다. 캡슐화를 강화하세요.

💡 TypeScript의 타입 시스템이 LSP 위반을 모두 잡아주지는 않습니다. 런타임 동작까지 고려한 설계가 필요합니다.


4. ISP 인터페이스 분리 원칙

시작하며

여러분이 특정 기능 몇 개만 필요한데 거대한 인터페이스를 구현해야 해서 사용하지 않는 메서드들을 빈 구현으로 채워야 했던 경험이 있나요? 예를 들어, 단순히 데이터를 읽기만 하면 되는데 쓰기, 삭제, 업데이트 메서드까지 모두 구현해야 하는 경우입니다.

이런 문제는 인터페이스가 너무 비대해질 때 발생합니다. 클라이언트가 필요하지 않은 메서드에까지 의존하게 되면, 해당 메서드가 변경될 때 영향을 받게 됩니다.

또한 불필요한 구현 코드가 늘어나고 테스트해야 할 범위도 넓어집니다. 바로 이럴 때 필요한 것이 인터페이스 분리 원칙(Interface Segregation Principle, ISP)입니다.

이 원칙을 따르면 클라이언트가 필요한 메서드만 가진 작은 인터페이스를 사용하여 불필요한 의존성을 제거할 수 있습니다.

개요

간단히 말해서, 인터페이스 분리 원칙은 "클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다"는 원칙입니다. 즉, 하나의 거대한 인터페이스보다 여러 개의 구체적인 인터페이스가 낫습니다.

왜 이 원칙이 필요한지 실무 관점에서 설명하자면, 불필요한 의존성은 시스템을 취약하게 만들기 때문입니다. 예를 들어, 읽기 전용 데이터 뷰어가 데이터 삭제 메서드를 가진 인터페이스에 의존한다면, 삭제 로직이 변경될 때마다 읽기 전용 코드도 재컴파일하고 재배포해야 할 수 있습니다.

이는 개발 효율성을 떨어뜨립니다. 기존에는 모든 기능을 하나의 인터페이스에 몰아넣었다면, 이제는 역할별로 인터페이스를 분리하여 각 클라이언트가 필요한 것만 의존하도록 할 수 있습니다.

ISP의 핵심 특징은 첫째, 인터페이스를 작고 응집력 있게 유지한다는 것입니다. 둘째, 클라이언트 관점에서 인터페이스를 설계합니다.

셋째, 필요에 따라 여러 인터페이스를 구현할 수 있습니다. 이러한 특징들이 시스템의 결합도를 낮추고 유연성을 높입니다.

코드 예제

// ❌ ISP 위반: 비대한 인터페이스
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  getSalary(): number;
}

class HumanWorker implements Worker {
  work() { console.log('Working...'); }
  eat() { console.log('Eating lunch...'); }
  sleep() { console.log('Sleeping...'); }
  getSalary() { return 50000; }
}

class RobotWorker implements Worker {
  work() { console.log('Working 24/7...'); }
  eat() { /* 로봇은 먹지 않음 - 불필요한 구현 */ }
  sleep() { /* 로봇은 자지 않음 - 불필요한 구현 */ }
  getSalary() { return 0; } // 로봇은 급여 없음 - 불필요한 구현
}

// ✅ ISP 준수: 인터페이스 분리
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

interface Payable {
  getSalary(): number;
}

class Employee implements Workable, Eatable, Sleepable, Payable {
  work() { console.log('Working...'); }
  eat() { console.log('Eating lunch...'); }
  sleep() { console.log('Sleeping...'); }
  getSalary() { return 50000; }
}

class Robot implements Workable {
  // 필요한 인터페이스만 구현
  work() { console.log('Working 24/7...'); }
}

class Manager {
  // 각 메서드는 필요한 인터페이스만 요구
  manageWork(worker: Workable) {
    worker.work();
  }

  provideLunch(eater: Eatable) {
    eater.eat();
  }

  processPayroll(employee: Payable) {
    console.log(`Paying ${employee.getSalary()}`);
  }
}

const employee = new Employee();
const robot = new Robot();
const manager = new Manager();

manager.manageWork(employee); // OK
manager.manageWork(robot); // OK
manager.provideLunch(employee); // OK
// manager.provideLunch(robot); // 컴파일 에러 - 타입 안전성!

설명

이것이 하는 일: 위 코드는 모든 기능을 하나의 인터페이스에 몰아넣은 잘못된 설계와, 역할별로 분리한 올바른 설계를 비교합니다. Worker 인터페이스는 사람과 로봇의 특성을 모두 포함하려다 보니 비효율적이게 되었습니다.

첫 번째로, 비대한 Worker 인터페이스는 RobotWorker에게 eat()과 sleep() 같은 불필요한 메서드를 강요합니다. 이를 구현하는 개발자는 빈 메서드를 만들거나 예외를 던져야 하는데, 이는 코드 냄새(code smell)이자 LSP 위반입니다.

또한 누군가 eat() 메서드의 시그니처를 변경하면 로봇 코드도 영향을 받게 됩니다. 그 다음으로, 분리된 인터페이스들은 각각 하나의 명확한 역할만 표현합니다.

Workable은 일할 수 있는 능력, Eatable은 먹을 수 있는 능력, Sleepable은 잘 수 있는 능력, Payable은 급여를 받을 수 있는 능력을 나타냅니다. 각 클래스는 자신에게 필요한 인터페이스만 골라서 구현할 수 있습니다.

마지막으로, Manager 클래스의 메서드들은 필요한 최소한의 인터페이스만 요구합니다. manageWork는 Workable만 필요로 하므로 Employee든 Robot이든 상관없이 받을 수 있습니다.

반면 provideLunch는 Eatable을 요구하므로 Robot을 전달하면 컴파일 에러가 발생합니다. 이는 런타임 에러를 컴파일 타임에 잡아주는 훌륭한 타입 안전성입니다.

여러분이 이 원칙을 따르면 불필요한 의존성을 제거할 수 있고, 인터페이스 변경의 영향 범위를 최소화할 수 있으며, 더 명확하고 읽기 쉬운 코드를 작성할 수 있습니다. 또한 테스트 시 필요한 인터페이스만 모킹하면 되므로 테스트가 간단해집니다.

실전 팁

💡 인터페이스에 메서드가 10개 이상이라면 분리를 고려해보세요. 대부분의 경우 2-5개의 관련된 메서드가 적절합니다.

💡 "role interface" 패턴을 사용하세요. 각 인터페이스는 하나의 역할을 표현해야 합니다.

💡 여러 인터페이스를 구현하는 것을 두려워하지 마세요. TypeScript는 다중 인터페이스 구현을 잘 지원합니다.

💡 빈 메서드나 NotImplementedException을 던지는 메서드가 있다면 ISP 위반 신호입니다. 인터페이스 분리를 고려하세요.

💡 클라이언트가 실제로 어떤 메서드를 사용하는지 분석하여 인터페이스를 설계하세요. 공급자가 아닌 소비자 관점이 중요합니다.


5. DIP 의존성 역전 원칙

시작하며

여러분이 고수준의 비즈니스 로직이 저수준의 구현 세부사항(데이터베이스, 외부 API 등)에 강하게 결합되어 있어서, 기술 스택을 변경하기 어렵거나 테스트가 힘들었던 경험이 있나요? 예를 들어, 사용자 서비스가 MySQL 구현에 직접 의존하여 PostgreSQL로 변경하기 위해 비즈니스 로직까지 수정해야 하는 경우입니다.

이런 문제는 의존성의 방향이 잘못되었을 때 발생합니다. 고수준 모듈이 저수준 모듈에 의존하면, 저수준의 작은 변경이 전체 시스템에 파급 효과를 일으킵니다.

또한 데이터베이스나 외부 서비스 없이는 단위 테스트를 수행할 수 없게 됩니다. 바로 이럴 때 필요한 것이 의존성 역전 원칙(Dependency Inversion Principle, DIP)입니다.

이 원칙을 따르면 구체적인 구현이 아닌 추상화에 의존하게 되어 유연하고 테스트 가능한 시스템을 만들 수 있습니다.

개요

간단히 말해서, 의존성 역전 원칙은 "고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다"는 원칙입니다. 또한 "추상화는 세부사항에 의존해서는 안 되며, 세부사항이 추상화에 의존해야 한다"는 의미도 포함합니다.

왜 이 원칙이 필요한지 실무 관점에서 설명하자면, 비즈니스 로직은 자주 변하지 않지만 기술적 세부사항은 자주 변경되기 때문입니다. 예를 들어, "사용자를 생성한다"는 비즈니스 규칙은 유지되지만, 데이터를 MySQL에 저장할지, MongoDB에 저장할지, 클라우드 서비스를 사용할지는 언제든 변경될 수 있습니다.

비즈니스 로직이 이런 세부사항에 의존하면 변경 비용이 매우 커집니다. 기존에는 UserService가 MySQLDatabase를 직접 생성하고 사용했다면, 이제는 DatabaseInterface에 의존하고 실제 구현체는 외부에서 주입받을 수 있습니다.

DIP의 핵심 특징은 첫째, 의존성의 방향을 역전시켜 안정적인 추상화에 의존한다는 것입니다. 둘째, 의존성 주입(DI)을 통해 구체적인 구현을 교체 가능하게 만듭니다.

셋째, 고수준 정책과 저수준 세부사항을 분리합니다. 이러한 특징들이 시스템의 유연성과 테스트 용이성을 극대화합니다.

코드 예제

// ❌ DIP 위반: 고수준 모듈이 저수준 모듈에 직접 의존
class MySQLDatabase {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
}

class UserService {
  private database = new MySQLDatabase(); // 구체 클래스에 직접 의존

  createUser(name: string): void {
    // 비즈니스 로직
    const userData = `User: ${name}`;

    // MySQL에 강하게 결합됨
    this.database.save(userData);
  }
}

// ✅ DIP 준수: 추상화에 의존
interface Database {
  save(data: string): void;
}

class MySQLDatabaseImpl implements Database {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
}

class PostgreSQLDatabase implements Database {
  save(data: string): void {
    console.log(`Saving to PostgreSQL: ${data}`);
  }
}

class MongoDatabase implements Database {
  save(data: string): void {
    console.log(`Saving to MongoDB: ${data}`);
  }
}

class UserServiceDIP {
  // 추상화(인터페이스)에 의존
  constructor(private database: Database) {}

  createUser(name: string): void {
    // 비즈니스 로직 (변하지 않음)
    const userData = `User: ${name}`;

    // 어떤 데이터베이스든 상관없이 동작
    this.database.save(userData);
  }
}

// 사용 예시: 런타임에 구현체를 선택
const mysqlDb = new MySQLDatabaseImpl();
const postgresDb = new PostgreSQLDatabase();
const mongoDb = new MongoDatabase();

const userService1 = new UserServiceDIP(mysqlDb);
const userService2 = new UserServiceDIP(postgresDb);
const userService3 = new UserServiceDIP(mongoDb);

userService1.createUser('Alice'); // MySQL에 저장
userService2.createUser('Bob'); // PostgreSQL에 저장
userService3.createUser('Charlie'); // MongoDB에 저장

// 테스트용 Mock 구현
class MockDatabase implements Database {
  savedData: string[] = [];

  save(data: string): void {
    this.savedData.push(data);
  }
}

const mockDb = new MockDatabase();
const testService = new UserServiceDIP(mockDb);
testService.createUser('Test User');
console.log(mockDb.savedData); // ['User: Test User'] - 테스트 검증 가능

설명

이것이 하는 일: 위 코드는 구체 클래스에 직접 의존하는 잘못된 설계와, 추상화를 통해 의존성을 역전시킨 올바른 설계를 비교합니다. UserService의 핵심 비즈니스 로직은 "사용자를 생성한다"이지 "MySQL에 저장한다"가 아닙니다.

첫 번째로, 잘못된 예제에서 UserService는 MySQLDatabase를 내부에서 직접 생성합니다. 이는 두 클래스를 강하게 결합시켜서, PostgreSQL로 변경하려면 UserService 코드를 수정해야 합니다.

또한 실제 데이터베이스 없이 UserService를 테스트할 방법이 없습니다. 이것이 바로 고수준(비즈니스 로직)이 저수준(기술 세부사항)에 의존하는 문제입니다.

그 다음으로, DIP를 준수한 버전에서는 Database 인터페이스를 정의하고, 모든 데이터베이스 구현체가 이를 따르도록 합니다. 이제 의존성의 방향이 역전됩니다.

UserServiceDIP는 Database 인터페이스(추상화)에 의존하고, 구체적인 구현체들(MySQLDatabaseImpl, PostgreSQLDatabase 등)도 Database 인터페이스(추상화)에 의존합니다. 저수준 모듈이 고수준 모듈이 정의한 인터페이스에 의존하게 된 것입니다.

마지막으로, 생성자를 통한 의존성 주입(Constructor Injection)을 사용하여 UserServiceDIP에 어떤 데이터베이스 구현체든 전달할 수 있습니다. 프로덕션에서는 실제 데이터베이스를, 테스트에서는 MockDatabase를 주입하면 됩니다.

이를 통해 비즈니스 로직을 외부 의존성 없이 순수하게 테스트할 수 있습니다. 여러분이 이 원칙을 따르면 데이터베이스나 프레임워크를 쉽게 교체할 수 있고, 빠르고 독립적인 단위 테스트를 작성할 수 있으며, 비즈니스 로직과 기술적 세부사항을 명확히 분리할 수 있습니다.

또한 마이크로서비스 아키텍처나 헥사고날 아키텍처 같은 현대적인 설계 패턴을 구현하기도 쉬워집니다.

실전 팁

💡 의존성 주입 컨테이너(NestJS의 IoC, Angular의 DI 등)를 활용하면 의존성 관리를 자동화할 수 있습니다.

💡 인터페이스는 고수준 모듈이 있는 패키지에 정의하세요. 저수준 구현체가 이를 import하도록 해야 의존성이 역전됩니다.

💡 "new" 키워드 사용을 최소화하세요. 객체 생성은 팩토리나 DI 컨테이너에 위임하는 것이 좋습니다.

💡 Clean Architecture의 핵심이 바로 DIP입니다. 도메인 레이어는 인프라 레이어를 모르고, 인프라가 도메인에 의존합니다.

💡 테스트 더블(Mock, Stub, Spy)을 쉽게 만들 수 있다면 DIP를 잘 따르고 있는 것입니다. 테스트하기 어렵다면 의존성 구조를 재검토하세요.


6. 실전 적용 전략

시작하며

여러분이 SOLID 원칙을 모두 이해했지만, 실제 프로젝트에 어떻게 적용해야 할지 막막한 경험이 있나요? 레거시 코드를 리팩토링할 때는 어디서부터 시작해야 하고, 새 프로젝트에서는 어떤 순서로 적용해야 효과적일지 고민되는 경우입니다.

이런 문제는 SOLID 원칙이 개별적으로는 이해되지만 실무에서 통합적으로 적용하는 방법을 모를 때 발생합니다. 모든 원칙을 한 번에 적용하려다 오히려 과도한 엔지니어링(over-engineering)을 하게 되거나, 실용성을 잃어버릴 수 있습니다.

바로 이럴 때 필요한 것이 단계적이고 실용적인 적용 전략입니다. SOLID 원칙을 언제, 어떻게, 얼마나 적용할지에 대한 실전 가이드를 통해 효과적으로 코드 품질을 향상시킬 수 있습니다.

개요

간단히 말해서, SOLID 원칙의 실전 적용은 "모든 곳에 모든 원칙을 적용"하는 것이 아니라 "필요한 곳에 필요한 만큼" 적용하는 것입니다. 실용주의와 균형이 중요합니다.

왜 이 전략이 필요한지 실무 관점에서 설명하자면, 과도한 추상화는 오히려 코드를 복잡하게 만들고 개발 속도를 늦추기 때문입니다. 예를 들어, 단 한 가지 구현만 있고 앞으로도 변경될 가능성이 거의 없는 부분에 DIP를 적용하면 불필요한 보일러플레이트 코드만 늘어납니다.

변경 가능성이 높은 부분, 복잡도가 높은 부분, 재사용이 필요한 부분에 집중하는 것이 효율적입니다. 기존에는 모든 코드에 SOLID를 적용하려 했다면, 이제는 변경의 축(axis of change)을 식별하고 전략적으로 적용할 수 있습니다.

실전 적용의 핵심 특징은 첫째, 점진적 개선(incremental improvement)을 추구한다는 것입니다. 둘째, 비즈니스 가치와 기술 부채의 균형을 맞춥니다.

셋째, 팀의 역량과 프로젝트 상황을 고려합니다. 이러한 특징들이 지속 가능한 코드 품질 향상을 가능하게 합니다.

코드 예제

// 실전 적용 체크리스트와 우선순위

// 1단계: 새 기능 개발 시 (가장 쉬운 시작점)
class OrderService {
  // DIP: 추상화에 의존 (외부 서비스 통합 시 필수)
  constructor(
    private paymentGateway: PaymentGateway,
    private notificationService: NotificationService
  ) {}

  // SRP: 주문 처리만 담당
  async createOrder(orderData: OrderData): Promise<Order> {
    const order = new Order(orderData);

    // OCP: 새로운 결제 방식 추가 시 코드 수정 불필요
    await this.paymentGateway.process(order.amount);

    // ISP: 필요한 메서드만 사용
    await this.notificationService.sendOrderConfirmation(order);

    return order;
  }
}

// 2단계: 리팩토링 우선순위 결정
class RefactoringStrategy {
  // 높은 우선순위: 자주 변경되는 코드
  identifyHighChangeFrequency(): string[] {
    return [
      '외부 API 통합 코드 → DIP 적용',
      '타입별 분기 코드 → OCP 적용',
      '비대한 클래스 → SRP 적용'
    ];
  }

  // 중간 우선순위: 테스트하기 어려운 코드
  identifyHardToTest(): string[] {
    return [
      '의존성이 많은 클래스 → DIP 적용',
      '불필요한 메서드 의존 → ISP 적용'
    ];
  }

  // 낮은 우선순위: 안정적이고 변경 없는 코드
  identifyStableCode(): string[] {
    return [
      '단순 유틸리티 함수',
      '변경 가능성 낮은 데이터 모델',
      '일회성 스크립트'
    ];
  }
}

// 3단계: 점진적 개선 예시
// Before: SOLID 위반이 많은 레거시 코드
class LegacyUserManager {
  createUser(data: any) {
    // 모든 것을 한 곳에서 처리
    const user = { ...data };
    fetch('/api/users', { method: 'POST', body: JSON.stringify(user) });
    console.log('User created');
    return user;
  }
}

// Step 1: SRP 적용 (가장 먼저)
class ImprovedUserService {
  createUser(data: UserData): User {
    return new User(data);
  }
}

class ImprovedUserRepository {
  save(user: User): Promise<void> {
    return fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(user)
    }).then(() => {});
  }
}

// Step 2: DIP 적용 (테스트 필요 시)
interface UserRepository {
  save(user: User): Promise<void>;
}

class FinalUserService {
  constructor(private repository: UserRepository) {}

  async createUser(data: UserData): Promise<User> {
    const user = new User(data);
    await this.repository.save(user);
    return user;
  }
}

// 4단계: 실용적 균형 찾기
class PragmaticApproach {
  // ✅ 좋은 예: 필요한 곳에만 적용
  handlePayment(gateway: PaymentGateway) {
    // 결제 게이트웨이는 자주 변경되므로 DIP 적용 가치 있음
  }

  // ✅ 좋은 예: 단순한 경우 과도한 추상화 피하기
  formatDate(date: Date): string {
    // 변경 가능성 낮음 - 직접 구현으로 충분
    return date.toISOString();
  }

  // ❌ 나쁜 예: 불필요한 추상화
  // interface DateFormatter { format(date: Date): string; }
  // class ISODateFormatter implements DateFormatter { ... }
  // 이런 간단한 경우 과도함
}

설명

이것이 하는 일: 위 코드는 SOLID 원칙을 실제 프로젝트에 적용하는 전략적 접근법을 보여줍니다. 핵심은 "언제", "어디에", "얼마나" 적용할지를 판단하는 것입니다.

첫 번째로, 새 기능을 개발할 때가 SOLID를 적용하기 가장 좋은 시점입니다. OrderService 예시처럼 처음부터 의존성 주입을 사용하고, 책임을 분리하며, 인터페이스에 의존하도록 설계하면 나중에 리팩토링 비용이 들지 않습니다.

새 코드는 레거시 제약이 없으므로 깨끗하게 시작할 수 있습니다. 그 다음으로, 레거시 코드를 리팩토링할 때는 우선순위를 정해야 합니다.

RefactoringStrategy 클래스가 보여주듯이, 가장 먼저 리팩토링해야 할 곳은 자주 변경되는 코드입니다. 예를 들어, 외부 API 연동 부분은 API 버전 업그레이드나 공급자 변경이 자주 일어나므로 DIP 적용의 가치가 높습니다.

반면 안정적이고 변경이 거의 없는 유틸리티 함수는 낮은 우선순위입니다. 세 번째로, 점진적 개선 전략을 사용합니다.

LegacyUserManager를 한 번에 완벽하게 리팩토링하려 하지 말고, Step 1에서 SRP를 적용하여 책임을 분리하고, 테스트가 필요해지면 Step 2에서 DIP를 추가로 적용합니다. 이렇게 하면 각 단계에서 테스트와 검증이 가능하여 위험을 줄일 수 있습니다.

마지막으로, PragmaticApproach가 보여주는 것처럼 실용적 판단이 중요합니다. 결제 게이트웨이처럼 교체 가능성이 높은 외부 의존성은 인터페이스로 추상화할 가치가 있지만, formatDate처럼 단순하고 변경 가능성이 낮은 함수에 DateFormatter 인터페이스를 만드는 것은 과도한 엔지니어링입니다.

여러분이 이 전략을 따르면 제한된 시간과 리소스를 효율적으로 사용할 수 있고, 팀원들의 혼란을 줄이며, 비즈니스 가치와 코드 품질의 균형을 맞출 수 있습니다. 또한 기술 부채를 관리 가능한 수준으로 유지할 수 있습니다.

실전 팁

💡 "변경의 축"을 식별하세요. 데이터베이스, 외부 API, UI 프레임워크처럼 변경될 가능성이 있는 부분을 먼저 추상화하세요.

💡 보이 스카우트 규칙을 따르세요. "코드를 발견했을 때보다 조금 더 깨끗하게 남겨라." 한 번에 완벽하게 만들려 하지 마세요.

💡 코드 리뷰와 페어 프로그래밍에서 SOLID 위반 사항을 지적하고 개선하세요. 팀 전체의 이해도를 높이는 것이 중요합니다.

💡 정적 분석 도구(ESLint, SonarQube)를 활용하여 순환 의존성, 높은 결합도 등을 자동으로 탐지하세요.

💡 "3번의 법칙"을 기억하세요. 같은 코드가 3번 반복되면 추상화를 고려하되, 그 전에는 중복을 허용하세요. 성급한 추상화는 해롭습니다.


7. 테스트와 SOLID

시작하며

여러분이 코드를 작성한 후 테스트를 작성하려는데, 의존성이 너무 많아서 목(mock) 객체를 수십 개 만들어야 하거나, 데이터베이스를 실제로 띄워야만 테스트할 수 있는 상황을 경험한 적 있나요? 또는 하나의 클래스를 테스트하는데 관련 없는 다른 기능까지 함께 테스트해야 하는 경우입니다.

이런 문제는 테스트하기 어려운 코드 구조를 가지고 있을 때 발생합니다. 테스트가 어렵다는 것은 코드 설계에 문제가 있다는 강력한 신호입니다.

테스트 작성이 고통스러우면 개발자들은 테스트를 건너뛰게 되고, 결국 버그가 많은 불안정한 코드가 됩니다. 바로 이럴 때 필요한 것이 SOLID 원칙입니다.

SOLID를 잘 따르는 코드는 자연스럽게 테스트하기 쉬운 구조를 가지게 됩니다. 테스트 용이성(testability)은 좋은 설계의 부산물입니다.

개요

간단히 말해서, SOLID 원칙과 테스트 주도 개발(TDD)은 서로를 강화하는 관계입니다. 테스트 가능한 코드는 곧 잘 설계된 코드이고, SOLID 원칙을 따르면 자연스럽게 테스트하기 쉬워집니다.

왜 이 관계가 중요한지 실무 관점에서 설명하자면, 테스트가 없는 코드는 리팩토링할 수 없기 때문입니다. 예를 들어, 결제 로직을 개선하고 싶지만 테스트가 없다면 변경 후 기존 기능이 정상 작동하는지 확인할 방법이 없습니다.

테스트가 있어야 안전하게 코드를 개선할 수 있고, SOLID가 그 테스트를 쉽게 만들어줍니다. 기존에는 통합 테스트에만 의존했다면, 이제는 빠른 단위 테스트를 작성하여 개발 피드백 루프를 크게 단축할 수 있습니다.

테스트와 SOLID의 핵심 특징은 첫째, SRP를 따르면 각 클래스를 독립적으로 테스트할 수 있다는 것입니다. 둘째, DIP를 따르면 외부 의존성을 모킹하기 쉬워집니다.

셋째, ISP를 따르면 테스트 더블 생성이 간단해집니다. 이러한 특징들이 테스트 커버리지를 높이고 버그를 조기에 발견하게 해줍니다.

코드 예제

// ❌ 테스트하기 어려운 코드 (SOLID 위반)
class PaymentProcessor {
  processPayment(amount: number, cardNumber: string) {
    // 외부 API에 직접 의존
    const apiUrl = 'https://payment-api.com/charge';
    fetch(apiUrl, {
      method: 'POST',
      body: JSON.stringify({ amount, cardNumber })
    });

    // 데이터베이스에 직접 의존
    const db = new Database();
    db.query('INSERT INTO payments ...');

    // 이메일 전송에 직접 의존
    const emailer = new EmailService();
    emailer.send('payment@example.com', 'Payment processed');
  }
}

// 이 코드를 테스트하려면:
// 1. 실제 결제 API를 호출해야 함 (비용 발생, 느림)
// 2. 실제 데이터베이스 필요 (설정 복잡, 느림)
// 3. 실제 이메일 발송 (외부 서비스 의존)
// 4. 세 가지 모두 성공해야 테스트 통과 (격리되지 않음)

// ✅ 테스트하기 쉬운 코드 (SOLID 준수)
interface PaymentGateway {
  charge(amount: number, cardNumber: string): Promise<PaymentResult>;
}

interface PaymentRepository {
  save(payment: Payment): Promise<void>;
}

interface EmailNotifier {
  sendPaymentConfirmation(email: string, payment: Payment): Promise<void>;
}

class PaymentService {
  constructor(
    private gateway: PaymentGateway,
    private repository: PaymentRepository,
    private emailNotifier: EmailNotifier
  ) {}

  async processPayment(
    amount: number,
    cardNumber: string,
    email: string
  ): Promise<Payment> {
    // 각 의존성은 인터페이스를 통해 주입됨
    const result = await this.gateway.charge(amount, cardNumber);

    const payment = new Payment(amount, result.transactionId);
    await this.repository.save(payment);

    await this.emailNotifier.sendPaymentConfirmation(email, payment);

    return payment;
  }
}

// 테스트 코드: Mock 객체로 쉽게 테스트 가능
class MockPaymentGateway implements PaymentGateway {
  async charge(amount: number, cardNumber: string): Promise<PaymentResult> {
    return { success: true, transactionId: 'mock-123' };
  }
}

class MockPaymentRepository implements PaymentRepository {
  savedPayments: Payment[] = [];

  async save(payment: Payment): Promise<void> {
    this.savedPayments.push(payment);
  }
}

class MockEmailNotifier implements EmailNotifier {
  sentEmails: Array<{ email: string; payment: Payment }> = [];

  async sendPaymentConfirmation(email: string, payment: Payment): Promise<void> {
    this.sentEmails.push({ email, payment });
  }
}

// 단위 테스트 예시
describe('PaymentService', () => {
  it('should process payment successfully', async () => {
    // Arrange: 테스트 준비
    const mockGateway = new MockPaymentGateway();
    const mockRepo = new MockPaymentRepository();
    const mockEmailer = new MockEmailNotifier();

    const service = new PaymentService(mockGateway, mockRepo, mockEmailer);

    // Act: 테스트 실행
    const payment = await service.processPayment(
      10000,
      '1234-5678-9012-3456',
      'user@example.com'
    );

    // Assert: 검증
    expect(payment.amount).toBe(10000);
    expect(mockRepo.savedPayments).toHaveLength(1);
    expect(mockEmailer.sentEmails).toHaveLength(1);
    expect(mockEmailer.sentEmails[0].email).toBe('user@example.com');
  });

  it('should handle payment gateway failure', async () => {
    // 실패 시나리오도 쉽게 테스트
    const failingGateway: PaymentGateway = {
      charge: async () => { throw new Error('Payment failed'); }
    };

    const service = new PaymentService(
      failingGateway,
      new MockPaymentRepository(),
      new MockEmailNotifier()
    );

    await expect(
      service.processPayment(10000, 'invalid', 'user@example.com')
    ).rejects.toThrow('Payment failed');
  });
});

설명

이것이 하는 일: 위 코드는 테스트하기 어려운 강결합 구조와 테스트하기 쉬운 느슨한 결합 구조를 비교합니다. 핵심은 외부 의존성을 격리하여 테스트 가능하게 만드는 것입니다.

첫 번째로, 테스트하기 어려운 PaymentProcessor는 내부에서 직접 외부 서비스를 생성하고 호출합니다. 이런 코드를 테스트하려면 실제 결제 API, 데이터베이스, 이메일 서버가 모두 동작해야 합니다.

테스트가 몇 초에서 몇 분이 걸리고, 네트워크 문제나 외부 서비스 장애로 인해 실패할 수 있습니다. 또한 실제 결제가 발생하면 비용이 들고, 테스트 데이터가 프로덕션 데이터베이스를 오염시킬 수 있습니다.

그 다음으로, SOLID를 준수한 PaymentService는 생성자를 통해 모든 의존성을 주입받습니다(DIP). PaymentGateway, PaymentRepository, EmailNotifier는 인터페이스(추상화)이므로, 테스트에서는 Mock 구현체를 주입할 수 있습니다.

이렇게 하면 외부 서비스 없이 순수하게 PaymentService의 로직만 테스트할 수 있습니다. 세 번째로, Mock 클래스들은 인터페이스를 구현하며 테스트에 필요한 최소한의 동작만 제공합니다.

MockPaymentRepository는 실제 데이터베이스 대신 메모리 배열에 저장하고, MockEmailNotifier는 실제 이메일을 보내지 않고 호출 내역만 기록합니다. 이를 통해 "이메일이 올바른 주소로 발송되었는가?"를 검증할 수 있습니다.

마지막으로, 단위 테스트 예시는 Arrange-Act-Assert 패턴을 보여줍니다. Mock 객체들을 준비하고(Arrange), 실제 메서드를 호출하고(Act), 결과를 검증합니다(Assert).

이 테스트는 외부 의존성이 전혀 없으므로 밀리초 단위로 실행되며, 언제든 재현 가능하고, CI/CD 파이프라인에서 자동으로 실행할 수 있습니다. 여러분이 이 방식을 사용하면 개발 속도가 크게 향상되고(빠른 피드백), 버그를 조기에 발견할 수 있으며(높은 커버리지), 리팩토링을 안전하게 수행할 수 있습니다(테스트가 안전망 역할).

또한 테스트가 문서 역할을 하여 코드의 사용 방법을 명확히 보여줍니다.

실전 팁

💡 테스트 작성이 어렵다면 코드 설계를 의심하세요. 테스트는 설계 피드백 도구입니다.

💡 TDD(Test-Driven Development)를 실천하면 자연스럽게 SOLID를 따르게 됩니다. 테스트를 먼저 작성하면 테스트 가능한 설계를 하게 됩니다.

💡 Jest, Mocha, Vitest 같은 모던 테스트 프레임워크의 모킹 기능을 활용하세요. jest.mock()으로 쉽게 의존성을 교체할 수 있습니다.

💡 테스트 커버리지 목표를 정하세요. 비즈니스 로직은 80% 이상, 전체 코드는 60% 이상을 권장합니다.

💡 통합 테스트와 단위 테스트의 균형을 맞추세요. 테스트 피라미드(많은 단위 테스트, 적은 통합 테스트, 최소한의 E2E 테스트)를 따르세요.


8. 일반적인 안티패턴

시작하며

여러분이 SOLID 원칙을 알고 있지만, 실제로는 자주 위반하는 패턴들을 반복하고 있다는 것을 깨닫는 순간이 있나요? 예를 들어, "잠깐만 여기 if 문 하나만 추가하면 되는데..."라고 생각하며 OCP를 위반하거나, "이 클래스가 좀 크긴 한데 뭐 동작은 하니까"라며 SRP를 무시하는 경우입니다.

이런 문제는 시간 압박, 레거시 코드의 영향, 또는 "나중에 리팩토링하면 되지" 같은 생각 때문에 발생합니다. 하지만 기술 부채는 복리로 늘어나며, 나중에는 감당할 수 없는 수준이 됩니다.

작은 타협들이 쌓여서 결국 유지보수 불가능한 코드가 됩니다. 바로 이럴 때 필요한 것이 일반적인 안티패턴을 인식하고 조기에 차단하는 것입니다.

이러한 패턴들을 알아보고 리팩토링할 수 있다면, 코드 품질을 지속적으로 유지할 수 있습니다.

개요

간단히 말해서, 안티패턴은 "처음에는 해결책처럼 보이지만 결국 더 큰 문제를 만드는 패턴"입니다. SOLID 위반은 대부분 특정 안티패턴의 형태로 나타납니다.

왜 이를 인식하는 것이 중요한지 실무 관점에서 설명하자면, 안티패턴을 조기에 발견하면 적은 비용으로 수정할 수 있기 때문입니다. 예를 들어, God Object(신 객체) 패턴을 초기에 발견하여 클래스를 분리하면 몇 시간이면 되지만, 1년 후에는 수백 개의 의존성이 얽혀서 몇 주가 걸릴 수 있습니다.

기존에는 "일단 돌아가면 OK"라는 사고방식이었다면, 이제는 "코드 냄새(code smell)"를 맡고 즉시 대응하는 습관을 가질 수 있습니다. 안티패턴 인식의 핵심 특징은 첫째, 반복되는 문제 패턴을 학습한다는 것입니다.

둘째, 자동화된 도구로 조기 감지합니다. 셋째, 리팩토링 전략을 미리 준비합니다.

이러한 특징들이 기술 부채를 관리 가능한 수준으로 유지하게 해줍니다.

코드 예제

// 안티패턴 1: God Object (SRP 위반)
// ❌ 모든 것을 하는 거대한 클래스
class UserManager {
  // 사용자 관리
  createUser(data: any) { /* ... */ }
  updateUser(id: string, data: any) { /* ... */ }
  deleteUser(id: string) { /* ... */ }

  // 인증
  login(email: string, password: string) { /* ... */ }
  logout(userId: string) { /* ... */ }

  // 권한 관리
  assignRole(userId: string, role: string) { /* ... */ }
  checkPermission(userId: string, permission: string) { /* ... */ }

  // 이메일 발송
  sendWelcomeEmail(userId: string) { /* ... */ }
  sendPasswordReset(email: string) { /* ... */ }

  // 통계 및 분석
  getUserStatistics(userId: string) { /* ... */ }
  generateReport() { /* ... */ }

  // ... 100개 이상의 메서드
}

// ✅ 리팩토링: 책임별로 분리
class UserService { /* 사용자 CRUD만 */ }
class AuthenticationService { /* 인증만 */ }
class AuthorizationService { /* 권한만 */ }
class EmailService { /* 이메일만 */ }
class UserAnalyticsService { /* 통계만 */ }

// 안티패턴 2: Shotgun Surgery (낮은 응집도)
// ❌ 한 가지 변경을 위해 여러 클래스 수정 필요
class OrderCalculator {
  calculateSubtotal(items: Item[]): number { /* ... */ }
}

class OrderValidator {
  validateDiscount(discount: number): boolean { /* ... */ }
}

class OrderFormatter {
  formatTotal(total: number): string { /* ... */ }
}

// 할인 정책 변경 시 위 세 클래스를 모두 수정해야 함!

// ✅ 리팩토링: 관련 기능을 한 곳에
class OrderPricing {
  calculateSubtotal(items: Item[]): number { /* ... */ }
  applyDiscount(subtotal: number, discount: Discount): number { /* ... */ }
  calculateTotal(subtotal: number, tax: number): number { /* ... */ }
}

// 안티패턴 3: Feature Envy (잘못된 책임 배치)
// ❌ 다른 클래스의 데이터를 지나치게 사용
class InvoiceService {
  calculateTotal(customer: Customer): number {
    let total = 0;

    // Customer의 내부 데이터를 직접 꺼내서 사용
    for (const order of customer.getOrders()) {
      for (const item of order.getItems()) {
        total += item.getPrice() * item.getQuantity();
      }
    }

    // Customer의 할인율도 직접 계산
    if (customer.isPremium()) {
      total *= 0.9;
    }

    return total;
  }
}

// ✅ 리팩토링: 데이터가 있는 곳에 로직도 위치
class Customer {
  calculateTotalSpent(): number {
    return this.orders.reduce((sum, order) => sum + order.getTotal(), 0);
  }

  applyDiscount(amount: number): number {
    return this.isPremium() ? amount * 0.9 : amount;
  }
}

class InvoiceService {
  calculateTotal(customer: Customer): number {
    const subtotal = customer.calculateTotalSpent();
    return customer.applyDiscount(subtotal);
  }
}

// 안티패턴 4: Primitive Obsession (원시 타입 집착)
// ❌ 의미 있는 개념을 원시 타입으로만 표현
function createUser(
  email: string,
  phone: string,
  zipCode: string,
  age: number
) {
  // 유효성 검증이 흩어짐
  if (!email.includes('@')) throw new Error('Invalid email');
  if (age < 0 || age > 150) throw new Error('Invalid age');
  // ...
}

// ✅ 리팩토링: 값 객체(Value Object) 사용
class Email {
  constructor(private value: string) {
    if (!value.includes('@')) throw new Error('Invalid email');
  }

  toString(): string { return this.value; }
}

class Age {
  constructor(private value: number) {
    if (value < 0 || value > 150) throw new Error('Invalid age');
  }

  isAdult(): boolean { return this.value >= 18; }
}

function createUserImproved(
  email: Email,
  phone: PhoneNumber,
  zipCode: ZipCode,
  age: Age
) {
  // 타입 자체가 유효성을 보장
  // 비즈니스 로직에만 집중 가능
}

// 안티패턴 5: Long Parameter List (긴 매개변수 목록)
// ❌ 매개변수가 너무 많음
function sendNotification(
  userId: string,
  title: string,
  message: string,
  priority: string,
  channel: string,
  retryCount: number,
  timeout: number,
  shouldLog: boolean
) { /* ... */ }

// ✅ 리팩토링: 매개변수 객체 사용
interface NotificationOptions {
  userId: string;
  title: string;
  message: string;
  priority: Priority;
  channel: Channel;
  retryCount?: number;
  timeout?: number;
  shouldLog?: boolean;
}

function sendNotificationImproved(options: NotificationOptions) {
  // 가독성 향상, 기본값 설정 쉬움
  const { retryCount = 3, timeout = 5000, shouldLog = true } = options;
  // ...
}

설명

이것이 하는 일: 위 코드는 실무에서 자주 발생하는 다섯 가지 대표적인 안티패턴과 각각의 리팩토링 방법을 보여줍니다. 이러한 패턴들은 SOLID 원칙 위반의 전형적인 징후입니다.

첫 번째로, God Object는 SRP를 심각하게 위반하는 패턴입니다. UserManager처럼 하나의 클래스가 수십 개의 책임을 가지면, 코드를 이해하기 어렵고 수정 시 다른 기능에 영향을 주기 쉽습니다.

리팩토링 방법은 간단합니다. 각 책임을 독립된 서비스 클래스로 분리하면 됩니다.

두 번째로, Shotgun Surgery는 낮은 응집도의 결과입니다. 할인 정책 하나를 변경하기 위해 세 개의 클래스를 수정해야 한다면, 관련 기능이 잘못 분산되어 있다는 신호입니다.

OrderPricing처럼 가격 계산 관련 모든 로직을 한 곳에 모으면 변경이 훨씬 쉬워집니다. 세 번째로, Feature Envy는 메서드가 자신이 속한 클래스보다 다른 클래스의 데이터를 더 많이 사용하는 패턴입니다.

InvoiceService가 Customer의 내부 구조를 파고들어 계산을 한다면, 그 계산은 Customer에 있어야 합니다. "데이터와 로직은 함께 있어야 한다"는 객체 지향의 기본 원칙을 따르세요.

네 번째로, Primitive Obsession은 의미 있는 도메인 개념을 string, number 같은 원시 타입으로만 표현하는 패턴입니다. Email 클래스를 만들면 이메일 유효성 검증 로직이 한 곳에 모이고, 타입 시스템의 도움을 받아 실수를 방지할 수 있습니다.

"문자열이 아니라 이메일이다"라는 도메인 지식을 코드에 표현하세요. 다섯 번째로, Long Parameter List는 함수가 너무 많은 매개변수를 받는 패턴입니다.

매개변수가 5개를 넘어가면 가독성이 떨어지고 함수 호출 시 순서를 혼동하기 쉽습니다. NotificationOptions 같은 인터페이스로 묶으면 의미 단위로 그룹화되고, 선택적 매개변수 처리도 쉬워집니다.

여러분이 이러한 안티패턴을 인식하고 조기에 리팩토링하면 코드베이스가 계속 건강한 상태를 유지할 수 있습니다. 코드 리뷰에서 이런 패턴을 발견했다면 즉시 지적하고 개선하세요.

나중에 할수록 비용은 기하급수적으로 증가합니다.

실전 팁

💡 정기적인 코드 리뷰에서 안티패턴 체크리스트를 사용하세요. 팀 전체가 같은 기준을 공유하는 것이 중요합니다.

💡 SonarQube, ESLint 같은 정적 분석 도구로 복잡도, 중복 코드, 긴 메서드 등을 자동 탐지하세요.

💡 "리팩토링" 책(Martin Fowler)의 카탈로그를 참고하세요. 각 안티패턴마다 검증된 리팩토링 기법이 있습니다.

💡 보이 스카우트 규칙: 안티패턴을 발견하면 지금 당장 고치세요. "나중에"는 영원히 오지 않습니다.

💡 클래스가 200줄, 메서드가 50줄을 넘어가면 분리를 고려하세요. 이는 경험적으로 검증된 좋은 기준입니다.


#TypeScript#SOLID#OOP#DesignPatterns#CleanCode

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

Application Load Balancer 완벽 가이드

AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.

고객 상담 AI 시스템 완벽 구축 가이드

AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.

에러 처리와 폴백 완벽 가이드

AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.

AWS Bedrock 인용과 출처 표시 완벽 가이드

AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.