세 줄 요약:
SOLID 원칙은 객체지향 프로그래밍과 설계의 다섯 가지 핵심 원칙이며, 이를 통해 코드의 유연성과 확장성을 높일 수 있음. SRP는 단일 책임 원칙으로, 객체가 하나의 책임만을 가져야 함을 강조함. OCP는 개방-폐쇄 원칙으로, 확장에는 열려있고 변경에는 닫혀있어야 함을 명시함. LSP는 리스코프 치환 원칙으로, 하위 타입의 객체가 상위 타입의 객체로 대체되어도 프로그램의 의도가 바뀌지 않아야 함을 강조함. 그리고 ISP는 인터페이스 분리 원칙으로, 특정 클라이언트를 위한 여러 인터페이스가 범용 인터페이스보다 나은 설계를 제시함. DIP는 의존성 역전 원칙으로, 추상화를 구체화 대비 강조함.
/** SRP Before **/
class UserSettings {
constructor(user) { // UserSettings 클래스 생성자
this.user = user;
}
changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
if (this.verifyCredentials()) {
//...
}
}
verifyCredentials() { // 사용자의 인증을 검증하는 메소드
//...
}
}
/** 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 객체의 메소드를 사용한다.
//...
}
}
}
/** 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)인 “확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”를 위반하게 됨./** 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 함수로 전달합니다.
/** 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와 Rectangle클래스에서 같은 메서드를 호출하더라도 다른 결과값이 반환되는 것을 확인할 수 있음. 예제에서 높이를 7로 설정하려 하였지만, Square 클래스에서는 너비와 높이가 동일해야 하므로 결과적으로 너비가 7로 설정됨 = LSP 위반./** 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)을 만족하도록 구성된 것./** 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 기능을 지원하지 않습니다.');
}
}
/** 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 기능을 지원한다.
// ...
}
}
인터페이스 분리 원칙(ISP)을 적용하면 어플리케이션의 복잡성을 줄이고, 각 클래스가 필요한 기능에만 집중할 수 있게 됨!/** 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');