본문 바로가기

SOLID 원칙 적용

단일 책임과 개방-폐쇄, 리스코프 치환, 인터페이스 분리, 의존성 역전의 다섯 원칙을 타입스크립트의 클래스와 인터페이스로 적용해 보고, 각 원칙이 어떤 변경을 쉽게 만드는지와 타입 검사가 확인하지 못하는 행동의 약속을 예제로 구분합니다.

클래스와 인터페이스를 쓴다고 해서 설계가 저절로 좋아지지는 않습니다. 기능 하나를 고치려는데 관계없어 보이는 여러 파일을 함께 고쳐야 하거나, 새 기능을 더하려면 잘 동작하던 코드에 분기를 하나 더 넣어야 한다면, 설계가 변경을 어렵게 만들고 있는 것입니다. SOLID는 로버트 C. 마틴(Robert C. Martin)이 정리한 다섯 가지 객체지향 설계 원칙의 앞 글자를 모은 이름으로, 이런 문제를 찾아 고치는 질문을 제공합니다.

이 글은 다섯 원칙을 하나씩 타입스크립트 코드로 적용해 봅니다. 원칙마다 고치기 전의 코드에서 변경이 어디에 몰리는지 보고, 고친 뒤에는 같은 변경이 어디로 옮겨 가는지 비교합니다. 클래스나 인터페이스의 개수가 늘었다는 사실만으로 설계가 나아졌다고 판단하지 않고, 실제 변경이 쉬워졌는지를 기준으로 봅니다. 예제는 원칙마다 ts-first 프로젝트에 새 파일을 만들어 확인합니다.

단일 책임 원칙

단일 책임 원칙(single responsibility principle)은 "클래스를 바꿀 이유는 하나뿐이어야 한다"는 원칙입니다. 여기서 책임은 메서드의 개수가 아니라, 함께 바뀌는 업무 규칙의 묶음을 뜻합니다. 서로 다른 사람이나 부서가 따로 바꾸는 규칙들이 한 클래스에 모여 있으면, 한쪽의 변경이 다른 쪽에 영향을 주기 쉽습니다.

srp-before.ts
class UserProfile {
  constructor(private user: { id: string; name: string; email: string }) {}

  displayUser(): void {
    console.log(`User ID: ${this.user.id}, Name: ${this.user.name}`);
  }

  saveUserToDatabase(): void {
    console.log(`Saving user ${this.user.name} to database...`);
  }

  isValidEmail(): boolean {
    return this.user.email.includes("@");
  }
}

UserProfile에는 화면에 보여 주는 일과 저장하는 일, 이메일을 검사하는 일이 함께 있습니다. 화면을 맡은 사람이 표시 방식을 바꾸든, 저장 방식이 바뀌든, 가입 정책이 바뀌든 모두 이 클래스를 고쳐야 합니다. 세 가지 바뀔 이유를 각각의 클래스로 나누면 다음과 같습니다.

srp-after.ts
interface User {
  id: string;
  name: string;
  email: string;
}

class UserView {
  displayUser(user: User): void {
    console.log(`User ID: ${user.id}, Name: ${user.name}`);
  }
}

class UserRepository {
  save(user: User): void {
    console.log(`Saving user ${user.name} to database...`);
  }
}

class UserValidator {
  isValidEmail(email: string): boolean {
    return email.includes("@");
  }
}

const newUser: User = { id: "123", name: "Alice", email: "[email protected]" };
new UserView().displayUser(newUser);
new UserRepository().save(newUser);
console.log(new UserValidator().isValidEmail(newUser.email));
User ID: 123, Name: Alice
Saving user Alice to database...
true

이제 표시 방식이 바뀌면 UserView만, 저장 방식이 바뀌면 UserRepository만, 가입 정책이 바뀌면 UserValidator만 고치면 됩니다. 이메일 검사는 원래의 @ 포함 규칙을 그대로 옮겼으므로, 나누기 전과 후의 결과가 같아야 합니다. 구조를 나누는 일과 검사 규칙을 엄격하게 바꾸는 일을 한 번에 섞으면, 결과가 달라졌을 때 어느 쪽 때문인지 알 수 없게 됩니다.

