Go의 struct embedding으로 상속 대체하기
1. 개요
Java나 C#에 익숙한 개발자들이 Go를 처음 배울 때 자주 하는 질문이 있습니다: “extends는 어디에?“라는 것입니다. Go는 상속을 지원하지 않습니다. 대신 Go는 struct embedding을 제공합니다: 다른 struct에 type을 내장하여 field와 method를 재사용하는 것입니다.
이 글에서는 embedding이 어떻게 작동하는지, 상속과의 차이점, 그리고 흔한 함정들을 설명합니다. 이 글을 읽은 후, 당신은 embedding을 사용하여 동작을 공유하고, 깔끔한 decorator를 작성하며, 이것의 특징적인 오류를 피할 수 있게 될 것입니다.
2. 기본 struct embedding
Embedding은 field 이름 없이 type만 선언하는 것입니다:
package main
import "fmt"
type Address struct {
Street string
City string
}
func (a Address) FullAddress() string {
return a.Street + ", " + a.City
}
type Customer struct {
Name string
Address // 내장 필드
}
func main() {
c := Customer{
Name: "Lan",
Address: Address{Street: "12 Lê Lợi", City: "Huế"},
}
fmt.Println(c.City) // 필드가 승격됨
fmt.Println(c.FullAddress()) // 메서드가 승격됨
fmt.Println(c.Address.City) // 명시적 접근
}
Huế
12 Lê Lợi, Huế
Huế
분석:
Address는Customer내에 field 이름 없이 포함됩니다. Go는 field의 이름을 자동으로Address(type의 이름)로 설정합니다.Address의 fieldCity와 methodFullAddress()는 승격되어Customer로 올라갑니다. 따라서c.Address.City대신c.City로 작성할 수 있습니다.- composite literal로 초기화할 때, field 이름
Address:를 사용해야 합니다.City를 직접Customer에 “채워넣는” 구문은 없습니다.
기본적으로, Customer는 Address를 포함합니다. 이것은 has-a 관계이며, 단지 컴파일러가 편의상 문법 설탕을 추가합니다. Customer는 Address가 아닙니다. Address를 매개변수로 받는 함수에 Customer를 전달할 수 없습니다:
func printCity(a Address) { fmt.Println(a.City) }
// printCity(c) // compile error: cannot use c (type Customer) as Address
printCity(c.Address) // OK
3. Shadowing으로 method “오버라이딩”
외부 struct가 내장된 type의 method와 같은 이름의 method를 선언하면, 외부 struct의 method가 내부 method를 가립니다 (shadow):
package main
import "fmt"
type Logger struct {
Prefix string
}
func (l Logger) Log(msg string) {
fmt.Println(l.Prefix + msg)
}
type OrderService struct {
Logger
}
func (s OrderService) Log(msg string) {
s.Logger.Log("[order] " + msg) // 내장된 type의 메서드 호출
}
func main() {
s := OrderService{Logger: Logger{Prefix: "> "}}
s.Log("created #1001")
s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
s.Log(...)는OrderService의 method를 호출합니다. 이것이 더 얕은 깊이에 있기 때문입니다.s.Logger.Log(...)는 여전히 원본을 호출할 수 있습니다. 이는 Java의super.log(...)과 같습니다.
일견 override처럼 보입니다. 하지만 다음 섹션에서 보듯이 여기가 많은 사람들이 실수하는 지점입니다.
4. 가장 큰 함정: Virtual dispatch 없음
Java에서는 부모 클래스가 this.channel()을 호출하고 자식 클래스가 channel()을 override하면, 자식의 메서드가 실행됩니다. Go는 그렇지 않습니다:
package main
import "fmt"
type Notifier struct{}
func (n Notifier) Channel() string { return "email" }
func (n Notifier) Send(msg string) string {
return "[" + n.Channel() + "] " + msg
}
type SMSNotifier struct {
Notifier
}
func (s SMSNotifier) Channel() string { return "sms" }
func main() {
s := SMSNotifier{}
fmt.Println(s.Channel())
fmt.Println(s.Send("배송 완료"))
}
sms
[email] 배송 완료
s.Send(...)은 실제로 s.Notifier.Send(...)입니다. Send 내부에서 receiver n의 타입은 Notifier입니다. 이것은 자신이 SMSNotifier 내부에 있다는 것을 알지 못하므로, n.Channel()은 항상 "email"을 반환합니다.
이것이 핵심 차이점입니다. 내장된 type은 그것을 포함하는 struct에 대한 역참조가 없습니다. Java 스타일의 “template method” 패턴을 직접 적용할 수 없습니다.
4.1. 해결책: 변경 부분을 interface로 분리
변경 가능한 동작이 필요하면, 명시적으로 이를 전달하세요:
package main
import "fmt"
type Channel interface {
Name() string
}
type Email struct{}
func (Email) Name() string { return "email" }
type SMS struct{}
func (SMS) Name() string { return "sms" }
type Notifier struct {
Channel Channel
}
func (n Notifier) Send(msg string) string {
return "[" + n.Channel.Name() + "] " + msg
}
func main() {
fmt.Println(Notifier{Channel: SMS{}}.Send("배송 완료"))
fmt.Println(Notifier{Channel: Email{}}.Send("10월 청구서"))
}
[sms] 배송 완료
[email] 10월 청구서
이제 변경 부분(Channel)은 명확한 의존성입니다. 이것이 override를 모방하려고 하는 것보다 더 “Go다운” 방식입니다.
5. Embedding과 interface
Go의 interface는 암묵적으로 만족됩니다. 승격된 method도 외부 struct의 method set에 포함됩니다. 따라서 embedding은 method를 다시 작성하지 않고도 struct가 interface를 만족하도록 합니다:
package main
import "fmt"
type Describer interface {
FullAddress() string
}
type Address struct{ Street, City string }
func (a Address) FullAddress() string { return a.Street + ", " + a.City }
type Warehouse struct {
Code string
Address
}
func describe(d Describer) { fmt.Println(d.FullAddress()) }
func main() {
w := Warehouse{Code: "HN-01", Address: Address{"KCN Thăng Long", "Hà Nội"}}
describe(w) // Warehouse는 승격된 메서드로 인해 Describer를 만족합니다
}
KCN Thăng Long, Hà Nội
5.1. Pointer receiver 주의사항
embedding할 때 method set 규칙은 다음과 같습니다:
S에서의 선언 |
S의 method set |
*S의 method set |
|---|---|---|
T 내장 |
receiver T의 메서드 |
receiver T 및 *T의 메서드 |
*T 내장 |
receiver T 및 *T의 메서드 |
receiver T 및 *T의 메서드 |
예를 들어, Counter가 func (c *Counter) Inc()를 가지고 있고 Counter(포인터 아님)를 Stats에 내장하면, *Stats만 Inc()를 가진 interface를 만족합니다. Stats 값은 그렇지 않습니다. “Stats does not implement Incrementer (method Inc has pointer receiver)” 형태의 컴파일 오류는 보통 여기서 옵니다.
6. Struct에 interface 내장: 깔끔한 decorator
struct에 interface를 내장할 수 있습니다. struct는 자동으로 interface의 모든 메서드를 가지게 되며, 각 메서드는 내부 값에 위임됩니다. 변경하고 싶은 메서드만 override하면 됩니다:
package payment
import "fmt"
type PaymentService interface {
Charge(orderID string, amount int) error
Refund(orderID string) error
}
type LoggingPayment struct {
PaymentService // 모든 메서드 위임
Logs []string
}
func (l *LoggingPayment) Charge(orderID string, amount int) error {
l.Logs = append(l.Logs, fmt.Sprintf("charge %s: %d", orderID, amount))
return l.PaymentService.Charge(orderID, amount)
}
LoggingPayment는Charge만 다시 작성합니다.Refund는 내부의PaymentService에 자동으로 위임됩니다.*LoggingPayment는PaymentService를 만족하므로, 호출하는 쪽에서 알 필요 없이 모든 구현을 래핑할 수 있습니다.
interface가 10개의 메서드를 가지고 있는데 일부만 가로채야 할 때, 이 기법은 많은 반복 코드를 절약합니다.
주의: 내장된 interface field가
nil이면, 코드는 정상적으로 컴파일됩니다. 하지만 override되지 않은 메서드(예:Refund)를 호출하면 runtime에 panic nil pointer dereference가 발생합니다. 항상 생성자를 통해 초기화하세요. 예:NewLoggingPayment(inner PaymentService).
7. 위임 동작 테스트
Go는 JUnit 대신 표준 testing package를 사용합니다. 다음 테스트는 두 가지를 확인합니다: Charge는 로깅되고 여전히 원본 서비스를 호출하며, Refund는 로깅 없이 위임됩니다.
package payment
import "testing"
type fakePayment struct {
charged []string
refunded []string
}
func (f *fakePayment) Charge(orderID string, amount int) error {
f.charged = append(f.charged, orderID)
return nil
}
func (f *fakePayment) Refund(orderID string) error {
f.refunded = append(f.refunded, orderID)
return nil
}
func TestLoggingPayment(t *testing.T) {
inner := &fakePayment{}
var svc PaymentService = &LoggingPayment{PaymentService: inner}
if err := svc.Charge("ORD-1", 250000); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := svc.Refund("ORD-1"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
lp := svc.(*LoggingPayment)
if len(lp.Logs) != 1 || lp.Logs[0] != "charge ORD-1: 250000" {
t.Errorf("logs = %v, want [charge ORD-1: 250000]", lp.Logs)
}
if len(inner.charged) != 1 || len(inner.refunded) != 1 {
t.Errorf("inner not called: charged=%v refunded=%v", inner.charged, inner.refunded)
}
}
테스트를 실행하세요:
go test ./...
ok example.com/shop/payment 0.002s
var svc PaymentService = &LoggingPayment{...} 줄은 또한 컴파일 타임 검사입니다: *LoggingPayment가 interface를 만족하지 않으면, 테스트가 빌드되지 않습니다. 프로덕션 코드에서, 같은 목적으로 익숙한 var _ PaymentService = (*LoggingPayment)(nil) 방식을 사용할 수 있습니다.
8. 이름 충돌 및 일반적인 오류
8.1. Ambiguous selector
같은 깊이에서 두 개의 내장된 type이 같은 이름의 field나 method를 가지면, 외부 struct를 통해 접근할 때 컴파일 오류가 발생합니다:
type Billing struct{ Email string }
type Shipping struct{ Email string }
type Order struct {
Billing
Shipping
}
func main() {
o := Order{}
// fmt.Println(o.Email) // compile error: ambiguous selector o.Email
fmt.Println(o.Billing.Email) // OK
}
Order struct는 여전히 유효합니다. 오류는 o.Email에 접근할 때만 나타납니다. 처리 방법은 명시적으로 호출하거나, 둘 다 가리기 위해 Order에 직접 Email을 선언하는 것입니다.
8.2. 의도하지 않은 API 노출: sync.Mutex 예시
type Inventory struct {
sync.Mutex
stock map[string]int
}
이 코드는 실행됩니다. 하지만 Lock()과 Unlock()은 이제 Inventory의 public 메서드입니다. 내장된 field 이름 Mutex가 export되기 때문입니다. 패키지 외부의 코드가 당신의 inventory를 잠글 수 있습니다. 다음과 같이 작성해야 합니다:
type Inventory struct {
mu sync.Mutex
stock map[string]int
}
일반 원칙: embedding은 내장된 type의 모든 메서드를 당신의 API에 넣습니다. 이들을 공개하고 싶지 않다면, 이름이 있는 field를 사용하세요.
8.3. Nil 포인터 내장
Address 대신 *Address를 내장하면, 포함하는 struct의 zero value는 nil 포인터입니다. 이 경우 c.City는 panic을 발생시킵니다. 정말로 instance를 공유해야 하거나 *T의 method set이 필요할 때만 포인터를 내장하고, 반드시 초기화하세요.
9. Embedding을 사용해야 할 때와 하지 말아야 할 때
다음의 경우 사용해야 합니다:
- 외부 struct가 내장된 type의 전체 동작을 실제로 노출하고 싶을 때입니다. 예를 들어
http.Handler나io.Reader를 내장합니다. - 큰 interface의 몇 가지 메서드만 변경하는 decorator나 wrapper를 작성할 때입니다.
Audit{CreatedAt, UpdatedAt}과 같이 여러 entity에서 사용되는 공통 field를 그룹화할 때입니다.
다음의 경우 사용하지 말아야 합니다:
- “부모 클래스가 자식 메서드를 호출”하는 다단계 상속 트리를 재현하려고 할 때입니다. interface와 명시적 의존성을 사용하세요 (섹션 4.1).
- 내장된 type이 공개하고 싶지 않은 메서드를 가지고 있을 때입니다 (섹션 8.2).
- 다른 type의 1-2개 메서드만 필요할 때입니다. 이름이 있는 field에 몇 가지 직접 작성된 위임 메서드가 더 명확합니다.
주의: Embedding은 is-a 관계를 만들지 않습니다. 다형성이 필요하면, interface를 정의하고 여러 type이 그것을 만족하도록 하세요. struct 자식이 struct 부모가 된다고 기대하지 마세요.
10. 결론
Struct embedding은 Go의 composition 도구이며, 약간의 편리한 문법 설탕을 제공합니다: 내장된 type의 field와 method는 외부 struct로 승격됩니다. 이것은 코드를 재사용하고, interface를 만족하며, 매우 깔끔한 decorator를 작성하는 데 도움이 됩니다.
하지만 embedding은 상속이 아닙니다. Virtual dispatch가 없고, 내장된 type은 그것을 포함하는 struct를 모르며, 승격된 모든 메서드는 당신의 API의 일부가 됩니다. 세 가지를 기억하세요: 변경 가능한 동작에는 interface를 사용하고, 내장된 interface나 포인터 field를 완전히 초기화하며, 메서드를 노출하고 싶지 않을 때는 이름이 있는 field로 전환하세요.