Go에는 왜 상속이 없을까 — 임베딩·인터페이스로 객체지향 다시 짜기
이 글의 순서9개 절
Go에는 클래스도 상속도 없다. 그런데 객체지향이 아니라고 하기도 어렵다. “Go는 객체지향 언어인가”에 대한 공식 FAQ의 답이 “Yes and no”인 이유를, 상속을 뺀 자리에 무엇을 넣었는지 따라가며 정리했다.
객체지향 4대 특징은 Go에서 어디에 있나
면접 단골 질문이라 외워는 뒀는데, Go로 넘어오면서 매핑이 깨졌다. 캡슐화, 추상화, 상속, 다형성 모두 클래스 기반 언어에서 쓰던 장치는 Go에 하나도 그대로 있지 않다. 그런데 목표는 전부 다른 방식으로 달성한다.
| 특징 | 클래스 기반 언어 | Go |
|---|---|---|
| 캡슐화 | 클래스 + private / protected |
패키지 경계 + 식별자 대소문자. 단위가 패키지다 |
| 추상화 | 추상 클래스, 인터페이스 | 인터페이스. 추상 클래스는 없다 |
| 상속 | extends, 타입 계층 |
없다. 임베딩은 상속이 아니다 |
| 다형성 | 오버라이드 + vtable | 오직 인터페이스. 다른 경로가 없다 |
첫 줄부터 짚고 넘어갈 게 있다. Go에서 캡슐화의 단위는 패키지다. 같은 패키지 안에서는 다른 타입의 소문자 필드도 전부 보인다. 한 타입이 곧 한 클래스라는 감각으로 오면 캡슐화가 헐거워 보이는데, 실제로는 패키지 경계가 진짜 방어선이고 그 안쪽은 하나의 구현 단위로 본다.
잘 안 알려진 장치가 하나 더 있다. internal/ 디렉터리다. 이 아래에 있는 패키지는 internal의 부모 디렉터리 트리 안에서만 import할 수 있다. 대문자로 공개해도 프로젝트 밖에서는 못 쓰게 막는, 패키지보다 한 단계 넓은 캡슐화다.
myapp/├── internal/│ └── auth/ ← myapp/ 아래에서만 import할 수 있다│ └── token.go 대문자로 공개해도 외부 모듈은 못 쓴다├── pkg/│ └── client/ ← 누구나 import할 수 있다└── main.go메서드도 클래스에 묶여 있지 않다. Go에서는 구조체가 아닌 타입에도 메서드를 붙일 수 있다. type Celsius float64처럼 기본 타입을 바탕으로 만든 타입에도 붙는다. 객체를 클래스의 인스턴스로 보는 전제가 처음부터 없다.
공식 FAQ는 “Go는 객체지향 언어인가”에 “Yes and no”라고 답한다. 타입과 메서드가 있고 객체지향 스타일로 짤 수 있지만, 타입 계층이 없다는 이유에서다. 누가 물으면 나는 클래스 기반 상속만 없고 나머지 목표는 다른 수단으로 이룬다고 답한 뒤, 위 표의 수단을 하나씩 짚는다.
상속을 뺀 게 아니라 하나를 둘로 쪼갰다
왜 상속이 없냐는 질문을 오래 무언가를 안 넣었다는 뜻으로만 이해했는데, 그렇게 보면 답이 궁색해진다. 정확히는 상속이 한 덩어리로 제공하던 두 기능을 분리한 것이다.
클래스 상속은 사실 서로 다른 두 일을 동시에 한다. 부모의 구현을 물려받는 것(코드 재사용)과, 자식을 부모 자리에 쓸 수 있게 하는 것(다형성)이다. 둘이 묶여 있어서, 구현만 재사용하고 싶은데 타입 관계까지 딸려 온다.
상속을 덜어 내면서 같이 사라진 문제들이 있다.
| 상속의 문제 | 내용 |
|---|---|
| 강한 결합 | 구현까지 물려받으니 부모와 자식이 단단히 묶인다 |
| 취약한 기반 클래스 | 부모를 고치면 자식 전체가 깨진다. 자식이 어디 있는지도 모르는 채로 |
| 깊은 계층 | 이 메서드가 어디서 왔는지 추적하려면 계층을 다 타고 올라가야 한다 |
| 다이아몬드 문제 | 다중 상속에서 같은 조상을 두 경로로 물려받으면 무엇이 이기는지 모호해진다 |
공식 FAQ가 덧붙이는 이유가 하나 더 있는데, 개인적으로는 이게 가장 와닿았다. 객체지향 언어에서는 타입 사이의 관계를 미리 선언하고 논의하는 데 시간이 너무 많이 든다는 것이다. Go는 그 관계를 선언하지 않고 자동으로 성립시키는 쪽을 택했다. 1편에서 본 암묵적 구현이 바로 그 장치다. 관계를 선언하지 않으니 관리할 타입 계층 자체가 없다.
물론 대가도 있다. 위임 코드가 늘어난다. 상속이라면 공짜였을 부모 메서드 그대로 쓰기를 일일이 써 줘야 하는데, 임베딩의 승격이 그 반복을 줄여 준다. 그래서 임베딩이 정확히 무엇인지부터 봐야 한다.
임베딩의 정체: 익명 필드와 승격
필드 이름 없이 타입만 적으면 그 타입 이름이 곧 필드 이름이 되고, 안쪽 필드와 메서드를 한 단계 건너뛰고 부를 수 있게 된다. 이 건너뛰기가 승격(promotion)이다.
임베딩 (익명 필드)
type Dog struct { Animal Breed string}d.Name으로 바로 부른다- 승격된 메서드가
Dog의 메서드셋에도 들어간다
이름 있는 필드
type Dog struct { Animal Animal Breed string}d.Animal.Name으로만 부른다Dog의 메서드셋은 그대로다
메모리 배치는 둘이 완전히 같다. 차이는 승격 하나인데, 그 승격이 두 가지 일을 한다. 이름을 줄여 주고, 안쪽 메서드를 바깥 타입의 메서드셋에 넣어 준다. 두 번째 덕분에 Animal이 만족하던 인터페이스를 Dog도 만족한다. 뒤에서 볼 추상 클래스 대용이 이 성질에 기댄다.
type Animal struct{ Name string }func (a Animal) Sound() string { return "..." }func (a Animal) Speak() string { return a.Name + ": " + a.Sound() }
type Dog struct { Animal // 익명 필드: 배치는 Animal Animal 필드와 같다 Breed string}func (d Dog) Sound() string { return "멍멍" } // 재정의처럼 보이지만…
d := Dog{Animal: Animal{Name: "보리"}, Breed: "진돗개"}fmt.Println(d.Name) // 보리: 승격, d.Animal.Name의 줄임fmt.Println(d.Sound()) // 멍멍: Dog의 것 (얕은 쪽이 이긴다)fmt.Println(d.Animal.Sound()) // ...: 안쪽 메서드에 직접 접근d.Animal.Sound()처럼 타입 이름으로 안쪽을 직접 부를 수도 있다. 바깥에서 가려진 안쪽 메서드를 명시적으로 부르는 통로로, 다른 언어의 super 호출과 비슷한 자리다.
이름이 겹치면
규칙은 두 줄이다. 얕은 깊이가 이긴다. 바깥 타입에 같은 이름이 있으면 안쪽을 가린다. 그리고 같은 깊이에 둘 이상이면 모호해진다. 흔한 오해가 하나 있는데, 모호한 타입을 선언하는 것 자체는 에러가 아니다. 그 이름을 실제로 쓰는 순간 컴파일 에러가 난다.
type A struct{ Name string }type B struct{ Name string }
type C struct{ A; B; Name string } // C.Name이 얕으므로 이긴다 (가리기)type D struct{ A; B } // 선언은 통과한다
var d D_ = d.Name // 여기서 컴파일 에러: ambiguous selector d.Name_ = d.A.Name // 명시하면 된다오버라이드가 없다: 되돌아갈 길이 없다
앞의 코드에서 진짜 중요한 건 출력하지 않은 한 줄이다. d.Speak()를 부르면 무엇이 나올까.
🧪 직접 해보기
d.Speak()는 무엇을 출력하나
Dog이 Sound()를 다시 정의했으니, 상속에 익숙하면 보리: 멍멍을 기대한다. 직접 돌려 봤다.
d := Dog{Animal: Animal{Name: "보리"}, Breed: "진돗개"}fmt.Println(d.Speak())보리: ...Speak()는 Animal의 메서드이고, 그 안의 a.Sound()에서 a는 그냥 Animal이다. 바깥에 Dog이 있다는 사실을 알 방법이 없다.
공식 FAQ가 이걸 한 문장으로 못 박는다. 동적 디스패치를 얻는 유일한 방법은 인터페이스이고, 구조체를 비롯한 구체 타입의 메서드는 언제나 정적으로 해결된다. Animal.Speak() 안의 a.Sound()는 a가 Animal 타입이라는 이유만으로 컴파일 시점에 Animal.Sound로 확정된다. 런타임에 실제로는 Dog이 아니냐고 따져 볼 기회 자체가 없다. 승격은 이름을 줄여 줄 뿐이고, 호출을 바깥 타입으로 되돌리지는 않는다.
그럼 어떻게 설계하나: 방향을 뒤집는다
부모가 자식이 재정의한 메서드를 부르게 하고 싶다는 요구는 템플릿 메서드 패턴이다. Go에서는 이 구조를 만들 수 없으니, 가변 부분을 인터페이스로 뽑아 바깥에서 넣어 주는 전략 패턴으로 뒤집는다.
type Sounder interface{ Sound() string }
type Animal struct { Name string S Sounder // 바깥에서 넣어 받는다}func (a Animal) Speak() string { return a.Name + ": " + a.S.Sound() }
type DogSound struct{}func (DogSound) Sound() string { return "멍멍" }
a := Animal{Name: "보리", S: DogSound{}}fmt.Println(a.Speak()) // 보리: 멍멍, 이제 의도대로 동작한다추상 클래스가 없는 자리는
추상 클래스는 일부는 내가 구현하고 일부는 자식에게 강제한다는 두 가지를 한 타입에 담는 장치다. Go에는 그런 게 없으니 둘로 나눠서 쓴다. 공통 구현은 임베딩으로 물려받고, 강제할 계약은 인터페이스로 선언한다.
// ① 공통 구현: 임베드해서 승격받는다type BaseJob struct{ ID string }func (b BaseJob) Key() string { return "job:" + b.ID }
// ② 강제할 계약: 인터페이스로 선언한다type Runner interface { Key() string Run(context.Context) error}
type SyncJob struct{ BaseJob } // 공통은 승격받고func (SyncJob) Run(ctx context.Context) error { return nil } // 계약은 직접 구현
var _ Runner = SyncJob{} // Run을 빼먹으면 여기서 컴파일 에러 (1편 참고)SyncJob이 Runner를 만족하는 건 Key()가 승격돼 SyncJob의 메서드셋에 들어갔기 때문이다. 앞에서 본 승격의 두 번째 일이 여기서 쓰인다.
임베딩의 실전 함정
편해 보이는 만큼 대가도 분명하다. 특히 라이브러리를 만든다면 아래는 알고 써야 한다.
| 함정 | 내용 |
|---|---|
| 내부 구현이 공개 API로 샌다 | 임베드한 타입의 공개 메서드가 전부 내 타입의 공개 메서드가 된다. 나중에 빼려면 호환성이 깨진다 |
sync.Mutex 임베드 |
Lock, Unlock이 공개 API가 되어 아무나 잠글 수 있다. 이름 있는 필드 mu sync.Mutex가 안전하다 |
| 포인터 리시버 메서드 | 값으로 임베드한 타입의 메서드가 포인터 리시버면 *Outer만 인터페이스를 만족한다 (1편 메서드셋) |
| 이름 충돌 | 얕은 쪽이 이기고, 같은 깊이면 쓰는 순간 ambiguous 에러 |
가장 자주 문제가 되는 건 첫 줄이다. 편의 하나 때문에 API 표면적이 통째로 늘어난다. 그래서 실제로는 필요한 메서드만 골라 명시적으로 위임하는 편이 나은 경우가 더 많다. 코드는 몇 줄 길어지지만, 무엇을 공개하는지 내가 통제한다.
// 나쁨: Client의 공개 메서드 수십 개가 전부 LeakyStore의 API가 된다type LeakyStore struct{ *clientv3.Client }
// 좋음: 필요한 것만 위임한다. 공개 표면적을 내가 정한다type Store struct{ c *clientv3.Client }
func (s *Store) Get(ctx context.Context, key string) (*clientv3.GetResponse, error) { return s.c.Get(ctx, key)}
// 잠금은 이름 있는 필드로: Lock/Unlock이 밖으로 새지 않는다type Cache struct { mu sync.RWMutex // 임베드하지 않는다 m map[string]int}인터페이스 임베딩: 합성과 테스트 더블
구조체뿐 아니라 인터페이스도 임베드할 수 있고, 이쪽은 훨씬 안전하게 쓰인다. 표준 라이브러리가 작은 인터페이스를 조합하는 방식이 이거다. 참고로 Go 1.14부터는 같은 메서드가 겹쳐도 합성할 수 있다. 예전에는 중복 메서드가 있으면 컴파일 에러였다.
type ReadWriter interface{ io.Reader; io.Writer } // 작은 것을 조합한다
// 테스트 더블: 큰 인터페이스를 임베드하고 쓸 것만 구현한다type fakeStore struct{ VMStore } // 임베드된 값은 nil인 채로 둔다func (f fakeStore) Get(id string) (VM, error) { return VM{ID: id}, nil }
// 구현하지 않은 메서드를 부르면 nil 인터페이스 호출이라 panic이 난다.// 버그가 아니라 이 테스트에서 여긴 불리면 안 된다는 의도의 표현이다.제네릭 vs 인터페이스: 세 번째 축
1.18에 제네릭이 들어오면서 선택지가 하나 늘었다. 처음엔 인터페이스 대신 쓰는 것으로 오해했는데, 써 보니 둘이 답하는 질문이 달랐다. 고르는 기준은 타입마다 무엇이 달라지는가 하나면 된다.
행동이 다르다 → 인터페이스
- 타입마다 다르게 동작해야 한다
- 다형성, 목킹, 구현 교체
- 예: 슬랙, 이메일, SMS로 보내는
Notifier
타입만 다르다 → 제네릭
- 로직은 글자 하나 안 바뀐다
- 컨테이너,
Map,Filter - 런타임 타입 단언이 사라지고 컴파일 시점에 타입이 검사된다
// 제네릭: 타입만 다르고 로직이 완전히 같다func Map[T, U any](in []T, f func(T) U) []U { out := make([]U, 0, len(in)) for _, v := range in { out = append(out, f(v)) } return out}
doubled := Map([]int{1, 2}, func(i int) int { return i * 2 }) // [2 4]names := Map(users, func(u User) string { return u.Name })
// 인터페이스: 타입마다 하는 일이 다르다type Notifier interface{ Notify(msg string) error } // 슬랙/이메일/SMS…다만 제네릭을 남용하면 시그니처 가독성이 급격히 떨어진다. 같은 코드를 실제로 두세 번 복붙하게 됐을 때 도입하는 정도가 적당했다.
성능도 짚어 두면, Go의 제네릭은 C++ 템플릿처럼 타입마다 코드를 전부 복제하지도, Java처럼 타입을 지워 박싱하지도 않는다. GC shape이 같은 타입끼리 코드 한 벌을 공유하고 차이는 딕셔너리로 넘기는 중간 지점이다. 포인터 타입은 전부 같은 shape으로 묶이지만 값 타입은 대개 제 몫의 코드를 받는다. 그래서 값 타입 제네릭은 구체 타입과 사실상 같은 속도가 나온다. 이전 글에서 세 방식을 실측했을 때 값 타입 제네릭이 구체 타입과 거의 같고 인터페이스만 2.4배 느렸던 이유가 이거다.
추상화가 필요하면 제네릭부터 고려하라는 가이드를 볼 때마다 갸웃했다. 질문을 “바꿔 끼울 일이 있느냐”로 바꾸고 나서야 정리가 됐다. 바꿔 끼울 일이 있으면 인터페이스, 그냥 타입만 다르면 제네릭이다. 목킹할 일도 없는데 인터페이스를 만들고 있다면 제네릭이나 구체 타입으로 충분한 자리일 가능성이 크다.
디자인 패턴은 Go에서 어떻게 생겼나
패턴 이름을 그대로 옮기면 대개 어색해진다. Go에서는 형태가 달라지는데, 원인은 지금까지 본 것들이다. 상속이 없으니 상속 기반 패턴이 사라지고, 함수가 일급이니 클로저 기반 형태가 많아진다.
| 패턴 | Go에서의 모습 |
|---|---|
| 전략 | 인터페이스 주입. 압도적으로 자주 쓴다. 템플릿 메서드가 불가능해 자연히 이 형태로 모인다 |
| 데코레이터 | http.Handler 미들웨어. 핸들러를 받아 감싼 핸들러를 돌려주는 클로저 |
| 팩토리 | NewXxx() 생성자 함수. 별도 팩토리 타입을 만들 일이 거의 없다 |
| 빌더 | 함수형 옵션 패턴. 클로저와 가변 인자로 대신한다 |
| 싱글턴 | sync.Once. 1.21부터는 sync.OnceValue가 더 짧다 |
| 옵저버 | 채널. 등록과 해제 구조를 직접 만들 일이 줄어든다 |
// 함수형 옵션 패턴: 빌더 자리를 클로저가 메운다type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }func WithRetries(n int) Option { return func(s *Server) { s.retries = n } }
func NewServer(opts ...Option) *Server { s := &Server{timeout: 5 * time.Second} // 기본값 for _, o := range opts { o(s) // 옵션이 스스로를 적용한다 } return s}
srv := NewServer(WithTimeout(3*time.Second), WithRetries(2))
// 싱글턴: 1.21부터는 이 한 줄이면 끝난다var conn = sync.OnceValue(func() *DB { return mustConnect() })db := conn() // 몇 번을 불러도 초기화는 한 번패턴을 설명할 때 나는 이름보다 왜 그 모양이 됐는지를 먼저 말하려고 한다. 전략 패턴이라면 템플릿 메서드가 안 되니까 방향을 뒤집었다는 인과가 이름보다 많은 걸 알려 준다.
정리
- 재사용은 임베딩과 위임, 다형성은 인터페이스, 확장은 인터페이스 주입이 나눠 맡는다.
- 임베딩의 메모리 배치는 이름 있는 필드와 같다. 승격은 이름을 줄이고 메서드셋을 넓혀 줄 뿐 호출을 바깥으로 되돌리지 않는다.
- 구체 타입의 메서드는 언제나 컴파일 시점에 정해지니, 템플릿 메서드가 필요하면 전략 패턴으로 뒤집는다.
- 라이브러리라면 임베딩 대신 필요한 메서드만 위임하고,
sync.Mutex는 이름 있는 필드로 둔다. - 바꿔 끼울 일이 있으면 인터페이스, 타입만 다르면 제네릭.
네 편을 쓰면서 Go의 설계가 하나로 읽히기 시작했다. 타입들이 서로의 관계를 미리 선언하지 않는다는 원칙 하나가 암묵적 구현으로도 나오고 타입 계층의 부재로도 나온다. 관계를 선언하지 않으니 관리할 계층도 없고, 대신 필요할 때 인터페이스로 관계를 만들어 쓴다. 불편해 보였던 것들이 대부분 여기서 나온 결과였다.
참고 자료
- Go FAQ: Is Go an object-oriented language? · Why is there no type inheritance? · How do I get dynamic dispatch of methods?
- Effective Go: Embedding
- Go 명세: Struct types (임베디드 필드와 승격 규칙)
- Go 1.4 릴리스 노트: Internal packages
- Go 1.14 릴리스 노트 (겹치는 메서드를 가진 인터페이스 임베딩 허용)
- Generics implementation: GC shape stenciling with dictionaries