개방-폐쇄 원칙

개방-폐쇄 원칙(open-closed principle)은 "확장에는 열려 있고, 수정에는 닫혀 있어야 한다"는 원칙입니다. 4장에서 본 것처럼, 새 기능을 더할 때 이미 동작하는 코드를 고치는 대신 새 코드를 추가하는 것으로 해결할 수 있도록 만드는 것입니다.

ocp-before.ts
class DiscountCalculator {
  calculateDiscount(amount: number, customerType: "regular" | "premium" | "vip"): number {
    if (customerType === "regular") return amount * 0.05;
    if (customerType === "premium") return amount * 0.1;
    if (customerType === "vip") return amount * 0.15;
    return 0;
  }
}

새 고객 등급이 생길 때마다 이 메서드에 분기를 더하고 유니온 타입도 고쳐야 하므로, 할인 정책이 바뀔 때마다 잘 동작하던 계산 코드가 함께 바뀝니다. 할인을 계산하는 방법을 인터페이스로 빼내면, 새 정책은 새 클래스를 더하는 것으로 끝납니다.

ocp-after.ts
interface DiscountStrategy {
  calculate(amount: number): number;
}

class RegularDiscount implements DiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.05;
  }
}

class PremiumDiscount implements DiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.1;
  }
}

class OrderCalculator {
  totalPrice(amount: number, strategy: DiscountStrategy): number {
    return amount - strategy.calculate(amount);
  }
}

class GoldDiscount implements DiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.2;
  }
}

const calculator = new OrderCalculator();
console.log(calculator.totalPrice(1000, new RegularDiscount()));
console.log(calculator.totalPrice(1000, new PremiumDiscount()));
console.log(calculator.totalPrice(1000, new GoldDiscount()));
950
900
800

1000원에서 5퍼센트, 10퍼센트, 20퍼센트를 뺀 값이 차례로 출력됩니다. 나중에 더한 GoldDiscount는 OrderCalculator를 한 줄도 고치지 않고 새 클래스만으로 추가되었습니다. 다만 어떤 고객에게 어떤 전략을 줄지 고르는 조립 코드는 바뀌어야 하며, 이 설계는 할인 정책이 바뀐다는 가정에 맞춘 것이므로 세금처럼 다른 종류의 변경까지 해결해 주지는 않습니다.

리스코프 치환 원칙

리스코프 치환 원칙(Liskov substitution principle)은 "부모 타입이 쓰이는 자리에 자식 타입을 넣어도 프로그램이 올바르게 동작해야 한다"는 원칙입니다. 자식 클래스가 부모의 메서드를 재정의하더라도, 부모가 약속한 동작을 어기면 안 된다는 뜻입니다.

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

class Penguin extends Bird {
  override fly(): void {
    throw new Error("Penguins cannot fly!");
  }
}

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

makeBirdFly(new Bird());
makeBirdFly(new Penguin());
I can fly!

첫 호출은 문장을 출력하지만, 둘째 호출은 Error: Penguins cannot fly!를 던지고 멈춥니다. makeBirdFly는 Bird라면 날 수 있다고 믿고 만든 함수인데, Penguin은 Bird의 자리에 들어가면서 그 약속을 어겼습니다. 타입 검사는 Penguin이 Bird의 모양을 갖추었으므로 통과시키며, 이 문제는 실행할 때에야 드러납니다.

해결 방법은 "모든 새는 난다"는 잘못된 약속을 고치는 것입니다. 나는 능력과 걷는 능력을 인터페이스로 나누고, 실제로 그 능력이 있는 클래스만 구현하게 합니다.

lsp-after.ts
interface Flyable {
  fly(): void;
}

interface Walkable {
  walk(): void;
}

class Bird implements Walkable {
  walk(): void {
    console.log("I can walk.");
  }
}

