Go

Go의 Structs와 Interfaces, 객체지향 프로그래밍을 넘어서

드리프트2 2025. 3. 19. 22:11

Go의 Structs와 Interfaces, 객체지향 프로그래밍을 넘어서

1. 역사적 맥락: Go는 왜 OOP가 아닌가요?

 

Go는 2007년 Google에서 Rob Pike, Robert Griesemer, Ken Thompson에 의해 설계되었습니다.

C++의 복잡성과 Java의 본문 부담, 그리고 시스템 프로그래밍에서 필요한 안전성과 성능을 제공하지 못하는 동적 언어의 유연함과 같은 기존 언어의 고질적인 문제점에서 출발했습니다.

Go의 설계자들은 이를 해결하기 위해 전통적인 객체지향 프로그래밍 패러다임, 특히 상속을 피하기로 결정했습니다.

Go는 다음을 통해 이 문제를 해결합니다:

  • 구성보다 상속: 간단한 타입을 결합하여 복잡한 타입을 구축
  • 인터페이스를 통한 추상화: 구현을 지정하지 않고 행동을 정의
  • 암묵적 인터페이스 충족: 타입이 인터페이스를 구현한다고 선언할 필요가 없음

이러한 접근 방식은 "duck typing" 원칙을 기반으로 하지만 Go의 정적 타입 안전성과 결합되어 있습니다.

2. Structs: Go의 빌딩 블록

 

Defining and Creating Structs

Go에서 structs는 변수를 하나의 이름 아래 그룹화하는 복합 데이터 타입입니다.

이는 일반적인 OOP 언어의 "클래스"와 유사하지만 상속이 없습니다.

type Person struct {
    FirstName string
    LastName  string
    Age       int
}

func main() {
    p1 := Person{"John", "Doe", 30}
    p2 := Person{FirstName: "Jane", LastName: "Doe", Age: 28}
    var p3 Person
    p3.FirstName = "Jim"
    p3.LastName = "Smith"
    p3.Age = 35

    p4 := new(Person)
    p4.FirstName = "Alice"
}

 

Go에서는 일반적으로 생성자 함수를 사용하여 structs를 초기화하는 것이 좋습니다.

이는 Go 표준 라이브러리에서 널리 사용되며 Go에서 structs를 다룰 때의 최상의 관행으로 간주됩니다.

func NewPerson(firstName, lastName string, age int) Person {
    return Person{
        FirstName: firstName,
        LastName: lastName,
        Age: age,
    }
}

 

Struct Fields and Methods

Go에서 메서드는 특정 타입과 연관된 함수입니다.

메서드에는 func 키워드와 메서드 이름 사이에 "수신자"가 있습니다.

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

func main() {
    r := Rectangle{Width: 10, Height: 5}
    fmt.Println("Area:", r.Area())
    r.Scale(2)
    fmt.Println("After scaling - Width:", r.Width, "Height:", r.Height)
    fmt.Println("New area:", r.Area())
}

 

3. Embedding and Composition

 

Go는 상속 대신 embedding을 지원하여 구성을 통해 복잡한 타입을 구축할 수 있습니다.

이는 하나의 struct 타입을 다른 struct 내에 포함하는 것을 의미합니다.

type Address struct {
    Street  string
    City    string
    Country string
}

type Employee struct {
    Person   
    Address  
    Position string
    Salary   float64
}

func main() {
    emp := Employee{
        Person: Person{
            FirstName: "John",
            LastName:  "Smith",
            Age:       35,
        },
        Address: Address{
            Street:  "123 Main St",
            City:    "Boston",
            Country: "USA",
        },
        Position: "Software Engineer",
        Salary:   90000,
    }

    fmt.Println(emp.FirstName)
    fmt.Println(emp.Street)
    fmt.Println(emp.Person.LastName)
    fmt.Println(emp.Address.City)
}

 

4. Interfaces: 행동보다 상속

 

Interface Basics

Go에서 인터페이스는 메서드 시그니처의 집합을 정의합니다.

어떤 타입이 인터페이스에 정의된 모든 메서드를 구현하면 그 타입은 그 인터페이스를 충족합니다.

Go에서는 명시적으로 선언하지 않아도 됩니다.

type Geometry interface {
    Area() float64
    Perimeter() float64
}

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2*r.Width + 2*r.Height
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

func measure(g Geometry) {
    fmt.Printf("Area: %.2f\n", g.Area())
    fmt.Printf("Perimeter: %.2f\n", g.Perimeter())
}

func main() {
    r := Rectangle{Width: 3, Height: 4}
    c := Circle{Radius: 5}

    measure(r)
    measure(c)
}

 

Implicit Implementation

Go의 가장 독특한 특징 중 하나는 암묵적 인터페이스 구현입니다.

타입은 요구되는 모든 메서드를 구현하면 자동으로 인터페이스를 충족합니다.

이는 Go의 디자인에 깊이 뿌리내린 장점을 제공합니다.

5. 실용적 패턴

 

Error Handling with Interfaces

Go의 error 인터페이스는 인터페이스의 가장 일반적인 응용 중 하나입니다:

type error interface {
    Error() string
}

type NotFoundError struct {
    Resource string
}

func (e NotFoundError) Error() string {
    return fmt.Sprintf("%s not found", e.Resource)
}

type PermissionError struct {
    Resource string
    User     string
}

func (e PermissionError) Error() string {
    return fmt.Sprintf("user %s does not have permission to access %s", e.User, e.Resource)
}

 

Testing with Interfaces

인터페이스는 테스트에서도 뛰어납니다.

의존성을 쉽게 가짜로 만들 수 있습니다:

type Database interface {
    GetUser(id int) (string, error)
    SaveUser(id int, name string) error
}

type UserService struct {
    db Database
}

func (s *UserService) RenameUser(id int, newName string) error {
    name, err := s.db.GetUser(id)
    if err != nil {
        return err
    }

    if name == newName {
        return nil 
    }

    return s.db.SaveUser(id, newName)
}

type MockDB struct {
    users map[int]string
}

func (m *MockDB) GetUser(id int) (string, error) {
    name, exists := m.users[id]
    if !exists {
        return "", NotFoundError{Resource: "user"}
    }
    return name, nil
}

func (m *MockDB) SaveUser(id int, name string) error {
    m.users[id] = name
    return nil
}

func TestRenameUser(t *testing.T) {
    mockDB := &MockDB{
        users: map[int]string{1: "Alice"},
    }

    service := UserService{db: mockDB}

    err := service.RenameUser(1, "Alicia")
    if err != nil {
        t.Errorf("Expected no error, got %v", err)
    }
    if mockDB.users[1] != "Alicia" {
        t.Errorf("Expected name to be Alicia, got %s", mockDB.users[1])
    }

    err = service.RenameUser(999, "Nobody")
    if _, ok := err.(NotFoundError); !ok {
        t.Errorf("Expected NotFoundError, got %v", err)
    }
}

 

결론

 

Go의 structs와 인터페이스에 대한 접근 방식은 전통적인 객체지향 프로그래밍과는 다른 새로운 방식을 제공합니다.

구성과 행동에 중점을 두고 타입 계층 구조를 피함으로써 Go는 더 유연하고 느슨하게 결합된 디자인을 장려합니다.

Go의 설계 선택은 재미있게 다르기 위해 하는 것이 아니라, 대규모 소프트웨어 시스템을 구축하는 데 도움이 되는 교훈에서 나온 것입니다.

이러한 패턴을 이해하고 수용함으로써, 단순히 기능적인 Go 코드뿐만 아니라 우아하고 견고한 Go 코드를 작성할 수 있습니다.