Skip to content

SOLID

단일 책임 원칙(SRP)

Clean Code에서 언급한 대로, "클래스를 변경해야 하는 이유는 한 가지 이상이어서는 안 됩니다". 비행기로 한 개의 수하물만 가지고 갈 수 있을 때처럼 클래스에 많은 기능을 집어넣고 싶은 유혹이 있습니다. 하지만 이 경우 클래스가 개념적으로 응집력을 잃고 변경 사유가 많아집니다. 클래스 수정 횟수를 최소화하는 것이 중요한데, 이는 하나의 클래스에 너무 많은 기능이 있고 일부를 수정할 경우 코드베이스의 다른 의존 모듈에 어떤 영향을 미칠지 파악하기 어렵기 때문입니다.

나쁜 예:

javascript
class UserSettings {
  constructor(user) {
    this.user = user;
  }

  changeSettings(settings) {
    if (this.verifyCredentials()) {
      // ...
    }
  }

  verifyCredentials() {
    // ...
  }
}

좋은 예:

javascript
class UserAuth {
  constructor(user) {
    this.user = user;
  }

  verifyCredentials() {
    // ...
  }
}

class UserSettings {
  constructor(user) {
    this.user = user;
    this.auth = new UserAuth(user);
  }

  changeSettings(settings) {
    if (this.auth.verifyCredentials()) {
      // ...
    }
  }
}

개방-폐쇄 원칙(OCP)

Bertrand Meyer가 설명한 대로 "소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에는 열려있고 수정에는 닫혀 있어야 합니다". 구체적으로 이 원칙은 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있도록 설계해야 함을 의미합니다.

나쁜 예:

javascript
class AjaxAdapter extends Adapter {
  constructor() {
    super();
    this.name = "ajaxAdapter";
  }
}

class NodeAdapter extends Adapter {
  constructor() {
    super();
    this.name = "nodeAdapter";
  }
}

class HttpRequester {
  constructor(adapter) {
    this.adapter = adapter;
  }

  fetch(url) {
    if (this.adapter.name === "ajaxAdapter") {
      return makeAjaxCall(url).then(response => {
        // transform response and return
      });
    } else if (this.adapter.name === "nodeAdapter") {
      return makeHttpCall(url).then(response => {
        // transform response and return
      });
    }
  }
}

function makeAjaxCall(url) {
  // request and return promise
}

function makeHttpCall(url) {
  // request and return promise
}

좋은 예:

javascript
class AjaxAdapter extends Adapter {
  constructor() {
    super();
    this.name = "ajaxAdapter";
  }

  request(url) {
    // request and return promise
  }
}

class NodeAdapter extends Adapter {
  constructor() {
    super();
    this.name = "nodeAdapter";
  }

  request(url) {
    // request and return promise
  }
}

class HttpRequester {
  constructor(adapter) {
    this.adapter = adapter;
  }

  fetch(url) {
    return this.adapter.request(url).then(response => {
      // transform response and return
    });
  }
}

리스코프 치환 원칙(LSP)

간단한 개념을 어렵게 표현한 용어입니다. 공식적인 정의는 "S가 T의 하위 타입일 때, 프로그램의 정확성이나 수행 기능 등 주요 특성을 변경하지 않고 T 타입 객체를 S 타입 객체로 대체할 수 있어야 한다"입니다. 이 정의는 오히려 이해를 더 어렵게 합니다.

이를 설명하는 가장 좋은 방법은 부모 클래스와 자식 클래스가 서로 교체 가능해야 한다는 것입니다. 사각형-직사각형 예제를 살펴보면, 수학적으로 정사각형은 직사각형이지만 'is-a' 상속 관계로 모델링할 경우 문제가 발생합니다.

나쁜 예:

javascript
class Rectangle {
  constructor() {
    this.width = 0;
    this.height = 0;
  }

  setColor(color) {
    // ...
  }

  render(area) {
    // ...
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  setWidth(width) {
    this.width = width;
    this.height = width;
  }

  setHeight(height) {
    this.width = height;
    this.height = height;
  }
}

function renderLargeRectangles(rectangles) {
  rectangles.forEach(rectangle => {
    rectangle.setWidth(4);
    rectangle.setHeight(5);
    const area = rectangle.getArea(); // BAD: Returns 25 for Square. Should be 20.
    rectangle.render(area);
  });
}

const rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles(rectangles);

좋은 예:

javascript
class Shape {
  setColor(color) {
    // ...
  }

