Golang의 상속 완벽 이해하기

Golang의 상속 완벽 이해하기

안녕하세요, 오늘은 Golang의 '상속'에 대해 깊이 있게 이야기해 보려고 합니다.

많은 객체지향 프로그래밍(OOP) 언어에 익숙한 개발자들이 Golang을 처음 접할 때 가장 궁금해하는 것 중 하나가 바로 상속의 부재입니다.

Java나 C++과 같은 언어에서는 클래스 기반의 상속이 매우 중요한 개념이지만, Golang은 의도적으로 다른 길을 선택했습니다.

이 글에서는 Golang이 왜 전통적인 상속을 지원하지 않는지, 그리고 그 대안으로 어떤 강력한 기능들을 제공하는지 자세히 알아보겠습니다.

상속보다 컴포지션 Go의 철학

Golang의 설계자들은 전통적인 상속이 가진 문제점, 예를 들어 클래스 간의 강한 결합(tight coupling)이나 '깨지기 쉬운 기본 클래스 문제(fragile base class problem)'를 인지하고 있었습니다.

상속 계층이 복잡해질수록 코드를 이해하고 유지보수하기가 어려워지는 경향이 있습니다.

그래서 Golang은 '상속보다 컴포지션(composition over inheritance)'이라는 원칙을 채택했습니다.

컴포지션은 여러 개의 작은 객체를 조합하여 더 복잡한 객체를 만드는 방식으로, 코드의 재사용성과 유연성을 높이는 데 더 효과적이라고 평가받습니다.

Golang에서는 이 철학을 '구조체 임베딩(struct embedding)'과 '인터페이스(interface)'라는 두 가지 핵심 기능으로 구현합니다.

구조체 임베딩 코드 재사용의 시작

Golang에서 상속과 가장 유사한 형태의 코드 재사용을 구현하는 방법은 바로 구조체 임베딩입니다.

이는 한 구조체가 다른 구조체를 필드처럼 포함하는 기능으로, 상속처럼 계층 구조를 만드는 대신 필요한 기능을 '조립'하는 방식입니다.

예를 들어 Person이라는 기본 구조체가 있고, 이 Person의 속성과 기능을 모두 가지는 Employee 구조체를 만든다고 가정해 보겠습니다.

package main

import "fmt"

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

// Person 구조체에 속한 메서드입니다.
func (p Person) Greet() {
    fmt.Printf("안녕하세요, 제 이름은 %s입니다.\n", p.Name)
}

// Person 구조체를 임베딩하는 Employee 구조체입니다.
type Employee struct {
    Person   // 타입 이름만 명시하여 임베딩합니다.
    Position string
}

func main() {
    emp := Employee{
        Person:   Person{Name: "김철수", Age: 30},
        Position: "소프트웨어 엔지니어",
    }

    // 임베드된 구조체의 필드에 직접 접근할 수 있습니다.
    fmt.Println(emp.Name)      // "김철수" 출력
    fmt.Println(emp.Age)       // 30 출력
    fmt.Println(emp.Position)  // "소프트웨어 엔지니어" 출력

    // 임베드된 구조체의 메서드도 직접 호출할 수 있습니다.
    emp.Greet() // "안녕하세요, 제 이름은 김철수입니다." 출력
}

 

위 코드에서 Employee 구조체는 Person을 임베딩했습니다.

이렇게 하면 Person이 가진 NameAge 필드, 그리고 Greet() 메서드가 Employee 구조체로 '승격(promoted)'되어 마치 Employee 자신의 것처럼 직접 접근할 수 있습니다.

emp.Name은 사실 emp.Person.Name의 축약형 표현이며, Golang이 편의를 위해 이를 허용하는 것입니다.

이것이 바로 Golang이 컴포지션을 통해 코드 재사용을 달성하는 핵심 방식입니다.

메서드 오버라이딩의 진실

전통적인 상속에서는 부모 클래스의 메서드를 자식 클래스에서 재정의하는 '메서드 오버라이딩(method overriding)'이 가능합니다.

Golang에서도 이와 유사한 효과를 낼 수 있습니다.

임베딩하는 구조체가 임베드된 구조체와 동일한 이름의 메서드를 정의하면, 해당 메서드를 호출할 때 바깥쪽 구조체의 메서드가 우선적으로 실행됩니다.

package main

import "fmt"

type Person struct {
    Name string
}

func (p Person) Greet() {
    fmt.Println("Person에서 인사드립니다.")
}

type Employee struct {
    Person
}

// Employee 구조체에서 Greet 메서드를 '재정의'합니다.
func (e Employee) Greet() {
    fmt.Println("Employee에서 인사드립니다.")
}

func main() {
    emp := Employee{Person: Person{Name: "이영희"}}
    emp.Greet() // "Employee에서 인사드립니다." 출력
}

 

위 예제에서 Employee 인스턴스인 emp에서 Greet()를 호출하면 PersonGreet()가 아닌 Employee가 직접 정의한 Greet()가 실행됩니다.

