뽀미의 개발노트

Go 디자인패턴 - Structural Patterns (구조 패턴) 본문

아키텍처

Go 디자인패턴 - Structural Patterns (구조 패턴)

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

코드 참고 링크

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) Composite Pattern

객체들을 트리 구조로 구성하는 방식. 다른 언어들과 달리 Go는 상속을 지원하지 않으므로 내장 필드와 인터페이스를 사용하여 composite 패턴 구현함. 내장 필드, 인터페이스, 또는 명시적 필드를 활용해서 상속’처럼’ 보이는 구조를 활용함.

명시적 필드 활용

 
type CompositeSwimmerA struct {
    MyAthlete Athlete
    MySwim    func()
}

func Swim() {
    fmt.Println("Swimming!")
}

swimmer := CompositeSwimmerA{
    MySwim: Swim,
}
swimmer.MyAthlete.Train() // Athlete의 Train 메서드 호출
swimmer.MySwim()          // Swim 함수 호출

CompositeSwimmerA는 명시적 필드를 활용했다! Athlete 타입이고 이름이 MyAthlete인 필드와 func() 타입이고 이름이 MySwim인 필드를 명시적으로 나타냈음. 그래서 의존성을 한눈에 파악할 수 있음.

인터페이스 활용

 
type Swimmer interface {
    Swim()
}

type Trainer interface {
    Train()
}

type SwimmerImpl struct{}
func (s *SwimmerImpl) Swim() {
    println("Swimming!")
}

type CompositeSwimmerB struct {
    Trainer // 필드명을 넣지 않으면 구조체 내에 인터페이스를 포함
    Swimmer
}

CompositeSwimmerB{}.Train() // Trainer 인터페이스를 통해 Train 호출
CompositeSwimmerB{}.Swim()  // Swimmer 인터페이스를 통해 Swim 호출

CompositeSwimmerB는 Trainer와 Swimmer 인터페이스를 내장하여 기능 제공

내장 필드로 구조체 활용

 
type Animal struct{}
func (r *Animal) Eat() {
    println("Eating")
}

type Shark struct {
    Animal
    Swim func()
}
Shark{}.Eat()
Shark{}.Swim() // Swim() 함수 초기화하지 않아서 컴파일 에러

Shark는 Animal을 내장하고 있음. (구조체가 구조체 포함) Eat() 메서드는 Animal로부터 왔음. Swim() 메서드는 별도의 함수 포인터로 제공되었는데 저 필드가 초기화되지 않았으므로 호출시 nil 참조로 인해 컴파일 에러 발생함.

Composite 패턴의 장단점

장점 : 객체를 자유롭게 조합하여 다양한 동작 설계 가능. 다른 구조체에서 재사용 가능.

단점 : 여러 구조체와 인터페이스를 조합해야 하므로 코드가 복잡해질 수 있음. 필드를 명시하면 수동으로 관리해야 함. 내장 필드나 함수 포인터 사용시 잘못된 참조로 인해 런타임 오류 발생할 수 있음.

Class diagram

 

구조패턴 2) Adapter Pattern

호환되지 않는 인터페이스를 연결해주는 패턴. 기존에 사용중인 구조체를 수정하지 않고도 새로운 인터페이스와 함께 사용할 수 있도록 중간 다리 역할을 함.

Class diagram

예시

 
type LegacyPrinter interface {
	Print(s string) string
}

type MyLegacyPrinter struct {
}

func (l *MyLegacyPrinter) Print(s string) (newMsg string) {
	newMsg = fmt.Sprintf("Legacy Printer: %s\n", s)
	println(newMsg) // 실제로는 콘솔에 출력
	return
}

LegacyPrinter라는 기존 인터페이스가 있었음. 얘는 print()라는 메서드를 사용중임.

 
type ModernPrinter interface {
	PrintStored() string
}

새로운 시스템에서 ModernPrinter라는 인터페이스를 사용해야 하는데 PrintStored()라는 메서드를 사용함. 두 인터페이스는 호환되지 않는데 이 둘을 연결해줘야 함.

 
type PrinterAdapter struct {
	OldPrinter LegacyPrinter
	Msg        string
}

func (p *PrinterAdapter) PrintStored() (newMsg string) {
	if p.OldPrinter != nil {
		// 어댑터가 기존 Printer를 이용하여 메시지를 처리
		newMsg = fmt.Sprintf("Adapter: %s", p.Msg)
		newMsg = p.OldPrinter.Print(newMsg)
	} else {
		// OldPrinter가 없으면 메시지만 반환
		newMsg = p.Msg
	}
	return
}

