📍단일 책임 원칙
- 클래스의 응집도가 낮다 === 변경될 상황이 많다.
- 많은 기능이 하나의 클래스에 있고, 해당 클래스의 기능을 수정한다면 다른 종속된 모듈에 미칠 영향을 파악하는 것이 어렵다.
📍개방 폐쇄 원칙
- 소프트웨어 엔터티(클래스, 모듈, 함수 등)는 상속에 개방되어 있지만 수정에는 폐쇄 되어 있다.
- 즉, 기존 코드를 변경하지 않고 새로운 기능을 추가 할 수 있도록 해야한다.
Bad
class AjaxAdapter extends Adapter{
constructor(){
super();
}
}
class NodeAdapter extends Adapter{
consructor(){
super();
}
}
class HttpRequester {
constructor(private readonly adapter: Adapter){}
async fetch<T>(url:string): Promise<T>{
if(this.adapter instanceof AjaxAdapter){
const response = await makeAjaxCall<T>(url);
} else if(this.adaper instanceof NodeAdapter){
const response = await makeHttpCall<T>(url);
}
}
}
function makeAjaxCall<T>(url:string): Promise<T>{ };
function makeHttpCall<T>(url:string): Promise<T>{ };
- 클래스 HttpRequester의 fetch 메서드 내부에서 현재 adapter가 어떤 클래스의 객체인지에 따라서 각각 다른 요청 함수를 호출하고 있다.
- 요청 함수의 기능이 달라질 경우 여러 군데를 수정해야하는 문제점이 있고, 요청 함수가 수정될 경우 fetch 메서드 내부 구현이 수정될 수 있는 문제가 있다.
Good
abstract class Adapter {
abstract async request<T>(url:string): Promise<T>;
}
class AjaxAdapter extends Adapter{
constructor(){
super();
}
async reqeust<T>(url:string): Promise<T>{}
}
class NodeAdapter extends Adapter{
consructor(){
super();
}
async reqeust<T>(url:string): Promise<T>{}
}
class HttpRequester{
constructor(private reqdonly adapter:Adapter){}
async fetch<T>(url:string):Promise<T>{
const response= await this.adapter.request<T>(url);
}
}
- 추상클래스를 사용하여 하위 클래스들과 공유하는 추상 메서드를 정의한 후 각 클래스에서 해당 request 메서드를 구현한다.
- 이에 따라 HttpRequester 의 adapter 객체가 어떤 클래스의 인스턴스인지 검사하지 않고 바로 해당 adapter에 따른 request 메서드를 호출 할 수 있으며, Adapter 클래스를 상속한 각 자식 클래스에서 request 메서드를 관리 할 수 있다.
📍리스코프 치환 원칙
- 만약 S가 T의 하위 타입이라면, T타입의 객체는 S타입의 객체로 대체 될 수 있다.
- 즉, 만약 부모 클래스와 자식 클래스가 있다면 부모 클래스와 자식 클래스는 잘못된 결과 없이 서로 교환하여 사용 될 수 있다는 것이다.
Bad
class Rectangle {
constructor(
protected width: number =0,
protected height: number =0
){}
setColor(color: string): this {}
render(area:number){}
setWidth(width:number):this{
this.width = width;
return this;
}
setHeight(height:number):this{
this.height = height;
return this;
}
getArea():number {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number): this {
this.width = width;
this.height = width;
return this;
}
setHeight(height: number): this {
this.width = height;
this.height = height;
return this;
}
}
function renderLargeRectangles(rectangles: Rectangle[]) {
rectangles.forEach((rectangle) => {
const area = rectangle
.setWidth(4)
.setHeight(5)
.getArea();
rectangle.render(area);
});
}
const rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles(rectangles);
- 수학적으로 정사각형은 직사각형이지만, 이를 상속으로 구현하여 위와 같이 정사각형이 직사각형을 상속되도록 구현하여, 부모-자식을 서로 치환한다면 정사각형의 넓이를 제대로 구하지 못한다.
Good
abstract class Shape {
setColor(color: string): this {}
render(area: number) {}
abstract getArea(): number;
}
class Rectangle extends Shape {
constructor(
private readonly width = 0,
private readonly height = 0) {
super();
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(private readonly length: number) {
super();
}
getArea(): number {
return this.length * this.length;
}
}
function renderLargeShapes(shapes: Shape[]) {
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이란 상위 클래스에 공통되는 메서드를 정의 한후 각 클래스에서 메서드를 구현한다.
- 이를 통해 부모-자식이 치환되어도 각각 올바른 결과가 도출됨을 알 수 있다.
📍인터페이스 분리 원칙
- 클라이언트는 사용하지 않는 인터페이스에 의존하지 않는다.
- 즉, 클라이언트가 노출된 메서드를 사용하는 데신에 전체 파이를 얻지 않는 방식으로 추상화를 설계 해야한다.
Bad
interface SmartPrinter {
print();
fax();
scan();
}
class AllInOnePrinter implements SmartPrinter {
print() {
}
fax() {
}
scan() {
}
}
class EconomicPrinter implements SmartPrinter {
print() {
}
fax() {
throw new Error('Fax not supported.');
}
scan() {
throw new Error('Scan not supported.');
}
}
- Econimicprinter 클래스 에는 fax와 scan 기능이 필요하지 않지만, SmartPrinter 라는 interface에 정의 되어 있어서 구현 후 Error를 throw 하고 있다. 즉 불필요한 인터페이스에 의존되고 있다.
Good
interface Printer {
print();
}
interface Fax {
fax();
}
interface Scanner {
scan();
}
class AllInOnePrinter implements Printer, Fax, Scanner {
print() {
}
fax() {
}
scan() {
}
}
class EconomicPrinter implements Printer {
print() {
}
}
- SmartPrinter Interface를 각각 Printer, Fax 그리고 Scanner Interface로 분리하여 추상화한다.
📍의존성 역전 원칙
- 상위 레벨의 모듈은 하위 레벨의 모듈에 의존하지 않아야 한다. 두 모듈은 추상화에 의존해야한다.
- 추상화는 세부사항에 의존하지 않아야 한다. 세부사항은 추상화에 의존해야 한다.
- 이를 통해 모듈 간 결합도를 줄일 수 있다.
Bad
import { readFile as readFileCb } from 'fs';
import { promisify } from 'util';
const readFile = promisify(readFileCb);
type ReportData = {
}
class XmlFormatter {
parse<T>(content: string): T {
}
}
class ReportReader {
private readonly formatter = new XmlFormatter();
async read(path: string): Promise<ReportData> {
const text = await readFile(path, 'UTF8');
return this.formatter.parse<ReportData>(text);
}
}
const reader = new ReportReader();
await report = await reader.read('report.xml');
- 위 코드는 ReportReader 내부에서 fomatter 객체를 생성한다.
- 해당 객체는 '특정 요청' (여기에서는 Xml 포맷팅) 의 구현에 의존하게 된다.
- 이에 따라서 ReportReader 클래스와 XmlFomatter 클래스 간의 결합도가 높아지게 된다.
Good
import { readFile as readFileCb } from 'fs';
import { promisify } from 'util';
const readFile = promisify(readFileCb);
type ReportData = {
}
interface Formatter {
parse<T>(content: string): T;
}
class XmlFormatter implements Formatter {
parse<T>(content: string): T {
}
}
class JsonFormatter implements Formatter {
parse<T>(content: string): T {
}
}
class ReportReader {
constructor(private readonly formatter: Formatter) {
}
async read(path: string): Promise<ReportData> {
const text = await readFile(path, 'UTF8');
return this.formatter.parse<ReportData>(text);
}
}
const reader = new ReportReader(new XmlFormatter());
await report = await reader.read('report.xml');
const reader = new ReportReader(new JsonFormatter());
await report = await reader.read('report.json');
- parse 메서드를 갖는 Fomatter 클래스를 상속받는 XmlFomatter와 JsonFomatter 클래스를 구현했다.
- ReportReader 클래스 내부에서 직접 특정 객체를 생성하지 않고, 생성자의 매개변수로 받아옴으로써 특정 클래스에 의존하지 않고 Fomatter 라는 추상화에 의존하고 있다.
참고
Clean-Code-Typescript Solid