본문 바로가기

SOLID 원칙 적용

단일 책임·개방 폐쇄·리스코프 치환·인터페이스 분리·의존성 역전 원칙을 TypeScript 설계 사례에 적용합니다.

객체지향 프로그래밍(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('@'); // 기존 예제의 검사 규칙을 그대로 이동
  }

  isValidUser(user: User): boolean {
    return this.isValidEmail(user.email);
  }
}

// 사용 예시
const newUser: User = { id: '123', name: 'Alice', email: '[email protected]' };

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.");
}

각 클래스나 인터페이스가 명확히 하나의 책임만을 가지도록 분리했습니다.

책임 분리와 정책 변경을 구별합니다. 여기서는 원래 isValidEmail()의 @ 포함 규칙을 UserValidator로 그대로 옮겼습니다. 예를 들어 a@b는 분리 전후 모두 true입니다. . 포함이나 이름 길이 조건을 함께 추가하면 구조 변경과 동작 변경이 섞입니다. 더 엄격한 가입 정책이 필요하면 별도 요구와 기대값으로 검증합니다. 이 검사 자체는 실제 이메일 주소의 완전한 유효성 판정이 아닙니다.

이제 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 준수 (좋은 예)
// 할인 전략을 위한 인터페이스 정의
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에는 필요한 fly 멤버가 없으므로
                               // 함수 인자로 전달될 수 없다는 것을 컴파일러가 알려줍니다.

Flyable과 Walkable 인터페이스를 분리하여 더 세분화된 계약을 만들었습니다.

이제 makeFlyingAnimalFly는 fly(): void 멤버가 있는 값을 받습니다. 위 Penguin에는 그 멤버가 없어 정적 검사에서 거부됩니다. implements 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 인터페이스를 여러 개의 작은 인터페이스로 분리했습니다.

이제 JuniorDeveloper나 CEO는 자신이 실제로 필요한 기능에 해당하는 인터페이스만 구현하면 됩니다.

이는 클래스가 불필요한 메서드를 구현해야 하는 부담을 줄여주고, 코드의 재사용성과 유연성을 높입니다.

ISP의 클라이언트는 그 계약을 사용하는 쪽입니다. 코딩 작업을 요청하는 함수가 필요한 것은 code()이지 식사·팀 관리 능력 전체가 아닙니다. 아래는 별도 파일에서 실행할 수 있는 작은 호출자 예제입니다.

coding-client.ts
interface Codeable {
  code(): void;
}

function runCodingTask(worker: Codeable): void {
  worker.code();
}

const codeOnly = {
  code(): void { console.log("코딩 작업 완료"); },
};
runCodingTask(codeOnly);

codeOnly는 implements 선언 없이도 필요한 멤버를 제공합니다. 호출자는 Codeable에만 의존하므로 팀 관리 메서드가 추가되어도 이 함수의 계약에는 영향을 주지 않습니다. 작은 인터페이스를 무조건 많이 만드는 것보다 실제 사용자가 함께 필요로 하는 능력을 기준으로 나눕니다.


아키텍처 설계와 의존성 역전 원칙

원칙

고수준 모듈은 저수준 모듈에 의존해서는 안 된다.

이들 모두 추상화에 의존해야 한다.

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

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

설명: 특정 구현체에 직접 의존하는 대신, 추상화된 인터페이스나 추상 클래스에 의존해야 합니다.

즉, 변화하기 쉬운 구체적인 구현(저수준 모듈)보다는 변하기 어려운 추상화(고수준 모듈)에 의존해야 한다는 원칙입니다.

