단일책임원칙에 대한 사전적 의미는 다음과 같다.
클래스는 단일책임을 맡는다.
쉽게 와닿지 않는다.
빠른 이해를 위해서 맥도날드 매점의 분업화를 떠올려보자.
테이블이 더럽다. 누구의 책임인가?
패티가 덜 익혀졌다. 누구의 책임인가?
계산 오류가 발생했다. 누구의 책임인가?
책임소재가 불분명하면 작은 오류에도 시스템 전체가 흔들릴 수 있다.
이는 프로그래밍에서도 마찬가지로 하나의 클래스에 모든 동작을 집어 넣으면 시스템은 비대해지고, 한 부분의 오류로 전체가 무너지게된다.
예시를 통해 살펴보자.
//Bad👎
class UserSettings {
constructor(user) {
this.user = user;
}
changeSettings(settings) {
if (this.verifyCredentials()) {
// ...
}
}
verifyCredentials() {
// ...
}
}
UserSettings 클래스 안에 changeSettings 메소드와 verifyCredentials 메소드가 함께 존재한다.
//Good👍
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()) {
// ...
}
}
}
UserAuth 클래스에 verifyCredentials 메소드, UserSettings 클래스에 changeSettings 메소드로 나누어 클래스의 단일책임원칙을 지키도록 바꾼다.
개방/폐쇄 원칙을 더 구체적으로 말하자면, "확장은 개방, 수정은 폐쇄" 원칙이다.
개방/폐쇄 원칙을 한 마디로 표현하자면 "아웃소싱" 개념에 가깝다. (Airbnb VS 힐튼 호텔)
변화하는 부분을 추상화함으로써 기존 코드를 수정하지 않고도, 확장을 할 수 있게 만들어 준다.
//Bad
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 === 'httpNodeAdapter') {
return makeHttpCall(url).then((response) => {
// transform response and return
});
}
}
}
function makeAjaxCall(url) {
// request and return promise
}
function makeHttpCall(url) {
// request and return promise
}
//Good👍
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
});
}
}
객체지향 프로그래밍에서 리스코프 치환 원칙을 사용해야하는 이유는 "복제"와 관련이 깊다.
객체 지향에서 "복제"를 간단하게 하는 방법은 "상속"으로 상속은 extends 를 사용한다.
그리고 super()라는 메소드로 상위 클래스의 특징과 기능을 복제할 수 있다.
//Bad
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(); // 정사각형일때 25를 리턴합니다. 하지만 20이어야 하는게 맞습니다.
rectangle.render(area);
});
}
const rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles(rectangles);
//Good👍
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);
Shape 클래스를 상속받은 후 getArea 메소드는 Rectangle, Square 클래스 별로 따로 정의하여 문제 해결.
Javascript에서 인터페이스 분리 원칙을 보여주는 가장 좋은 예는 방대한 양의 설정 객체가 필요한 클래스입니다.
설정을 선택적으로 할 수 있다면 무거운 인터페이스를 만드는 것을 방지할 수 있습니다.
//Bad
class DOMTraverser {
constructor(settings) {
this.settings = settings;
this.setup();
}
setup() {
this.rootNode = this.settings.rootNode;
this.animationModule.setup();
}
traverse() {
// ...
}
}
const $ = new DOMTraverser({
rootNode: document.getElementsByTagName('body'),
animationModule() {} // 우리는 대부분의 경우 DOM을 탐색할 때 애니메이션이 필요하지 않습니다.
// ...
});
//Good
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() {}
}
});
setupOptions 메소드를 추가하여 설정을 선택적으로 할 수 있도록 함.
고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다."를 의미합니다. 이것을 아주 쉽게 말하면, "자신보다 변하기 쉬운 것에 의존하지 마라"라고 할 수 있다.
의존 역전 원칙은 그 중에서도 추상화를 이용한다. (스노우 타이어나 일반 타이어를 '타이어' 자체로 추상화)
//Bad
class InventoryRequester {
constructor() {
this.REQ_METHODS = ['HTTP'];
}
requestItem(item) {
// ...
}
}
class InventoryTracker {
constructor(items) {
this.items = items;
// 안좋은 이유: 특정 요청방법 구현에 대한 의존성을 만들었습니다.
// requestItems는 한가지 요청방법을 필요로 합니다.
this.requester = new InventoryRequester();
}
requestItems() {
this.items.forEach(item => {
this.requester.requestItem(item);
});
}
}
const inventoryTracker = new InventoryTracker(['apples', 'bananas']);
inventoryTracker.requestItems();
//Good
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) {
// ...
}
}
// 의존성을 외부에서 만들어 주입해줌으로써,
// 요청 모듈을 새롭게 만든 웹소켓 사용 모듈로 쉽게 바꿔 끼울 수 있게 되었습니다.
const inventoryTracker = new InventoryTracker(['apples', 'bananas'], new InventoryRequesterV2());
inventoryTracker.requestItems();