SOLID with Typescript

realBro·2024년 3월 13일

SOLID

  • 객체 지향 개발 5대 원리
  • 유지 보수의 용이성, 유연한 구조, 확장이 쉬운 소프트웨어를 만들기 위해 사용

SRP (단일책임의 원칙; Single Responsibility Principle)

  • 클래스는 하나의 기능만 가지며 하나의 책임을 수행하는데 집중
  • 어떤 변화에 의해 클래스를 변경해야 하는 이유는 오직 하나여야 함
  • 하나의 책임이 여러개의 클래스에 분산되어 있는 경우, 하나의 클래스에 모아서 하나의 책임을 할당

예시

< 커피 주문 시스템 개발 >

  • 1) 주문 받기, 2) 커피 제조, 3) 커피 전달의 기능 필요 가 필요하다고 했을때,
    Cashier 라는 객체에 모든 책임을 할당한다고 가정.
class Cashier {
    orderbook: Orderbook;
  
    constructor(orderbook: Orderbook) {
      this.orderbook = orderbook;
    }
  
    takeOrders(menuName: string, quantity: number): boolean {
        const madeCoffee = this.makeCoffee(menuName, quantity);
        this.deliveryOrder(this.orderbook.getUserName(), madeCoffee);
    }

    calculatePrice(menuName: string, quantity: number): number {}

    makeCoffee(menuName: string, quantity: number): Coffee {}

    deliveryOrder(toCustomer: string, coffee: Coffee): void {}
}

처음엔 문제가 없겠지만 메뉴가 추가되거나 가격 계산 방식이 변경된다고 했을때 Cashier 클래스는 변경되어야 한다.
즉, Cashier 클래스를 변경시키는 요소가 2개 이상이다. -> SRP 원칙 위배

  • 위 코드의 문제점
    • 각기 다른 책임의 기능들이 책임의 범위가 다름에도 강한 결함도를 맺게 되고, 변화가 발생했을 때 연쇄적으로 변화가 발생한다.
    • 적절한 관심사가 분리되어 있지 않아서 코드의 가독성이 떨어짐
    • 재가용성이 떨어짐, 관심사를 분리하지 않는다면 코드의 중복이 자연스럽게 생기고 유지 보수성과 재사용성이 떨어짐
export interface Beverage {}

export class OrderTakerService {
    constructor(private readonly orderbookService: OrderbookService) {}
    takeOrders(menuName: string, quantity: number): boolean {}
}

export class BeverageMakerService {
    makeBeverage(menuName: string, quantity: number): Beverage {}
}

export class PriceCalculatorService {
    calculatePrice(menuName: string, quantity: number): number {}
}

export class OrderDeliveryService {
    deliveryOrder(toCustomer: string, beverage: Beverage): void {}
}

// Beverage 클래스
  • Cashier 클래스가 하던 역할을 네가지로 분리 -> 주문, 음료 제작, 계산, 전달
  • Coffee 에서 다른 음료가 사용 될 수 있게 Beverage로 수정
  • 각 역할에 따라 분리, 어떤 변화에 의해 변경될 수 있는 이유를 하나로 만들기!
  • 최종적으로 다음 컨트롤러 처럼 사용
@Controller('orders')
export class OrdersController {
    constructor(
        private readonly orderTakerService: OrderTakerService,
        private readonly beverageMakerService: BeverageMakerService,
        private readonly priceCalculatorService: PriceCalculatorService,
        private readonly orderDeliveryService: OrderDeliveryService,
    ) {}

    @Post()
    async createOrder(@Body() body: { menuName: string; quantity: number }) {
        const { menuName, quantity } = body;

        if (this.orderTakerService.takeOrders(menuName, quantity)) {
            const beverage = this.beverageMakerService.makeBeverage(menuName, quantity);
            const price = this.priceCalculatorService.calculatePrice(menuName, quantity);
            this.orderDeliveryService.deliveryOrder('Customer Name', beverage);
            return { beverage, price };
        } else {
            return { status: 'Order failed' };
        }
    }
}

