구조체 임베딩과 조합
이 챕터에서 다루는 것
Go에는 클래스 상속이 없다. 대신 임베딩(embedding) 이 있다. 겉보기에는 상속과 비슷해서 그렇게 쓰다가 반드시 한 번 크게 데인다. 여기서는 임베딩이 실제로 하는 일 — 이름의 자동 전달 — 과 그것이 상속과 어떻게 다른지를 확실히 갈라 둔다.
문제 — 코드를 재사용하는 두 가지 방법
로그를 찍는 기능을 여러 타입이 공유해야 한다고 하자. 상속이 있는 언어라면 Logger를
상속받게 한다. Go에는 두 가지 선택지가 있다.
// 1. 필드로 갖기 (일반적인 조합)
type Server struct {
log Logger
Addr string
}
s.log.Log("시작")
// 2. 임베딩
type Server struct {
Logger
Addr string
}
s.Log("시작")
차이는 딱 하나, .log를 적어야 하느냐다. 임베딩은 필드 이름을 생략한 필드일 뿐이고,
그 대가로 안쪽의 이름들이 바깥으로 승격(promotion) 된다.
이 작은 차이가 중요한 이유는 승격된 메서드가 바깥 타입의 메서드 집합에 들어가기
때문이다. Server가 아무 메서드도 선언하지 않고도 Logger가 만족시키던 인터페이스를
만족시키게 된다.
임베딩과 승격
package main
import "fmt"
// Logger는 독립적으로도 쓸 수 있는 타입이다.
type Logger struct {
prefix string
}
func (l Logger) Log(msg string) {
fmt.Println(l.prefix + msg)
}
// Server는 Logger를 임베딩한다. 필드 이름이 없고 타입만 적혀 있다.
type Server struct {
Logger
Addr string
}
// Base와 Derived는 "임베딩은 상속이 아니다"를 보이기 위한 것이다.
type Base struct{}
func (b Base) Name() string { return "Base" }
// Greet는 b.Name()을 부른다. 여기서 b의 타입은 언제나 Base다.
func (b Base) Greet() string { return "안녕, " + b.Name() }
type Derived struct {
Base
}
// Name을 다시 정의해도 Base.Greet가 부르는 Name은 바뀌지 않는다.
func (d Derived) Name() string { return "Derived" }
// Counter는 포인터 리시버 메서드를 가진다.
type Counter struct {
n int
}
func (c *Counter) Inc() { c.n++ }
func (c *Counter) Value() int { return c.n }
// Stats는 Counter를 값으로 임베딩한다.
type Stats struct {
Counter
Label string
}
func main() {
s := Server{
Logger: Logger{prefix: "[srv] "},
Addr: ":8080",
}
// 승격 — Logger의 메서드를 Server가 그대로 쓴다.
s.Log("시작 " + s.Addr)
// 필드도 승격된다.
fmt.Println("승격된 필드:", s.prefix)
// 임베딩된 값에는 타입 이름으로도 접근할 수 있다.
s.Logger.Log("명시적 경로")
fmt.Println("두 경로가 같은가:", s.prefix == s.Logger.prefix)
// 상속이 아니다.
d := Derived{}
fmt.Println("d.Name():", d.Name())
fmt.Println("d.Greet():", d.Greet())
fmt.Println("d.Base.Name():", d.Base.Name())
// 포인터 리시버 메서드의 승격
st := Stats{Label: "요청"}
st.Inc() // (&st.Counter).Inc()
st.Inc()
fmt.Println(st.Label, st.Value())
// Stats 값을 복사하면 Counter도 통째로 복사된다.
cp := st
cp.Inc()
fmt.Println("원본:", st.Value(), "복사본:", cp.Value())
}
go run ./02-embedding
[srv] 시작 :8080
승격된 필드: [srv]
[srv] 명시적 경로
두 경로가 같은가: true
d.Name(): Derived
d.Greet(): 안녕, Base
d.Base.Name(): Base
요청 2
원본: 2 복사본: 3
문법 요약
- 필드 이름을 생략하고 타입만 적으면 임베딩이다.
Logger,*Logger,io.Reader모두 가능하다. - 임베딩된 필드의 암묵적 이름은 타입 이름이다.
s.Logger로 접근할 수 있고, 구조체 리터럴에서도Logger: Logger{...}라고 쓴다. - 패키지가 다르면 패키지 이름을 뺀 부분이 이름이다.
sync.Mutex를 임베딩하면x.Mutex다. - 승격되는 것은 필드와 메서드 둘 다다.
s.prefix는 소문자여도 같은 패키지 안이라 보인다.
임베딩은 상속이 아니다
출력의 이 두 줄이 핵심이다.
d.Name(): Derived
d.Greet(): 안녕, Base
Derived가 Name을 다시 정의했는데도 Greet는 여전히 Base를 부른다.
이유는 단순하다. Greet의 리시버 타입은 Base다. d.Greet()는
d.Base.Greet()의 축약형이고, 그 안에서 b.Name()의 b는 Base 값이다.
Base는 자신이 Derived 안에 들어 있다는 사실을 모른다. 알 방법도 없다.
객체지향 언어에 익숙하다면 이 지점이 가장 크게 어긋난다.
| Java/C++/Python 상속 | Go 임베딩 | |
|---|---|---|
| 부모 메서드가 자식 재정의를 부르는가 | 부른다 (가상 디스패치) | 부르지 않는다 |
is-a 관계인가 | 그렇다. Derived를 Base 자리에 넣을 수 있다 | 아니다. 별개의 타입이다 |
| 재정의(override) 개념 | 있다 | 없다. 이름을 가릴 뿐(shadowing) |
| 관계가 언제 결정되는가 | 컴파일 시점에 타입 계층으로 | 이름 조회 규칙으로 |
Derived를 Base 자리에 넘길 수도 없다.
func takeBase(b Base) {}
takeBase(d) // 컴파일 에러: cannot use d (variable of struct type Derived) as Base value
takeBase(d.Base) // 이렇게 해야 한다
그러면 Go에서 "자식이 부모 동작을 바꾸게" 하려면 어떻게 하는가? 답은 "부모를 상속하지 말고, 바꿀 수 있는 부분을 인터페이스로 빼서 주입한다"이다. 아래 인터페이스 임베딩 절에서 그 형태를 본다. 그리고 그 결론이 Go 설계의 표어인 "조합이 상속보다 낫다(composition over inheritance)" 다.
임베딩된 값도 그냥 필드다
원본: 2 복사본: 3
cp := st로 복사하니 Counter도 통째로 복사됐다. 임베딩은 마법이 아니라 필드 이름을
생략한 필드일 뿐이라는 사실이 여기서 드러난다.
같은 이유로, sync.Mutex를 임베딩한 구조체를 값으로 복사하면 잠금 상태까지 복사된다.
go vet의 copylocks가 passes lock by value로 잡아 주는 그 사고다
(3-5). Part 7에서 다룬다.
승격과 메서드 집합
Counter의 메서드는 전부 포인터 리시버인데 Stats는 Counter를 값으로
임베딩했다. st.Inc()가 되는 것은 4-1의 자동 주소 변환 덕이다 —
(&st.Counter).Inc()로 바뀐다. 하지만 메서드 집합은 다르다.
| 임베딩한 것 | Outer의 메서드 집합 | *Outer의 메서드 집합 |
|---|---|---|
Counter (값) | 값 리시버 메서드만 | 값 + 포인터 리시버 메서드 |
*Counter (포인터) | 값 + 포인터 리시버 메서드 | 값 + 포인터 리시버 메서드 |
즉 Stats 값은 Inc()를 요구하는 인터페이스를 만족시키지 못한다. *Stats는
만족시킨다. *Counter를 임베딩했다면 둘 다 만족시키지만, 그러면 nil 포인터를
임베딩한 채로 메서드를 부를 위험이 생긴다.
실무 기본값은 "값으로 임베딩하고 바깥 타입을 포인터로 다룬다" 이다.
이름 충돌
두 임베딩이 같은 이름을 가지면 어떻게 되는가?
package main
import "fmt"
type Job struct {
ID string
Name string
}
func (j Job) Describe() string { return "job " + j.Name }
type User struct {
ID string
Name string
}
func (u User) Describe() string { return "user " + u.Name }
// Assignment는 둘 다 임베딩한다. ID, Name, Describe가 전부 충돌한다.
type Assignment struct {
Job
User
Note string
}
// 바깥에서 선언한 것이 깊이 0이므로 언제나 이긴다.
func (a Assignment) Describe() string {
return a.Job.Describe() + " / " + a.User.Describe()
}
// Inner는 깊이 규칙을 보이기 위한 것이다.
type Inner struct {
Value string
}
type Middle struct {
Inner
}
type Outer struct {
Middle
Value int // 깊이 0. Middle.Inner.Value(깊이 2)를 가린다.
}
func main() {
a := Assignment{
Job: Job{ID: "J-1", Name: "빌드"},
User: User{ID: "U-9", Name: "김개발"},
Note: "야간",
}
// fmt.Println(a.Name)
// 컴파일 에러: ambiguous selector a.Name
fmt.Println("명시적으로:", a.Job.Name, "/", a.User.Name)
// Describe는 바깥에서 다시 선언했으므로 모호하지 않다.
fmt.Println(a.Describe())
o := Outer{
Middle: Middle{Inner: Inner{Value: "안쪽"}},
Value: 42,
}
fmt.Println("얕은 쪽이 이긴다:", o.Value)
fmt.Println("깊은 쪽은 경로로:", o.Middle.Inner.Value)
fmt.Println("중간 이름은 생략 가능:", o.Inner.Value)
}
go run ./02-collision
명시적으로: 빌드 / 김개발
job 빌드 / user 김개발
얕은 쪽이 이긴다: 42
깊은 쪽은 경로로: 안쪽
중간 이름은 생략 가능: 안쪽
규칙은 두 줄이다
- 깊이가 얕은 쪽이 이긴다. 바깥 타입이 직접 선언한 것은 깊이 0이므로 언제나 이긴다.
- 같은 깊이에 둘 이상이면 모호(ambiguous) 하다. 컴파일 에러인데, 쓰는 자리에서만 난다.
두 번째가 중요하다. Assignment 타입 선언 자체는 통과한다. a.Name이라고 쓰는
순간에야 에러가 난다.
./main.go:15:16: ambiguous selector a.Name
a.ID, a.Describe()도 마찬가지였을 텐데, Describe는 바깥에서 다시 선언해서
깊이 0으로 만들어 해결했다. 이것이 Go에서 "재정의"에 가장 가까운 것이다 — 재정의가
아니라 더 얕은 이름을 새로 두는 것이다.
:::warning 모호함이 조용히 생기는 경우 임베딩한 타입이 다른 패키지 것이라면, 그쪽에 필드가 하나 추가되는 것만으로 내 코드가 컴파일되지 않을 수 있다. 임베딩은 그 타입의 이름 공간 전체를 내 타입으로 끌어오는 일이다. 외부 타입을 임베딩할 때는 이 비용을 감안해야 한다. :::
인터페이스 임베딩
인터페이스에도 인터페이스를 임베딩할 수 있다. 표준 라이브러리가 이것으로 만들어져 있다.
// io 패키지의 실제 선언
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type ReadWriter interface {
Reader
Writer
}
io 패키지에는 Reader, Writer, Closer, Seeker 네 개의 기본 인터페이스와,
그것을 조합한 ReadWriter, ReadCloser, WriteCloser, ReadWriteCloser,
ReadSeeker… 가 있다. 작은 것을 정의하고 조합해서 큰 것을 만든다는 원칙의 교과서적
사례다. 4-3의 주제이기도 하다.
구조체에 인터페이스를 임베딩할 수도 있다. 이쪽이 훨씬 흥미롭다.
package main
import (
"fmt"
"strings"
)
// 작은 인터페이스를 조합해 큰 인터페이스를 만든다. io.ReadWriter와 같은 방식이다.
type Fetcher interface {
Fetch(key string) (string, error)
}
type Closer interface {
Close() error
}
type FetchCloser interface {
Fetcher
Closer
}
// mapFetcher는 FetchCloser의 평범한 구현이다.
type mapFetcher struct {
data map[string]string
closed bool
}
func (f *mapFetcher) Fetch(key string) (string, error) {
if f.closed {
return "", fmt.Errorf("fetch %s: 이미 닫힘", key)
}
v, ok := f.data[key]
if !ok {
return "", fmt.Errorf("fetch %s: 없는 키", key)
}
return v, nil
}
func (f *mapFetcher) Close() error {
f.closed = true
return nil
}
// upperFetcher는 인터페이스를 임베딩한 구조체다.
// Fetch만 새로 정의하고 Close는 임베딩된 값에 그대로 위임된다.
type upperFetcher struct {
FetchCloser
}
func (u upperFetcher) Fetch(key string) (string, error) {
v, err := u.FetchCloser.Fetch(key)
if err != nil {
return "", err
}
return strings.ToUpper(v), nil
}
func main() {
base := &mapFetcher{data: map[string]string{"greeting": "hello"}}
var fc FetchCloser = upperFetcher{FetchCloser: base}
v, err := fc.Fetch("greeting")
if err != nil {
fmt.Println("에러:", err)
return
}
fmt.Println("Fetch:", v)
// Close는 정의하지 않았지만 임베딩 덕에 호출된다.
if err := fc.Close(); err != nil {
fmt.Println("Close 에러:", err)
}
fmt.Println("base.closed:", base.closed)
// 닫힌 뒤의 Fetch는 안쪽 구현이 낸 에러를 그대로 돌려준다.
if _, err := fc.Fetch("greeting"); err != nil {
fmt.Println("닫힌 뒤:", err)
}
// 인터페이스를 임베딩한 구조체의 함정 — 안쪽이 nil이면 호출 시점에 패닉이다.
var empty upperFetcher
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("nil 임베딩 패닉:", rec)
}
}()
_, _ = empty.Fetch("greeting")
}()
}
go run ./02-interface-embedding
Fetch: HELLO
base.closed: true
닫힌 뒤: fetch greeting: 이미 닫힘
nil 임베딩 패닉: runtime error: invalid memory address or nil pointer dereference
이 패턴이 해결하는 것
"메서드 열 개짜리 인터페이스에서 하나만 바꾸고 싶다" 는 상황이다. 인터페이스를 임베딩하면 나머지 아홉 개는 자동으로 위임된다. 데코레이터를 만들 때, 테스트에서 일부만 가짜로 바꿀 때 쓴다.
그리고 이것이 앞에서 말한 "가상 디스패치의 대체물" 이다. Base/Derived와
비교해 보자.
- 임베딩된 것이 구체 타입이면 안쪽 메서드는 언제나 자기 자신을 부른다 (
Base.Greet). - 임베딩된 것이 인터페이스면 실제로 무엇이 들어 있는지는 런타임에 정해진다. 바깥에서 주입할 수 있다.
"동작을 바꿀 수 있게 하고 싶다면 그 자리에 인터페이스를 두라"는 것이 Go의 답이다.
:::danger 인터페이스 임베딩의 대가
upperFetcher는 컴파일러가 검사해 주지 않는다. FetchCloser에 메서드가 추가되면
upperFetcher는 여전히 컴파일되지만, 그 새 메서드를 부르는 순간 nil 인터페이스
역참조로 패닉이다. 위 예제의 마지막 출력이 그것이다.
"미구현 메서드를 컴파일 에러 대신 런타임 패닉으로 바꾸는" 거래다. 그래서 이 패턴은 테스트 더블처럼 범위가 좁고 수명이 짧은 코드에 어울린다. 프로덕션 타입이라면 메서드를 전부 명시적으로 위임하는 편이 안전하다. :::
흔히 하는 실수
1. 임베딩을 is-a로 쓴다
type Animal struct{ Name string }
func (a Animal) Speak() string { return "..." }
type Dog struct{ Animal }
func (d Dog) Speak() string { return "멍멍" }
이 코드는 컴파일되고 d.Speak()도 멍멍을 준다. 하지만 []Animal에 Dog를 담을 수
없고, Animal의 다른 메서드가 Speak를 부르면 ...이 나온다. 동물 계층을 만들고
싶었다면 인터페이스를 정의했어야 한다.
type Speaker interface{ Speak() string }
임베딩은 "코드를 물려받기" 위한 것이지 "타입 계층을 만들기" 위한 것이 아니다. 타입 계층은 인터페이스가 담당한다.
2. 필요 없는데 임베딩한다
.log를 안 적으려고 임베딩하는 것은 손해다. 임베딩은 안쪽 타입의 이름 공간 전체와
메서드 집합 전체를 바깥으로 노출한다. Server가 Logger를 임베딩하면
Server의 사용자에게 Log 메서드가 공개 API가 된다. 그럴 의도가 아니었다면
그냥 필드로 둔다.
type Server struct {
log *slog.Logger // 대부분 이쪽이 맞다
Addr string
}
판단 기준: 안쪽 타입의 메서드를 바깥 타입의 공개 API로 삼을 생각인가. 그렇다면 임베딩, 아니면 필드다.
3. 임베딩한 뒤 nil인 채로 쓴다
*Logger나 인터페이스를 임베딩하면 제로값이 nil이다. 승격된 메서드 호출은
컴파일되고, 런타임에 패닉이 난다. 생성자를 두고 거기서 채우는 것이 답이다.
4. 여러 개를 임베딩해 놓고 이름 충돌을 나중에 발견한다
모호함은 쓰는 자리에서만 에러가 된다. 라이브러리를 올리다가 갑자기 컴파일이 깨지는 경로다. 임베딩은 두 개를 넘지 않는 편이 좋고, 넘는다면 필드로 바꿀 때다.
5. String()이 무한 재귀에 빠진다
type Wrapper struct{ Inner }
func (w Wrapper) String() string { return fmt.Sprintf("Wrapper(%v)", w) }
%v에 w 자신을 넘겼으니 String()이 다시 불린다.
4-1에서 본 것과 같은 사고인데, 임베딩이 끼면 fmt가
승격된 String을 부르는 경로까지 겹쳐 더 찾기 어렵다. 필드를 하나씩 적거나
w.Inner처럼 안쪽 값을 넘긴다.
정리
- 임베딩은 필드 이름을 생략한 필드다. 안쪽의 필드와 메서드가 바깥으로 승격된다.
- 승격된 메서드는 바깥 타입의 메서드 집합에 들어간다. 그래서 인터페이스를 만족시킨다.
- 임베딩은 상속이 아니다. 가상 디스패치가 없고,
is-a관계도 아니고, 재정의가 아니라 이름 가리기다. - 이름 충돌: 얕은 쪽이 이기고, 같은 깊이면 쓰는 자리에서 컴파일 에러다.
- 값으로 임베딩하면 복사할 때 통째로 복사된다.
sync.Mutex를 품었다면 사고다. - 인터페이스 임베딩으로 작은 인터페이스를 조합한다.
io.ReadWriter가 그 방식이다. - 구조체에 인터페이스를 임베딩하면 일부만 재정의하고 나머지를 위임할 수 있다. 대가는 미구현이 컴파일 에러가 아니라 런타임 패닉이 된다는 것이다.
- 판단 기준: 안쪽 타입의 메서드를 바깥의 공개 API로 삼을 것인가. 아니면 그냥 필드.
연습문제
-
Base/Derived예제를 고쳐서Greet가Derived의Name을 부르게 만들어 보자. 힌트: 임베딩으로는 안 된다.Namer인터페이스를 만들고Greet(n Namer) string함수로 바꾸거나,Base에Namer필드를 두고 주입한다. 두 방법의 차이는 무엇인가? -
Assignment에서Describe를 다시 선언하지 말고a.Describe()를 불러 보자. 에러 메시지가a.Name의 것과 어떻게 다른가?Job만 임베딩하고User는user User라는 이름 있는 필드로 두면 어떻게 되는가? -
upperFetcher처럼 인터페이스를 임베딩하는 대신,Fetch와Close를 둘 다 명시적으로 선언해 위임하는 버전을 써 보자. 그다음FetchCloser에 메서드를 하나 추가하고, 두 버전이 각각 어떻게 반응하는지 비교해 보자. 어느 쪽이 나은가?