Go 언어의 '명시적' 설계 철학: 왜 기본값 파라미터는 없다?
Go는 출시 15년 동안 기본값 파라미터를 단 한 번도 도입하지 않았다. 이는 기술적 불편함이 아니라, 의도적인 설계 원칙이다. 다른 언어에서 흔히 볼 수 있는 간결한 문법을 거부하는 이유는 무엇인가? 본문에서는 그 깊은 배경과 실용적인 대안을 살펴본다.
1. 문제의 시작: 기본값 없는 함수 호출
Python이나 Java에서 익숙한 아래 코드와 비교해보자.
def create_user(name: str, age: int = 18, country: str = "Korea", vip: bool = False):
return {"name": name, "age": age, "country": country, "vip": vip}
# 사용 예
create_user("Anna") # 모든 값이 기본값
create_user("Ben", age=25) # 일부만 덮어씀
create_user("Chris", vip=True, country="US")
반면, 같은 기능을 Go로 구현하면:
func CreateUser(name string, age int, country string, vip bool) *User {
return &User{
Name: name,
Age: age,
Country: country,
VIP: vip,
}
}
// 호출 시 모든 인자를 반드시 전달해야 함
user := CreateUser("Anna", 18, "Korea", false)
왜 이렇게까지 명시적으로 작성해야 하는가?
2. 디자인 철학: '암묵적 행동'을 배제하라
Go의 공동 설계자들, 특히 Rob Pike는 다음과 같이 말한다:
"기본값은 함수의 행위를 암묵적으로 정의한다. 하지만 우리는 코드의 의미가 항상 명확해야 한다고 믿는다. 어떤 값이 기본으로 설정되었는지 모른다면, 코드의 의도를 이해하기 어렵다."
또한 Ken Thompson은 강조한다:
"특성이 추가되기 위해서는 모두가 동의해야 한다. 단순히 '이거 있으면 편하겠네'라는 생각만으로는 안 된다."
3. 기본값의 위험: 감추어진 의도
실제 사례를 보자. 주문 처리 시스템의 함수다.
def process_order(user_id, amount, currency="USD", discount=0,
tax_rate=0.1, express_shipping=False, gift_wrap=False,
priority="normal", notify=True, retry_count=3):
# ... 처리 로직
이 함수를 호출할 때:
process_order("U123", 50000, discount=0.15, priority="urgent")
여기서 tax_rate, express_shipping, notify 등은 어떻게 되었는가? 6개월 후에는 누구도 기억하지 못한다.
기본값의 문제점
- 암묵적 종속성: 호출자가 기본값을 몰라야 API의 진짜 의미를 알 수 없다.
- 파라미터 폭증: 기능이 늘어날수록 파라미터 수가 급격히 증가한다.
- 버전 호환성 문제: 기본값 변경 시 기존 코드가 예상치 못한 동작을 하게 된다.
- 테스트 난이도 상승: 모든 조합을 다 테스트해야 하므로 테스트 케이스 수가 지수적으로 증가한다.
4. Go의 대안: 명시적 구성 방식
4.1 래퍼 함수 (Wrapper Functions)
간단한 경우에 적합하다. 기본값을 함수 이름으로 표현한다.
type User struct {
Name string
Age int
Country string
VIP bool
}
func newUser(name string) *User {
return &User{
Name: name,
Age: 18,
Country: "Korea",
VIP: false,
}
}
func newVIPUser(name string, age int) *User {
return &User{
Name: name,
Age: age,
Country: "Korea",
VIP: true,
}
}
// 사용
user1 := newUser("Dana")
user2 := newVIPUser("Eve", 27)
4.2 구성 구조체 (Config Struct)
파라미터가 많아질수록 유리하다. 구성 요소를 명확하게 그룹화할 수 있다.
type ServerConfig struct {
Host string
Port int
Timeout time.Duration
Debug bool
Logger *log.Logger
}
func NewServer(config ServerConfig) *Server {
if config.Host == "" {
config.Host = "localhost"
}
if config.Port == 0 {
config.Port = 8080
}
if config.Timeout == 0 {
config.Timeout = 30 * time.Second
}
if config.Logger == nil {
config.Logger = log.Default()
}
return &Server{
host: config.Host,
port: config.Port,
timeout: config.Timeout,
debug: config.Debug,
logger: config.Logger,
}
}
// 사용
server := NewServer(ServerConfig{
Port: 9090,
Debug: true,
})
4.3 함수형 옵션 패턴 (Functional Options) 🌟
고도로 유연한 설정이 필요한 라이브러리나 프레임워크에서 널리 사용된다. gRPC, etcd, Kubernetes 등에서 활용됨.
type Option func(*Server)
func WithHost(host string) Option {
return func(s *Server) {
s.host = host
}
}
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeout(d time.Duration) Option {
return func(s *Server) {
s.timeout = d
}
}
func WithLogger(l *log.Logger) Option {
return func(s *Server) {
s.logger = l
}
}
func NewServer(options ...Option) *Server {
server := &Server{
host: "localhost",
port: 8080,
timeout: 30 * time.Second,
logger: log.Default(),
}
for _, opt := range options {
opt(server)
}
return server
}
// 사용
server := NewServer(
WithHost("api.example.com"),
WithPort(8000),
WithTimeout(10*time.Second),
WithLogger(customLogger),
)
이 방식의 장점은:
- 옵션을 자유롭게 조합 가능
- 새 옵션이 추가되어도 기존 코드 영향 없음
- 코드의 의도가 매우 명확함
- JSON/YAML 등으로 직렬화 가능한 구성 정보 제공
5. 결론: '빈곤함'이 오히려 '풍요'다
Go가 기본값 파라미터를 거부한 것은 기술적 비효율이 아니라, 장기적인 유지보수성과 코드 가독성을 위한 선택이다. 명시적인 구성은 더 많은 코드를 요구하지만, 그만큼 의도가 분명하고, 오류가 발생할 여지를 줄인다.
실제로, 함수형 옵션 패턴은 기능적으로 기본값보다 훨씬 더 유연하며, 확장성과 테스트 용이성에서도 앞선다.
결국, '더 적은 기능'을 선택한 것이 아니라, '더 나은 설계'를 선택한 것이다.