반짝반짝 작은 별~

공부/서적

[Go 100가지 실수 패턴과 솔루션] #11 함수형 옵션 패턴

open_alpaca 2024. 6. 18. 18:39
앞으로 토이 프로젝트를 하면서 틈틈이 공부한 내용을 적용 및 정리해보고자 한다.

https://product.kyobobook.co.kr/detail/S000211704725

 

100 Go Mistakes Go 100가지 실수 패턴과 솔루션 | 테이바 하사니 - 교보문고

100 Go Mistakes Go 100가지 실수 패턴과 솔루션 | 모든 Go 개발자가 꼭 읽어야 할 책이다 Go 개발자들이 흔히 만나는 일반적인 실수를 피하고, 생산성과 효율성을 최대한 끌어내자!고 프로그래머가 고

product.kyobobook.co.kr

 

오늘의 주제는 #11 함수형 옵션 패턴~

 


 

 

우리의 코드는 한번 짜고나서 끝이 아니다. 항상 요구사항과 리팩토링 등의 이유로 코드를 고쳐야한다.

이 과정에서 함수의 파라미터에 변화가 필요한 경우가 발생한다.

이런 변동사항에 대응하기 위해 함수 시그니처가 변해야한다!

 

함수 시그니처


아래의 함수를 고려해보자.

func IsAirConditoner(temp int) bool {
	return temp >= 30
}

어떤 사람이 30도 이상인 경우 에어컨을 킨다고 할 때, 이를 판단하는 간단한 함수이다.

 

이 함수를 기반으로 어떤 아파트에서 에어컨이 하나라도 켜지고 있는지 확인하기 위한 함수로 아래와 같은 함수가 있다.

func anyAirConditioner(temps []int, condition func(int) bool) bool {
	for _, t := range(temps) {
		if condition(t) {
			return true
		}
	}
	return false
}

 


 

그러나 사람들이 가난해진 나머지 그들의 지갑 사정을 고려하기 시작했다!

이제 사람들은 그들의 통장잔고가 300만원 이상이어야만 에어컨을 키게 되었다.

 

위 사항을 고려하면, 다음과 같이 함수를 고칠 수 있다.

func IsAirConditoner(temp, money int) bool {
	return temp >= 30 && money >= 300 // 만원 단위
}

변경이 어렵지는 않지만, 함수 시그니쳐가 변함으로서 기존의 anyAirConditoner 또한 수정해주어야 한다.

isAirConditioner 를 사용하는 함수가 많을수록 그 귀찮음과 버그는 배가 된다!

 

 


Golang 에는 모든 매개변수에는 값이 전달되어야 한다.

variadic parameter 이라 불리는 ... 연산자를 통해 변수의 개수를 조절할 수 있지만,

모두 같은 타입이어야하며, 사실상 배열(슬라이스)을 입력받는 것과 크게 다르지 않다. (함수 호출시의 파라미터의 형태가 조금 달라진다.)

func example1(nums ...int) {
...
}

func example(nums []int) {
...
}

...

example1(1, 2, 3, 4, 5)
example2([]int{1, 2, 3, 4, 5})

 

즉, 정해진 개수의 매개변수를 전달해야하며, 이에 따라 기본값 설정도 불가능하다.

 

이를 위해 Golang 에서는 Config struct 를 사용할 수 있다.

이러한 구조체를 함수 매개변수로 하는 함수는 변동사항에 있음에도 함수 시그니쳐를 바꾸지 않아도 된다.

 

Config struct


type Config struct {
    temperature int
    money int
    ...
}

func isAirConditioner(cfg Config) bool {
	return cfg.temperature >= 30 && cfg.money >= 300
}

Config 구조체에 field 가 추가되더라도 함수의 시그니처가 변하지 않아도 된다. 원하는 field 만 사용하면 된다!

하지만, Config 구조체 사용자가 직접 구조체를 설정해서 코드를 짜는 방식은 바람직하지 않다.

 

우리는 Config 의 field 는 상황에 따라 검증로직이 필요할 수 있다.

예컨데, 온도는 -100도 이상 40도 이하 라는 조건이라든가(-100도 이하 40도 이상은 지구가 잘못된게 아닌 이상 입력이 잘못된 것이다!)

