[간단 요약]
1. 객체 지향 설계 5원칙은 기본 원칙의 맨 앞글자를 따서 SOLID 라고 불린다.
2. 객제 지향 설계의 5원칙
1) 단일 책임의 원칙 (Single Responsibility Principle, SRP)
2) 개방-폐쇄 원칙(Open-Closed Principle, OCP)
3) 리스코프 치환 원칙(Liskov substitution Principle,LSP)
4) 인터페이스 분리 원칙(Interface segregation Priciple, ISP)
5) 의존성 역전 원칙(Dependency Inversion Principle, DIP)
#1. 단일 책임의 원칙 (Single Responsibility Principle)
하나의 객체는 단 하나의 책임을 가져야 한다.
즉, 클래스나 모듈을 변경할 이유가 단 하나 뿐이어야 한다는 원칙
책임이라는 개념을 정의하며 적절한 클래스의 크기를 제시한다.
아래 예제 코드를 보면서 확인해보자
/** SRP Before **/
class UserSettings {
constructor(user) { // UserSettings 클래스 생성자
this.user = user;
}
changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
if (this.verifyCredentials()) {
//...
}
}
verifyCredentials() { // 사용자의 인증을 검증하는 메소드
//...
}
}
UserSettiongs 라는 클래스 안에 설정을 변경하는 메소드, 인증 검증하는 메소드 즉 2개의 책임을 가지고 있다.
각 책임에 따라 클래스를 나누어 SRP에 부합되게 리팩토링을 진행해보자 !
/** 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개의 책임을 가지게 되었다.
#2. 개방-폐쇄 원칙(Open-Closed Principle)
소프트웨어 개체의 행위는 확장될 수 있어야 하지만, 개체를 변경해서는 안된다.
기존 코드에 영향을 주지 않고 소프트웨어에 새로운 기능이나 구성 요소를 추가할 수 있어야 한다.
위 설명 역시 아래 예제 코드를 확인해보자.
calculator라는 함수는 더하거나 빼거나 2개의 기능을 가지고 있다.
/** 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
만약 여기에서 곱셈, 나눗셈 등을 추가하게 될 경우에는 함수 자체가 수정되어야 할 것이다.
위 코드를 콜백함수를 이용하여 개선해보자
/** 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 함수는 nums와 콜백함수를 인자로 받고 함수 내부에서 콜백함수를 실행하도록 변경했다.
이로써 함수 내부에서 수정할 필요 없이 외부에서 추가된 기능을 정의하여 확장이 가능하게 할 수 있다.
#3. 리스코프 치환 원칙(Liskov substitution Principle)
객체는 프로그램의 동작에 영향을 주지 않으면서, 하위 타입의 객체로 바꿀 수 있어야 한다.
즉, S가 T의 하위 유형이라면, 프로그램의 기능에 변화를 주지 않고서도 T 타입의 객체를 S객체로 대체할 수 있어야 한다.
결과적으로 부모 클래스와 자식 클래스가 있을 때 두 클래스의 객체를 서로 바꾸더라도 잘못된 결과가 도출되면 안된다는 뜻이다.
역시 아래 예제를 확인해보자.
/** 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
직사각형과 정사각형의 넒이를 구하는 예시인데
정사각형(square)의 경우 너비와 높이가 같은 사각형이기 때문에 setHeight 메서드를 통해 최종적으로 너비와 높이가 7로 설정되는 것을 볼 수 있다. (setWidth 로 설정된 5의 값이 7로 재설정 된 것)
결국 두 클래스를 서로 바꾸었을 때 동일한 결과 값이 도출되지 않을 것이다.
인터페이스의 역할을 하는 새로운 부모 클래스를 정의하여 문제를 해결할 수 있다.
/** 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
#4. 인터페이스 분리 원칙(Interface segregation Principle)
사용자가 필요하지 않은 것들에 의존하지 않도록, 인터페이스는 작고 구체적으로 유지해야한다.
/** 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 기능을 지원하지 않습니다.');
}
}
Smart Printer라는 인터페이스는 print(), fax(), scan() 의 메서드를 가지게 설정되어 있지만, EconomicPrinter의 경우 프린트 기능만 지원하기 때문에 fax, scan 기능은 throw new Error를 던지고 있다.
Smart Printer라는 인터페이스로 통합적으로 관리하는 것이 아닌
Printer / Fax / Scanner 라는 인터페이스로 나누어 관리하는 것이 더 부합하다.
/** 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 기능을 지원한다.
// ...
}
}
위와 같이 코딩하면서 불필요한 기능을 분리하여 각 클래스가 필요한 기능에만 집중할 수 있게 되었다.
#5. 의존성 역전 원칙(Dependency Inversion Principle)
프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다.
즉, 높은 계층의 모듈(도메인)이 저수준의 모듈(하부구조)에 의존하면 안된다.
/** DIP Before **/
import { readFile } from 'node:fs/promises';
class XmlFormatter {
parseXml(content) {
// Xml 파일을 String 형식으로 변환합니다.
}
}
class JsonFormatter {
parseJson(content) {
// JSON 파일을 String 형식으로 변환합니다.
}
}
class ReportReader {
async read(path) {
const fileExtension = path.split('.').pop(); // 파일 확장자
if (fileExtension === 'xml') {
const formatter = new XmlFormatter(); // xml 파일 확장자일 경우 XmlFormatter를 사용한다.
const text = await readFile(path, (err, data) => data);
return formatter.parseXml(text); // xmlFormatter클래스로 파싱을 할 때 parseXml 메소드를 사용한다.
} else if (fileExtension === 'json') {
const formatter = new JsonFormatter(); // json 파일 확장자일 경우 JsonFormatter를 사용한다.
const text = await readFile(path, (err, data) => data);
return formatter.parseJson(text); // JsonFormatter클래스로 파싱을 할 때 parseJson 메소드를 사용한다.
}
}
}
const reader = new ReportReader();
const report = await reader.read('report.xml');
// or
// const report = await reader.read('report.json');
각 파일의 확장자에 따라 다른 클래스와 다른 메서드를 이용하며 구체적인 구현에 의존하고 있는 상황이다.
아래 개선된 코드를 확인해보자.
/** DIP After **/
import { readFile } from 'node:fs/promises';
class Formatter { // 인터페이스지만, Javascript로 구현하기 위해 클래스로 선언합니다.
parse() { }
}
class XmlFormatter extends Formatter {
parse(content) {
// Xml 파일을 String 형식으로 변환합니다.
}
}
class JsonFormatter extends Formatter {
parse(content) {
// JSON 파일을 String 형식으로 변환합니다.
}
}
class ReportReader {
constructor(formatter) { // DI 패턴을 적용하여, Formatter를 생성자를 통해 주입받습니다.
this.formatter = formatter;
}
async read(path) {
const text = await readFile(path, (err, data) => data);
return this.formatter.parse(text); // 추상화된 formatter로 데이터를 파싱합니다.
}
}
const reader = new ReportReader(new XmlFormatter());
const report = await reader.read('report.xml');
// or
// const reader = new ReportReader(new JsonFormatter());
// const report = await reader.read('report.json');
XmlFormatter와 JsonFormatter 클래스가 동일한 인터페이스인 Formatter를 상속받도록 수정하였고, 이로써 ReportReader 클래스는 Formatter 인터페이스의 parse 메서드만 의존하게 된다.
이로써 객체 지향 설계의 5원칙에 대해서 간략하게나마 정리해보았다.
역시나 개념적인 부분이라 이해가 확실히 되진 않았지만 추가로 정리할 수 있는 기회가 올것이다.
'Node.js 도전기' 카테고리의 다른 글
| Node.js_3 Layerd Architecture 로 리팩토링하기 ! (2) (2) | 2023.12.08 |
|---|---|
| Node.js_3 Layerd Architecture 로 리팩토링하기 ! (1) (1) | 2023.12.08 |
| Node.js_객체 지향 프로그래밍 (OOP) (1) | 2023.12.05 |
| Node.js_트랜잭션 (Transaction) (2) | 2023.12.04 |
| Node.js_AccessToken / RefreshToken ~ (1) | 2023.12.01 |