solid는 객체지향 프로그래밍의 설계 원칙을 다섯가지로 정의한 것이다. 유지보수와 확장에 유연한 프로그래밍을 하기위해서 SOLID원칙을 적용한다.
클래스를 수정할 땐 수정할 이유가 2개 이상 생기면 잘못 설계된 것으로 본다.
2개 이상의 이유는 너무 많은 기능들을 한 클래스가 수행한다는 의미가 되기 때문이다.
class cafeOwner {
constructor(coffeebeans) {
this.coffeebeans = coffeebeans;
}
manageShop(time){
console.log(`managing coffee shop at ${time}`)
}
makeCoffee(coffeebeans) {
console.log(`making cofffe with ${coffeebeans}`)
}
serveCoffee(guest) {
console.log(`serving coffee to ${guest}`)
}
}
카페사장이 가게도 관리하고 커피도 만들고 서빙도하고 있다.
커피가 너무 쓴 것 같다는 컴플레인을 받아서 커피를 만들 때 원두양을 1/2로 줄이려한다.
따라서 makeCoffee()
메서드를 수정할것이다.
수정으로 인해 다른 곳에 영향을 끼치지는 않는 지 확인이 필요하다.
그런데 원두의 양을 변경하는 것과 전혀 관련이 없는 manageShop()
메서드까지 확인을 해주려니 너무 비효율적이라는 생각이 든다.
class coffemaker {
makeCoffee(coffeebeans) {
console.log(`making cofffe with ${coffeebeans}`)
}
}
class coffeeServer{
serveCoffee(guest) {
console.log(`serving coffee to ${guest}`)
}
}
class cafeOwner {
coffee;
constructor(coffeebeans) {
this.coffeebeans = coffeebeans;
}
manageShop(time){
console.log(`managing coffee shop at ${time}`)
}
}
이렇게 책임을 나누면 클래스를 체크해야 하는 범위가 줄어들어 관리가 쉬워질 것이다.
확장에는 개방적이며, 수정에는 폐쇠적이어야 한다는 원칙이다.
- 기능 추가가 필요할 때 기존 코드의 수정이 일어나지 않도록 한다.
- 내부 매커니즘이 변경되어도 외부에는 코드변화가 없어야 한다.
이 두가지를 만족시켜야 하는 원칙이다.
class LatteMaker {
coffee = "Latte";
}
class TeaMaker {
coffee = "Blacktea"
}
class cafeOwner {
constructor(maker, server) {
this.maker = maker
}
makeCoffee(){
if(this.maker.coffee==="Latte"){
brewingLatte()
}else if(this.maker.coffee==="Tea"){
brewingBlacktea()
}
}
}
function brewingLatte(){
console.log(`making coffee with Milk`)
}
function brewingBlacktea(){
console.log(`making coffee without Milk`)
}
현재 카페사장은 라떼머신이 무슨 커피가 담겨있는지 확인한 뒤 라떼를 만들 수 있고, 홍차머신이 무슨 커피가 담겨있는지 확인한 뒤 홍차를 만들 수 있다.
분명 두 머신은 구분되어있는데 이렇게 일일히 확인하고 커피를 내리는 것은 정말 비효율적인 작업이다.
실제로 이렇게 커피를 내린다면 답답한 손님들은 더 이상 이 가게를 찾지 않을 것이다.
안에 들어있는게 무엇인지 확인할 필요없이 머신이 알아서 커피를 내려주도록 수정해보자.
class LatteMaker {
coffee = "Latte";
brewingCoffee(){
console.log(`making coffee with Milk`)
}
}
class BlackteaMaker {
coffee = "Blacktea"
brewingCoffee(){
console.log(`making coffee without Milk`)
}
}
class cafeOwner {
constructor(maker, server) {
this.maker = maker
}
makeCoffee(){
this.maker.brewingCoffee()
}
}
이제 카페사장은 필요한 커피 머신을 받아서 makeCoffee()
버튼만 누르면 머신이 알아서 커피를 내릴 수 있게 되었다.
부모클래스와 자식클래스가 있다면 부모는 자식으로 교체되어도 프로그램이 정상 동작해야한다는 원칙이다.
올바르지 못한 복제는 오류를 야기한다.
따라서 상속을 할때는 리스코프 치환원칙을 지키도록 설계해야한다.
class TeaMaker {
coffee = ""
makeCoffee(){
this.coffee += "🍵"
}
serveCoffee(){
console.log(`${this.coffee}서빙 완료`)
}
}
class LatteMaker extends TeaMaker{}
const coffeeMaker = [new TeaMaker(), new TeaMaker(), new LatteMaker()]
coffeeMaker.forEach((maker)=>{
try{
maker.makeCoffee()
maker.serveCoffee()
}catch(err){
console.log(err)
}
})
찻집 가게에는 teaMaker
가 2대있었다.
teaMaker
는 차를 내리고 서빙까지 해주는 첨단 로봇으로 전자공학부 출신인 가게 사장이 만들었다.
사장은 teaMaker
를 복제하여 LatteMaker
를 만들어 두고 알바한테 잘 사용하라고 편지를 남긴 뒤 휴가를 떠났다.
손님이 와서 녹차 2잔과 라떼 1잔을 주문했다.
알바는 녹차 2잔은 teaMaker
에게, 라떼 1잔은 LatteMaker
에게 차를 내린 후 서빙하라고 명령했다.
서빙이 완료되고 손님은 왜 녹차가 3잔이 왔냐며 알바를 구박했다.
사장이 복제를 잘못해서 발생한 실수이다.
LatteMaker는 teaMaker가 고장났을 때 대신 차를 내려주지 못한다. 즉, 치환되지 못한다.
그러므로 사장은 복제를 하면 안되는 것이었다.
class coffeeMaker {
coffee = ""
serveCoffee(){
console.log(`${this.coffee}서빙 완료`)
}
}
class TeaMaker extends coffeeMaker {
makeCoffee(){
this.coffee += "🍵"
}
}
class LatteMaker extends coffeeMaker{
makeCoffee(){
this.coffee += "☕"
}
}
복제를 하려했다면 위 코드와 같이 서빙기능을 수행하는 로봇을 만들어서 그것을 복제하여 다양한 커피머신을 만들었어야 했다.
TeaMaker, LatteMaker 모두 coffeeMaker가 하는 일들을 모두 수행할 수 있다. 따라서 Lsp원칙을 만족하므로 복제를 해도 괜찮은 것이다.
자바스크립트는 인터페이스 기능이 없다.
ISP와 DIP는 이해를 위해 자바스크립트와 유사한 문법으로 인터페이스를 구현할 수 있는 타입스크립트를 사용하여 예시를 작성했다.
사용자는 자신이 이용하지 않는 메소드에 의존할 필요가 없어야 한다는 원칙이다.
큰 덩어리의 인터페이스들을 더 작은 단위로 분리시킴으로써 사용자는 꼭 필요한 메서드들만 이용할 수 있게 된다.
앞에서 말했듯 자바스크립트에서는 인터페이스기능이 없다. 인터페이스를 사용하려면 타입스크립트를 활용해야 한다.
interface robot{
brew() : void;
serve() : void;
}
class oldCoffeeRobot implements robot {
brew(){
console.log('커피 내리는 중')
}
serve(){
console.log('서빙 완료!')
}
}
class newCoffeRobot implements robot {
brew(){
console.log('커피 내리는 중')
}
serve(){
console.log('서빙 완료!')
}
clean(){
console.log('자체 청소 완료')
}
}
최신 커피로봇은 청소기능이 추가되었다.
하지만 사용자는 이 기능을 사용할 수 없으며 있는지도 알 수 없다.
오래된 커피로봇의 규격서가 최신 커피로봇에 동일하게 적용되어있기 때문이다.
interface robot{
brew() : void;
serve() : void;
}
interface cleanableRobot extends robot{
clean() : void;
}
class oldCoffeeRobot implements robot {
brew(){
console.log('커피 내리는 중')
}
serve(){
console.log('서빙 완료!')
}
}
class newCoffeRobot implements cleanableRobot {
brew(){
console.log('커피 내리는 중')
}
serve(){
console.log('서빙 완료!')
}
clean(){
console.log('자체 청소 완료')
}
}
이렇게 인터페이스를 쓰임새에 맞게 작게 만들어 사용자가 필요한 메소드만 확인할 수 있도록 만드는 것이 ISP원칙이다.
- 상위 모듈은 하위 모듈에 종속되지 않고 추상화에 의존해야 한다
- 세부사항 역시 추상화에 의해 달라져야 한다.
interface robot{
brew() : void;
serve() : void;
}
class fastCoffeeRobot implements robot {
brew(){
console.log('더 빠르게 아메리카노 내리는 중')
}
serve(){
console.log('서빙 완료!')
}
}
class CafeOwner {
constructor(){
const coffeeRobot = new fastCoffeeRobot()
coffeeRobot.brew()
coffeeRobot.serve()
}
}
new CafeOwner()
새로 고용한 카페사장은 항상 fastCoffeeRobot
만 사용할 수 있다.
최근 빠른 장비보다 안전한 장비가 개발되면서 safeCoffeRobot
이 개발이 되었다.
이에 따라 카페의 특성에 맞게 어떤 카페에는 safeCoffeRobot
를 사용하는 사장을, 어떤 카페에는 fastCoffeeRobot
를 사용하는 사장을 배치시키고 싶다.
interface robot{
brew() : void;
serve() : void;
}
class fastCoffeeRobot implements robot {
brew(){
console.log('더 빠르게 아메리카노 내리는 중')
}
serve(){
console.log('서빙 완료!')
}
}
class safeCoffeRobot implements robot {
brew(){
console.log('아메리카노 내리기')
}
serve(){
console.log('안전하게 서빙 완료!')
}
}
class CafeOwner {
constructor(myRobot : robot){
myRobot.brew()
myRobot.serve()
}
}
const machine1 = new fastCoffeeRobot()
const cafeOwner1 = new CafeOwner(machine1)
const machine2 = new safeCoffeRobot()
const cafeOwner2 = new CafeOwner(machine2)
카페 사장은 특정 기계를 가지는 것이 아닌 mechine
이라는 규격만 맞는다면 그게 어떤 기계이든 사용할 수 있다. 따라서 fastCoffeeRobot
과 safeCoffeRobot
중 어떤 것을 전달해주더라도 오류가 발생하지 않는다.
이러한 방식을 DI(의존성주입)이라한다.
의존성주입을 사용하면 규격에 맞는 부품을 유연하게 갈아끼워 줄 수 있기 때문에 리팩토링을 하기 수월해진다.