OCP (개방폐쇄의 원칙; Open and Close Principle)

  • 소프트웨어의 구성요스는 확장에는 열려있고 변경에는 닫혀있어햐 한다.
    • 클래스를 수정하지 않고 확장할 수 있어야 한다.
  • 변경될 것과 변하지 않을 것을 엄격히 구분
  • 공통된 특징을 기반으로 추상화 혹은 인터페이스를 정의
  • 구현에 의존하지 않고 인터페이스에 의존

예시

< 결제 할인 기능 >

switch(payType){
	case "telecome":
		// 통신사 할인 정책 기반 할인 요금 계산 로직
	case "membership":
		// 맴버쉽 할인 정책 기반 할인 요금 계산 로직
	case "payco":
		// 페이코 할인 정책 기반 할인 요금 계산 로직
	default:
		return 0;
}
  • 위 코드에서 새로운 정책(새로운 기능)을 추가 할 경우, 기존 코드를 수정해야한다. -> OCP 위반
  • 해당 경우에도기존 코드를 수정하지 않는 것을 OCP라고 함

적용 방법

  1. 변경될 것과 변화하지 않는 것을 구분해야함.
    • 결제 방식, 결제 정책은 변경 가능하나, 계산한다는 행위는 변하지 않음
  2. 공통된 특징 기반으로 추상화 또는 인터페이스 정의
  3. 구현에 의존하지말고 추상화에 의존
// 추상화 또는 인터페이스
interface DiscountPolicy {
    isSatisfied(): boolean;
    calculateDiscountAmount(): number;
}

// 팩토리얼
function makeDiscountPolicyBy(discountType: string): DiscountPolicy {
    switch(discountType) {
        case "telecome":
            return new TelecomeDiscountPolicy();
        case "membership":
            return new MembershipDiscountPolicy();
        case "payco":
            return new PaycoDiscountPolicy();
        default:
            return new NoneDiscountPolicy();
    }
}

// 호출부 예시
const discountType: string = "telecome"; // 가정: "telecome"이 입력됨
const discountPolicy: DiscountPolicy = makeDiscountPolicyBy(discountType);

function getDiscountAmount(discountPolicy: DiscountPolicy): number {
    if (discountPolicy.isSatisfied()) {
        return discountPolicy.calculateDiscountAmount();
    }
    return 0;
}

LSP (리스코브 치환의 원칙; Liskov Substitution Principle)

  • 서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다.
  • LSP가 위반되면 OCP또한 지킬 수 없게 됨
  • 기능 명세를 잘 지켜야 한다!

예시 1

class Item {
    itemName: string;
    price: number;

    constructor(itemName: string, price: number) {
        this.itemName = itemName;
        this.price = price;
    }

    getPrice(): number {
        return this.price;
    }
}

class NoDiscountItem extends Item {}

class DoubleDiscountItem extends Item {}

class OwnerCrazyItem extends Item {}

class Coupon {
    discountRate: number;

    constructor(discountRate: number) {
        this.discountRate = discountRate;
    }

    calculateDiscountAmount(item: Item): number {
        if (item instanceof NoDiscountItem) {
            return 0;
        } else if (item instanceof DoubleDiscountItem) {
            return item.getPrice() * (this.discountRate * 2);
        } else if (item instanceof OwnerCrazyItem) {
            return item.getPrice() * (this.discountRate * 70);
        } else {
            return item.getPrice() * this.discountRate;
        }
    }
  • calculateDiscountAmount는 item에 대한 하위 타입을 알 필요가 없지만 내부 로직상 NoDiscountItem인지 혹은 다른 할인인지 확인 하고 있다. 부모인 Item이 자식인 NoDiscountIteme등에 대해 완벽하게 대체할 수 없다. -> LSP 위반
  • 만약 새로운 타입이 생긴다면 계속해서 if else가 이어짐
수정 방법(1); 추상화
abstract class Item {
  private itemName: string;
  private price: number;
  private isDiscount: boolean;

