icon
10장 : 객체지향 프로그래밍

SOLID 원칙 적용


객체지향 프로그래밍(OOP)은 소프트웨어 설계를 위한 강력한 패러다임이지만, 단순히 클래스와 객체를 사용하는 것만으로는 좋은 설계를 보장하지 않습니다. 유지보수하기 쉽고, 확장 가능하며, 견고한 소프트웨어를 만들기 위해서는 특정 설계 원칙들을 따라야 합니다. 그중 가장 널리 알려지고 중요한 원칙들이 바로 SOLID 원칙입니다.

SOLID는 로버트 C. 마틴(Robert C. Martin), 일명 "Uncle Bob"이 소개한 다섯 가지 객체지향 설계 원칙의 약어입니다. 이 원칙들은 타입스크립트와 같은 객체지향을 지원하는 언어로 소프트웨어를 개발할 때도 동일하게 적용될 수 있으며, 타입 시스템의 이점을 활용하여 더욱 견고한 설계를 구현할 수 있게 돕습니다.

각 원칙을 타입스크립트 예시와 함께 자세히 살펴보겠습니다.


단일 책임 원칙

원칙: 클래스는 단 하나의 변경 이유만 가져야 한다. 즉, 클래스나 모듈은 오직 하나의 기능에 대한 책임만 져야 합니다.

설명: 어떤 클래스를 변경해야 하는 이유가 여러 가지라면, 이 클래스는 SRP를 위반하고 있는 것입니다. SRP를 준수하면 코드의 응집도가 높아지고, 특정 기능의 변경이 다른 기능에 미치는 영향을 최소화하여 유지보수성을 향상시킵니다.

타입스크립트 적용 예시

SRP 위반 (나쁜 예)

class UserProfile {
  private user: { id: string; name: string; email: string };

  constructor(id: string, name: string, email: string) {
    this.user = { id, name, email };
  }

  // 사용자 정보 표시 (UI 관련 책임)
  displayUser(): void {
    console.log(`User ID: ${this.user.id}`);
    console.log(`Name: ${this.user.name}`);
    console.log(`Email: ${this.user.email}`);
  }

  // 사용자 데이터 저장 (데이터 저장 책임)
  saveUserToDatabase(): void {
    console.log(`Saving user ${this.user.name} to database...`);
    // 실제 데이터베이스 로직 (생략)
  }

  // 사용자 이메일 유효성 검사 (유효성 검사 책임)
  isValidEmail(): boolean {
    return this.user.email.includes('@');
  }
}

// 이 클래스는 사용자 정보 표시, 저장, 유효성 검사라는 세 가지 책임을 가집니다.
// 만약 UI가 변경되거나, 데이터베이스 로직이 변경되거나, 유효성 검사 로직이 변경되면
// 이 UserProfile 클래스를 수정해야 합니다.

SRP 준수 (좋은 예)

// 1. 사용자 데이터 모델 (데이터 구조 정의 책임)
interface User {
  id: string;
  name: string;
  email: string;
}

// 2. 사용자 정보 표시 로직 (UI 표시 책임)
class UserView {
  displayUser(user: User): void {
    console.log(`User ID: ${user.id}`);
    console.log(`Name: ${user.name}`);
    console.log(`Email: ${user.email}`);
  }
}

// 3. 사용자 데이터 저장 로직 (데이터 저장 책임)
class UserRepository {
  save(user: User): void {
    console.log(`Saving user ${user.name} to database...`);
    // 실제 데이터베이스 로직
  }
}

// 4. 사용자 데이터 유효성 검사 로직 (유효성 검사 책임)
class UserValidator {
  isValidEmail(email: string): boolean {
    return email.includes('@') && email.includes('.');
  }

  isValidUser(user: User): boolean {
    return this.isValidEmail(user.email) && user.name.length > 0;
  }
}

// 사용 예시
const newUser: User = { id: '123', name: 'Alice', email: 'alice@example.com' };

const userView = new UserView();
userView.displayUser(newUser);

const userRepository = new UserRepository();
userRepository.save(newUser);

const userValidator = new UserValidator();
if (userValidator.isValidUser(newUser)) {
  console.log("User data is valid.");
}

각 클래스나 인터페이스가 명확히 하나의 책임만을 가지도록 분리했습니다. 이제 UI가 변경되면 UserView만, 데이터베이스가 변경되면 UserRepository만, 유효성 검사가 변경되면 UserValidator만 수정하면 됩니다.


개방-폐쇄 원칙

원칙: 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.

설명: 새로운 기능을 추가할 때 기존 코드를 수정하는 대신, 확장을 통해 기능을 추가할 수 있도록 설계해야 합니다. 이는 주로 인터페이스나 추상 클래스를 사용하여 추상화된 기반 위에서 새로운 기능을 구현하도록 유도합니다.

