Go 구조체 임베딩, '상속' 없이 코드 재사용하는 가장 우아한 방법

Go 구조체 임베딩, '상속' 없이 코드 재사용하는 가장 우아한 방법

객체 지향 프로그래밍을 경험해 보신 분이라면 '상속(Inheritance)'이라는 개념이 아주 익숙하실 텐데요.

부모 클래스의 속성과 기능을 자식 클래스가 물려받는, 코드 재사용의 아주 고전적인 방법이죠.

그런데 Go 언어의 세계에 오면 이 '상속'이라는 단어가 보이지 않아서 처음엔 조금 당황스러울 수 있거든요.

그렇다고 Go가 코드 재사용을 포기했을 리는 없겠죠?

Go는 상속 대신 '컴포지션(Composition)', 그중에서도 '구조체 임베딩(Struct Embedding)'이라는 아주 독특하고 우아한 방법으로 이 문제를 해결합니다.

오늘은 Go 언어의 철학이 담겨있는 이 강력한 무기, 구조체 임베딩에 대해 깊이 파고들어 보겠습니다.

상속이 아니라고? 'Is-A' vs 'Has-A'

구조체 임베딩을 이해하기 전에, 먼저 '상속'과 '컴포지션'의 근본적인 차이를 짚고 넘어가는 게 좋은데요.

이걸 설명하는 가장 고전적인 방법이 바로 'Is-A' 관계와 'Has-A' 관계입니다.

  • 상속 (Is-A 관계): '자식은 부모의 한 종류다'라는 관계입니다.

    예를 들어, '자동차는 탈것이다 (Car is a Vehicle)' 같은 경우죠.

    자식 클래스가 부모 클래스의 모든 것을 물려받는 강력한 결합이지만, 때로는 너무 많은 것을 물려받아 불필요한 복잡성을 만들기도 합니다.
  • 컴포지션 (Has-A 관계): '어떤 객체가 다른 객체를 포함한다'는 관계입니다.

    '자동차는 엔진을 가지고 있다 (Car has an Engine)'가 대표적이죠.

    필요한 부품(객체)만 가져와 조립하는 방식이라, 훨씬 유연하고 독립적인 설계가 가능해집니다.

Go 언어는 바로 이 'Has-A' 관계, 즉 컴포지션을 선호하는 철학을 가지고 있습니다.

그리고 구조체 임베딩은 이 컴포지션을 아주 편리하고 직관적으로 사용할 수 있게 해주는 마법 같은 기능이죠.

구조체 임베딩, 어떻게 쓰는 걸까?

구조체 임베딩의 사용법은 정말 놀라울 정도로 간단한데요.

한 구조체 안에 다른 구조체의 '타입'만 적어주면 끝입니다.

필드 이름을 따로 지정하지 않는 게 핵심이죠.

말로만 하면 어려우니, 가장 고전적인 PersonEmployee 예제로 한번 살펴볼까요?

package main

import "fmt"

// 기본이 되는 Person 구조체입니다.
type Person struct {
    Name string
    Age  int
}

// Person 구조체를 임베딩하는 Employee 구조체입니다.
type Employee struct {
    Person     // 필드 이름 없이 타입만 선언했죠. 이게 바로 임베딩입니다.
    EmployeeID string
}

func main() {
    e := Employee{
        Person: Person{
            Name: "Alice",
            Age:  30,
        },
        EmployeeID: "E12345",
    }

    // 임베딩된 Person의 필드에 바로 접근할 수 있습니다.
    fmt.Println("Name:", e.Name)
    fmt.Println("Age:", e.Age)
    fmt.Println("Employee ID:", e.EmployeeID)
}


코드를 보시면 `Employee` 구조체 안에 `Person`이라고 타입만 덜렁 들어가 있는 걸 볼 수 있는데요.

이렇게 하면 `Person` 구조체의 필드인 `Name`과 `Age`가 `Employee` 구조체의 필드인 것처럼 '승격(Promoted)'됩니다.

덕분에 우리는 `e.Person.Name`처럼 번거롭게 접근할 필요 없이, `e.Name`이라고 바로 접근할 수 있는 거죠.

마치 상속을 받은 것처럼 보이지만, 내부적으로는 `Employee`가 `Person`을 '포함'하고 있는 컴포지션 관계입니다.

필드뿐만 아니라 메소드까지, '메소드 승격'

구조체 임베딩의 진짜 강력함은 필드뿐만 아니라 '메소드'까지 승격된다는 점에서 드러나는데요.

임베딩된 구조체가 가진 메소드를, 바깥 구조체가 마치 자신의 메소드인 것처럼 바로 호출할 수 있거든요.

package main

import "fmt"

type Person struct {
    Name string
}

// Person 타입에 Greet 메소드를 정의합니다.
func (p Person) Greet() {
    fmt.Println("안녕하세요, 제 이름은", p.Name, "입니다.")
}

type Employee struct {
    Person
    EmployeeID string
}