  constructor(itemName: string, price: number, isDiscount: boolean) {
    this.itemName = itemName;
    this.price = price;
    this.isDiscount = isDiscount;
  }

  isSatisfied(): boolean {
    return this.isDiscount;
  }

  getPrice(): number {
    return this.price;
  }
}

class EnableDiscountItem extends Item {
  constructor(itemName: string, price: number) {
    super(itemName, price, true); // EnableDiscountItem 항상 할인 적용
  }
}

class DisableDiscountItem extends Item {
  constructor(itemName: string, price: number) {
    super(itemName, price, false); // DisableDiscountItem 할인 적용 안 함
  }
}

class Coupon {
  private discountRate: number;

  constructor(discountRate: number) {
    this.discountRate = discountRate;
  }

  calculateDiscountAmount(item: Item): number {
    if (item.isSatisfied()) {
      return item.getPrice() * this.discountRate;
    } else {
      return 0;
    }
  }
}

// 사용 예시
const coupon = new Coupon(0.1); // 예시로 할인율을 10%로 설정
console.log(coupon.calculateDiscountAmount(new DisableDiscountItem("iPhone15", 1500000)));
수정 방법(2); 인터페이스
interface Item {
    isEnableDiscount(): boolean;
    getPrice(): number;
}

class EnableDiscountItem implements Item {
    private readonly itemName: string;
    private readonly price: number;
    private readonly isDiscount: boolean;

    constructor(itemName: string, price: number) {
        this.itemName = itemName;
        this.price = price;
    }

    isEnableDiscount(): boolean {
        return true;
    }

    getPrice(): number {
        return this.price;
    }
}

class DisableDiscountItem implements Item {
    private readonly itemName: string;
    private readonly price: number;
    private readonly isDiscount: boolean;

    constructor(itemName: string, price: number) {
        this.itemName = itemName;
        this.price = price;
    }

    isEnableDiscount(): boolean {
        return false;
    }

    getPrice(): number {
        return this.price;
    }
}

class Coupon {
    private discountRate: number;

    constructor(discountRate: number) {
        this.discountRate = discountRate;
    }