타입스크립트 적용 예시

OCP 위반 (나쁜 예)

// 할인율을 계산하는 클래스
class DiscountCalculator {
  calculateDiscount(amount: number, customerType: 'regular' | 'premium' | 'vip'): number {
    if (customerType === 'regular') {
      return amount * 0.05; // 5% 할인
    } else if (customerType === 'premium') {
      return amount * 0.10; // 10% 할인
    } else if (customerType === 'vip') {
      return amount * 0.15; // 15% 할인
    }
    return 0;
  }
}

// 새로운 고객 유형(예: 'gold')이 추가되면 calculateDiscount 메서드를 수정해야 합니다.
// 이는 수정에 대해 열려 있는 것으로 OCP를 위반합니다.

OCP 준수 (좋은 예)

// 할인 전략을 위한 인터페이스 정의
interface IDiscountStrategy {
  calculate(amount: number): number;
}

// 일반 고객 할인 전략
class RegularDiscountStrategy implements IDiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.05;
  }
}

// 프리미엄 고객 할인 전략
class PremiumDiscountStrategy implements IDiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.10;
  }
}

// VIP 고객 할인 전략
class VipDiscountStrategy implements IDiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.15;
  }
}

// 할인을 적용하는 계산기 클래스 (수정할 필요 없음)
class OrderCalculator {
  calculateTotalPrice(amount: number, discountStrategy: IDiscountStrategy): number {
    const discount = discountStrategy.calculate(amount);
    return amount - discount;
  }
}

// 사용 예시
const orderCalculator = new OrderCalculator();

const regularCustomerPrice = orderCalculator.calculateTotalPrice(1000, new RegularDiscountStrategy());
console.log(`Regular customer price: ${regularCustomerPrice}`); // 950

const premiumCustomerPrice = orderCalculator.calculateTotalPrice(1000, new PremiumDiscountStrategy());
console.log(`Premium customer price: ${premiumCustomerPrice}`); // 900

// 새로운 고객 유형 'Gold' 추가 시
class GoldDiscountStrategy implements IDiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.20; // 20% 할인
  }
}

const goldCustomerPrice = orderCalculator.calculateTotalPrice(1000, new GoldDiscountStrategy());
console.log(`Gold customer price: ${goldCustomerPrice}`); // 800

// OrderCalculator 클래스는 수정 없이 새로운 할인 전략을 '확장'할 수 있습니다.

IDiscountStrategy 인터페이스를 도입하여 할인 전략을 추상화했습니다. 새로운 할인 정책이 추가되더라도 OrderCalculator 클래스는 변경할 필요 없이 새로운 IDiscountStrategy 구현체를 생성하여 주입해주기만 하면 됩니다.


리스코프 치환 원칙

원칙: 서브타입은 언제나 자신의 기반 타입(Base type)으로 교체할 수 있어야 한다. 즉, 부모 클래스의 객체 대신 자식 클래스의 객체를 사용해도 프로그램의 동작에는 이상이 없어야 합니다.

설명: 상속 관계에서 부모 클래스의 기능을 자식 클래스가 오버라이드할 때, 계약(Contract)을 위반해서는 안 됩니다. 예를 들어, 부모 클래스가 특정 메서드가 항상 양수를 반환한다고 약속했다면, 자식 클래스도 이 약속을 지켜야 합니다.

타입스크립트 적용 예시

LSP 위반 (나쁜 예)

class Bird {
  fly(): void {
    console.log("I can fly!");
  }
}

class Penguin extends Bird {
  // 펭귄은 날 수 없으므로, fly 메서드를 오버라이드하여 예외를 발생시키거나 빈 동작을 합니다.
  fly(): void {
    throw new Error("Penguins cannot fly!"); // 또는 console.log("Penguins can't fly.");
  }
}

function makeBirdFly(bird: Bird): void {
  bird.fly();
}

const eagle = new Bird();
const penguin = new Penguin();

makeBirdFly(eagle);    // "I can fly!"
makeBirdFly(penguin);  // 런타임 오류 발생: "Penguins cannot fly!"
// makeBirdFly 함수는 Bird 타입의 객체를 기대하지만, Penguin 객체는 예상치 못한 동작을 합니다.

LSP 준수 (좋은 예)

// 비행 능력을 나타내는 인터페이스
interface Flyable {
  fly(): void;
}

// 걷기 능력을 나타내는 인터페이스
interface Walkable {
  walk(): void;
}

// 일반적인 조류
class Bird implements Walkable { // 모든 새가 날 수 있는 것은 아니므로, Flyable을 직접 구현하지 않습니다.
  walk(): void {
    console.log("I can walk.");
  }
}

// 독수리는 날 수 있으므로 Flyable과 Walkable 모두 구현
class Eagle extends Bird implements Flyable {
  fly(): void {
    console.log("Eagle soars through the sky!");
  }
}

