SOLID 공부

kio·2023년 6월 12일
0

개발지식

목록 보기
1/1

SOLID 란?

SOLID는 객체 지향 설계를 더 이해하기 쉽고 유연하며 유지 관리할 수 있도록 하기 위한 5가지 설계 원칙의 약어입니다. - Wikipedia

즉 객체지향적으로 프로그램을 설계할때, 지켜야하는 원칙 → 코드를 짤 때 5가지를 고려해서 짜라

그럼 이제부터 S부터 시작을 해보겠다


SRP(Single Responsibility Principle) - 단일 책임 원칙

우리 회사에서 10년동안 사용중인 class가 있는데, 최근 업데이트 진행한 후 뭔가 제대로 돌아가지 않기 시작했습니다.
class를 보니 네트워크 파싱, 네트워크에서 데이터 필터링, 네트워킹등이 모두 이루어지고 있었습니다.
그 중 데이터 필터링이 잘못되고 있었네요~~
기존에 설계문제가 생겼는데 새로 업데이트된 내용은 건들일 수 없어서 기존에 필터링 방식을 완전히 바꿨습니다.
그랬더니 에러가 200개 이상 잡히네요 ㅎㅎ

SRP를 설망하기 앞서서 이런 말이 있습니다.

어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다. - 로버트 C.마틴

코드는 항상 바뀔 가능성이 있습니다. class도 마찬가지이구요.
근데 바꿀때, 해당 class를 사용하는 모든 곳에서 심지어 해당 function을 사용하지도 않는 곳에서 바꿔야 한다면 어떻게 할까요?

컴퓨터를 샀는데 CPU가 고장나면 우리는 어떻게 해야할까요?

  1. CPU를 새로사다가 교체한다.
  2. 컴퓨터를 새로 산다.

당연히 1번이죠! 왜냐하면 컴퓨터는 각자 역할과 책임이 각 부품에 있으니까!

만약 모든일을 한 부품에서 한다면? 생각만해도 돈도 많이 들고 힘들죠?

컴퓨터를 바꾸거나 고치는 이유는 무엇일까요? 너무 많습니다.

CPU가 고장났을수도 있고, 메인보드가 고장났을 수도 있습니다. 그렇기 때문에 우리는 한 class에는 하나의 책임을 부여하고 그것들을 결합하는 것이 좋습니다.

class Restaurant {
		
		func work() {
				order()
				cook()
				serve()	
		}

		func order() {
				// ...
		}	
		
		func cook() {
				// ...
		}

		func serve() {
				// ...
		}
}

위는 레스토랑이라는 class가 음식도 만들고 주문도 받고 서빙도 한다.

이렇게 된다면 한 부분에 문제가 생기게 된다면 class를 수정하면서 다른 곳에서 사이드이펙트가 발생할 수 있다.

class Restaurant {
		var orderHandler: OrderHandler
		var cookHandler: CookHandler
		var serveHandler: ServeHandler

		init(...) {
			self.orderHandler = // ...
		}

		func work() {
			oderHandler.order()
			// ...
		}
}

이렇게 각 역할을 분리하게 되면 문제를 해결할 수 있다.

Shotgun Surgery vs Divergent Change

Shotgun Surgery occur when a single change requires edits in many classes.
Divergent Change occur when a single class needs to be edited many times when changes are made outside the class.

이 둘은 SRP를 지키지 않았을 때 생기는 대표적인 문제이다.

Shotgun Surgery는 하나의 책임을 변경하기 위해 많은 클래스의 변경을 요구할때 발생하고,

Divergent Change는 하나의 클래스가 다른 책임으로 변경되는 것을 의미한다.

즉 위에 코드는 Divergent Change라는 code smell인 것이고, 아래 처럼 책임별로 나눈 것이다.


OCP(Open Close Principle) - 개방 폐쇄 원칙

키오! 우리 강아지 고양이만 서비스를 지원했는데, 이제 토끼도 추가 할까요?
기존에 서비스는 다 유지할거고 따로 소변처리라는 함수를 지원할 예정입니다.
뭐 같은 포유류니까 큰 문제 없겠죠?

이번엔 코드부터 보겠습니다.

enum Pet {

		case dog
		case cat
}

class Tamer {
		
		var pet: Pet 

		init(pet: Pet) {
				self.pet = pet
		}

		func feed() {
				switch pet {
				case dog:
						print("my pet love meat")
				case cat:		
						print("my pet love fish")
		}
		// ..
}

아 우리 서비스는 참고로 feed같은 함수가 102개 있습니다. 요구사항대로 코딩해봅시다.

벌써 한숨부터 나오죠?

