01. 객체 지향 설계 5원칙 (SOLID)
- 1) 객체 지향 설계 5원칙
출처: https://www.linkedin.com/pulse
💡 **객체 지향 프로그래밍** 및 **설계**의 다섯 가지 핵심 원칙을 **SOLID**라고 불리고 있습니다.
- **SOLID**는 **객체 지향 프로그래밍** 및 **설계**의 다섯 가지 기본 원칙의 **맨 앞단어**를 하나씩 가져와 만든 것 입니다.
- **SOLID** 원칙을 따르면 프로그래머는 시간이 지나도 **유지 보수**와 **확장**이 쉬운 시스템을 구축할 수 있습니다.
**SOLID의 종류**
- **단일 책임의 원칙 (Single Responsibility Principle, SRP)**
- **개방-폐쇄 원칙 (Open-Closed Principle, OCP)**
- **리스코프 치환 원칙 (Liskov substitution principle, LSP)**
- **인터페이스 분리 원칙 (Interface segregation principle, ISP)**
- **의존성 역전 원칙 (Dependency Inversion Principle, DIP)**
- 2) 단일 책임의 원칙 (Single Responsibility Principle, SRP)
💡 **하나의 객체는 단 하나의 책임을 가져야 한다.**
즉, **클래스**나 **모듈**을 **변경할 이유**가 **단 하나** 뿐이어야 한다는 원칙입니다.
**SRP**는 **책임**이라는 개념을 정의하며 **적절한 클래스의 크기**를 제시합니다.
**SRP**는 **객체 지향설계**에서 중요한 개념이고 이해하고 따르기 쉬운 개념이지만, 프로그래머가 가장 무시하는 규칙 중 하나입니다. 일반적인 프로그래머는 “**깨끗하고 우아하게 작성된 소프트웨어**"보다 “**동작하기만 하는 소프트웨어**"에 초점을 맞추기 때문입니다.
그렇다면 **SRP**를 이용해서 코드를 개선해보도록 할까요?
아래의 `UserSettings` 클래스는 하나의 클래스가 가지는 **책임**이 여러개가 존재합니다.
1. `changeSettings`: Settings를 변경한다.
2. `verifyCredentials`: 인증을 검증한다.
```jsx
/** SRP Before **/
class UserSettings {
constructor(user) { // UserSettings 클래스 생성자
this.user = user;
}
changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
if (this.verifyCredentials()) {
//...
}
}
verifyCredentials() { // 사용자의 인증을 검증하는 메소드
//...
}
}
```
그렇다면 2가지의 책임을 가지고 있는 `UserSettings` 클래스를 어떻게 분리할 수 있을까요?
1. 사용자의 **설정**을 **변경**하는 **책임**을 가진 `UserSettings` 클래스
2. 사용자의 **인증**을 **검증**하는 **책임**을 가진 `UserAuth` 클래스
```jsx
/** SRP After **/
class UserAuth {
constructor(user) { // UserAuth 클래스 생성자
this.user = user;
}
verifyCredentials() { // 사용자의 인증을 검증하는 메소드
//...
}
}
class UserSettings {
constructor(user, userAuth) { // UserSettings 클래스 생성자
this.user = user;
this.userAuth = userAuth; // UserAuth를 생성자를 통해 주입받는다.
}
changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
if (this.userAuth.verifyCredentials()) { // 생성자에서 주입 받은 userAuth 객체의 메소드를 사용한다.
//...
}
}
}
```
🔥 이제 책임을 분리하여 개선된 코드는 클래스마다 **단 1개**의 **책임**을 가지게되었습니다!
- 3) 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
💡 **소프트웨어 엔티티 또는 개체(클래스, 모듈, 함수 등)는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.**
- 즉, 소프트웨어 개체의 **행위**는 **확장**될 수 있어야 하지만, 개체를 **변경**해서는 **안됩니다.**
- 조금 더 쉽게 설명하자면, **기존 코드**에 영향을 주지않고 소프트웨어에 **새로운 기능**이나 **구성 요소**를 **추가**할 수 있어야 한다는 것입니다.
만약 요구사항을 **조금** 반영하는 데 소프트웨어를 **엄청나게 수정**해야 한다면, 소모되는 **개발 코스트** 또한 **엄청나게 증가**할 것입니다. 이러한 문제를 개선하기 위해선 **개방-폐쇄 원칙**을 따라야합니다.
그렇다면 이제 **개방-폐쇄 원칙(OCP)**를 이용해서 코드를 개선해보도록 할까요?
현재 우리에게는 `calculator` 라는 계산기 함수가 있습니다.이 함수는 **덧셈**, **뺄셈** 기능만 지원하고 있습니다.
```jsx
/** OCP Before **/
function calculator(nums, option) {
let result = 0;
for (const num of nums) {
if (option === "add") result += num; // option이 add일 경우 덧셈 연산을 합니다.
else if (option === "sub") result -= num; // option이 sub일 경우 뺄셈 연산을 합니다.
// 새로운 연산(기능)을 추가 하기 위해서는 함수 내부에서 코드 수정이 필요합니다.
}
return result;
}
console.log(calculator([2, 3, 5], "add")); // 10
console.log(calculator([5, 2, 1], "sub")); // -8
```
만약 **곱셈**, **나눗셈**, **제곱** 연산 등 다양한 계산기의 **기능**을 추가하려면 `calculator` 함수 자체를 수정해야할 것입니다. 이런 접근 방식은 **개방-폐쇄 원칙(OCP)**인 **“확장에는 열려 있으나 변경에는 닫혀 있어야 한다.**”를 위반하게 됩니다.
그렇다면 `calculator` 함수를 어떻게 수정해야 개방-폐쇄 원칙에 위배되지 않고 새로운 기능을 추가할 수 있을까요?
`calculator` 함수에서 전달받은 `option` 매개변수를 **콜백 함수**로 변경하여 새로운 계산 조건이 추가되더라도 실제 `calculator` 함수에서는 어떠한 변화가 발생하지 않도록 만들 수 있습니다!
→ 여기서 **콜백 함수**란 함수의 매개변수로 다른 함수를 전달하고, 그 함수를 나중에 호출하는 것을 의미합니다.
```jsx
/** OCP After **/
function calculator(nums, callBackFunc) { // option을 CallbackFunc로 변경
let result = 0;
for (const num of nums) {
result = callBackFunc(result, num); // option으로 분기하지 않고, Callback함수를 실행하도록 변경
}
return result;
}
const add = (a, b) => a + b; // 함수 표현식을 정의합니다.
const sub = (a, b) => a - b;
const mul = (a, b) => a * b;
const div = (a, b) => a / b;
console.log(calculator([2, 3, 5], add)); // add 함수 표현식을 Callback 함수로 전달합니다.
console.log(calculator([5, 2, 1], sub)); // sub 함수 표현식을 Callback 함수로 전달합니다.
```
🔥 이제 계산기에 어떠한 기능을 추가 하더라도 더이상 `calculator`함수 내부의 코드를 수정하지 않을 수 있게 되었습니다!
- 4) 리스코프 치환 원칙 (Liskov substitution principle, LSP)
💡 **어플리케이션에서 객체는 프로그램의 동작에 영향을 주지 않으면서, 하위 타입의 객체로 바꿀 수 있어야 한다.**
- **즉, `S`가 `T`의 하위 유형이라면, 프로그램의 기능에 변화를 주지 않고서도 `T` 타입의 객체를 `S` 객체로 대체할 수 있어야합니다.**
우리가 **부모 클래스(Parents)**와 **자식 클래스(Child)** 를 가지고 있다면, 이 두가지의 클래스의 객체를 **서로를 바꾸더라도** 해당 프로그램에서 잘못된 결과를 도출하지 않아야하는 원칙입니다.
그렇다면, **정사각형(Square)**과 **직사각형(Rectangle)** 예제를 이용해서 **LSP**를 어떻게 적용하는지 확인해보도록 하겠습니다!
**정사각형(Square)**의 특징은 무엇일까요? 그것은 바로 **높이와 너비가 동일**하다는 것입니다.
반면에, **직사각형(Rectangle)**은 **높이와 너비가 서로 독립적으로 변경될 수 있다는** 특성을 가지고 있습니다.
위에서 설명한 각각의 사각형들의 특성을 바탕으로 클래스를 구현해보겠습니다!
```jsx
/** LSP Before **/
class Rectangle {
constructor(width = 0, height = 0) { // 직사각형의 생성자
this.width = width;
this.height = height;
}
setWidth(width) { // 직사각형은 높이와 너비를 독립적으로 정의한다.
this.width = width;
return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
}
setHeight(height) { // 직사각형은 높이와 너비를 독립적으로 정의한다.
this.height = height;
return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
}
getArea() { // 사각형의 높이와 너비의 결과값을 조회하는 메소드
return this.width * this.height;
}
}
class Square extends Rectangle { // 정사각형은 직사각형을 상속받습니다.
setWidth(width) { // 정사각형은 높이와 너비가 동일하게 정의된다.
this.width = width;
this.height = width;
return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
}
setHeight(height) { // 정사각형은 높이와 너비가 동일하게 정의된다.
this.width = height;
this.height = height;
return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
}
}
const rectangleArea = new Rectangle() // 35
.setWidth(5) // 너비 5
.setHeight(7) // 높이 7
.getArea(); // 5 * 7 = 35
const squareArea = new Square() // 49
.setWidth(5) // 너비 5
.setHeight(7) // 높이를 7로 정의하였지만, 정사각형은 높이와 너비를 동일하게 정의합니다.
.getArea(); // 7 * 7 = 49
```
위에 구현한 `Rectangle`과 `Square`클래스에서는 **어떠한 문제**가 있을까요?
`Square`와 `Rectangle`클래스에서 같은 메서드를 호출하더라도 다른 결과값이 반환되는 것을 확인할 수 있었습니다. 예제에서 **높이를 7**로 설정하려 하였지만, `Square` 클래스에서는 **너비와 높이가 동일해야** 하므로 결과적으로 **너비가 7**로 설정되었습니다.
만약 **두 클래스**를 서로 **교체**하였을 때에도 **동일한 결과 값**이 **도출되지 않는 것**을 확인 할 수 있겠죠?
위에서 확인한 결과로 **LSP**의 원칙 중에서 “**부모 클래스와 자식 클래스가 있는 경우 서로를 바꾸더라도 해당 프로그램에서 잘못된 결과를 도출하지 않는 것**”에 해당하는 원칙이 깨지게 된 것을 확인 할 수 있습니다.
그렇다면, 어떻게 `Square`과 `Rectangle`클래스를 수정해야 **LSP**원칙을 위반하지 않게 구현할 수 있을까요?
언뜻 보면 `Rectangle`이 `Square`를 포함하고 있는 것처럼 보이지만 `setWidth`, `setHeigth` 메서드처럼 다르게 동작해야하는 경우가 존재하기 때문에 `Square` 클래스는 `Rectangle`을 상속받는 것은 옳은 방법이 아닙니다.
이럴 경우 **두 클래스**를 모두 포함하는 **인터페이스**를 구현해야합니다. 여기서는, `Shape` 라는 **인터페이스(Interface) 역할을 수행하는 새로운 부모 클래스**를 생성하고, `Rectangle`과 `Square`가 이를 상속받도록 코드를 수정해보겠습니다.
```jsx
/** LSP After **/
class Shape { // Rectangle과 Square의 부모 클래스를 정의합니다.
getArea() { // 각 도형마다 계산 방법이 다를 수 있으므로 빈 메소드로 정의합니다.
}
}
class Rectangle extends Shape { // Rectangle은 Shape를 상속받습니다.
constructor(width = 0, height = 0) { // 직사각형의 생성자
super();
this.width = width;
this.height = height;
}
getArea() { // 직사각형의 높이와 너비의 결과값을 조회하는 메소드
return this.width * this.height;
}
}
class Square extends Shape { // Square는 Shape를 상속받습니다.
constructor(length = 0) { // 정사각형의 생성자
super();
this.length = length; // 정사각형은 너비와 높이가 같이 때문에 width와 height 대신 length를 사용합니다.
}
getArea() { // 정사각형의 높이와 너비의 결과값을 조회하는 메소드
return this.length * this.length;
}
}
const rectangleArea = new Rectangle(7, 7) // 49
.getArea(); // 7 * 7 = 49
const squareArea = new Square(7) // 49
.getArea(); // 7 * 7 = 49
```
수정된 코드에서는 `Rectangle`과 `Square` 객체를 생성하고, 각각의 `getArea` 메서드를 호출하면, 둘 다 49라는 동일한 넓이가 반환되는 것을 확인할 수 있습니다. 따라서, 이 코드는 **리스코프 치환 원칙(LSP)**을 만족하도록 구성된 것입니다!
🔥 `Rectangle`클래스와 `Square`클래스에서 상위 타입의 `getArea` 메소드를 호출하더라도 문제없이 원하는 결과값을 도출할 수 있게 되었습니다!
- 5) 인터페이스 분리 원칙 (Interface segregation principle, ISP)
💡 **특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.**
- 클라이언트가 **필요하지 않는 기능**을 가진 **인터페이스**에 의존해서는 안 되고, 최대한 **인터페이스**를 **작게** 유지해야합니다.
- 즉, 사용자가 **필요하지 않은 것**들에 **의존하지 않도록**, **인터페이스는 작고 구체적으로 유지**해야 한다는 것입니다.
여기서 설명하는 **인터페이스**(`interface`)는 대표적으로 **Java**, **C++** 그리고 **Typescript**에서 사용하는 문법입니다.
**인터페이스**는 특정 클래스가 반드시 구현해야 할 메서드와 속성을 정의하는 일종의 템플릿입니다. 이를 통해 서로 다른 클래스가 **동일한 동작을 하는것을 유추**할 수 있게 되는것이죠.
**Javascript**에서는 `interface` 기능을 제공하지 않으므로 이번 예제는 **Typescript**로 진행하도록 하겠습니다! 🙂
```jsx
/** ISP Before **/
interface SmartPrinter { // SmartPrinter가 사용할 수 있는 기능들을 정의한 인터페이스
print();
fax();
scan();
}
// SmartPrinter 인터페이스를 상속받은 AllInOnePrinter 클래스
class AllInOnePrinter implements SmartPrinter {
print() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
// ...
}
fax() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
// ...
}
scan() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
// ...
}
}
// SmartPrinter 인터페이스를 상속받은 EconomicPrinter 클래스
class EconomicPrinter implements SmartPrinter {
print() { // EconomicPrinter 클래스는 print 기능만 지원한다.
// ...
}
fax() { // EconomicPrinter 클래스는 fax 기능을 지원하지 않는다.
throw new Error('팩스 기능을 지원하지 않습니다.');
}
scan() { // EconomicPrinter 클래스는 scan 기능을 지원하지 않는다.
throw new Error('Scan 기능을 지원하지 않습니다.');
}
}
```
가장 처음 선언된 `SmartPrint` 인터페이스는 `print()`, `fax()`, `scan()` 세 가지의 기능을 정의하고 있습니다.
`AllInOnePrinter` 클래스는 `print`, `fax`, `scan` 3가지의 기능이 모두 필요하지만, `EconomicPrinter` 클래스의 경우 `print`기능만 지원하는 클래스입니다.
만약 `EconomicPrinter` 클래스가 `SmartPrinter` 인터페이스를 상속받는다면, 필요하지 않은 `fax`, `scan` 2가지의 기능을 **예외 처리**를 해줘야 하는 상황이 발생하게 됩니다.
이후에도 `fax` 기능이 필요한 `FacsimilePrinter` 클래스가 `SmartPrinter` 인터페이스를 상속하면, 역시 `scan` 기능을 **예외 처리**해야 하는 문제가 발생하게 되는것이죠.
그럼 어떻게 `SmartPrinter` 인터페이스를 분리해야 **ISP** 원칙에 위배되지 않고 코드를 구현할 수 있을까요?
`SmartPrinter` 인터페이스에 정의된 기능을 `Printer`, `Fax`, `Scanner` 인터페이스로 분리하면 **ISP** 원칙에서 “**클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 되고, 최대한 인터페이스를 작게 유지해야한다.**”에 해당하는 원칙을 수행하는 코드로 개선할 수 있습니다! 🙂
```jsx
/** ISP After **/
interface Printer { // print 기능을 하는 Printer 인터페이스
print();
}
interface Fax { // fax 기능을 하는 Fax 인터페이스
fax();
}
interface Scanner { // scan 기능을 하는 Scanner 인터페이스
scan();
}
// AllInOnePrinter클래스는 print, fax, scan 기능을 지원하는 Printer, Fax, Scanner 인터페이스를 상속받았다.
class AllInOnePrinter implements Printer, Fax, Scanner {
print() { // Printer 인터페이스를 상속받아 print 기능을 지원한다.
// ...
}
fax() { // Fax 인터페이스를 상속받아 fax 기능을 지원한다.
// ...
}
scan() { // Scanner 인터페이스를 상속받아 scan 기능을 지원한다.
// ...
}
}
// EconomicPrinter클래스는 print 기능을 지원하는 Printer 인터페이스를 상속받았다.
class EconomicPrinter implements Printer {
print() { // EconomicPrinter 클래스는 print 기능만 지원한다.
// ...
}
}
// FacsimilePrinter클래스는 print, fax 기능을 지원하는 Printer, Fax 인터페이스를 상속받았다.
class FacsimilePrinter implements Printer, Fax {
print() { // FacsimilePrinter 클래스는 print, fax 기능을 지원한다.
// ...
}
fax() { // FacsimilePrinter 클래스는 print, fax 기능을 지원한다.
// ...
}
}
```
이제 `EconomicPrinter`는 `Printer` 인터페이스만 상속 받아, 필요한 `print` 기능만을 구현하면 됩니다. 이렇게 **인터페이스 분리 원칙(ISP)**을 적용하면 어플리케이션의 복잡성을 줄이고, 각 클래스가 필요한 기능에만 집중할 수 있게 되는 것입니다!
🔥 불필요한 인터페이스를 분리하여 **ISP**원칙을 수행하는 코드를 구현할 수 있게 되었습니다.
결국 **불필요한 기능**을 포함한 **인터페이스**에 의존하게 되면 **예상치도 못한 문제**에 빠질 수 있다는것을 알게 되었습니다!
- 6) 의존성 역전 원칙 (Dependency Inversion Principle, DIP)