    calculateDiscountAmount(item: Item): number {
        if (item.isEnableDiscount()) {
            return item.getPrice() * this.discountRate;
        } else {
            return 0;
        }
    }
}

// 사용 예시
const coupon = new Coupon(10); // 예시로, 할인율을 10으로 설정
const enableDiscountItem = new EnableDiscountItem("EnableItem", 100);
console.log(coupon.calculateDiscountAmount(enableDiscountItem)); // 할인 적용 예시

const disableDiscountItem = new DisableDiscountItem("DisableItem", 100;
console.log(coupon.calculateDiscountAmount(disableDiscountItem)); // 할인 미적용 예시

예시2

  • 잘못된 코드
interface Bird {
    fly(): void;
    walk(): void;
}

interface FlyingBird extends Bird {
    fly(): void;
}

interface WalkingBird extends Bird {
    walk(): void;
}

class Parrot implements Bird {
    fly(): void {}
    walk(): void {}
}

class Penguin implements WalkingBird {
    fly(): void {} // 날지 못하지만 인터페이스 때문에 구현해야함.
    walk(): void {}
}

const p: Bird = new Penguin();
p.fly(); // 실행 가능하지만, 펭귄이 실제로 날지 못해 에러(라고 가정)
  • 수정된 코드
interface Bird {}

interface FlyingBird extends Bird {
    fly(): void;
}

interface WalkingBird extends Bird {
    walk(): void;
}

class Parrot implements FlyingBird, WalkingBird {
    fly(): void {}
    walk(): void {}
}

class Penguin implements WalkingBird {
    walk(): void {}
}

ISP (인터페이스 분리의 원칙; Interface Segregation Principle)

  • 객체는 자신이 사용하는 기능에만 의존해야 한다. 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야한다.
  • car라는 class가 AutoPilot을 상속하는것은 문제가 되지 않지만 오른쪽, 왼쪽이 없는 기차는 해당 기능을 사용하지 않기 때문에 사용하지 않는 method와 의존성이 생긴다.
  • IAutoPilot의 인터페이스 규모가 필요보다 크다는 이야기
  • 위 그림에 대해서 ISP를 위반하지 않고 제대로 정의하면 다음과 같다
interface IGoforward {
    goForward(distance: number): void;
}

interface IGobackward {
    goBackward(distance: number): void;
}

interface IGoLeft {
    goLeft(distance: number): void;
}

interface IGoRight {
    goRight(distance: number): void;
}

class Car implements IGoforward, IGobackward, IGoLeft, IGoRight {
    goForward(distance: number): void {}
    goBackward(distance: number): void {}
    goLeft(distance: number): void {}
    goRight(distance: number): void {}
}

class Train implements IGoforward, IGobackward {
    goForward(distance: number): void {}
    goBackward(distance: number): void {}
}

DIP (의존성역전의 원칙; Dependency Inversion Principle)

  • 추상화, 인터페이스에 의존해야 함
  • A 객체에서 기능 구현을 위해 B객체의 C 메서드를 사용해야 하는 경우, A는 B에 대한 정보를 알고 있어야함.
  • 이런 구체화된 객체와 의존 관계를 맺는것이 아니라, 추상화된 혹은 인터페이스와 의존 관계를 맺어야 한다는 원칙
  • 변화에 유연하게 대처할 수 있는 구조 방식을 만들어줌, OCP를 위한 뒷받침
  • 하위 타입의 구체적인 내용에 클라이언트가 의존하게 된다면 하위 타입의 변화가 있을 때마다 클라이언트나 상위 모듈의 코드를 자주 수정하게 된다. 수정은 나쁘다!
  • 규칙
    • 상위 타입은 하위 타입에 의존해선 안된다
    • 추상화는 세부 사항에 의존해선 안된다.

예시

  • PayService가 SamsungPay의 pay에 의존하여 결제를 진행 중.
interface PaymentMethod {
    pay(amount: number): void;
}

class SamsungPay implements PaymentMethod {
    connect(): boolean {
        return this.auth();
    }

    private auth(): boolean {
        return true;
    }

    public pay(amount: number): void {
        console.log("SamsungPay: PAY", amount);
    }
}
....
class PayService {
    private samsungPay: SamsungPay;
    private applyPay: ApplyPay;
    private payco: Payco;
    private naverPay: NaverPay;

    constructor(s: SamsungPay, a: ApplyPay, p: Payco, n: NaverPay) {
        this.samsungPay = s;
        this.applyPay = a;
        this.payco = p;
        this.naverPay = n;
    }

    public pay(type: string, amount: number): void {
        console.log("간편결제 서비스 호출");
        switch (type) {
            case "samsung":
                this.samsungPay.pay(amount);
                break;
            case "apply":
                this.applyPay.pay(amount);
                break;
            case "payco":
                this.payco.pay(amount);
                break;
            case "naverpay":
                this.naverPay.pay(amount);
                break;
            default:
                throw new Error("지원하지 않는 결제수단 입니다.");
        }
    }
}

수정

interface Payment {
    pay(amount: number): boolean;
}

class PayService {
    private payment: Payment;

    constructor(payment: Payment) {
        this.payment = payment;
    }

    pay(toAmount: number): void {
        console.log("간편결제 서비스 호출");
        this.payment.pay(toAmount);
    }
}

class SamsungPay implements Payment {
    connect(): boolean {
        return this.auth();
    }

    private auth(): boolean {
        return true;
    }

    pay(amount: number): boolean {
        console.log("SamsungPay: PAY", amount);
        return true;
    }
}

참고

https://www.nextree.co.kr/p6960/
https://i-am-your-father.notion.site/SOLID-java-e535e8a0473842f1960d418cb0610c3e#8a705566a05e4ca38ac5ebb369a91cfe
https://charming-kyu.tistory.com/35
https://levelup.gitconnected.com/the-liskov-substitution-principle-made-simple-5e69165e7ab5

0개의 댓글