나의 통장잔고는 0원 이상이라든가(마이너스 통장도 있는데요 라고 태클걸지 말자)

 

혹은 입력이 없을 시에 기본값 설정이 필요할 수 있다.

통장잔고 입력이 없을 시, 기본값 0원 설정의 예시가 있다.

 

이러한 설정의 반영을 Config 구조체를 사용하는 곳에서 바로 작성하는 것은 바람직하지 않다.

 

1. 코드가 너무 복잡해진다.

clean code 의 내용 중에는

"function do one thing." -clean code

라는 내용이 있다.

한 함수에 Config 검증 로직과 실제 함수의 동작, 두 기능이 동시에 존재하게 된다.

 

2. 디버깅이 힘들어진다.

위의 Config 를 그대로 사용하며, 온도 조건을 -100 이상 40도 이하 로 검증하고자 했다고 가정해보자.

type Config struct {
	temperature int
    ... // 그 외는 잠깐 무시하자
}

func ex1(cfg Config) error {
	if !(-100 <= cfg.temperature && cfg.temperature <= 40) {
    	return errors.New("온도 조건이 잘못되었습니다!")
    }
...
}

func ex2(cfg Config) error {
	if !(-100 < cfg.temperature && cfg.temperature <= 40) {
    	return errors.New("온도 조건이 잘못되었습니다!")
    }
...
}

위의 코드에서 문제점이 한눈에 보일까?

사실 2번째 함수의 온도 조건 체크가 잘못되었다. -100도 이상이 아닌 초과이다.

만약 온도체크가 필요한 함수가 수십개만 되더라도, 검증로직 확인에만 큰 비용이 들어간다.


 

clean code 의 내용을 고려하여, 새로운 헬퍼함수로 분리하여 구현하고 싶어진다.

이에 대한 고전적인 방법에는 GoF 의 빌더 패턴이 있다.

 

해결책 - 빌더 패턴


최종적으로 Config 구조체를 반환해주는 Builder 함수와,

Config 의 옵션을 설정해주는 헬퍼함수들의 구성으로 사용자가 원하는대로 Config 세팅을 할 수 있다.

type Config struct {
	temperature int
	money       int
}

type ConfigBuilder struct {
	temperature *int
	money       *int
}

func (b *ConfigBuilder) setTemp(temp int) *ConfigBuilder {
	b.temperature = &temp
	return b
}

func (b *ConfigBuilder) setMoney(money int) *ConfigBuilder {
	b.money = &money
	return b
}

func (b *ConfigBuilder) build() (*Config, error) {
	var cfg Config

	// temperature
	if b.temperature == nil {
		return nil, errors.New("온도 설정이 비었습니다.") // 비어있으면 안된다!
	} else {
		if !(-100 <= *b.temperature && *b.temperature <= 40) {
			return nil, errors.New("온도 설정이 이상합니다!")
		} else {
			cfg.temperature = *b.temperature
		}
	}
	// money
	if b.money == nil {
		cfg.money = 0 // 비어있어도 된다!
	} else {
		if !(*b.money < 0) {
			return nil, errors.New("통장잔고 설정이 이상합니다!")
		} else {
			cfg.money = *b.money
		}
	}

	return &cfg, nil
}

 

앞서 논의한 모든 점들이 반영되어 있다.

온도, 통장잔고의 검증로직, 잔고의 기본값 설정 모두 반영하며, 코드의 재활용으로 이 부분의 로직만 디버깅하면 된다.

함수에 사용할 때도 비교적 깔끔해진다.

builder := ConfigBuilder{}
cfg, err := builder.
	setTemp(35).
	setMoney(500).
	build()
if err != nil {
    return err
}

example(cfg)

 

하지만, 이 방법에도 문제점은 존재한다.

우리는 메소드 체이닝을 사용하기 위해 각 헬퍼(설정)함수는 빌더를 리턴해야 한다.

즉, 각각의 field 를 설정할 때, 에러를 검출하고 싶어도 에러 객체를 반환할 수 없기 때문에,

모든 에러 검증 로직은 빌드할 때 확인되어야한다.

 

함수형 옵션 패턴을 사용하면 이를 해결할 수 있다.

함수형 옵션 패턴


이 패턴에서는 헬퍼함수가 어떤 함수를 반환한다.

