https://en.wikipedia.org/wiki/SOLID
SOLID - Wikipedia
From Wikipedia, the free encyclopedia Object-oriented software engineering design principles This article is about the SOLID principles of object-oriented programming. For the fundamental state of matter, see Solid. For other uses, see Solid (disambiguatio
en.wikipedia.org
SOLID는 Object-oriented design을 더욱 understandable, flexible, and maintainable하게 만드는 5가지 원칙의 줄임말이다.
=> High-cohension, loose-coupling을 지향하는 SOLID!
1. Single-responsibility pricinple (단일 책임 원칙)
2. Open-closed principle (개방 폐쇄 원칙)
3. Liskov substitution principle (리스코프 치환 원칙)
4. Interface segregation principle (인터페이스 분리 원칙)
5. Dependency inversion principle (의존성 역전 원칙)
이렇게 5개 원칙의 앞글자를 따서 만들었다. 하나씩 살펴보자!
1. Single-responsibility pricinple, 단일 책임 원칙
: 객체는 한번에 하나의 책임만 져야 한다. (하나의 객체에서 여러 행동을 하는 것을 지양)
ex) 간단한 예제
type FinanceReport struct{ //보고서
report string
}
func (r *FinanceReport) SendReport(email string){ //보고서 전송
fmt.Println("send",email)
}
경제 리포트라는 struct 객체와, 해당 객체의 method로 SendReport()를 생성했다.
이 상황에서 다른 종류의 리포트가 생기면 어떻게 될까?
type MarketingReport struct{ //보고서
report string
}
func (r *MarketingReport) SendReport(email string){ //보고서 전송 => 보고서를 전송하는 기능은 동일하지만 따로 만들어줘야 하는 불편함이 존재함.
fmt.Println("send",email)
}
마케팅 리포트 struct 객체를 추가했다. 해당 리포트도 경제 리포트와 마찬가지로 Report를 Send하는 기능이 필요해 마케팅 리포트 struct에 Method로 SendReport()를 추가해주었다.
==> 그럼 앞으로 리포트를 100개 추가해야 한다면? 같은 기능을 하는 SendReport를 100개 만들어야 하는건가?
이 문제는 하나의 struct 객체에서 보고서와 보고서 전송, 총 두가지의 책임을 지녔기 때문에 생긴 문제이다. 이 문제를 해결하기 위해서 Single-responsibility pricinple을 다음 구조 형식으로 적용해보자.
type Report interface{
Report() string
}
type FinanceReport struct{
report string
}
func (r *FinanceReport) Report() string{
return r.report
}
type ReportSender struct{
}
func (s *ReportSender) SendReport(report Report){
}
Report 인터페이스를 생성해, 추가되는 Report마다 해당 인터페이스를 implement하도록 한다. 이후 ReportSender struct 객체를 생성하고 SendReport() 기능만 담당하도록 한다.
Report 인터페이스는 Report에 대한 책임만, ReportSender struct는 SendReport()의 책임만 갖게 됐다. 여기서 새로운 Report가 추가될 때, Report 인터페이스를 implement하면 SendReport()에 대한 기능은 추가로 구현해주지 않아도 된다.
=> 책임을 분리했기 때문에 가져다가 쓰기만 하면 됨.
즉, 코드의 재사용성을 높인다는 이점을 챙길 수 있었다!
2. Open-closed principle, 개방 폐쇄 원칙
: 확장(새로운 기능 추가)에는 열려(쉽게 추가 가능) 있고, 변경(기존 코드 변경)에는 닫혀(하기 힘듦) 있다.
=> 기존 코드 변경 없이 새로운 기능 추가가 쉽다!
"새로운 기능을 추가하는 상황에서 기존 코드를 많이 수정할 필요가 없게 해라"라는 의미로 받아들이면 될듯..?
ex) 간단한 예제
위에서 활용한 ReportSender 객체의 SendReport() 기능을 다음과 같이 작성해야 한다.
func SendReport(r *Report, method SendType, receiver string){
switch method{
case Email:
case Fax:
...
}
}
보내는 Report가 Email인지, Fax인지, Marketing인지에 따라 수행해야 할 Send기능이 다를 수 있다. 이를 해결하기 위한 switch-case문을 작성하고, 각 case별로 수행할 내용을 내부에 작성해서 각 Report별 기능을 구현할 수 있다.
하지만 이렇게 하는 경우, case가 추가될 때마다 혹은 코드의 수정이 필요할 때마다 SendReport에서 많은 코드 수정이 필요하다.
이를 OCP에 맞게 수정하기 위해 ReportSender 인터페이스를 추가하고, 각각의 case를 따로 구현해서 사용해보자
package main
import "fmt"
type Report interface {
Report() string
}
type EmailReport struct {
email string
}
type FaxReport struct {
fax string
}
func (e *EmailReport) Report() string {
return e.email
}
func (f *FaxReport) Report() string {
return f.fax
}
type ReportSender interface {
Send(r *Report)
}
type EmailSender struct {
}
func (e *EmailSender) Send(r *Report) {
fmt.Println("Email!")
// 이메일 전송 구현
}
type FaxSender struct {
}
func (f *FaxSender) Send(r *Report) {
// 팩스 전송 구현
fmt.Println("Fax!")
}
type SendType string
func SendReport(r *Report, method SendType, receiver string) {
switch method {
case "Email":
sender := EmailSender{}
sender.Send(r)
case "Fax":
sender := FaxSender{}
sender.Send(r)
}
}
func main() {
t_email := &EmailReport{}
t_fax := &FaxReport{}
var report Report
report = t_email
SendReport(&report, "Email", "test") //Email!
report = t_fax
SendReport(&report, "Fax", "test") //Fax!!
}
이렇게 구현하게 된다면 Email이나 Fax의 Send 부분에 변동 사항이 있을때 SendReport부분은 건드릴 필요 없다. 각 EmailSender or FaxSender 함수만 수정하면 된다. 또한 새로운 Report가 추가되더라고 SendReport 부분에 추가할 내용은 많지 않다. => 아예 기존 코드를 안건드리는 것은 아님. 조금은 수정해야 하는데 그저 항목 추가 정도만 하는것임.
즉, OCP의 "확장(새로운 기능 추가)에는 열려(쉽게 추가 가능) 있고, 변경(기존 코드 변경)에는 닫혀(하기 힘듦) 있다"를 지향하도록 구성된 코드이다.
근데 의문이 생긴다. SRP(단일 책임 원칙)에서 애써 책임 분리를 통해, 각 Report마다 SendReport()를 만들지 않아도 되게 해놨는데 OCP를 구현하기 위한 구성에서는 새로운 Report마다 SendReport() 기능을 만들어줘야 한다..
그래도 SRP의 원래 목적인 책임 분리는 아직 유지되는 모습이다.
3. Liskov substitution principle, 리스코프 치환 원칙
: "q(x)를 타입 T의 객체 x에 대해 증명할 수 있는 속성이라 하자. 그렇다면 S가 T의 하위 타입이라면 q(y)는 타입 S의 객체 y에 대해 증명할 수 있어야 한다."
정의는 이렇다고 한다. 뭔소리야!!
class Rectangle{
width int
height int
setWitdh(w int) {width = w}
setHeight(h int) {height = h}
}
class Square extends Rectangle{
@override
setWidth(w int) {width = w ; height = w;}
@override
setHeight(h int) {height = h; width = h;}
}
func FillScreenWidth(screenSize Rectangle, imageSize *Rectangle){
if imageSize.width < screenSize.width{
imageSize.setWidth(screenSize.width)
}
}
FillScreenWidth함수에서 만약 imageSize *Rectangle이 Rectangle을 상속받는 Square라면 width만 바꾸고자 하는 FillScreenWidth함수의 본 기능을 제대로 이행하지 못하는 문제가 있다. (Square에서는 override를 통해 setwidth시 height까지 바뀌기 때문)
즉, 함수가 상속 관계에 의해 본래의 기능을 상실했다!
Go에서는 상속이 없지만 위와 같은 문제가 그래도 발생하곤 한다.
type Report interface{
Report() string
}
type MarketingReport{
}
func (m *MarketingReport) Report() string{
}
func SendReport(r Report){
if _,ok := r.(*MarketingReport); ok{ //r이 마케팅 보고서일 경우 패닉
panic("Can't send MarketingReport")
}
}
var report = &MarketingReport{}
SendReport(report) // 패닉 발생
Go에서는 interface를 concrete 객체로 치환할 수 있는 기능(dynamic casting)이 존재한다. 위 상황에서 SendReport() 수행시 만약 Report(상위)가 Marketing(하위)인 경우 panic이 발생한다.
즉, 하위 객체에서 해당 함수가 올바르게 동작하지 않는 경우가 발생할 수 있다는 것이다.
Go에서는 크게 신경 안써도 될듯하다. (보통 override 하면서 생기는 문제이니...)
4. Interface segregation principle, 인터페이스 분리 원칙
: "클라이언트는 자신이 이용하지 않는 메서드에 의존하지 않아야 함"
ex)
type Report interface{
Report() string
Pages() int
Author() string
}
func SendReport(r Report){
send(r.Report())
}
=> SendReport에서는 Report method만 사용한다. 즉, 전달받은 r 객체의 다른 method는 필요하지 않다! (내가 이용하지 않는 메소드랑의 접점을 끊어야 한다!)
type Report interface{
Report() string
}
type WrittenInfo interface{
Pages() int
Author() string
WrittenDate() time.Time
}
func SendReport(r Report){
send(r.Report())
}
이러면 SendReport에서는 자신이 사용하는 method만 사용해 불필요한 의존성을 제거할 수 있다.
의문이 생긴다...
지금까지의 과정을 모두 100프로 이행할 수 있을까? 당장 Interface segregation principle만 봐도 코드의 양이 늘고 가독성이 떨어진 느낌이 든다. Single responsible principle의 경우에서도 책임 분리를 위해 interface를 생성하는 과정에서 코드의 양이 많이 늘었고 코드 가독성이 떨어졌다. (내가 말하는 코드 가독성은 코드를 보고 이해하기 까지 걸리는 시간 + 적응하는데 걸리는 시간이다. 보통 이런 의미로 사용하나...?)
그래서 객체 "지향" 이라고 한다. 어디까지나 "지향"하는거지 이것을 100% 만족할 순 없다. 내 걱정대로 오히려 코드의 양과 가독성이 떨어져 더 비효율적인 경우가 생길 수 있다.
결론은 특정 원칙을 지향하기 위한 코드 리팩토링 과정에서 원칙 적용 결과의 코드 가독성이 더 비효율적으로 변하지 않는가를 언제나 고민해봐야 할듯 하다. 아직 하나 남았다 ㅎㅎ
5. Dependency inversion principle (의존성 역전 원칙)
: "상위 계층이 하위 계층에 의존하는 전통적인 의존 관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다."
=> 상위 계층에서 사용하는 하위 계층을 직접적으로 연관시키지 말아라.
바로 코드 보자
func main(){
//DIP 구현
var mail = &Mail{}
var listener EventListener
listener = &Alarm{}
mail.Register(listener)
mail.OnRecv()
}
type Event interface {
Register(EventListener)
}
type EventListener interface {
OnFire()
}
type Mail struct {
listener EventListener
}
func (m *Mail) Register(listener EventListener) {
m.listener = listener
}
func (m *Mail) OnRecv() {
m.listener.OnFire()
}
type Alarm struct {
}
func (a *Alarm) OnFire() {
fmt.Println("메일이 왔습니다")
}
즉, 아래 그림과 같은 구조를 가져야 한다는 것이다.
이렇게 설계하지 않고
이렇게 설계하게 되는 경우 Mail에서 사용하는 Alarm 객체의 수정이 발생한 경우 Mail에도 영향을 받는다. 즉, Mail(상위 계층)이 Alarm(하위 계층)의 동작으로부터 독립적이지 않다는 것이다.
반면 위의 구현에서는 Alarm이 바뀌어도 Mail은 추상객체와의 의존성만 존재하기 때문에 영향을 받지 않는다는 장점이 있다.
이를 정리하자면, concrete - concrete 간의 직접적인 의존성은 지양하고 concrete - interface - concrete 혹은 concrete - interface - interface - concrete와 같이 interface를 통한 의존성 분리를 지향해라 라고 하면 될듯 하다.
추가적인 이점으로는 각 interface의 모듈의 extension이 효율적이다. interface의 구현체로 추가된 객체를 상위 모듈에서 손쉽게 사용할 수 있다.
이것도 위에서의 문제와 같이 비효율을 자아낼 수 있기 때문에 결국 잘 써야 한다...
결론
객체 지향적 프로그래밍을 위한 SOLID 원칙은 어디까지나 "지향"이다. 되도록 원칙들을 "지향"하며 프로그래밍을 해야하지만 오히려 비효율을 낳을 수 있으니 무조건적인 원칙 도입은 "지양"해야 한다고 생각한다.
바윙
'IT 일기 > GO' 카테고리의 다른 글
Go 채널이란? (0) | 2023.04.02 |
---|---|
What is GoRoutine? (0) | 2023.03.31 |
Go Slice 사용법, 구조 그리고 append() 원리까지 Deep Dive (0) | 2023.03.29 |
vscode Terminal 설정 변경 (Go에서 sqlite3 사용하기) (0) | 2023.03.23 |
HTTP/1.1 persistent-connection 사용 예시 (0) | 2023.03.18 |