하지만 여기서 중요한 점은 이것이 진정한 의미의 '오버라이딩'이라기보다는 '섀도잉(shadowing)'에 가깝다는 것입니다.

임베드된 PersonGreet() 메서드가 사라진 것이 아니라, EmployeeGreet() 메서드에 가려진 것뿐입니다.

따라서 원한다면 여전히 임베드된 구조체의 메서드에 명시적으로 접근할 수 있습니다.

func main() {
    emp := Employee{Person: Person{Name: "이영희"}}

    // 바깥쪽 Employee의 메서드 호출
    emp.Greet() // "Employee에서 인사드립니다." 출력

    // 안쪽 Person의 메서드를 명시적으로 호출
    emp.Person.Greet() // "Person에서 인사드립니다." 출력
}

 

이처럼 필요에 따라 내부 구조체의 원본 메서드를 호출할 수 있다는 점은 코드의 유연성을 높여주는 중요한 특징입니다.

인터페이스를 통한 다형성 구현

객체지향 프로그래밍의 또 다른 핵심 개념은 '다형성(polymorphism)'입니다.

다형성은 서로 다른 타입의 객체들이 동일한 인터페이스를 통해 공통된 방식으로 동작할 수 있게 하는 능력입니다.

Golang은 인터페이스를 통해 강력하고 명시적인 다형성을 지원합니다.

인터페이스는 특정 메서드들의 집합을 정의하며, 어떤 타입이든 해당 인터페이스가 요구하는 모든 메서드를 구현하면 그 인터페이스 타입으로 취급될 수 있습니다.

package main

import "fmt"

// Greeter 인터페이스는 Greet() 메서드를 가져야 함을 정의합니다.
type Greeter interface {
    Greet()
}

type Person struct {
    Name string
}

// Person은 Greet() 메서드를 구현하므로 Greeter 인터페이스를 만족합니다.
func (p Person) Greet() {
    fmt.Printf("안녕하세요, 저는 사람 %s입니다.\n", p.Name)
}

type Robot struct {
    Model string
}

// Robot도 Greet() 메서드를 구현하므로 Greeter 인터페이스를 만족합니다.
func (r Robot) Greet() {
    fmt.Printf("삐빅... 저는 로봇 %s입니다.\n", r.Model)
}

// Greeter 인터페이스를 매개변수로 받는 함수입니다.
func SayHello(g Greeter) {
    // g가 Person인지 Robot인지 신경 쓸 필요 없이 Greet() 메서드를 호출합니다.
    g.Greet()
}

func main() {
    p := Person{Name: "김철수"}
    r := Robot{Model: "R2D2"}

    SayHello(p) // "안녕하세요, 저는 사람 김철수입니다." 출력
    SayHello(r) // "삐빅... 저는 로봇 R2D2입니다." 출력
}

 

SayHello 함수는 Greeter 인터페이스 타입의 매개변수를 받습니다.

이 함수는 전달된 값이 Person인지 Robot인지 전혀 알 필요가 없습니다.

단지 Greet() 메서드를 가지고 있다는 사실, 즉 Greeter 인터페이스를 만족한다는 사실만 중요합니다.

이처럼 인터페이스는 시스템의 각 컴포넌트 간의 결합도를 낮추고, 유연하고 확장 가능한 설계를 가능하게 합니다.

언제 무엇을 사용해야 할까

그렇다면 구조체 임베딩과 인터페이스는 언제 각각 사용해야 할까요?

'is-a' 관계와 'behaves-like' 관계를 기준으로 생각하면 명확합니다.

구조체 임베딩은 'is-a' 관계에 가깝습니다.

예를 들어, 'Employee는 Person이다'와 같이 한 타입이 다른 타입의 데이터와 행동을 구체적으로 포함하고 확장할 때 적합합니다.

구현을 재사용하는 것이 주 목적일 때 사용합니다.

인터페이스는 'behaves-like' 관계를 표현합니다.

예를 들어, 'Person과 Robot은 Greeter처럼 행동할 수 있다'와 같이 여러 타입이 공통된 '행동'이나 '역할'을 수행할 수 있음을 나타낼 때 사용합니다.

구현이 아닌 동작을 추상화하는 것이 목적일 때 빛을 발합니다.

마무리하며

Golang은 전통적인 상속을 제공하지 않지만, 이는 제한이 아니라 의도된 설계 철학의 결과입니다.

구조체 임베딩을 통한 컴포지션으로 코드 재사용성을 높이고, 인터페이스를 통한 다형성으로 유연하고 확장 가능한 시스템을 구축할 수 있습니다.

이러한 접근 방식은 복잡한 상속 계층 구조를 피하고, 코드를 더 단순하고 명확하며 유지보수하기 쉽게 만들어 줍니다.

Golang의 방식을 이해하고 나면, 이것이 얼마나 실용적이고 강력한 접근법인지 깨닫게 될 것입니다.