// 펭귄은 날 수 없지만 걸을 수 있음
class Penguin extends Bird { // Bird를 상속하여 walk 기능을 가짐
  // fly 메서드를 구현하지 않음 (Flyable 인터페이스를 구현하지 않았으므로 문제가 없음)
}

function makeFlyingAnimalFly(animal: Flyable): void {
  animal.fly();
}

function makeWalkingAnimalWalk(animal: Walkable): void {
  animal.walk();
}

const eagle = new Eagle();
const penguin = new Penguin();

makeWalkingAnimalWalk(eagle); // "I can walk."
makeWalkingAnimalWalk(penguin); // "I can walk."

makeFlyingAnimalFly(eagle); // "Eagle soars through the sky!"
// makeFlyingAnimalFly(penguin); // 컴파일 오류: 'Penguin' 형식은 'Flyable' 형식에 할당될 수 없습니다.
                               // Penguin은 Flyable 인터페이스를 구현하지 않았으므로
                               // 함수 인자로 전달될 수 없다는 것을 컴파일러가 알려줍니다.

FlyableWalkable 인터페이스를 분리하여 더 세분화된 계약을 만들었습니다. 이제 makeBirdFly와 같은 함수는 Flyable 인터페이스를 구현한 객체만 받으므로, 펭귄이 인자로 들어와 예외를 발생시키는 일이 없어집니다. LSP는 주로 디자인 패턴에서 다형성(Polymorphism)을 올바르게 활용하는 데 중요합니다.


인터페이스 분리 원칙

원칙: 클라이언트는 자신이 사용하지 않는 인터페이스에 의존해서는 안 된다. 즉, 인터페이스는 작고, 응집력 있으며, 구체적인 여러 개의 인터페이스로 분리되어야 합니다.

설명: 거대한 "뚱뚱한" 인터페이스를 사용하는 대신, 각 클라이언트가 필요한 기능만을 담은 작고 특화된 인터페이스를 제공해야 합니다. 이는 불필요한 의존성을 줄이고, 구현체의 유연성을 높입니다.

타입스크립트 적용 예시

ISP 위반 (나쁜 예)

// 너무 큰 단일 인터페이스
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  manageTeam(): void; // 모든 Worker에게 필요하지 않을 수 있는 기능
  code(): void;       // 모든 Worker에게 필요하지 않을 수 있는 기능
  designUI(): void;   // 모든 Worker에게 필요하지 않을 수 있는 기능
}

class JuniorDeveloper implements Worker {
  work(): void { console.log("Junior dev working..."); }
  eat(): void { console.log("Junior dev eating..."); }
  sleep(): void { console.log("Junior dev sleeping..."); }
  manageTeam(): void { /* 필요 없지만 구현해야 함 */ } // 빈 구현 또는 오류
  code(): void { console.log("Junior dev coding..."); }
  designUI(): void { /* 필요 없지만 구현해야 함 */ } // 빈 구현 또는 오류
}

class CEO implements Worker {
  work(): void { console.log("CEO working..."); }
  eat(): void { console.log("CEO eating..."); }
  sleep(): void { console.log("CEO sleeping..."); }
  manageTeam(): void { console.log("CEO managing team..."); }
  code(): void { /* 필요 없지만 구현해야 함 */ } // 빈 구현 또는 오류
  designUI(): void { /* 필요 없지만 구현해야 함 */ } // 빈 구현 또는 오류
}
// JuniorDeveloper나 CEO는 자신이 사용하지 않는 manageTeam, code, designUI 등의 메서드를
// 강제로 구현해야 하므로 ISP를 위반합니다.

ISP 준수 (좋은 예)

// 작은 단위로 분리된 인터페이스들
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

interface Managable {
  manageTeam(): void;
}

interface Codeable {
  code(): void;
}

interface Designable {
  designUI(): void;
}

class JuniorDeveloper implements Workable, Eatable, Sleepable, Codeable {
  work(): void { console.log("Junior dev working..."); }
  eat(): void { console.log("Junior dev eating..."); }
  sleep(): void { console.log("Junior dev sleeping..."); }
  code(): void { console.log("Junior dev coding..."); }
  // manageTeam()이나 designUI()는 구현할 필요가 없습니다.
}

class CEO implements Workable, Eatable, Sleepable, Managable {
  work(): void { console.log("CEO working..."); }
  eat(): void { console.log("CEO eating..."); }
  sleep(): void { console.log("CEO sleeping..."); }
  manageTeam(): void { console.log("CEO managing team..."); }
  // code()나 designUI()는 구현할 필요가 없습니다.
}

// 각 클라이언트(클래스)는 자신이 필요한 인터페이스만 구현합니다.
// 이는 불필요한 구현을 강제하지 않고 코드의 유연성을 높입니다.

