개발 기술/개발 이야기

SOLID 윈칙 (solid principle)

by GicoMomg 2021. 10. 25.

코드의 유지보수와 확장을 쉽게 하기 위해 지켜야 하는 원칙들이 있는데, 이번에는 SOLID원칙에 대해 알아본다.

1. solid 원칙?

1) solid 원칙의 정의

  • solid 원칙이란, 코드의 유지보수와 확장을 쉽게 하기 위해 지켜야 하는 원칙들을 말한다.
  • 이 원칙을 적용하게 되면, 읽기 쉬운 코드를 만들 수 있으며 리팩토링도 원활히 진행할 수 있다.
  • solid 원칙에는 5가지가 있는데, 이 5가지 원칙들에 대해 코드와 함께 살펴보자!




2. S.O.L.I.D 원칙 살펴보기

1) S (Single responsibility) 단일 책임 원칙

(1) 단일 책임 원칙은 분업화와 같다.

  • SRP는 하나의 클래스는 하나의 책임만을 가져야 한다는 원칙이다.
  • 우리의 일상생활을 예시로 들면 아래와 같다.
사람을 클래스로 보고, 업무를 책임으로 봅시다.

카페에서 카운터 직원은 손님에게 주문을 받습니다.
바리스타는 커피를 만듭니다.

카운터 직원은 "주문 받기" 업무를 수행하며,
바리스타는 "커피 만들기"를 수행합니다.

그런데 이 모든 업무를 카운터 직원이 하면 어떻게 될까요?
  • 카페에서는 한 사람(클래스)이 특정 업무(책임)를 수행하도록 분업화되어 있다.
  • 만약 커피맛이 이상하다면 이는 바라스타의 책임임을 알고 정정할 수 있다.
  • 그러나 이 업무를 카운터 직원이 모두 수행하게 되면 문제가 발생하는데,
  • 바로 책임 소재가 불분명하다는 점이다.

(2) 단일 책임을 지키지 않은 예시

  • 분업화(단일 책임 원칙)을 지키지 않은 코드를 살펴보자
  • Cafe클래스에는 counter, barista, cleaner 메소드가 있으며,
  • 주문받기, 커피 만들기, 청소하기 책임을 가진다.
  • 만약 매장 바닥이 더럽다면 Cafe클래스가 책임을 지고, 커피 맛이 이상하면 Cafe클래스가 책임져야하며,
  • 주문을 잘 못 받았다면 이 또한 Cafe클래스가 책임을 지게된다.
class Cafe {
  constructor() {
    this.coffee = null;
  }
  counter() {}   //주문 받기
  barista() {}   //커피 만들기
  cleaner() {}   //청소하기
}
  • 이 말은 하나의 클래스에 여러 책임소재가 있다는 말인데, 만약 코드상 문제가 발생하고 이를 해결하기 위해 해당 클래스를 살펴보면 혼란스러울 것이다.
  • 왜냐하면 해당 클래스에는 여러 책임소재가 있기 때문에, 불필요한 코드도 체크해야 하기 때문이다.

(3) 단일 책임을 지킨 예시

  • 이번에는 분업화가 잘 된 코드를 살펴보자.
class Counter {   // 주문받기 역할 수행
  getOrder() {}
}
class Barista {  // 커피 만들기 역할 수행
  cook() {}
}
class Cleaner() { // 청소 역할 수행
  clean() {}
}
  • Counter 클래스는 주문받기만 수행하며, Barista 클래스는 커피 만들기만 수행한다.
  • 만약 커피에 문제가 발생했다면 Barista클래스를 바로 확인하면 된다.
  • 이처럼, 분업화는 코드작성자로 하여금 쉬운 코드 수정은 물론 리소스 낭비도 줄여줄 수 있다.




2) O (Open/closed) 개방 폐쇄 원칙

(1) 확장시 기존 코드를 수정치 않게 한다.

  • OCP는 모듈은 확장성을 위해 열려있어야 하지만, 수정에 있어선 닫혀 있어야 한다는 원칙이다.
  • 쉽게 말하면 모듈의 동작을 확장할 때는, 기존 코드를 수정할 필요가 없게 해야 한다는 말이다.

(2) 확장성을 고려치 않은 예시

  • TodoList클래스에 item을 추가 혹은 수정하는 itemChange메소드가 있다.
class TodoList {
  constructor() {
   this.items = [];
  }
  itemChange(mode, idx, item) {
    if (mode === 'add') {
      this.items.push(item);
    }
    else if (mode === 'update') {
      this.items[idx] = item;
    }
  }
}

  • 만약 item을 삭제하는 동작을 추가하려고 한다면, 기존 메소드를 수정해야 하며,
  • 해당 동작 추가시 기존 메소드 동작에 문제가 있는지 살펴보기까지 해야한다.
class TodoList {
  constructor() {
   this.items = [];
  }
  itemChange(mode, idx, item) {
    if (mode === 'add') {
      this.items.push(item);
    }
    else if (mode === 'update') {
      this.items[idx] = item;
    }
    else if (mode === 'delete') { //동작 추가
      this.items.splice(idx, 1);
    }
  }
}

(3) 확장성을 고려한 예시

  • TodoList클래스에 addItem, updateItem메소드가 있다.
class TodoList {
  constructor() {
   this.items = [];
  }
  addItem(item) {
    this.items.push(item);
  },
  updateItem(idx, item) {
    this.items[idx] = item;
  },
}

  • 만약 item을 삭제 동작을 메소드로 선언하면, 기존 메소드 수정이 필요치 않게 된다.
class TodoList {
  deleteItem(idx) {
    this.items.splice(idx, 1);
  }
}




3) L (Liskov substitution) 리스코프 치환 원칙