이는 흔히 의존성 주입(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 구현체든 주입받을 수 있으며, 이는 유연성과 테스트 용이성을 크게 향상시킵니다.

정책 PaymentProcessor와 구체 logger 구현에서 ILogger로 소스 의존 화살표가 향한다. 객체 생성과 연결은 별도의 조립 코드가 담당한다.

DIP / SOURCE DEPENDENCY

결제 정책과 로거 구현이 같은 ILogger 계약에 의존한다

PaymentProcessor는 구체적인 FileLogger 대신 ILogger를 받습니다. FileLogger와 DatabaseLogger도 그 계약을 구현하고, 바깥의 조립 코드가 실제 로거를 골라 생성자에 주입합니다.

결제 정책과 로거 구현이 같은 ILogger 계약에 의존한다정책 PaymentProcessor와 구체 logger 구현에서 ILogger로 소스 의존 화살표가 향한다. 객체 생성과 연결은 별도의 조립 코드가 담당한다.PaymentProcessorILogger를 사용ILoggerlog(message): voidFile / DatabaseLoggerILogger를 구현화살표: 소스 의존이 향하는 계약
소스 의존은 ILogger 계약으로 향한다위의 PaymentProcessor와 아래의 File·DatabaseLogger가 가운데 ILogger 계약으로 의존 화살표를 보낸다. 객체 생성·주입 방향의 그림이 아니다.PaymentProcessorILogger를 사용ILoggerlog(message): voidFile / DatabaseLoggerILogger를 구현

POLICY

필요한 최소 계약

PaymentProcessor의 생성자와 필드는 ILogger를 사용합니다. FileLogger를 직접 만드는 책임을 정책에서 꺼냅니다.

ADAPTER

구현체가 계약을 따른다

FileLogger·DatabaseLogger는 log를 구현합니다. 계약을 만족하는 새 구현을 주입하면 결제 코드를 바꾸지 않고 교체할 수 있습니다.

COMPOSE

바깥에서 생성하고 연결

new PaymentProcessor(fileLogger)처럼 환경에 맞는 객체를 조립합니다. 실제 연결과 수명 정책은 이 경계에서 따로 정합니다.

LESSON SCOPE

실제 예제의 범위

교재의 두 로거는 콘솔에만 출력합니다. 파일·DB 연결과 객체 수명 관리까지 구현한 예는 아닙니다. domain port/adapter 설계는 이 의존 방향을 일반화한 형태입니다.

의존성 역전은 인터페이스 개수보다 구체 구현 의존과 생성 책임이 어디에 있는지를 확인합니다.

위 그림은 로거 예제를 정책 소유 계약과 외부 구현의 의존 방향으로 일반화한 것입니다. 원문 예제의 FileLogger와 DatabaseLogger는 콘솔로 메시지만 출력하며, 실제 파일·DB 연동과 객체 수명 관리는 구현하지 않습니다.


변경 신호로 설계 검토하기

SRP 변경 주체, OCP 새 할인 전략, LSP 비행 계약, ISP 불필요한 Worker 메서드, DIP ILogger 의존을 동등한 5카드로 비교한다.

TYPE / RUNTIME / CONTRACT

다섯 SOLID 질문을 구체적인 변경 사례에 대응시킨다

행동과 의존 두 갈래만으로 원칙을 고르지 않습니다. 어떤 변경 때문에 기존 코드를 고쳤는지 기록하고, 각 원칙의 질문으로 작은 설계 변경을 검토합니다.

SRP

누가 바꾸는 책임인가?

UI·저장·검증 변경이 UserProfile 한 곳을 고친다면 UserView·Repository·Validator 경계를 검토합니다. 하나의 함수만 갖는다는 뜻은 아닙니다.

OCP

새 변형은 어디에 추가하는가?

할인 정책이 추가될 때 기존 계산 조건을 계속 고치는 비용을 확인합니다. IDiscountStrategy 구현을 더하는 확장점을 비교합니다.

LSP

대체해도 행동 계약을 지키는가?

Bird 대신 Penguin을 넣어 fly가 예외를 던지면 대체 약속이 깨집니다. Flyable/Walkable로 필요한 능력을 나누고 실제 행동을 검증합니다.

ISP

사용하지 않는 메서드를 요구하는가?

JuniorDeveloper·CEO가 Worker의 불필요한 메서드를 채운다면 각 클라이언트가 사용하는 작은 계약으로 나눕니다.

DIP

정책이 구체 기술을 만드는가?

PaymentProcessor의 new FileLogger()를 찾아 ILogger 계약과 외부 생성자 주입으로 바꿨을 때 변경 비용을 확인합니다.

원칙 이름을 붙인 것만으로 성공하지 않습니다. 실제 변경과 행동 검증으로 비용이 줄었는지 확인합니다.

책임 분리, 확장 경계, 대체 가능성, 인터페이스 크기, 의존 방향을 실제 변경 사례의 질문으로 확인합니다. 테스트가 어느 책임의 실패를 가리키는지 좁히고, 변경 비용이 줄었는지 판단합니다.

변경을 따라가며 확인하기

SRP 예제에서 화면 담당자가 이메일 표시를 숨기고, 저장 담당자가 저장 방식을 바꾸며, 가입 정책 담당자가 허용할 이메일 조건을 바꾼다고 가정해 보세요. 원래 UserProfile에서는 세 변경이 같은 클래스에 닿지만 분리한 코드에서는 각각 UserView·UserRepository·UserValidator로 이동합니다. User 데이터의 필드 자체가 바뀌면 세 사용처에 영향이 갈 수도 있습니다. SRP는 모든 변경을 한 파일에 가두는 보장이 아닙니다. 이메일 검사는 원문에서 제시한 간단한 규칙을 비교하는 예이며 실제 이메일 주소의 모든 유효성을 검증한 구현은 아닙니다.

OCP 예제에서는 새 할인율을 전략 구현에 추가해도 OrderCalculator의 계산 경로를 유지할 수 있습니다. 어떤 전략을 쓸지 고르는 조립 코드는 바뀔 수 있습니다. 확장하려는 변화가 할인 정책이라는 가정이 있으므로, 새로운 세금 계산이나 잘못된 금액 처리까지 이미 해결했다고 읽지 않습니다.

LSP는 인터페이스의 모양과 실행 행동을 함께 봅니다. TypeScript는 필요한 멤버의 구조가 맞으면 값을 받을 수 있으므로, 다음 값도 Flyable을 받는 함수에 전달할 수 있습니다.

flight-contract-check.ts
interface Flyable {
  fly(): void;
}

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

const brokenFlyer = {
  fly(): void {
    throw new Error("비행 동작을 제공하지 않습니다");
  },
};

makeFlyingAnimalFly(brokenFlyer);

이 별도 파일은 정적 검사에서는 멤버의 모양이 맞지만 실행하면 예외를 던집니다. 호출자가 정상 비행 동작을 기대한다는 행동 계약까지 타입만으로 증명한 것은 아닙니다. 원래 Penguin처럼 fly 자체가 없는 경우의 정적 오류와 구별하세요.

스스로 변경 경계를 고르기

다음 세 요구에서 먼저 검토할 원칙, 고칠 위치, 그대로 둘 위치를 적어 보세요. 하나의 요구에 원칙이 여러 개 관련될 수 있으므로 이름만 쓰지 말고 이유를 설명합니다.

  1. 새 할인율 25%를 추가한다. 금액은 1,000이고 기존 할인 계산 계약을 따른다.
  2. 결제 로깅을 콘솔 대신 테스트용 배열에 남긴다. 결제 정책은 log(message)만 호출하며 파일·DB 연결은 사용하지 않는다.
  3. Flyable 형태의 값을 받았지만 fly가 항상 예외를 던진다. 호출자는 정상 비행 동작을 기대한다.
판단과 풀이 확인
  1. OCP의 확장점을 검토합니다. 새 전략의 계산은 1,000 × (1 − 0.25) = 750입니다. 전략 구현과 이를 선택하는 조립 코드를 추가하고 기존 계산기가 전략에 위임하는 경로는 유지할 수 있습니다. 금액 검증 같은 별도 요구는 이 산술의 전제가 아닙니다.
  2. DIP의 소스 의존 방향과 DI의 객체 조립을 함께 확인합니다. log를 구현하는 배열 로거를 바깥에서 생성해 PaymentProcessor에 넘깁니다. 결제 정책은 같은 ILogger 계약을 사용하며, 배열이 실제 파일에 저장된다고 주장하지 않습니다.
  3. LSP의 행동 계약 문제입니다. 형태 호환이 되어도 정상 동작이라는 전제가 깨지므로 실제 호출 검증이 필요합니다. 메서드 이름을 추가하는 것만으로 비행 능력이 생기지 않습니다.

SRP의 변경 주체 해석은 Robert C. Martin의 원문 설명을, 구조에 따른 할당 가능성은 TypeScript 타입 호환성 문서를 함께 읽어 확인할 수 있습니다.

구조 변경의 회귀와 호출자의 계약 연습

  1. 책임만 분리하는 작업에서 a@b, [email protected], invalid의 이메일 검사 결과를 분리 전후 비교하세요. a@b를 거부하자는 새 요구가 생겼다면 어느 변경 단계와 테스트에서 다룹니까?
  2. const managerOnly = { manageTeam(): void {} };를 앞의 runCodingTask에 넘길 수 있습니까? implements Codeable을 선언하지 않은 codeOnly는 왜 받을 수 있습니까?
계약 보존과 타입 판단 풀이
  1. 기존의 @ 포함 규칙에서는 차례로 true, true, false이며 분리 뒤에도 같습니다. 첫 값이 갑자기 false가 되면 책임을 옮기는 작업에 새 정책이 섞인 것입니다. a@b를 거부하는 조건은 가입 정책 변경으로 따로 명세하고, 해당 기대값과 기존 허용·거부 사례를 함께 검증합니다. SRP는 더 엄격한 이메일 검사의 정답을 정하는 원칙이 아닙니다.
  2. managerOnly에는 필수 code()가 없어 정적 검사에서 거부됩니다. codeOnly에는 그 메서드가 있으므로 구조가 맞습니다. 두 판단 모두 클래스 이름이나 명시적 implements의 유무 대신 호출자가 요구한 멤버를 확인합니다. 다만 실제 코딩 동작을 수행하는지는 타입 검사만으로 증명하지 않습니다.