Go/디자인 패턴

[Go] SOLID in Go - 개방 폐쇄 원칙

piatoss 2023. 10. 12. 16:08

📺 시리즈

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

🎯 개방-폐쇄 원칙의 이점 정리

  • 확장성:  기존 코드의 수정을 최소화하고 새로운 기능을 추가할 수 있습니다.
  • 안정성 및 유지보수성 향상: 코드의 변경으로 인한 예상치 못한 영향을 줄이고 기존 동작의 안정성을 유지합니다. 
  • 중복 코드 감소: 중복된 코드를 줄이고 기존의 코드를 확장 및 재사용할 수 있습니다.
  • 테스트 용이: 새로운 코드를 기존의 코드와 독립적으로 테스트할 수 있습니다.

🙏 마치며

 개방-폐쇄 원칙에 대해 알아보았습니다. 변경과 확장이 일상적으로는 모호한 기준으로 혼용되고 있지만, 소프트웨어 개발 맥락에서는 분명한 차이가 있다는 것을 기억하고 '기존 코드의 변경을 최소화'하는 개발을 할 수 있는 연습이 필요할 것 같습니다.


📖 참고자료

 

[Must Have] Tucker의 Go 언어 프로그래밍(세종도서 선정작) - 골든래빗

게임 회사 서버 전문가가 알려주는 Go 언어를 내 것으로 만드는 비법! 구글이 개발한 Go는 고성능 비동기 프로그래밍에 유용한 언어입니다. 이 책은 Go 언어로 ‘나만의 프로그램’을 만들 수 있

goldenrabbit.co.kr

https://www.udemy.com/course/design-patterns-go/

글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!