class Eagle extends Bird implements Flyable {
  fly(): void {
    console.log("Eagle soars through the sky!");
  }
}

class Penguin extends Bird {}

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

new Penguin().walk();
makeFly(new Eagle());
I can walk.
Eagle soars through the sky!

이제 makeFly(new Penguin())은 Penguin에 fly가 없으므로 Property 'fly' is missing in type 'Penguin' but required in type 'Flyable'.을 포함한 진단으로 실행 전에 막힙니다. 다만 타입은 모양만 보므로, fly 메서드가 있지만 호출하면 항상 예외를 던지는 객체도 Flyable로 통과합니다. "정상적으로 난다"는 행동의 약속은 타입이 아니라 테스트로 확인해야 하며, 이 차이는 연습 문제에서 다시 봅니다.

더 깊이: 재정의한 메서드의 매개변수를 좁혀도 통과하는 이유

컴파일러는 자식 클래스가 재정의한 메서드의 시그니처가 부모의 것과 맞는지 검사하므로, 치환의 조건 가운데 일부는 타입으로도 확인됩니다. 예를 들어 반환 타입은 부모가 약속한 타입이나 그보다 좁은 타입이어야 하고, 넓은 타입을 돌려주면 진단됩니다. 그런데 매개변수 쪽에는 빈틈이 있습니다. 치환할 수 있으려면 자식의 메서드는 부모의 메서드가 받던 값을 모두 받아야 하는데, 부모의 feed(food: Food)를 자식이 feed(food: Fish)처럼 더 좁은 타입으로 재정의해도 검사를 통과합니다.

이것은 strict에 포함된 strictFunctionTypes의 예외 때문입니다. 이 옵션의 엄격한 비교는 함수 타입 문법에만 적용되고, 클래스와 인터페이스의 메서드 문법에는 매개변수를 어느 방향으로든 할당되기만 하면 통과시키는 느슨한 비교가 남아 있습니다. TSConfig 레퍼런스는 이 예외를 둔 이유로, DOM을 포함해 본래 안전하지 않은 클래스 계층이 많았다는 점을 듭니다. 그래서 부모 타입의 변수로 그 객체를 다루는 코드가 Food의 다른 하위 타입을 넘기면, 검사는 통과하고 실행 중에 자식의 메서드가 예상하지 못한 값을 받게 됩니다. 재정의할 때 매개변수의 타입을 좁히지 않는 것은 타입이 대신 지켜 주지 않는 규칙이므로, 코드 검토와 테스트에서 따로 살펴야 합니다.

인터페이스 분리 원칙

인터페이스 분리 원칙(interface segregation principle)은 "클라이언트가 쓰지 않는 메서드에 의존하게 만들지 말라"는 원칙입니다. 여기서 클라이언트는 그 인터페이스를 쓰는 쪽입니다. 하나의 큰 인터페이스에 모든 기능을 몰아넣으면, 구현하는 클래스는 쓰지도 않는 메서드를 빈 본문으로 채워야 하고, 쓰는 쪽은 필요 없는 기능에까지 묶입니다.

isp-before.ts
interface Staff {
  work(): void;
  manageTeam(): void;
  code(): void;
  designUI(): void;
}

class JuniorDeveloper implements Staff {
  work(): void { console.log("Junior dev working..."); }
  manageTeam(): void {}
  code(): void { console.log("Junior dev coding..."); }
  designUI(): void {}
}

JuniorDeveloper는 팀을 관리하지도 화면을 설계하지도 않지만, Staff의 모양을 갖추려고 빈 메서드를 두 개 두었습니다. 이 빈 메서드는 부르면 아무 일도 하지 않아, 실수로 부른 쪽이 그 사실을 알아차리기도 어렵습니다. 능력마다 작은 인터페이스로 나누면 각 클래스는 실제로 가진 능력만 구현합니다.

isp-after.ts
interface Workable {
  work(): void;
}

interface Manageable {
  manageTeam(): void;
}

interface Codeable {
  code(): void;
}