  1. 우선 새로 무언가를 추가하는데 기존 class를 수정합니다. → 어떤 사이드 이펙트가 발생할지 모름 ㅡㅡ
  2. 새로 추가할때 해야할게 너무 많습니다 → 함수 102개면 그 case를 다 추가해야합니다 ㅠㅠ

자 그럼 여기서 OCP의 정의

You should be able to extend a classes behavior, without modifying it

요약하자면 개체는 확장에는 열려있고(Open), 변경에는 닫혀있어야(Close)한다.

어떻게 OCP를 지킬 수 있을까?

  1. 변경(확장)될 것과 변하지 않을 것을 엄격히 구분합니다.
  2. 이 두 모듈이 만나는 지점에 인터페이스를 정의합니다.
  3. 구현에 의존하기보다 정의한 인터페이스에 의존하도록 코드를 작성 합니다.

말이 어려우니까 코드로 보겠습니다.

protocol pet {
		var name: String { get set }
		var feed: String { get set }
		func feeding()
		// ...
}

extension pet {
		func feeding() {
				print("my pet love \(feed)")
		}
}

class dog: pet {

		var name: String
		var feed: String
		
		init(name: String, feed: String) {
				self.name = name
				self.feed = feed
		}

		// ...
}
  1. 확장될 것과 변하지 않을 것을 구분합니다.
    변하는 않는 것 : pet에는 이름과 먹이가 있다는 것, feed라는 함수가 있다는 것
    변하는 것 : 어떤 종류에 팻이 있는지는 언제든지 확장가능하다.
    나는 여기서 default implementation을 사용해 좀 더 확장을 쉽게 하도록 하였다.
  2. 이 두 모듈이 만나는 지점에 인터페이스를 정의합니다.
    여기서 class 와 pet의 특성을 protocol에 담았다.
  3. 구현에 의존하기보다 정의한 인터페이스에 의존하도록 코드를 작성 합니다.
    class는 단지 protocol에서 필요한 부분을 채웠을 뿐, 큰 신경을 쓰지 않고 오로지 protocol의 구현에 의존하도록 구현되어있다.

주의할 점 !

  1. 적당히 크기조절을 하고 적당히 추상화 시켜야 확장에 좀 더 유연하다.
    protocol은 왠만하면 변하면 안된다.
    protocol이 변하면 너무 많은 클래스를 수정해야하는 이는 큰 문제를 일으킬 수 있다.
    하지만 protocol의 변경을 너무 의식하면 과도한 볼륨의 protocol이 만들어지고 이 또한 어떤 사이드이펙트를 발생할지 알 수 없다.

적당한 추상화란?

추상화는 ‘구체적이지 않은’ 정도의 레벨이 포함되어있다. 추상화를 통해서 다른 개체와는 완벽히 식별되어야하지만 너무 핵심만 추상화하게 된다면 protocol을 확장할 때, 불필요한 작업만 생기거나 protocol에 의존하는 class가 너무 독립적이게 될 수 있다.

그래서 우리는 적당히 구체적이게 추상화하는 설계를 해야한다.
( 뭐 나중에 어캐 확장될지 모르지만 그 적당선을 지키는 것 예지력 이런게 감이고 실력아니겠슴까? )


LCP(Liskov Substitution Principle) - 리스코프 치환 원칙

창교님!
저희 pet protocol을 잘 봤습니다! 저희 pet protocol에 다른 팀이 신발이라는 변수를 추가했습니다.
그래서 신발 신기는 함수도 만들었는데 아뿔싸?
저희 pet에 뱀을 추가했습니다 어떻게 할까요?

자 뱀에 신발을 신기다니 문제가 가득하다.
코드부터 봐봅시다.

protocol Pet: class {
    
    var name: String { get set }
    var feed: String { get set }
    var shoes: String { get set }
}

class Snake: Pet {
    
    var name: String
    var feed: String
    var shoes: String
    
    init(name: String, feed: String){
        self.name = name
        self.feed = feed
    }
}

class PetShop {
    
    func take(shoes: String, pet: Pet) {
        pet.shoes = shoes
    }
}

뭐 코드가 이렇게 되어있다면 snake는 문제가 생긴다.

왜냐하면 snake란 자고로 신발이 있으면 안되는데 신발이 생기는 것이다. 즉 protocol에 의존하는 class가 protocol에 정확성을 깨뜨리는 것이다.

리스코프 치환 원칙이란

부모 객체와 이를 상속한 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있다는 원칙이다.

그럼 이러한 문제를 해결하기위해 우리는 어떻게 해야할까요?

2가지 정도의 방법이 떠오릅니다.
1. 추상화레벨에 따라호출을 막습니다.
2. 조금 더 상위레벨의 추상화를 만듭니다.

특정 레벨에서 호출을 막습니다.