func main() {
    e := Employee{
        Person: Person{
            Name: "Bob",
        },
        EmployeeID: "E67890",
    }

    // Employee 인스턴스에서 바로 Greet() 메소드를 호출할 수 있습니다.
    e.Greet() // 출력: 안녕하세요, 제 이름은 Bob 입니다.
}


보세요, `Employee` 타입에는 `Greet` 메소드를 따로 정의하지 않았죠?

하지만 `Person` 구조체를 임베딩했기 때문에, `Person`의 `Greet` 메소드를 `e.Greet()`처럼 바로 호출할 수 있습니다.

이 기능 덕분에 공통적인 행위를 하는 메소드를 기본 구조체에 만들어두고, 여러 다른 구조체에서 재사용하기가 정말 편해지더라고요.

필요하다면 '오버라이딩'도 가능

"만약 바깥 구조체에도 똑같은 이름의 메소드가 있으면 어떻게 되나요?"라고 궁금해하실 수 있는데요.

그럴 땐 바깥 구조체의 메소드가 임베딩된 구조체의 메소드를 '덮어씁니다(Override)'.

우선순위가 더 높은 거죠.

package main

import "fmt"

type Person struct {
    Name string
}

func (p Person) Greet() {
    fmt.Println("안녕하세요, 제 이름은", p.Name, "입니다.")
}

type Employee struct {
    Person
    EmployeeID string
}

// Employee가 자신만의 Greet 메소드를 정의합니다.
func (e Employee) Greet() {
    fmt.Println("안녕하세요, 사원번호", e.EmployeeID, "입니다.")
}

func main() {
    e := Employee{
        Person: Person{
            Name: "Charlie",
        },
        EmployeeID: "E54321",
    }

    // Employee의 Greet 메소드가 호출됩니다.
    e.Greet() // 출력: 안녕하세요, 사원번호 E54321 입니다.

    // 하지만 원한다면 원래 Person의 메소드도 호출할 수 있습니다.
    e.Person.Greet() // 출력: 안녕하세요, 제 이름은 Charlie 입니다.
}


`e.Greet()`를 호출하면 `Employee`에 정의된 메소드가 실행되는 걸 볼 수 있죠.

하지만 그렇다고 `Person`의 `Greet` 메소드가 완전히 사라진 건 아닙니다.

`e.Person.Greet()`처럼 임베딩된 구조체의 이름을 명시적으로 적어주면, 언제든지 원래의 메소드를 호출할 수 있어요.

이런 유연함이 바로 Go가 상속 대신 컴포지션을 선택한 이유입니다.

실전에서 마주할 수 있는 함정, '모호한 셀렉터'

구조체 임베딩은 정말 강력하지만, 딱 한 가지 조심해야 할 함정이 있는데요.

바로 서로 다른 두 개의 구조체를 임베딩했는데, 그 둘에 '같은 이름의 필드'가 있을 경우입니다.

이럴 때 컴파일러는 누구의 필드를 써야 할지 몰라 '모호한 셀렉터(ambiguous selector)' 에러를 발생시키거든요.

import "fmt"

type Camera struct {
    Model string
}

type Phone struct {
    Model string
}

// Camera와 Phone을 모두 임베딩하는 SmartDevice
type SmartDevice struct {
    Camera
    Phone
}

func main() {
    d := SmartDevice{}
    // d.Model = "Galaxy" // 이 코드는 컴파일 에러를 발생시킵니다!
    // ./main.go:20:2: ambiguous selector d.Model

    // 이럴 땐 명시적으로 지정해야 합니다.
    d.Camera.Model = "Canon R5"
    d.Phone.Model = "iPhone 16"

    fmt.Println("카메라:", d.Camera.Model)
    fmt.Println("휴대폰:", d.Phone.Model)
}


`SmartDevice`는 `Camera`와 `Phone`을 모두 임베딩하고 있고, 두 구조체 모두 `Model`이라는 필드를 가지고 있죠.

이때 `d.Model`이라고 접근하면 Go는 이게 카메라 모델인지, 휴대폰 모델인지 알 수가 없는 겁니다.

이런 상황을 마주하면 당황하지 말고, `d.Camera.Model`처럼 어떤 구조체의 필드인지를 명확하게 밝혀주면 간단하게 해결됩니다.

마무리하며

Go의 구조체 임베딩은 처음에는 조금 낯설게 느껴질 수 있지만, 한번 그 철학을 이해하고 나면 상속보다 훨씬 유연하고 강력한 도구라는 걸 깨닫게 될 거예요.

복잡한 상속 계층 구조에서 오는 두통 없이, 필요한 기능들을 레고 블록처럼 자유롭게 조립하며 코드를 재사용하는 즐거움을 누릴 수 있죠.

Go 언어의 '단순함 속에 숨겨진 강력함'을 제대로 느끼고 싶다면, 이 구조체 임베딩을 꼭 마스터하시길 바랍니다.