그래서 어댑터로 PrinterAdapter로 둘을 연결해줌. 일단 이 구조체는 LegacyPrinter와 Msg를 필드로 가짐. 그리고 어댑터는 ModernPrinter의 PrintStored() 메서드를 구현함! 어댑터 내부에서 기존 LegacyPrinter의 Print() 메서드를 호출하여 동작을 처리해줌. 그럼 main 함수에서 어떻게 사용하냐면?

 
func main() {
	// LegacyPrinter 인스턴스 생성
	legacyPrinter := &MyLegacyPrinter{}

	// PrinterAdapter를 ModernPrinter로 사용
	adapter := &PrinterAdapter{
		OldPrinter: legacyPrinter,
		Msg:        "Hello, World!",
	}

	// ModernPrinter 인터페이스로 호출
	fmt.Println(adapter.PrintStored()) // Legacy Printer 출력 방식 호출
}

이렇게 사용함!! LegacyPrinter 만들고, 어댑터에 그 레거시프린터를 인자로 넘겨준 뒤, 어댑터의 메서드(모던프린터의 함수)를 사용하면 됨!!! 그럼 레거시 코드를 수정하지 않고도 레거시와 모던을 연결했음. 기존 코드의 로직을 재사용할 수 있고 기존 시스템과 새로운 시스템을 독립적으로 두고 따로 통합 가능함. 외부 라이브러리를 사용하는 경우 라이브러리 자체의 코드를 수정하지 않고 원하는 대로 쓸 수 있도록 함.

 

구조패턴 3) Bridge Pattern

브릿지 패턴은 구현과 추상화를 분리하여 둘을 독립적으로 확장할 수 있도록 설계하는 디자인 패턴임. Go에서는 인터페이스과 구조체의 조합을 사용하여 구현함. 브리지 패턴은 사전에 설계되고, 앱의 다양한 부분을 독립적으로 개발할 수 있도록 함.

구현부

 
type PrinterAPI interface {
	PrintMessage(string) error
}

type PrinterImpl1 struct{}

func (p *PrinterImpl1) PrintMessage(msg string) error {
	fmt.Printf("%s\n", msg)
	return nil
}

기능을 실제로 수행하는 구현부. PrinterImpl1은 PrinterAPI 인터페이스를 구현한 구조체로, 구현의 구체적인 동작을 정의함. 구체적인 ‘구현체’임.

추상화

 
type PrinterAbstraction interface {
	Print() error
}

type NormalPrinter struct {
	Msg     string
	Printer PrinterAPI
}

func (c *NormalPrinter) Print() error {
	c.Printer.PrintMessage(c.Msg)
	return nil
}

PrinterAbstraction 추상화 인터페이스는 동작의 공통 부분을 정의함. NormalPrinter는 추상화를 구현한 구체적인 구조체임. PrinterAbstraction은 실제 메시지를 출력하는 동안 구체적인 구현체에 위임됨.

코드 활용 예시

 
func main() {
	// 콘솔에 메시지 출력
	consolePrinter := &PrinterImpl1{}
	normalPrinter := &NormalPrinter{
		Msg:     "Hello, Console!",
		Printer: consolePrinter,
	}
	normalPrinter.Print()
}

브릿지 패턴의 장점과 단점

장점 : 추상화된 동작(PrinterAbstraction)과 구체적인 구현(PrinterAPI)을 분리하여 독립적으로 확장 가능함. 새로운 프린터 API 구현체를 추가하거나, 새로운 프린터를 추가할 때 기존 코드를 수정하지 않고 유연하게 확장 가능. NormalPrinter와 PacktPrinter는 동일한 구현체를 재사용하면서도 각각 다른 방식으로 메시지 처리할 수 있음.

단점 : 계층이 늘어나면 구조 복잡해짐. 단순한 요구사항에는 과잉 설계가 될 수 있음.

Class diagram

 

구조패턴 4) Proxy Pattern

원래 객체에 대한 접근을 제어하는 방식. 사용자에게 권한을 부여하거나 DB에 대한 엑세스 권한을 부여하는 등 중간 작업이 필요할 때 적합.

클라이언트는 실제 객체와 프록시 객체를 구분하지 못하며, 프록시는 실제 객체와 동일한 인터페이스를 구현함.

 
type UserList []User

