들어가며

좋은 소프트웨어는 깔끔한 코드로부터 시작한다.
- 로버트 C. 마틴

건물을 지을 때 좋은 벽돌을 사용하지 않으면 건물의 구조가 좋고 나쁨은 큰 의미가 없다고 합니다.
반대로 좋은 벽돌을 사용하더라도 건물의 구조가 엉망이 될 수 있다고 합니다.
여기서 벽돌은 코드를 나타내고 건물의 구조는 소프트웨어 아키텍처를 나타냅니다.
그래서 좋은 코드로 좋은 아키텍처를 정의하는 원칙이 필요한데, 그게 바로 SOLID 원칙입니다.

모든 예제 코드는 TypeScript로 작성되어 있으며 Samuele Resca님의 코드를 그대로 사용했음을 밝힙니다.

SOLID

SOLID 원칙SRP(단일 책임 원칙), OCP(개방 폐쇄 원칙), LSP(리스코프 치환 법칙), ISP(인터페이스 분리 원칙), DIP(의존성 역전 원칙)으로 줄여서 SOLID 원칙이라 합니다.
SOLID 원칙은 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 이들 크래스를 서로 결합하는 방법을 설명해줍니다.
하지만 꼭 객체 지향 소프트웨어 설계 원칙에서만 적용된다는 뜻은 아니라는 것! 또한 원칙을 꼭 지킬 필요는 없다는 것!

SOLID 원칙의 목적

소프트웨어는 부드러움(soft)을 지니도록 만들어졌는데 딱딱한(hard) 기계의 행위를 부드럽게 변경할 수 있도록 하기 위한 것입니다.
다시 말해 소프트웨어는 변경하기 쉬워야 합니다. 따라서 아키텍처는 형태에 독립적이어야 하고, 그럴수록 더 실용적인 아키텍처가 됩니다.
따라서 SOLID의 궁극적인 목적은 변경에 유연해야 한다는 것입니다.

SRP (Single Responsibility Principle)

소프트웨어 모듈은 변경의 이유가 단 하나여야만 한다.

변경의 이유가 단 하나여야만 한다라는 것은 하나의 모듈은 오직 하나의 액터만 책임져야 한다는 뜻입니다.
하나의 모듈의 가장 단순한 정의는 소스 파일입니다. (소스 파일이 모듈이라고는 정의하기는 어려울 것 같네요)
액터는 사용자가 될 수 있고, 이해관계자가 될 수도 있습니다.

소스 파일에 다양하고 많은 메서드를 포함하면 병합이 자주 발생할 것입니다. 특히 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 확실히 더 높습니다. 이 문제를 해결하는 방법은 서로 다른 액터를 뒷받침하는 코드를 분리하는 것입니다.

코드

// User 클래스는 SRP를 따르지 않는다.
class User {
  private db: Database;
  private name: string;
  private birth: Date;
  
  constructor(name: string, birth: Date) {
    this.db = Database.connect();
  }
  
  getUser() {
    return this.name + "(" + this.birth + ")";
  }
  
  save() {
    this.db.users.save({ name: this.name, birth: this.birth });
  }
}


User 클래스는 User에 관한 책임을 가져야만 합니다. 즉, User 클래스는 사용자 모델과 관련된 속성을 정의해야 합니다.
하지만 데이터 접근 기능과 저장 기능까지 정의하고 있습니다.
다시 말해 단일 책임 원칙을 따르지 않고 있다고 할 수 있습니다.

// SRP를 만족한다.
class User {
  constructor(private name: string, private birth: Date) {}

  getUser() {
    return this.name + "(" + this.birth + ")";
  }
}

class UserRepository {
  private db: Database;

  constructor() {
    this.db = Database.connect();
  }

  save(user: User) {
    this.db.users.save(JSON.stringify(user));
  }
}

코드를 분리하여 새롭게 수정했습니다.
이제 User 클래스는 데이터 모델과 관련된 속성을 정의하는 책임만 존재하게 되므로 SRP를 만족하게 됩니다.

OCP (Open-Closed Principle)

소프트웨어 개체는 확장에 열려 있어야 하고, 변경에는 닫혀 있어야 한다.

소프트웨어 개체의 행위는 확장될 수 있어야 하지만, 이때 개체를 변경해서는 안된다는 원칙입니다.
만약 요구사항을 살짝 확장하는 데 소프트웨어를 엄청나게 수정해야 한다면 그 소프트웨어 아키텍처는 엄청난 실패에 맞닥뜨린 것이라고 합니다. 즉, OCP는 시스템의 아키텍처를 떠받치는 원동력 중 하나입니다.
OCP는 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하는 데 있습니다.

