뽀미의 개발노트

Go 디자인패턴 - Creational Patterns (생성패턴) 본문

아키텍처

Go 디자인패턴 - Creational Patterns (생성패턴)

산타는 뽀미 2024. 12. 19. 23:10

새로운 과제를 받았다. 바로 Go로 디자인 패턴 공부하기!! 사실 이전 회사에서도 디자인 패턴 다 쓰고 있었는데,, 나는 그게 패턴인지 모르고 있었당,,ㅎㅎ 그냥 과거 언젠가 시작되서 내려오는 규칙인줄,, 암튼 디자인 패턴을 이제서야 첨 공부한다는게 넘 창피했으나 그래도 이제라도 공부해서 다행다행~~!! 오히려 한번 경험해보고나서 배우니까 더 잘 와닿는 것일지도~? 암튼 이거 공부하는 동안 재밌었다. Miro라는 프로그램을 이용해서 UML도 그려봤따.

코드 참고 링크

https://github.com/gobenpark/go-design-pattern

 

GitHub - gobenpark/go-design-pattern

Contribute to gobenpark/go-design-pattern development by creating an account on GitHub.

github.com

패턴 설명 참고 링크

https://refactoring.guru/ko/design-patterns/what-is-pattern

 

디자인 패턴이란?

디자인 패턴이란? 디자인 패턴은 소프트웨어 디자인 과정에서 자주 발생하는 문제들에 대한 전형적인 해결책입니다. 이는 코드에서 반복되는 디자인 문제들을 해결하기 위해 맞춤화할 수 있는

refactoring.guru

 

 


 

생성패턴 1) SingleTon

글로벌하게 하나의 오브젝트에 대한 접근이 필요할 경우 사용. struct에 인스턴스가 하나만 있도록 함. struct가 처음 초기화될 때 싱글턴 인스턴스 생성됨. getInstance() 메서드는 singleton이 비어있는지 확인하는 nil 검사를 거친 후, 없어야만 새로 생성하고 이미 있으면 있던 instance를 반환함. 

Go에서 싱글톤 패턴 구현시 주의할 점

여러 고루틴이 싱글톤 struct에 접근하면 -> 자체 인스턴스를 각각 생성하여 동시성 문제가 발생할 가능성이 있음!! (싱글톤 구조체가 불변 상태이고 고루틴에서 읽기만 수행하면 괜찮음. 그러나 읽기와 쓰기가 모두 있는 경우에는 동기화 메커니즘 사용해야함!!)

해결법 1)

뮤텍스를 활용하여 싱글톤 구조체를 lock내에서 생성하기!

 
func GetInstance() *Singleton {
	if instance == nil { // 체크1: 초기화 여부 확인
		mu.Lock()         // 필요할 때만 락 획득
		defer mu.Unlock() // 락 해제

		if instance == nil { // 체크2: 다른 고루틴이 초기화했는지 확인
			instance = &Singleton{}
		}
	}
	return instance
}

위 코드에서 nil 체크를 이중으로 하는 이유 : 첫번째 nil 체크는 이미 인스턴스가 있는지 확인하는것, 두번째 nil 체크에서 lock 경쟁! (이미 인스턴스도 있는데 처음부터 lock을 걸 필요가 없기 때문) 이 방식을 Check-Lock-Check 패턴 또는 Double-Checked Locking이라고 부름.(블로그 참고)

해결법 2)

sync/atomic 패키지의 sync.Once 구조체 이용하기! (이게 더 일반적인 듯)

 
// GetInstance 함수: 싱글톤 인스턴스 반환
func GetInstance() *Singleton {
	once.Do(func() {
		instance = &Singleton{}
	})
	return instance
}

sync.Once가 더 간단하고 가독성이 좋아서 보통 싱글톤 초기화는 이걸로 하고 get/set 함수에 lock을 쓰는듯!

 

생성패턴 2) Builder