Worker 인터페이스를 여러 개의 작은 인터페이스로 분리했습니다. 이제 JuniorDeveloperCEO는 자신이 실제로 필요한 기능에 해당하는 인터페이스만 구현하면 됩니다. 이는 클래스가 불필요한 메서드를 구현해야 하는 부담을 줄여주고, 코드의 재사용성과 유연성을 높입니다.


의존성 역전 원칙

원칙

고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 이들 모두 추상화에 의존해야 한다.

추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.

설명: 특정 구현체에 직접 의존하는 대신, 추상화된 인터페이스나 추상 클래스에 의존해야 합니다. 즉, 변화하기 쉬운 구체적인 구현(저수준 모듈)보다는 변하기 어려운 추상화(고수준 모듈)에 의존해야 한다는 원칙입니다. 이는 흔히 의존성 주입(Dependency Injection, DI) 을 통해 구현됩니다.

타입스크립트 적용 예시

DIP 위반 (나쁜 예)

// 저수준 모듈: 파일 시스템에 직접 로그를 기록
class FileLogger {
  log(message: string): void {
    console.log(`[File Log]: ${message}`);
    // 실제 파일 쓰기 로직 (생략)
  }
}

// 고수준 모듈: 특정 로거 구현체(FileLogger)에 직접 의존
class PaymentProcessor {
  private fileLogger: FileLogger; // FileLogger 구체 클래스에 직접 의존

  constructor() {
    this.fileLogger = new FileLogger(); // 직접 인스턴스 생성
  }

  processPayment(amount: number): void {
    // 결제 처리 로직...
    this.fileLogger.log(`Payment processed: ${amount}`); // FileLogger에 직접 의존
  }
}

// 만약 로그 시스템을 데이터베이스 로거로 변경하려면 PaymentProcessor 코드를 수정해야 합니다.

DIP 준수 (좋은 예)

// 추상화: 로거 인터페이스 (고수준 모듈과 저수준 모듈 모두 이 인터페이스에 의존)
interface ILogger {
  log(message: string): void;
}

// 저수준 모듈 구현: 파일 시스템 로거 (ILogger 추상화에 의존)
class FileLogger implements ILogger {
  log(message: string): void {
    console.log(`[File Log]: ${message}`);
    // 실제 파일 쓰기 로직
  }
}

// 저수준 모듈 구현: 데이터베이스 로거 (ILogger 추상화에 의존)
class DatabaseLogger implements ILogger {
  log(message: string): void {
    console.log(`[DB Log]: ${message}`);
    // 실제 데이터베이스 로깅 로직
  }
}

// 고수준 모듈: PaymentProcessor (ILogger 추상화에 의존)
class PaymentProcessor {
  private logger: ILogger; // 구체적인 구현체가 아닌 ILogger 인터페이스에 의존

  // 의존성 주입 (생성자 주입)
  constructor(logger: ILogger) {
    this.logger = logger;
  }

  processPayment(amount: number): void {
    // 결제 처리 로직...
    this.logger.log(`Payment processed: ${amount}`); // ILogger 인터페이스를 통해 로그
  }
}

// 사용 예시
const fileLogger = new FileLogger();
const filePaymentProcessor = new PaymentProcessor(fileLogger);
filePaymentProcessor.processPayment(100); // [File Log]: Payment processed: 100

const databaseLogger = new DatabaseLogger();
const dbPaymentProcessor = new PaymentProcessor(databaseLogger);
dbPaymentProcessor.processPayment(200); // [DB Log]: Payment processed: 200

// PaymentProcessor는 FileLogger나 DatabaseLogger에 직접 의존하지 않고,
// ILogger라는 추상화에 의존합니다. 새로운 로거 구현체가 추가되어도 PaymentProcessor는 변경할 필요가 없습니다.

ILogger 인터페이스를 정의하여 PaymentProcessor (고수준 모듈)가 구체적인 로거 구현체(저수준 모듈)가 아닌 추상화에 의존하도록 만들었습니다. PaymentProcessor는 생성자를 통해 어떤 ILogger 구현체든 주입받을 수 있으며, 이는 유연성과 테스트 용이성을 크게 향상시킵니다.


SOLID 원칙은 단순히 외워야 할 규칙이 아니라, 재사용 가능하고, 유연하며, 이해하기 쉬운 소프트웨어를 만들기 위한 지침입니다. 타입스크립트는 강력한 타입 시스템과 인터페이스, 추상 클래스 등의 기능을 통해 이러한 SOLID 원칙을 더욱 효과적으로 구현할 수 있도록 돕습니다. 이 원칙들을 코드에 적용하려는 노력을 통해 더욱 견고하고 유지보수하기 쉬운 객체지향 설계를 만들 수 있을 것입니다.