코드

// OCP를 만족한다.
class Card {
  private code: String;
  private expiration: Date;
  protected monthlyCost: number;

  constructor(code: String, expiration: Date, monthlyCost: number) {
    this.code = code;
    this.expiration = expiration;
    this.monthlyCost = monthlyCost;
  }

  getCode(): String {
    return this.code;
  }

  getExpiration(): Date {
    return this.expiration;
  }

  monthlyDiscount(): number {
    return this.monthlyCost * 0.02;
  }
}

/** * 골드 카드 확장 */
class GoldCard extends Card {
  monthlyDiscount(): number {
    return this.monthlyCost * 0.05;
  }
}

/** * 실버 카드 확장 */
class SilverCard extends Card {
  monthlyDiscount(): number {
    return this.monthlyCost * 0.03;
  }
}

Card 클래스는 월간 할인을 계산하는 방법을 설명합니다.
월 할인은 카드 유형에 따라 달라지는데 계산을 변경하려면 새로운 카드 유형의 클래스를 만들고 Card 클래스를 상속받아 재정의 하는 것입니다. (확장에 열려있고 변경에 닫혀있게 됩니다.)
객체지향의 특징인 다형성까지 가져갈 수 있습니다.

LSP (Liskov Substitution Principle)

상호 대체 가능한 구성요소를 이용해 소프트웨어를 만들 수 있으려면 이들 구성 요소는 반드시 서로 치환 가능해야 한다.

어떠한 프로그램에서 상호 대체 가능한 객체가 있을 때 서로 치환하더라도 프로그램의 행위가 변하지 않아야 한다는 뜻입니다.
치환 가능성을 조금이라도 위배하면 시스템 아키텍처에 별도 매커니즘을 추가해야 할 수 있기 때문에 중요한 원칙입니다.
대표적으로 직사각형, 정사각형 문제가 있는데 찾아보시는 것을 추천드립니다..!

코드

// LCP을 만족한다.
abstract class Address {
  addressee: string;
  country: string;
  postalCode: string;
  city: string;
  street: string;
  house: number;

  abstract writeAddress(): string;
}

class KoreaAddress extends Address {
  writeAddress(): string {
    return "Formatted Address Korea" + this.city;
  }
}

class UKAddress extends Address {
  writeAddress(): string {
    return "Formatted Address UK" + this.city;
  }
}

class USAAddress extends Address {
  writeAddress(): string {
    return "Formatted Address USA" + this.city;
  }
}

// PrintAddress 메서드에서 받을 파라미터는 치환이 가능하다.
class AddressWriter {
  PrintAddress(writer: Address): string {
    return writer.writeAddress();
  }
}

개방 폐쇄 원칙의 예제와 비슷합니다..!
Address 추상 클래스는 주소에 대한 속성을 가진 클래스입니다.
나라별 주소를 표기하는 법이 모두 다르기 때문에 Address 추상 클래스를 상속받아 재정의하여 구현을 강제하고 있습니다.
AdressWriter 클래스는 주소를 표기하는 클래스입니다.
여기서 리스코프 치환 법칙을 만족하는 코드를 볼 수 있습니다. 즉, 다양한 나라의 클래스가 와도 프로그램의 행위가 변하지 않게 됩니다.
또한 객체지향 프로그래밍 패러다임에 다형성도 보여주고 있네요!

ISP (Interface Segregation Principle)

사용하지 않는 것에 의존하지 말아야 한다.

인터페이스 분리 원칙은 클래스에 의해 구현되는 더 작고 더 구체적인 일련의 인터페이스를 작성해야 한다고 명시합니다.
일반적으로, 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해롭기 때문입니다.
쉽게 말해 불필요한 짐을 실은 무언가에 의존하면 예상치도 못한 문제에 빠질 수 있다는 것입니다.
따라서 인터페이스를 분리하여 동작을 제공해야 합니다.

코드

// ISP를 만족하지 않는다.
interface Printer {
  copyDocument();
  printDocument(document: Document);
  stapleDocument(document: Document, tray: Number);
}

// 사용되지 않는 메서드를 구현하고 있다.
class SimplePrinter implements Printer {
  public copyDocument() {} // 사용 하지 않음

  public printDocument(document: Document) {
    console.log("simple copy", document);
  }

  public stapleDocument(document: Document, tray: Number) {} // 사용 하지 않음
}