복잡한 struct를 단계별로 생성하도록 하는 디자인 패턴. 구조체의 생성 로직과 표현 로직을 분리하는 것이 목표! 만약 자동차 struct를 만들어야 하면 바퀴, 좌석, 구조 등 만들어야 할 것이 많음. 그래서 빌더 구조체를 따로 만들어서 복잡한 구조체들을 단계별로 생성할 수 있도록 함!! 바퀴/좌석/구조 빌더를 따로 만들어서 각자 필요한 갯수를 따로 주문할 수 있음!! (예 : 바퀴 builder, 좌석 builder, 구조 builder를 따로 만든다면 이걸로 구조가 차이고, 바퀴가 4개고 좌석이 5개인 차를 만들수도 있고 구조가 오토바이고, 바퀴가 2개고 좌석이 2개인 오토바이를 만들 수도 있음!!) 또한 디렉터(관리자) 구조체를 만들어서 제작 단계를 실행하는 순서를 정의할 수 있음!!

Class diagram

예시와 같이 코드를 짜면 main함수에서 다음과 같이 이용할 수 있음.

 
package main

import (
	"builder"
	"fmt"
)

func main() {
	// Director 생성
	director := builder.ManufacturingDirector{}

	// CarBuilder를 사용하여 VehicleProduct 생성
	carBuilder := &builder.CarBuilder{}
	director.SetBuilder(carBuilder)
	director.Construct() // Construct를 통해 자동차를 구성
	car := carBuilder.GetVehicle()
	fmt.Printf("Car: Wheels=%d, Seats=%d, Structure=%s\n", car.Wheels, car.Seats, car.Structure)

	// BikeBuilder를 사용하여 VehicleProduct 생성
	bikeBuilder := &builder.BikeBuilder{}
	director.SetBuilder(bikeBuilder)
	director.Construct() // Construct를 통해 오토바이를 구성
	bike := bikeBuilder.GetVehicle()
	fmt.Printf("Bike: Wheels=%d, Seats=%d, Structure=%s\n", bike.Wheels, bike.Seats, bike.Structure)
}

실행시 다음과 같은 결과를 출력함.

 
// Car: Wheels=4, Seats=5, Structure=Car
// Bike: Wheels=2, Seats=2, Structure=Motorbike

디렉터는 SetBuilder로 빌더 객체 설정하고, Construct를 호출해서 Car 또는 Bike 각 빌더의 메서드를 체인으로 호출해 객체를 완성함. GetVehicle 메서드를 호출하여 최종적으로 구성된 VehicleProduct를 가져옴.

Go에서 빌드 패턴 구현시 장단점

장점 : 같은 인터페이스를 사용하여 여러 종류의 객체 만들 수 있음(유연성). Construct 메서드를 통해 객체를 단계적으로 생성할 수 있음. 새로운 Builder 타입을 추가해도 기존 코드를 수정할 필요가 없음(확장성)

단점 : 인터페이스와 구현체가 일어나 코드가 불필요하게 복잡해질 수 있음(가독성X). 간단한 구조체 생성시 빌더 패턴 보다는 단순한 팩토리 패턴으로 충분함. 성능 오버헤드가 발생할 수 있음(과잉설계). 구조체 리터럴을 사용하는 방법으로도 대부분의 객체 초기화가 가능함. 메서드 체이닝을 사용하므로 호출 순서에 의존하게 됨.

 

생성패턴 3) Factory Method

일반적으로 객체 생성할 때는 main 함수에서 dog := Dog{} 이런 식으로 직접 생성함. 그런데 팩토리 메서드 방식을 사용하면 객체 생성 책임을 팩토리 함수에 위임함. 특정 인터페이스 타입만 알면 되고 내부적으로 어떤 객체가 리턴되는지 신경 안 써도 됨!! 예를 틀어 NewAninal()이라는 팩토리함수를 만든다면, animal := NewAnimal(“dog”) 해주는 식으로! 그럼 객체 생성 로직이 캡슐화되어 그냥 내가 만든 팩토리 메서드를 호출해주기만 하면 됨. 생성 과정이 복잡한 경우, 객체 생성 방식이 바뀌더라도 팩토리 함수만 수정하면 되므로 유지보수가 쉬움.

 
func GetPaymentMethod(m int) (PaymentMethod, error) {
	switch m {
	case Cash:
		return new(CashPM), nil
	case DebitCard:
		return new(DebitCardPM), nil
	default:
		return nil, errors.New(fmt.Sprintf("Payment method %d not recognized\n", m))
	}
}