옵션의 설정값과 그 설정값을 검증 및 설정하는 함수의 조합으로 클로저가 반환된다.

이 옵션을 사용하는 함수는 이 클로저들을 받아,

순서대로 함수를 실행해 매 함수마다 에러를 확인한다.

type setCfg func(*Config) error

func WithTemperature(temp int) setCfg {
	return func(c *Config) error {
		if !(-100 <= temp && temp <= 40) {
			return errors.New("온도 설정이 이상합니다!")
		}
		c.temperature = temp
		return nil
	}
}

모든 헬퍼함수는 setCfg 라는 타입의 함수를 반환해야한다.

setCfgConfig 를 세팅하는 함수 시그니처로 각 field 에 대한 헬퍼함수를 모아서 각각 실행하면서 에러를 검출해내는 것이다.

func example(setCfgs ...setCfg) error {
	var cfg Config
	for _, setCfg := range setCfgs {
		err := setCfg(&cfg)
		if err != nil {
			return err
		}
	}
	...
}

위와 같이 간단하게 구현 가능하다. for 문이 끝나고나면, 입력받은 모든 세팅값이 설정되며 그 과정에서 에러를 반환해낼 수 있다.

 

example(WithTemperature(50),
	WithMoney(300))
example() // <- temperature 는 필수라서 error 반환
example(WithTemperature(50)) // <- money는 기본값으로 세팅

위와 같이 실제 옵션처럼 유연하게 활용가능해진다.

 

하지만, 이 방법에도 단점이 존재한다.

옵션값을 입력받지 않고 기본값을 사용하는 경우에는, 그 로직을 헬퍼함수에서 구현할 수 없다.

왜냐하면 애당초 그 함수를 사용하지 않기 때문이다.

위의 예시에서 money 는 기본값으로 0을 설정한다고 정했지만, 이를 헬퍼함수에 반영할 수 없으므로,

사용하는 함수(이 경우에는 example 함수) 에서 옵션 설정후 값이 비었는지 확인 후 세팅하는 추가 작업이 필요하다.

 

하지만, 함수 사용자 혹은 라이브러리 사용자에게 있어 가장 깔끔한 형태를 갖춰주기 때문에,

Golang 에서는 관용적으로, 다양한 라이브러리에서 사용하고 있다고 한다.

 

각 패턴에는 장단점이 있으니, 필요한 적재적소에 사용하는 것이 중요하다.

마지막으로 내 토이 프로젝트에 어떤 식으로 사용하고 있는지 소개하고 마치고자 한다.

https://github.com/cmj7271/discord-AI-bot/pull/7/commits

 

Option pattern by cmj7271 · Pull Request #7 · cmj7271/discord-AI-bot

funtional option pattern 을 적용해봄 (반쯤은 공부용)

github.com

// main.go
err := discord.Run(discord.WithToken(token))

위의 함수형 옵션 패턴을 활용하여 디스코드 봇의 token을 설정하여 봇을 실행시킨다.

후에 추가적인 옵션 추가에는 간단히 With~() 을 구현하고, 사용하는 것으로 간단히 수정가능하다.

 


프로젝트 여담


공부의 목적으로 다양한 세팅에 도전해보고 있다.

docker, docker-compose, 린터, github-action 등 배포 관련 내용이다.

아직까지 docker, docker-compose 의 장점은 못 느끼고 있다. (아직 코드가 별로 없어서?)

 

그런데 린터와 github-action 의 조합은 생각보다 유용하다고 느껴진다.

현재 구성은 PR 을 올리면, action 에 의해 자동으로 golangci-lint 가 돌아간다.

혼자 하는 프로젝트이다보니 리뷰어나 멘토 없이 혼자 하게 되어 놓치는 부분이 없을까 걱정되지만,

린트가 알아서 에러를 잡아주니 한결 편하게 작업할 수 있는거 같다.

당장 위 링크의 PR 에서도 나한테 "왜 에러 처리 안함?" 이라고 거부하여 수정 후 다시 PR을 올렸다.

앞으로도 느리지만 꾸준히 공부해보고자 한다. 아자아자 화이팅

'공부 > 서적' 카테고리의 다른 글

독서의 흔적(프로그래머의 뇌) - 23/12/20  (1) 2023.12.21