(1) 리스코프는 상속과 유사하다.

  • 상속이란 A객체(부모)의 특징을 B객체(자식)에게 넘겨주는 것을 말한다.
  • 리스코프는 상속과 유사하지만, 대신 부모 클래스 자리에 자식 클래스를 넣어도 원활히 작동되어야 한다.
  • 말이 조금 어려운 거 같으니 예시를 보자.

(2) 리스코프 원칙을 지킨 예시

  • 여기에 Shape 클래스가 존재하며 넓이를 리턴하는 메소드가 있다.
class Shape {
  constructor(width, height) {
    this._width = width
    this._height = height
  }
  get width() {
    return this._width
  }
  get height() {
    return this._height
  }

  set width(value) {
    this._width = value
  }
  set height(value) {
    this._height = value
  }
  getArea() {
    return this._width * this._height
  }
}

  • 만약 Rectangle클래스가 Shape클래스를 상속받는다면 부모의 메소드를 수정할 필요가 없다.
  • 이처럼 부모 클래스를 상속받되, 부모 클래스의 인스턴스를 수정치 않는 관계여야 한다.
class Rectangle extends Shape {}

(3) 리스코프 원칙이 위반된 예시

  • 앞선 예시와 마찬가지로 Shape클래스를 선언했다.
class Shape {
  constructor(width, height) {
    this._width = width
    this._height = height
  }
  get width() {
    return this._width
  }
  get height() {
    return this._height
  }
  set width(value) {
    this._width = value
  }
  set height(value) {
    this._height = value
  }
  getArea() {
    return this._width * this._height
  }
}

  • 다른 점은 Square는 넓이를 구할 때 width * width가 되어야 하므로 함수가 수정되었다.
  • 만약 자식클래스에서 부모클래스의 함수를 수정해서 사용하는 경우 일관성이 깨지므로 해당 원칙에 위배된다.
class Square extends Shape {
  constructor(size) {
    super(size, size)
  }
  getArea() {
    return this._width * this._width
  }
}




4) I (Interface segregation) 인터페이스 분리 원칙

(1) 필요한 메소드만 사용해야한다.

  • 이 법칙은 자신이 사용치 않는 메소드에 의존 관계를 맺으면 안된다는 걸 의미한다.
  • 만약 인터페이스의 일부 메소드만 사용한다면 해당 인터페이스를 잘게 쪼개야 한다.

(2) 인터페이스 분리에 위배되는 경우

  • Phone 클래스는 call, takePhoto, connectToWifi 메소드를 가진다.
class Phone {
  call(number) {}
  takePhoto() {}
  connectToWifi() {}
}

  • 그리고 OldPhone클래스는 Phone을 상속받는 형태를 띤다.
  • 하지만 OldPhone클래스는 두 개의 메소드가 필요치 않다.
  • 이 경우 자신이 사용치 않은 메소드와 의존 관계를 맺였으므로, 인터페이스 분리 법칙에 위배된다.
class OldPhone extends Phone {
  call(number) {}
  //takePhoto() {}
  //connectToWifi() {}
}

(3) 인터페이스 분리 법칙을 지킨 경우

  • 앞서 본 Phone 클래스를 다시 이용해보자.
class Phone {
  call(number) {}
  takePhoto() {}
  connectToWifi() {}
}

  • 이번에는 IPhone클래스는 Phone을 상속받는 형태를 띤다.
  • 그러나 OldPhone클래스와 달리 이 클래스는 모두 메소드를 사용하므로 해당 법칙에 위배되지 않았다.
class IPhone extends Phone {
  call(number) {}
  takePhoto() {}
  connectToWifi() {}
}




5) D (Dependency inversion) 의존 관계 역전 원칙

(1) 상위 모듈은 독립적이어야 한다.

  • 이 법칙은 상위 모듈이 하위 모듈에 종속되어선 안된다는 걸 말한다.
  • 만약 모듈간 의존성이 강하면, 하나의 모듈을 수정할 때 서로 영향을 줄 수 있기 때문이다.
  • 대신 상위, 하위 모듈은 추상화(객체의 형태, 틀)에 의해 달라져야 한다.

(2) 의존 관계 역전이 되지 않은 경우

  • FileSystem, ExternalDB클래스는 출력 메소드를 가지고 있다.
class FileSystem {
  writeToFile(data) {}
}
class ExternalDB {
  writeToDB(data) {}
}

  • WriteManagerFileSystem, ExternalDB의 상위모듈이며,
  • 조건문을 사용해 각 클래스의 출력 메소드를 실행하고 있다.
class WriteManager {
  saveData(db, data) {
    if (db instanceof FileSystem) {
      db.writeToFile(data)
    }
    if (db instanceof ExternalDB) {
      db.writeToDatabase(data)
    }
  }
}
  • 이처럼 조건문을 사용해 각기 다른 메소드를 실행하는 방법은 하위 모듈에 의존적인 형태이다.
  • 그 이유는 FileSystem의 메소드명이 수정될 시, 상위 모듈도 영향을 받기 때문이다.

(3) 의존 관계 역전이 된 경우

  • 앞서 본 FileSystem, ExternalDB클래스를 예로 들어보자
  • 대신 이번에는 이 클래스들은 유사한 형태의 출력 메소드를 가지게 했다.
class FileSystem {
  save(data) {}
}
class ExternalDB {
  save(data) {}
}

  • 하위 모듈은 추상화(객체 형태, 틀)에 기반해 구성되었기에,
  • 상위 모듈에서 하위 모듈 메소드를 사용할 때 특정 상황을 구체화하지 않아도 된다.
class WriteManager {
  saveData(db, data) {
    db.save(data)
  }
}
  • 결국 상위 모듈은 하위 모듈 의존성을 피할 수 있게 된다.





출처

반응형

댓글