	...
    class PetShop {
    	
    	func take(shoes: String, pet: Pet) {
        	if pet is Snake {
            	...
            }
            else {
            	...
            }
        }
     }
    ...

조금 더 상위레벨의 추상화를 만듭니다.

	protocol PetDecoration {
    	 var shoes: String { get set }
    }
    
    class PetShop {
    	
    	func take(shoes: String, pet: PetDecoration) {
        	...
        }
     }
    ...

실제 예시는 보통 사각형과 직사각형을 통해 설명하곤 하는데 그때 Shape라는 형태를 만들어서 보통 표현한다. 완전이 pet과 petdecoration은 서로 상하 개념이 존재하진 않지만 이렇게 나눔으로써 문제를 해결할 수 있다.


ISP(Interface Segregation Principle) - 인터페이스 분리 원칙

창교님!
저희 이번에 유니콘이라는 팻을 출시하려고 합니다!
근데요 글쎄 유니콘은 밥을 먹지 않는다고 하네요!
( 유니콘을 .... 하 ... )

우리는 아래와 같은 인터페이스를 사용하고 있었습니다.

	protocol Pet: class {
    
	    var name: String { get set }
	    var feed: String { get set }

		func feeding()
    }

그래서 구현한 유니콘은 아마 이런 형태가 될 것입니다.

	class Unicorn: Pet {

		var name: String
		
		//Don't use
		var feed: String 
		var feeding() { }
		...
	}

자 그런데 우리가 나중에 먹이정책이 바뀌어서 쓰지도 않는 feeding 때문에 또 클래스를 고쳐야한다면 힘들겠죠?

더군다나 우리가 처음에 알아봤던 SRP에 관점으로 보면 Pet protocol은 뭔가 잘못 됬습니다. 팻은 그 자체로써 feed와 name이 있다는 것은 어떻게보면 먹이라는 또하나의 새로운 책임이 주어진 것이나 다름 없습니다.
그렇기 때문에 Pet에 feeding이라는 방식을 지운다면 위와 같은 문제는 일어나지 않을 것입니다.

또 프로토콜로써 분리하는 것은 기존 다른 언어들이 가지고 있는 다중상속에 문제 또한 피할 수 있어 swift에서 인터페이스를 나누는 것은 더 좋은 일이 될 것입니다!


DIP(Dependency Inversion Principle)

창교님!
강아지를 키우는 주인이라는 새로운 class가 필요할 것 같습니다!
그래 한번 가보자!!


	class Dog {
		...
	}
	
	class DogTamer {
		let dog: Dog

		init(dog: Dog) {
			self.dog = dog
		}
		...
	}

	let puppy = Dog()
	let dogTamer = DogTamer(dog: puppy)

이렇게 코드를 짜면 뭐가 문제일까?
당장은 잘 되겠죠...
근데 언제나 문제가 생길 수 있습니다.

창교님!
이번에 견종을 추가하기로 대대적으로 결정해버렸습니다!!!
추가 부탁아리마셍~

자 저기다 추가해보자

	class Poodle: Dog {
		...
	}

	class DogTamer {
		...
	}

	class PoodleTamer {
		let poodle: Poodle

		init(poodle: Poodle) {
			self.poodle = poodle
		}
		...
	}

뭐 이런 형태가 되야하나?
그럼 기존에 DogTamer를 고쳐야 한다면 OCP에 문제가 될 것 입니다.
또 만약 PoodleTamer를 초기화 할 때 그냥 Dog를 넣으면 poodle만이 할 수 있는 일을 다른 강아지들도 할 수 있다는 뜻이 되고 이는 LSP를 위반하게 됩니다.
그렇다고 매번 새로운 Tamer를 만들면 기존의 Tamer를 사용하기 위해서 대대적인 코드 수정을 필요합니다.
그러면 어떻게 하면 되냐 의존을 추상레벨 즉 프로토콜을 통해 하게 하면 됩니다!

	protocol Dog {
		...
	}

	protocol Tamer {
		let dog: Dog
		...
	}

이렇게 하면 나중에 새로운 견종이 만들어져도 그 주인은 Poodle을 사용해서 만들어주면 똑같은 역할을 실행할 수 있습니다.

지금까지 SOLID에 대해서 알아봤습니다. 정확한 비유 원칙들을 설명할 때 다른 원칙들을 어겼지만 해당 상황에만 초점이 맞춰지 좋은 코드는 아니라는 것을 한번 강조합니다!!!

0개의 댓글