func (t *UserList) FindUser(id int32) (User, error) {
	for i := 0; i < len(*t); i++ {
		if (*t)[i].ID == id {
			return (*t)[i], nil
		}
	}
	return User{}, fmt.Errorf("User %d could not be found\n", id)
}

실제 객체 UserList는 실제 사용자 데이터를 관리하고 검색하는 역할을 함. FindUser 메서드는 사용자의 ID를 기준으로 리스트를 순회하여 사용자를 검색함.

 
type UserListProxy struct {
	SomeDatabase           UserList // 실제 데이터베이스 역할
	StackCache             UserList // 캐시로 사용
	StackCapacity          int      // 캐시 크기 제한
	DidLastSearchUsedCache bool     // 캐시 사용 여부 기록
}

user, err := u.StackCache.FindUser(id)
if err == nil {
	fmt.Println("Returning user from cache")
	u.DidLastSearchUsedCache = true
	return user, nil
}
user, err = u.SomeDatabase.FindUser(id)
if err != nil {
	return User{}, err
}

u.addUserToStack(user)
fmt.Println("Returning user from database")

프록시 객체 UserListProxy는 캐싱을 사용하여 성능을 최적화하는 역할을 함. 실제 객체인 UserList를 감싸며, FindUser 요청을 가로채 캐싱 로직을 추가함. 검색 요청을 먼저 캐시(StackCache)에서 확인하고 없으면 DB(SomeDatabase)에서 검색함. 그리고 DB에서 검색한 결과를 캐시에 추가함.

 
func (u *UserListProxy) addUserToStack(user User) {
	if len(u.StackCache) >= u.StackCapacity {
		u.StackCache = append(u.StackCache[1:], user) // 오래된 항목 제거 후 새 항목 추가
	} else {
		u.StackCache.addUser(user)
	}
}

캐시 크기를 초과하면 가장 오래된 사용자를 제거하고 새로운 사용자를 추가함.

이와 같이 프록시 객체는 캐시를 활용하여 검색 속도를 개선함. UserList와 UserListProxy는 동일한 UserFinder 인터페이스를 구현하므로 클라이언트 입장에서는 차이를 느끼지 못함.

프록시 패턴의 장단점

장점 : 캐싱을 통해 반복된 데이터 요청 속도를 개선할 수 있음. 요청을 프록시에서 필터링하거나 제한할 수 있음. 특히 대규모 시스템이나 리소스 사용이 중요한 애플리케이션에서 매우 효과적임.

단점 : 프록시 객체를 추가하며 코드가 더 복잡해질 수도 있음. 요청을 프록시로 중계하므로 약간 오버헤드 발생할 수 있음.

프록시 패턴 종류

원격 프록시 : 원격 서버에 저장된 대용량 비디오 파일 재생시 모든 데이터를 한번에 다운로드하지 않고 필요한 부분만 불러와 재생함.

가상 프록시 : 유튜브는 사용자가 실제로 비디오를 클릭하기 전까지는 비디오의 썸네일 미리보기 이미지만을 보여줌.

보호 프록시 : 기업 네트워크에서 중요한 문서에 접근시 권한이 있는 사용자만 문서를 볼 수 있도록 함.

캐싱 프록시 : 웹페이지 처음 방문시 페이지 내용을 로컬에 저장하고 다시 방문시에는 서버에 요청하지 않고 로컬에 캐시된 데이터를 사용하여 빠르게 페이지를 불러옴.

Class diagram

 

구조패턴 5) Decorator Pattern

객체에 기능을 동적으로 추가하거나 확장하기 위한 패턴. 자식 클래스들의 합성으로 클래스 수가 폭발적으로 많아지지 않도록 향상된 인터페이스를 래핑된 객체에 제공함. 이 패턴은 Composite 패턴과 유사하지만 자식 컴포넌트가 하나만 있음.

컴포넌트(Component)

 
type IngredientAdd interface {
	AddIngredient() (string, error)
}

기본 인터페이스를 정의하며, 객체에 적용 가능한 기본 동작을 제공함.

기본 컴포넌트(Concrete Component)

 
type PizzaDecorator struct {
	Ingredient IngredientAdd
}

func (p *PizzaDecorator) AddIngredient() (string, error) {
	return "Pizza with the following ingredients:", nil
}

기본 동작을 ‘구현’하는 객체

데코레이터(Decorator)

 
type Meat struct {
	Ingredient IngredientAdd
}