class JuniorDeveloper implements Workable, Codeable {
  work(): void { console.log("Junior dev working..."); }
  code(): void { console.log("Junior dev coding..."); }
}

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

runCodingTask(new JuniorDeveloper());
runCodingTask({ code: () => console.log("코딩 작업 완료") });
Junior dev coding...
코딩 작업 완료

runCodingTask가 필요로 하는 것은 code 하나뿐이므로, 매개변수의 타입도 Codeable로 좁혔습니다. 그래서 implements를 적지 않은 객체 리터럴도 code만 있으면 넘길 수 있고, 나중에 Manageable에 메서드가 늘어나도 이 함수에는 영향이 없습니다. 인터페이스를 무조건 잘게 나누는 것이 아니라, 실제로 쓰는 쪽이 함께 필요로 하는 능력을 기준으로 나눕니다.

의존성 역전 원칙

의존성 역전 원칙(dependency inversion principle)은 4장 5편에서 본 것처럼, 상위 정책이 하위 구현에 직접 의존하지 않고 둘 다 추상에 의존해야 한다는 원칙입니다. 결제를 처리하는 클래스가 기록하는 클래스를 직접 만들면, 기록 방법을 바꿀 때마다 결제 코드를 고쳐야 합니다.

dip-after.ts
interface PaymentLogger {
  log(message: string): void;
}

class FileLogger implements PaymentLogger {
  log(message: string): void {
    console.log(`[File Log]: ${message}`);
  }
}

class DatabaseLogger implements PaymentLogger {
  log(message: string): void {
    console.log(`[DB Log]: ${message}`);
  }
}

class PaymentProcessor {
  constructor(private logger: PaymentLogger) {}

  processPayment(amount: number): void {
    this.logger.log(`Payment processed: ${amount}`);
  }
}

new PaymentProcessor(new FileLogger()).processPayment(100);
new PaymentProcessor(new DatabaseLogger()).processPayment(200);
[File Log]: Payment processed: 100
[DB Log]: Payment processed: 200

PaymentProcessor는 PaymentLogger 인터페이스만 알고, 어떤 구현을 쓸지는 바깥에서 생성자로 넘겨받습니다. 이렇게 필요한 객체를 바깥에서 넘겨주는 방법을 의존성 주입(dependency injection)이라고 하며, 다음다음 글에서 자세히 다룹니다. 두 로거는 실제로 파일이나 데이터베이스에 쓰지 않고 출력만 하는 학습용 구현입니다.

정책 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 설계는 이 의존 방향을 일반화한 형태입니다.

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

위 그림처럼 결제 정책과 두 로거 구현이 모두 PaymentLogger라는 하나의 약속을 바라보고, 실제 객체를 만들어 잇는 일은 정책 바깥의 조립 코드가 맡습니다. 그래서 테스트에서는 기록을 배열에 모으는 구현을 넘겨, 결제 정책을 고치지 않고도 무엇이 기록되었는지 확인할 수 있습니다.

변경으로 설계 검토하기

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 계약과 외부 생성자 주입으로 바꿨을 때 변경 비용을 확인합니다.

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

다섯 원칙은 외워서 맞추는 규칙이 아니라, 실제로 겪은 변경에 던지는 질문입니다. 위 그림처럼 변경 하나가 여러 클래스를 함께 고치게 했다면 바뀔 이유가 섞였는지를, 새 기능을 더하려고 분기를 늘렸다면 확장할 자리가 있는지를, 자식 타입을 넣었더니 동작이 깨졌다면 부모의 약속이 맞는지를 묻습니다. 쓰지 않는 메서드를 빈 본문으로 채우고 있다면 인터페이스가 너무 큰지를, 구현을 바꾸려는데 정책 코드를 고쳐야 했다면 의존의 방향을 묻습니다.

원칙을 적용한 뒤에는 그 변경이 정말 쉬워졌는지와, 동작이 그대로인지를 테스트로 확인합니다. 원칙의 원래 설명은 로버트 C. 마틴의 단일 책임 원칙 글에서, 구조에 따른 할당 규칙은 공식 타입 호환성 문서에서 확인할 수 있습니다.

