https://youtu.be/4O6k9GN8FPo?si=ji-jjOpZPv5kbuuc
VIDEO
>> 이 강의를 토대로 정리했습니다. 예제는 클로드를 통해 정리했습니다. 강의 정말 좋습니다 추천~
SOLID 원칙
1. Single Responsibility principle (SRP, 단일 책임 원칙)
2. Open/ closed principle (OCP, 개발 / 폐쇄 원칙)
3. Liskov substitution principle (LSP, 리스코프 치환 원칙)
4. Interface segregation principle (ISP, 인터페이스 분리법칙)
5. Dependency inversion principle (DIP, 의존성 역전 법칙)
1. Single Responsibility principle (SRP, 단일 책임 원칙)
* 각 클래스는 하나의 책임만 가져간다. * 직업을 가졌다고 생각하면 편하다. * 클래스안에 여러 메소드는 생성 가능하다. 단, 이를 통해 수행하는 대표적인 업무는 하나어야만 한다. *각 클래스는 하나의 임무와 목적을 가진다. *각각 책임의 변경사항이 있는 때만 해당하는 곳을 변경하면 된다. * 책임별로 분리가 되어 있어, 재 사용하기도 좋다.
2. Open/ closed principle (OCP, 개발 / 폐쇄 원칙)
*각 클래스는 확장에는 열려있어야하고, 변경에는 닫혀있어야한다.
나는 저 닫혀있어야 한다는 말이 캡슐화를 시켜서 닫아놔야한다는 뜻인가? 했는데 다르다고 합니다.
캡슐화와는 조금 다른데:
캡슐화는 "비밀 레시피를 감추는 것"
개방/폐쇄는 "기본 레시피는 유지하면서 새로운 메뉴를 추가할 수 있게 하는 것" 이라고 보시면 됩니다 😊
/
// 기본 피자 만드는 과정
const createPizza = ( type ) => {
const makeDough = () => console . log ( "도우를 만듭니다" );
const addSauce = () => console . log ( "소스를 넣습니다" );
const bake = () => console . log ( "굽습니다" );
// 이 기본 과정은 절대 변경되지 않음
return {
prepare : () => {
makeDough ();
addSauce ();
bake ();
}
};
};
피자를 만드는 기본 과정(도우 만들기 → 소스 넣기 → 굽기)은 절대 변경되지 않아요
마치 맥도날드의 햄버거 만드는 기본 과정이 항상 동일한 것처럼요!
2. 확장해야 한다는 뜻은
// 페퍼로니 피자
const createPepperoniPizza = () => {
const pizza = createPizza ();
return {
... pizza ,
addToppings : () => console . log ( "페퍼로니를 올립니다" )
};
};
// 치즈 피자
const createCheesePizza = () => {
const pizza = createPizza ();
return {
... pizza ,
addToppings : () => console . log ( "치즈를 듬뿍 올립니다" )
};
};
// 새로운 피자도 쉽게 추가할 수 있음!
const createHawaiianPizza = () => {
const pizza = createPizza ();
return {
... pizza ,
addToppings : () => console . log ( "파인애플과 햄을 올립니다" )
};
};
기본 과정은 그대로 두고, 새로운 종류의 피자를 자유롭게 추가할 수 있어요
마치 맥도날드가 기본 햄버거 만드는 과정은 그대로 두고, 새로운 햄버거를 메뉴에 추가하는 것처럼요!
즉, 개방/폐쇄 원칙은:
레시피의 기본은 바꾸지 않고 (변경에 닫힘)
새로운 메뉴는 얼마든지 추가할 수 있다 (확장에 열림) 는 의미입니다!
캡슐화와는 조금 다른데:
캡슐화는 "비밀 레시피를 감추는 것"
개방/폐쇄는 "기본 레시피는 유지하면서 새로운 메뉴를 추가할 수 있게 하는 것" 이라고 보시면 됩니다 😊
<OCP 예제>
// 나쁜 예제 - OCP 위반
const badProcessPayment = ( paymentType , amount ) => {
if ( paymentType === 'credit' ) {
console . log ( `신용카드로 ${ amount } 원 결제 처리중...` );
return true ;
}
else if ( paymentType === 'kakao' ) {
console . log ( `카카오페이로 ${ amount } 원 결제 처리중...` );
return true ;
}
return false ;
};
// 좋은 예제 - OCP 준수
// 결제 처리기 생성 함수
const createPaymentProcessor = ( type ) => {
return {
process : ( amount ) => {
console . log ( ` ${ type } 로 ${ amount } 원 결제 처리중...` );
return true ;
}
};
};
// 각 결제 수단별 프로세서 생성
const creditCardProcessor = createPaymentProcessor ( '신용카드' );
const kakaoPayProcessor = createPaymentProcessor ( '카카오페이' );
const naverPayProcessor = createPaymentProcessor ( '네이버페이' );
// 결제 서비스 생성 함수
const createPaymentService = ( processor ) => ({
processPayment : ( amount ) => processor . process ( amount )
});
// 더 진보된 방식 - 커링과 합성 사용
const processPayment = ( processor ) => ( amount ) => processor . process ( amount );
// 사용 예시
console . log ( "===== 잘못된 방식 =====" );
badProcessPayment ( 'credit' , 50000 );
badProcessPayment ( 'kakao' , 30000 );
console . log ( " \n ===== 좋은 방식 =====" );
// 기본적인 방식
const creditCardService = createPaymentService ( creditCardProcessor );
creditCardService . processPayment ( 50000 );
const kakaoPayService = createPaymentService ( kakaoPayProcessor );
kakaoPayService . processPayment ( 30000 );
const naverPayService = createPaymentService ( naverPayProcessor );
naverPayService . processPayment ( 20000 );
// 커링을 사용한 더 함수형다운 방식
console . log ( " \n ===== 함수형 방식 =====" );
const processCreditCard = processPayment ( creditCardProcessor );
const processKakaoPay = processPayment ( kakaoPayProcessor );
const processNaverPay = processPayment ( naverPayProcessor );
processCreditCard ( 50000 );
processKakaoPay ( 30000 );
processNaverPay ( 20000 );
// 새로운 결제 수단 추가가 매우 간단
const tossPayProcessor = createPaymentProcessor ( '토스페이' );
const processTossPay = processPayment ( tossPayProcessor );
processTossPay ( 40000 );
3. Liskov substitution principle (LSP, 리스코프 치환 원칙)
*부모 클래스가 사용되는 곳에 자식 클래스도 문제 없이 사용될 수 있어야 한다.
<LSP 예제>
// 인터페이스 정의 (날 수 있는 능력)
const Flyable = {
fly : () => {
throw new Error ( 'fly 메서드를 구현해야 합니다' );
}
};
// 기본 조류 행동 정의
const createBird = () => ({
eat : () => {
console . log ( '새가 먹이를 먹습니다' );
}
});
// 날 수 있는 참새 만들기
const createSparrow = () => {
const bird = createBird (); // 기본 조류 행동 상속
// Flyable 인터페이스 구현
const flyable = {
fly : () => {
console . log ( '참새가 날고 있습니다' );
}
};
// 참새는 Bird이면서 Flyable
return {
... bird ,
... flyable
};
};
// 날지 못하는 펭귄 만들기
const createPenguin = () => {
const bird = createBird (); // 기본 조류 행동만 상속
// 펭귄만의 특별한 행동 추가
const swim = () => {
console . log ( '펭귄이 수영합니다' );
};
return {
... bird ,
swim
};
};
// 사용 예시
const sparrow = createSparrow ();
const penguin = createPenguin ();
// 모든 새는 먹이를 먹을 수 있음 (LSP 만족)
function feedBird ( bird ) {
bird . eat (); // 어떤 새가 와도 문제 없음
}
// 날 수 있는 새들만을 위한 함수
function makeBirdFly ( flyableBird ) {
if ( 'fly' in flyableBird ) {
flyableBird . fly ();
} else {
console . log ( '이 새는 날 수 없습니다' );
}
}
// 테스트
console . log ( '=== 참새 테스트 ===' );
feedBird ( sparrow ); // "새가 먹이를 먹습니다"
makeBirdFly ( sparrow ); // "참새가 날고 있습니다"
console . log ( ' \n === 펭귄 테스트 ===' );
feedBird ( penguin ); // "새가 먹이를 먹습니다"
makeBirdFly ( penguin ); // "이 새는 날 수 없습니다"
penguin . swim (); // "펭귄이 수영합니다"
이 예제를 통해 LSP를 설명하면: 기본 원칙:
const createBird = () => ({
eat : () => {
console . log ( '새가 먹이를 먹습니다' );
}
});
모든 새는 eat() 능력을 가집니다 이는 모든 새가 지켜야 할 기본 계약입니다 인터페이스 구현:
const Flyable = {
fly : () => {
throw new Error ( 'fly 메서드를 구현해야 합니다' );
}
};
날 수 있는 새들이 구현해야 할 인터페이스입니다 LSP 적용:
// 어떤 새가 와도 문제 없이 동작합니다
function feedBird ( bird ) {
bird . eat ();
}
sparrow든 penguin이든 feedBird 함수에서 문제 없이 동작합니다 이것이 바로 LSP의 핵심입니다! 안전한 기능 확장:
const createPenguin = () => {
const bird = createBird ();
const swim = () => {
console . log ( '펭귄이 수영합니다' );
};
return { ... bird , swim };
};
펭귄은 날지는 못하지만 수영할 수 있는 기능이 추가되었습니다 기본 새의 기능(eat)은 그대로 유지됩니다 LSP의 장점: *코드의 유연성이 증가합니다 * 새로운 종류의 새를 추가해도 기존 코드를 수정할 필요가 없습니다 * 각 새들의 특성에 맞는 기능을 자유롭게 구현할 수 있습니다 이렇게 함수형으로 구현하면: * 상속 대신 합성을 사용합니다 * 더 유연한 구조를 만들 수 있습니다 * 코드의 재사용성이 높아집니다 LSP를 지킴으로써 얻는 이점은: * 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있습니다 * 코드의 안정성이 높아집니다 * 유지보수가 쉬워집니다
4. Interface segregation principle (ISP, 인터페이스 분리법칙)
*클래스는 자신이 사용하지 않을 메소드를 구현하도록 강요 받지 말아야한다. *인터페이스는 책임에 따라 나눠야한다.
5. Dependency inversion principle (DIP, 의존성 역전 법칙)
*고수준 모듈이 저수준 모듈에 의존하면 안된다.
이건 예시가 애매해서 그런가 와닿지가 않는다...
<DIP 예시>
// 전자기기 인터페이스 (추상화)
const createSwitchableDevice = () => ({
turnOn : () => {
throw new Error ( 'turnOn 메서드를 구현해야 합니다' );
},
turnOff : () => {
throw new Error ( 'turnOff 메서드를 구현해야 합니다' );
}
});
// 저수준 모듈: 선풍기
const createFan = () => {
// 인터페이스 구현
return {
turnOn : () => {
console . log ( '선풍기가 돌아갑니다' );
},
turnOff : () => {
console . log ( '선풍기가 멈춥니다' );
}
};
};
// 저수준 모듈: LED 전구
const createLightBulb = () => {
return {
turnOn : () => {
console . log ( '전구가 켜집니다' );
},
turnOff : () => {
console . log ( '전구가 꺼집니다' );
}
};
};
// 고수준 모듈: 스위치
const createSwitch = ( device ) => {
// device가 필요한 메서드를 가지고 있는지 검증
if (! device . turnOn || ! device . turnOff ) {
throw new Error ( '유효한 디바이스가 아닙니다' );
}
return {
turnOn : () => device . turnOn (),
turnOff : () => device . turnOff ()
};
};
// 사용 예시
const fan = createFan ();
const lightBulb = createLightBulb ();
// 스위치에 각각의 기기를 연결
const fanSwitch = createSwitch ( fan );
const lightSwitch = createSwitch ( lightBulb );
// 테스트
console . log ( '=== 선풍기 테스트 ===' );
fanSwitch . turnOn (); // "선풍기가 돌아갑니다"
fanSwitch . turnOff (); // "선풍기가 멈춥니다"
console . log ( ' \n === 전구 테스트 ===' );
lightSwitch . turnOn (); // "전구가 켜집니다"
lightSwitch . turnOff (); // "전구가 꺼집니다"
// 새로운 기기 추가도 쉽게 가능
const createAircon = () => ({
turnOn : () => console . log ( '에어컨이 켜집니다' ),
turnOff : () => console . log ( '에어컨이 꺼집니다' )
});
const aircon = createAircon ();
const airconSwitch = createSwitch ( aircon );
console . log ( ' \n === 에어컨 테스트 ===' );
airconSwitch . turnOn (); // "에어컨이 켜집니다"
airconSwitch . turnOff (); // "에어컨이 꺼집니다"
추상화 정의:
const createSwitchableDevice = () => ({
turnOn : () => { throw new Error ( 'turnOn 메서드를 구현해야 합니다' ) },
turnOff : () => { throw new Error ( 'turnOff 메서드를 구현해야 합니다' ) }
});
이것이 우리의 추상화(인터페이스)입니다 모든 전자기기가 따라야 할 계약을 정의합니다 저수준 모듈 (구체적인 구현):
const createFan = () => ({
turnOn : () => { console . log ( '선풍기가 돌아갑니다' ) },
turnOff : () => { console . log ( '선풍기가 멈춥니다' ) }
});
추상화를 실제로 구현하는 부분입니다 구체적인 기기들(선풍기, 전구 등)을 만듭니다 고수준 모듈:
const createSwitch = ( device ) => ({
turnOn : () => device . turnOn (),
turnOff : () => device . turnOff ()
});
추상화에 의존하는 부분입니다 구체적인 구현(선풍기인지 전구인지)을 알 필요가 없습니다 의존성 주입:
const fan = createFan ();
const fanSwitch = createSwitch ( fan );
구체적인 구현을 외부에서 주입받습니다 이를 통해 결합도가 낮아집니다 DIP의 장점: *유연성: 새로운 기기를 쉽게 추가할 수 있습니다 * 테스트 용이성: 모의 객체(mock)를 쉽게 만들 수 있습니다 * 재사용성: 코드 재사용이 쉬워집니다 * 유지보수성: 변경이 다른 부분에 영향을 미치지 않습니다 이렇게 구현하면: * 고수준 모듈(스위치)은 저수준 모듈(선풍기, 전구)에 직접 의존하지 않고 * 둘 다 추상화(전자기기 인터페이스)에 의존하게 됩니다 * 이것이 바로 "의존성 역전" 입니다!
아직 애매하게 이해한 부분이 많은데, 마냥 무지성으로 소스 짤때보단 뭔가 깨닫는게 있었다 ㅋㅋㅋㅋㅋ
디자인 패턴 좀 더 파보도록 하자!