위처럼 팩토리 메서드를 만들면, 매개변수로 필요한 인자만 넘겨주면 객체 생성 방식을 모르고 단순히 저 메서드를 호출하기만 해도 객체 만들 수 있음!!

Go에서 팩토리 메서드를 사용하기 적합한 상황은?

객체 생성 로직이 복잡한 경우! 객체 생성이 매우 간단할 때 (예 : Dog{}) 는 안 써도 됨. 또한 다양한 조건 없이 항상 같은 객체를 생성할 때도 안 써도 됨. 그러나 객체 생성시 여러 설정이나 초기화가 필요할 때는 팩토리 메서드 적합. 또한 조건에 따라 다양한 객체를 생성해야할 때도 유용함. 동일한 생성 로직이 여러곳에 반복되는 경우 생성 로직을 팩토리 메서드 한 곳에 모아 관리할 수 있음.

 

생성패턴 4) Abstract Factory

팩토리 메서드가 객체를 반환한다면 추상 팩토리는 팩토리 자체를 반환하고 그 팩토리를 통해 객체를 받아서 처리함! 관련된 객체를 ‘세트’로, ‘패밀리’로 생성함. 추상 팩토리 구조체는 팩토리 메서드들의 집합으로 이루어진 경우가 많음.

 
func BuildFactory(f int) (VehicleFactory, error) {
	switch f {
	case CarFactoryType:
		return new(CarFactory), nil
	case MotorbikeFactoryType:
		return new(MotorbikeFactory), nil
	default:
		return nil, errors.New(fmt.Sprintf("Factory with id %d not recognized\n", f))
	}
}

BuildFactory는 팩토리를 생성하는 역할을 하는 추상 팩토리임. 그렇게 생성된 팩토리 객체의 Build 메서드를 호출하여 구체적인 제품을 생성할 수 있음. BuildFactory와 각 팩토리를 통해 객체 생성 로직이 캡슐화되므로, 클라이언트 코드에서 구체적인 클래스 이름을 알 필요가 없음. 

Go에 추상 팩토리 적용의 장단점

장점 : 새로운 팩토리 (ShipFactory 등)를 쉽게 추가할 수 있고, 각 팩토리에서 새로운 제품을 추가해도 클라이언트 코드를 수정할 필요가 없음. 객체 생성 로직이 중앙에서 관리되므로 유지보수에 용이함.

단점 : 팩토리 생성자, 팩토리, 제품 구조체를 전부 정의해야 하므로 단순한 객체 생성에 비해 더 많은 코드가 필요함.

Class diagram

 

생성패턴 5) Object Pool

미리 생성된 풀에서 객체를 받아오는 방식. 오브젝트 풀은 객체를 관리하는 그룹임. 객체 생성 비용이 많이 들거나, 객체 생성 및 소멸이 반복되거나, 다수의 고루틴이 동일한 객체를 필요로 할 때 유용함.

 
package object_pool

type Messenger struct{}

func (Messenger) Do() {}

type Pool chan *Messenger

func New(total int) *Pool {
	p := make(Pool, total)
	for i := 0; i < total; i++ {
		p <- new(Messenger)
	}
	return &p
}

func Start() {
	p := New(4)

	select {
	case obj := <-*p:
		obj.Do()
		*p <- obj
	}
}

Pool은 chan *Messenger 타입이고, 오브젝트 풀을 관리하는 역할임. New 함수는 Pool을 생성하는 팩토리 메서드임. 정해진 개수(total)의 Messenger 객체를 미리 생성하고 Pool(채널)에 저장함. 이로써 객체 생성 비용을 프로그램 실행 초기에 한 번만 지불함. Start 함수는 Pool 채널에서 객체를 가져와 작업을 수행한 후 다시 Pool에 반환함! 채널을 사용하여 여러 고루틴에서 동일한 Pool을 안전하게 공유 가능하고, 작업이 끝난 객체를 Pool로 다시 반환하여 객체가 재사용 가능해짐.

Object Pool 패턴의 장단점

장점 : 객체를 미리 생성하여 초기화 비용을 줄일 수 있음. Pool에 저장된 객체를 재사용하여 메모리 소비를 줄임.