정리

  • 단일 책임 원칙은 따로 바뀌는 규칙을 다른 클래스로 나누며, 구조를 나누는 일과 규칙을 바꾸는 일은 섞지 않습니다.
  • 개방-폐쇄 원칙은 바뀔 부분을 인터페이스로 빼내어, 새 기능을 기존 코드의 수정이 아닌 새 구현의 추가로 해결하게 합니다.
  • 리스코프 치환 원칙은 자식 타입이 부모의 약속을 지키게 하며, 타입은 모양만 확인하므로 행동의 약속은 테스트로 확인합니다.
  • 인터페이스 분리 원칙은 쓰는 쪽이 필요로 하는 능력만 담은 작은 인터페이스에 의존하게 합니다.
  • 의존성 역전 원칙은 정책과 구현이 모두 추상에 의존하게 하고, 실제 구현은 바깥에서 주입합니다.

연습 문제

1. 변경마다 고칠 곳

다음 세 요구에서 먼저 떠올릴 원칙과, 고칠 곳과 그대로 둘 곳을 적어 보세요. (가) 25퍼센트 할인 등급을 추가한다. (나) 결제 기록을 콘솔 대신 테스트용 배열에 남긴다. (다) Flyable을 받았는데 fly가 항상 예외를 던진다.

해설 보기

(가)는 개방-폐쇄 원칙의 확장 자리를 씁니다. amount * 0.25를 돌려주는 새 전략 클래스와 그것을 고르는 조립 코드만 더하면, 1000원은 750원이 되고 OrderCalculator는 그대로입니다. (나)는 의존성 역전 원칙에 따라 log로 배열에 메시지를 넣는 새 로거를 만들어 PaymentProcessor에 넘기며, 결제 정책은 고치지 않습니다. (다)는 리스코프 치환 원칙의 행동 약속을 어긴 경우로, 모양은 맞으므로 타입 검사는 통과하고 실제 호출을 확인하는 테스트로만 찾을 수 있습니다.

2. 모양은 맞지만 동작이 다른 객체

const brokenFlyer = { fly(): void { throw new Error("비행 불가"); } };를 makeFly(brokenFlyer)로 넘기면 검사와 실행은 각각 어떻게 되나요? 고치기 전의 Penguin을 makeFly에 넘기는 경우와는 무엇이 다른가요?

해설 보기

brokenFlyer는 fly(): void를 가졌으므로 검사를 통과하고, 실행하면 Error: 비행 불가를 던지며 멈춥니다. 고친 뒤의 Penguin은 fly 자체가 없어서 실행 전에 진단되지만, brokenFlyer는 모양이 맞으니 컴파일러가 막을 방법이 없습니다. 메서드의 이름과 타입은 정상적인 동작을 보장하지 않으므로, 행동의 약속은 실제로 불러 보는 테스트로 확인합니다.

3. 나누기 전과 후의 결과

이메일 검사를 UserValidator로 옮긴 뒤 "a@b", "[email protected]", "invalid"를 검사하면 각각 무엇이 나와야 하나요? 첫 값을 거부하자는 새 요구가 생기면 어떻게 다루어야 하나요?

해설 보기

@ 포함 규칙을 그대로 옮겼으므로 차례로 true, true, false이며, 나누기 전과 같아야 합니다. 첫 값이 갑자기 false가 된다면 구조를 옮기는 작업에 새 규칙이 섞인 것입니다. "a@b"를 거부하는 것은 가입 정책의 변경이므로, 구조를 나누는 일을 마친 뒤 따로 요구와 기대값을 정하고 기존의 허용과 거부 사례와 함께 테스트로 확인합니다.

다음 글 디자인 패턴 구현에서는 이 원칙들을 되풀이해 적용한 결과로 굳어진 설계의 모양인 디자인 패턴을 타입스크립트로 구현해 봅니다.