func (m *Meat) AddIngredient() (string, error) {
	if m.Ingredient == nil {
		return "", errors.New("An IngredientAdd is needed in the Ingredient field of the Meat")
	}
	s, err := m.Ingredient.AddIngredient()
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%s %s,", s, "meat"), nil
}

컴포넌트를 감싸며 기본 동작에 추가 기능을 동적으로 부여함. 다른 IngredientAdd 객체를 래핑(포함)하여 동작을 확장함. 피자에 meat를 추가함. Onion 구조체를 만들어 동일하게 쓰면 피자에 onion을 추가할 수도 있음.

코드 활용 예시

 
func main() {
	// 기본 피자 생성
	basePizza := &PizzaDecorator{}

	// 고기 데코레이터 추가
	meatPizza := &Meat{
		Ingredient: basePizza,
	}

	// 양파 데코레이터 추가
	meatAndOnionPizza := &Onion{
		Ingredient: meatPizza,
	}

	// 피자 구성 출력
	result, err := meatAndOnionPizza.AddIngredient()
	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println(result)
}

// 출력 결과
// Pizza with the following ingredients: meat, onion,

데코레이터 패턴의 장단점

장점 : 런타임에 객체의 기능을 확장하거나 조합할 수 있음. 데코레이터를 여러개 조합하여 다양한 구성을 만들 수 있음. 객체의 핵심 동작과 부가 기능을 분리할 수 있음 (단일 책임 원칙 준수 가능). Go는 상속 대신 구조체와 인터페이스를 사용하므로 데코레이터를 자연스럽게 구현할 수 있음.

단점 : 많아지면 코드 구조 복잡해짐. 각 데코레이터는 새로운 객체를 생성하므로 계층이 깊어지면 메모리 사용량 증가함.

Class diagram

 

 

구조패턴 6) Flyweight Pattern

객체를 재사용하여 메모리 사용량을 최소화하는 방식. 공유 가능한 상태는 공유하고 (풀에 의해 관리됨), 고유한 상태만 개별적으로 관리하여 메모리 낭비를 줄임. 필요한 최소한의 공유 상태만 관리하는 것이 핵심임.

코드 활용 예시

 
func main() {
	factory := NewTeamFactory()

	// TEAM_A 요청
	teamA := factory.GetTeam(TEAM_A)
	fmt.Printf("Team A: %+v\n", *teamA)

	// TEAM_B 요청
	teamB := factory.GetTeam(TEAM_B)
	fmt.Printf("Team B: %+v\n", *teamB)

	// TEAM_A 재요청 (이미 생성된 객체 사용)
	teamA2 := factory.GetTeam(TEAM_A)
	fmt.Printf("Reused Team A: %+v\n", *teamA2)

	// 생성된 객체 수 확인
	fmt.Printf("Total created teams: %d\n", factory.GetNumberofObjects())
}

Factory가 GetTeam 요청을 받으면, 특정 팀 객체를 요청함. 요청된 팀 객체가 createdTeams에 존재하면 이걸 리턴하고, 없으면 객체를 새로 생성하고 캐시에 저장함. 동일한 팀 ID로 요청하면 기존 객체를 리턴하여 메모리를 절약함. 팀 ID와 Name은 공유 가능한 필드이고, 다른 필드는 고유한 데이터임. createdTeams가 공유 객체를 저장하는 캐시 역할을 함.

플라이웨이트 패턴의 장단점 

장점 : 중복 객체 생성을 방지하여 메모리 사용량을 줄임. 필요한 객체만 생성하므로 시스템 확장시 유리함.

단점 : 코드가 복잡해질 수 있고, 공유 상태와 고유 상태를 명확히 구분해야함.

프록시 패턴과의 차이점

플라이웨이트 패턴 : 플라이웨이트 팩토리가 객체를 ‘캐싱’하며 동일하거나 비슷한 데이터를 가진 객체를 재사용함으로써 메모리 낭비를 방지하는 것. 대규모 객체 재사용이 필요한 경우 사용함.(게임에서 수많은 캐릭터, 아이템, 배경 타일 등의 데이터를 재사용)

프록시 패턴 : 프록시 객체가 ‘대리’ 역할을 수행하며 원래 객체에 대한 접근을 제어하거나 추가적인 처리를 수행하는 것. 객체 접근 제어가 필요한 경우 사용함. 보안 게이트 같은 역할

Class diagram