SimplePrinter 클래스는 Printer 인터페이스를 구현하고 있습니다.
하지만 SimplePrinter 클래스는 문서를 프린트하는 기능만 있으면 되므로 불필요한 기능들의 구현을 강제하게 됩니다.
다시 말해 사용하지 않는 것에 의존하게 되고 있으므로 ISP를 만족하지 않게 됩니다.

// ISP를 만족한다.
interface Printer {
  printDocument(document: Document);
}

interface Stapler {
  stapleDocument(document: Document, tray: number);
}

interface Copier {
  copyDocument();
}

class SimplePrinter implements Printer {
  public printDocument(document: Document) {}
}

class SuperPrinter implements Printer, Stapler, Copier {
  public copyDocument() {}
  public printDocument(document: Document) {}
  public stapleDocument(document: Document, tray: number) {}
}

인터페이스 분리를 통해 동작을 나눴습니다.
불필요한 기능에 의존하지 않게 되어 훨씬 깔끔한 코드가 되었습니다.
불필요한 기능 때문에 그 기능이 변경되면 의존하는 또 다른 코드는 재컴파일과 재배포를 할 수 있기 때문에 ISP 만족시켜 미연의 방지할 수 있게 됩니다.

DIP (Dependency Inversion Principle)

고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대 의존해서는 안 된다.

의존성 역전 원칙은 구체적이며 변동성이 큰 코드는 절대로 언급하지 말아야 한다는 것입니다.
유연성이 극대화된 시스템은 소스 코드 의존성이 abstraction에 의존하며 concretion에는 의존하지 않는 시스템을 말합니다.
더 구체적으로 말하면 import, include와 같은 구문은 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻입니다.
보통 안정성이 보장된 플랫폼이나 환경에서는 이 원칙을 무시해도 괜찮습니다.
우리가 의존하지 않도록 하는 것은 바로 변동성이 큰 구체적인 요소이기 때문에 현재 개발 중이라 변경될 수밖에 없는 모듈들이기 때문입니다.

코드

// DIP를 만족하지 않는다.
class CarWindow {
  open() {
    //...
  }

  close() {
    //...
  }
}

class WindowSwitch {
  private isOn = false;

  constructor(private window: CarWindow) {}

  onPress() {
    if (this.isOn) {
      this.window.close();
      this.isOn = false;
    } else {
      this.window.open();
      this.isOn = true;
    }
  }
}

고수준의 정책을 구현하는 곳인 WindowSwitch 클래스가 있다고 해봅시다. 그리고 저수준 세부사항을 구현하는 곳인 CarWindow 클래스가 있다고 해봅시다.
CarWindow 클래스가 왜 세부사항을 구현하는 저수준 코드냐면 open(), close() 세부사항을 구현하고 있기 때문입니다.
현재 WindowSwitch 클래스에서 저수준의 CarWindow를 의존하고 있는데 이는 DIP에 어긋나고 있다고 할 수 있습니다.

// DIP를 만족한다. 
// 인터페이스를 활용하여 고수준에서 저수준을 의존하는 것을 역전시킨다.
interface IWindow {
  open();
  close();
}

class CarWindow implements IWindow {
  open() {
    //...
  }

  close() {
    //...
  }
}
// 고수준인 WindowSwitch는 저수준인 CarWindow를 더 이상 의존하지 않는다.
class WindowSwitch {
  private isOn = false;

  constructor(private window: IWindow) {}

  onPress() {
    if (this.isOn) {
      this.window.close();
      this.isOn = false;
    } else {
      this.window.open();
      this.isOn = true;
    }
  }
}

DIP를 만족시키기 위해 고수준에서 저수준을 의존하는 것을 인터페이스를 통해 역전시킵니다.
이제 더 이상 고수준을 구현하는 WindowSwitch 클래스는 저수준 세부사항을 구현하는 코드에 의존하지 않게 되며 소스 코드의 의존성은 제어의 흐름과는 반대 방향으로 역전되게 됩니다. 이러한 이유로 이 원칙을 의존성 역전이라 부릅니다.

뛰어난 소프트웨어 설계자라면 인터페이스의 변동성을 낮추기 위해 애쓴다고 합니다.
즉, 안정된 소프트웨어 아키텍처는 변동성이 큰 구현체에 의존하는 일은 피하고, 안정된 추상 인터페이스를 선호해야 한다는 것입니다.

 

 

출처: https://bbaktaeho-95.tistory.com/98 [Bbaktaeho:티스토리]

'Develop' 카테고리의 다른 글

인텔리제이 단축키 모음  (0) 2022.06.14
객체지향 개발 5대 원칙(SOLID)  (0) 2022.06.13
  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기