  render(area) {
    // ...
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(length) {
    super();
    this.length = length;
  }

  getArea() {
    return this.length * this.length;
  }
}

function renderLargeShapes(shapes) {
  shapes.forEach(shape => {
    const area = shape.getArea();
    shape.render(area);
  });
}

const shapes = [new Rectangle(4, 5), new Rectangle(4, 5), new Square(5)];
renderLargeShapes(shapes);

인터페이스 분리 원칙(ISP)

자바스크립트는 인터페이스를 지원하지 않으므로 다른 언어에 비해 이 원칙이 덜 엄격하게 적용됩니다. 하지만 타입 시스템이 없더라도 중요한 의미를 가집니다.

ISP는 "클라이언트가 사용하지 않는 인터페이스에 의존하지 않아야 한다"고 명시합니다. 덕 타이핑(duck typing)으로 인해 자바스크립트의 인터페이스는 암묵적인 계약 형태로 존재합니다.

자바스크립트에서 이 원칙을 잘 보여주는 예시는 대규모 설정 객체가 필요한 클래스입니다. 사용자가 모든 옵션을 설정하도록 요구하지 않고 필요한 설정만 선택할 수 있게 하면, '비대한 인터페이스(fat interface)' 문제를 방지할 수 있습니다.

나쁜 예:

javascript
class DOMTraverser {
  constructor(settings) {
    this.settings = settings;
    this.setup();
  }

  setup() {
    this.rootNode = this.settings.rootNode;
    this.settings.animationModule.setup();
  }

  traverse() {
    // ...
  }
}

const $ = new DOMTraverser({
  rootNode: document.getElementsByTagName("body"),
  animationModule() {} // Most of the time, we won't need to animate when traversing.
  // ...
});

좋은 예:

javascript
class DOMTraverser {
  constructor(settings) {
    this.settings = settings;
    this.options = settings.options;
    this.setup();
  }

  setup() {
    this.rootNode = this.settings.rootNode;
    this.setupOptions();
  }

  setupOptions() {
    if (this.options.animationModule) {
      // ...
    }
  }

  traverse() {
    // ...
  }
}

const $ = new DOMTraverser({
  rootNode: document.getElementsByTagName("body"),
  options: {
    animationModule() {}
  }
});

의존 역전 원칙(DIP)

이 원칙은 두 가지 핵심 사항을 강조합니다:

  1. 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다
  2. 추상화는 구체적 구현에 의존하지 않아야 하며, 구현이 추상화에 의존해야 합니다

AngularJS의 의존성 주입(DI)은 이 원칙을 구현하는 대표적인 예입니다. 고수준 모듈이 저수준 모듈의 구현 세부사항을 알지 못하도록 함으로써 모듈 간 결합도를 낮춥니다. 결합도 감소는 리팩토링을 용이하게 하는 핵심 요소입니다.

자바스크립트의 경우 InventoryTrackerrequestItems 메서드와 같이 암묵적인 계약을 통해 추상화를 구현합니다. 즉, 객체/클래스가 노출하는 메서드와 속성이 추상화 계약 역할을 수행합니다.

나쁜 예:

javascript
class InventoryRequester {
  constructor() {
    this.REQ_METHODS = ["HTTP"];
  }

  requestItem(item) {
    // ...
  }
}

class InventoryTracker {
  constructor(items) {
    this.items = items;

    // BAD: We have created a dependency on a specific request implementation.
    // We should just have requestItems depend on a request method: `request`
    this.requester = new InventoryRequester();
  }

  requestItems() {
    this.items.forEach(item => {
      this.requester.requestItem(item);
    });
  }
}

const inventoryTracker = new InventoryTracker(["apples", "bananas"]);
inventoryTracker.requestItems();

좋은 예:

javascript
class InventoryTracker {
  constructor(items, requester) {
    this.items = items;
    this.requester = requester;
  }

  requestItems() {
    this.items.forEach(item => {
      this.requester.requestItem(item);
    });
  }
}

class InventoryRequesterV1 {
  constructor() {
    this.REQ_METHODS = ["HTTP"];
  }

  requestItem(item) {
    // ...
  }
}

class InventoryRequesterV2 {
  constructor() {
    this.REQ_METHODS = ["WS"];
  }

  requestItem(item) {
    // ...
  }
}

// By constructing our dependencies externally and injecting them, we can easily
// substitute our request module for a fancy new one that uses WebSockets.
const inventoryTracker = new InventoryTracker(
  ["apples", "bananas"],
  new InventoryRequesterV2()
);
inventoryTracker.requestItems();