[Go] SOLID in Go - 개방 폐쇄 원칙
📺 시리즈
2023.10.02 - [Go/디자인 패턴] - [Go] SOLID in Go - SOLID란?
2023.10.03 - [Go/디자인 패턴] - [Go] SOLID in Go - 구조체와 메서드
2023.10.04 - [Go/디자인 패턴] - [Go] SOLID in Go - 인터페이스
2023.10.09 - [Go/디자인 패턴] - [Go] SOLID in Go - 컴포지션
2023.10.10 - [Go/디자인 패턴] - [Go] SOLID in Go - 패키지
2023.10.11 - [Go/디자인 패턴] - [Go] SOLID in Go - 단일 책임 원칙
🚪 개방-폐쇄 원칙 (Open-Closed Principle)
확장에는 열려 있고, 변경에는 닫혀 있다.
개방-폐쇄 원칙은 상호 결합도를 낮춰 기존 코드를 수정하지 않고도 새로운 기능을 추가하거나 확장할 수 있게 하여 유지보수성과 확장성을 향상합니다.
확장과 변경은 일상적으로 비슷한 의미로 사용되곤 하지만, 소프트웨어 개발과 유지보수 맥락에서 다음과 같은 차이점이 있습니다.
- 확장 (Extension): 새로운 기능, 모듈 또는 컴포넌트를 기존 시스템과 통합하는 것. 가능한 기존의 시스템을 건드리지 않는 것이 중요.
- 변경 (Change): 기존 시스템, 모듈 또는 컴포넌트의 기능 또는 구현을 수정하거나 조정하는 것.
쉽게 말해, 개방-폐쇄 원칙은 '기존 코드의 변경을 최소화하면서 새로운 기능을 추가할 수 있어야 한다'라고 말할 수 있습니다.
그렇다면 왜 변경은 바람직하지 않은 것일까요? 예제를 통해 살펴보겠습니다.
개방-폐쇄 원칙을 따르지 않는 코드
type MomsDonaliaKiosk struct{}
func (m *MomsDonaliaKiosk) TakeOrder(o Order) {
totalPrice := o.CalculateTotal()
fmt.Printf("MomsDonalia: 주문 합계는 $%0.2f 입니다.\n", totalPrice)
fmt.Println("MomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.")
}
type Order struct {
PricePerUnit float64
Quantity int
}
func (o Order) CalculateTotal() float64 {
return o.PricePerUnit * float64(o.Quantity)
}
수제 버거집, '맘스도날리아(MomsDonalia)'가 있습니다. 이 가게에는 키오스크가 배치되어 있는데, 키오스크는 주문(Order)을 받으면 전체 가격을 계산(o.CalculateTotal())하여 손님에게 안내합니다(결제는 어떻게든 처리됐다고 칩시다). 장사는 생각보다 잘 되고 있고요.
그런데 마침 다음 달이면 가게를 오픈한 지 1년이 됩니다. 주년 행사 빼먹으면 손님들이 섭섭할 수 있기 때문에 맘스도날리아의 점장은 할인 행사를 기획하여 다음과 같은 주문 방식을 추가합니다.
type DiscountOrder struct {
PricePerUnit float64
Quantity int
Discount float64
}
func (o DiscountOrder) CalculateTotal() float64 {
return (o.PricePerUnit * float64(o.Quantity)) * (1.0 - o.Discount)
}
그런데 키오스크가 Order 구조체만을 매개변수로 받기 때문에 다음과 같이 키오스크에 새로운 기능을 추가해줘야 합니다. 기존의 키오스크를 변경하는 것이죠.
func (m *MomsDonaliaKiosk) TakeDiscountOrder(o DiscountOrder) {
totalPrice := o.CalculateTotal()
fmt.Printf("MomsDonalia: 주문 합계는 $%0.2f 입니다.\n", totalPrice)
fmt.Println("MomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.")
}
이제 키오스크에서 새로운 주문 방식을 처리할 수 있게 되었지만, 불필요하게 코드가 중복되는 것을 확인할 수 있습니다.
// 중복되는 코드
totalPrice := o.CalculateTotal()
fmt.Printf("MomsDonalia: 주문 합계는 $%0.2f 입니다.\n", totalPrice)
fmt.Println("MomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.")
그리고 키오스크는 이미 충분한 테스트를 거쳐 검증되어 있는 상태인데 새로운 기능을 추가하게 되면서 새로운 테스트 또한 피요해지고 이미 검증된 기능들의 안정성을 훼손할 우려도 존재합니다.
// 기존의 TakeOrder 메서드를 위한 테스트
func TestTakeOrder(t *testing.T) {
m := MomsDonaliaKiosk{}
o := Order{
PricePerUnit: 1.0,
Quantity: 2,
}
oldOut := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
m.TakeOrder(o)
w.Close()
out, _ := io.ReadAll(r)
r.Close()
os.Stdout = oldOut
expected := "MomsDonalia: 주문 합계는 $2.00 입니다.\nMomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.\n"
if string(out) != expected {
t.Errorf("Expected %s, got %s", expected, string(out))
}
}
$ go test -v ./ocp/
=== RUN TestTakeOrder
--- PASS: TestTakeOrder (0.00s)
PASS
ok solid/ocp 0.001s
이처럼 기존의 코드를 변경하게 되면 여러 가지 불안 요소들이 드러나게 되며, 이는 애초에 할인 주문 방식을 고려하지 않고 키오스크를 설계한 것에서 문제의 원인을 찾을 수 있습니다. 초기 설계 단계에서부터 어떤 것이 변경 또는 확장될 수 있는지 충분한 고려가 필요합니다. 이 경우는 키오스크는 변하지 않는 것이고 주문 방식이 변경 또는 확장될 수 있는 것입니다.
개방-폐쇄 원칙을 따르는 코드
Order 구조체와 DiscountOrder 구조체는 전체 금액을 계산해 주는 공통된 기능을 가지고 있으므로 이를 추상화하여 묶어줄 수 있는 'Order' 인터페이스를 정의합니다.
type Order interface {
CalculateTotal() float64
}
그리고 Order 구조체는 Order 인터페이스와 이름이 중복되므로 RegularOrder로 변경해 줍니다.
type RegularOrder struct {
PricePerUnit float64
Quantity int
}
키오스크는 더 이상 각각의 주문을 따로 처리하는 메서드를 가질 필요가 없습니다. 다음과 같이 Order 인터페이스 타입의 값을 받아주면 됩니다.
func (m *MomsDonaliaKiosk) TakeOrder(o Order) {
totalPrice := o.CalculateTotal()
fmt.Printf("MomsDonalia: 주문 합계는 $%0.2f 입니다.\n", totalPrice)
fmt.Println("MomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.")
}
이제 다음과 같이 새로운 유형의 주문 방식이 추가되더라도 키오스크의 기존 구현을 변경하지 않고 주문을 처리할 수 있습니다.
type GiftCardOrder struct {
PricePerUnit float64
Quantity int
}
func (o GiftCardOrder) CalculateTotal() float64 {
return 0.0
}
package ocp
import (
"io"
"os"
"testing"
)
func TestTakeOrder(t *testing.T) {
m := MomsDonaliaKiosk{}
tests := []struct {
order Order
expected string
}{
{
order: RegularOrder{
PricePerUnit: 1.0,
Quantity: 2,
},
expected: "MomsDonalia: 주문 합계는 $2.00 입니다.\nMomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.\n",
},
{
order: DiscountOrder{
PricePerUnit: 1.0,
Quantity: 2,
Discount: 0.1,
},
expected: "MomsDonalia: 주문 합계는 $1.80 입니다.\nMomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.\n",
},
{
order: GiftCardOrder{
PricePerUnit: 1.0,
Quantity: 2,
},
expected: "MomsDonalia: 주문 합계는 $0.00 입니다.\nMomsDonalia: 잠시만 기다려주세요. 주문하신 메뉴를 준비하겠습니다.\n",
},
}
for _, test := range tests {
oldOut := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
m.TakeOrder(test.order)
w.Close()
out, _ := io.ReadAll(r)
r.Close()
os.Stdout = oldOut
if string(out) != test.expected {
t.Errorf("Expected %s, got %s", test.expected, string(out))
}
}
}
$ go test -v ./ocp/
=== RUN TestTakeOrder
--- PASS: TestTakeOrder (0.00s)
PASS
ok solid/ocp 0.002s
🎯 개방-폐쇄 원칙의 이점 정리
- 확장성: 기존 코드의 수정을 최소화하고 새로운 기능을 추가할 수 있습니다.
- 안정성 및 유지보수성 향상: 코드의 변경으로 인한 예상치 못한 영향을 줄이고 기존 동작의 안정성을 유지합니다.
- 중복 코드 감소: 중복된 코드를 줄이고 기존의 코드를 확장 및 재사용할 수 있습니다.
- 테스트 용이: 새로운 코드를 기존의 코드와 독립적으로 테스트할 수 있습니다.
🙏 마치며
개방-폐쇄 원칙에 대해 알아보았습니다. 변경과 확장이 일상적으로는 모호한 기준으로 혼용되고 있지만, 소프트웨어 개발 맥락에서는 분명한 차이가 있다는 것을 기억하고 '기존 코드의 변경을 최소화'하는 개발을 할 수 있는 연습이 필요할 것 같습니다.
📖 참고자료
https://www.udemy.com/course/design-patterns-go/
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!