단점 : 미리 생성된 객체가 실제로 사용되지 않을 경우 메모리 자원을 낭비할 수 있음.

 

생성패턴 6) ProtoType Pattern

기존 객체를 복사하여 새로운 객체를 생성하는 디자인 패턴. 같은 구조체의 새 객체를 생성하고 원본 객체의 필드들을 복사한다면? → private 필드 때문에 객체를 외부에서 복사하는 것이 불가능할 수 있음!! 그러면 그 객체 안으로 그대로 들어간다?? → 그럼 그 구조체 자체를 알아야 하므로 해당 구조체에 의존하게 됨!! 그래서 이 패턴은 프로토타입 객체 자체에 복제 프로세스를 위임함.

아래는 셔츠 객체(Shirt)를 프로토타입으로 복제하는 방식임.

 
var whitePrototype *Shirt = &Shirt{
	Price: 15.00,
	SKU:   "empty",
	Color: White,
}

위처럼 whitePrototype, BlackPrototype, BluePrototype 등을 프로토타입 객체로 정의해둠.

 
func (s *ShirtsCache) GetClone(m int) (ItemInfoGetter, error) {
	switch m {
	case White:
		newItem := *whitePrototype
		return &newItem, nil
	case Black:
		newItem := *blackPrototype
		return &newItem, nil
	case Blue:
		newItem := *bluePrototype
		return &newItem, nil
	default:
		return nil, errors.New("Shirt model not recognized")
	}
}

ShirtsCache 구조체의 GetClone 메서드로 미리 정의된 프로토타입 객체를 복제할 수 있음. 

 
package main

import (
	"fmt"
	"prototype"
)

func main() {
	// 1. ShirtsCache 생성
	shirtCache := &prototype.ShirtsCache{}

	// 2. White 셔츠 복제
	whiteShirt, err := shirtCache.GetClone(prototype.White)
	if err != nil {
		fmt.Println(err)
		return
	}

	// 3. 복제된 White 셔츠 정보 출력
	fmt.Println(whiteShirt.GetInfo())

	// 4. Blue 셔츠 복제
	blueShirt, err := shirtCache.GetClone(prototype.Blue)
	if err != nil {
		fmt.Println(err)
		return
	}

	// 5. 복제된 Blue 셔츠 정보 출력
	fmt.Println(blueShirt.GetInfo())
}

위와 같이 프로토타입 객체를 이용해 유사한 객체를 생산해낼 수 있음. 아래처럼 복제된 객체의 필드를 변경하면 조금 다른 객체를 생성할 수도 있음.

 
// 복제된 White 셔츠의 Price를 변경
// Type assertion으로 *Shirt 타입으로 변환
customWhiteShirt := whiteShirt.(*prototype.Shirt)
customWhiteShirt.Price = 18.00 // 가격 변경

복제된 객체 whiteShirt는 ItemInfoGetter 인터페이스로 리턴되므로, *prototype.Shirt로 type assertion을 통해 원래 구조체 타입으로 변환함. 변환 후 Price를 원하는 값으로 변경함!! 복제는 얕은 복사(shallow copy)로 작동하므로 새로운 객체는 원본 프로토타입 객체(whitePrototype)와 독립적으로 존재함. 따라서 원본 프로토타입의 필드 값은 변경되지 않음. 만약 셔츠마다 가격이 전부 달라서 매번 복제 후 수동으로 수정하는 작업을 반복해야 한다면, 특정 필드를 쉽게 수정할 수 있도록 복제 메서드에 매개변수를 추가해도 됨.

프로토타입 패턴 사용 시 장단점

장점 : 새 객체를 생성할 때 초기화 비용이 많이 드는 경우에는 기존 객체를 복제하는 것이 더 효율적일 수 있음! 초기 프로토타입 객체를 정의한 뒤 복제 메서드를 계속 사용하면 되므로 코드 재사용성 증가하고 약간씩 다른 다양한 객체 생성 가능함.

단점 : 복제할 객체가 포인터 또는 중첩된 데이터 구조를 가질 경우 복제 로직이 복잡할 수도 있음. 미리 정의된 프로토타입 객체를 관리하고 초기화해야 함.

Class diagram