Go 언어 구조체와 메서드, 임베딩 패턴 분석

Go 언어 구조체: struct, 메서드, 임베딩 패턴 완전 정복

Go 언어에는 **클래스(Class)**가 없습니다. 대신 **구조체(struct)**와 메서드(Method), 그리고 **인터페이스(Interface)**를 조합하여 객체지향 프로그래밍의 핵심 개념들을 구현합니다. 이는 Go 언어의 설계 철학인 “단순함과 명확함”을 반영한 선택입니다.

이 글에서는 struct의 기본 정의부터 시작하여, 메서드 정의와 리시버(Receiver) 패턴, 임베딩(Embedding)을 통한 구조체 합성, 그리고 **구조체 태그(Struct Tags)**를 활용한 JSON 변환까지 실무에서 반드시 알아야 할 모든 패턴을 심층적으로 다룹니다.

🛠️ 실무에서 struct의 중요성

필자가 웹 API 서버나 데이터 파이프라인을 구축할 때, struct는 피할 수 없는 핵심 도구입니다. 데이터베이스 모델, HTTP 요청/응답 구조, 비즈니스 로직의 도메인 객체 등 모든 곳에서 struct를 사용하게 됩니다. 특히 타입 안전성(Type Safety)을 보장하면서도 유연한 설계가 가능하다는 점이 Go struct의 가장 큰 강점입니다.


1. 구조체(struct)의 기본 정의: 새로운 타입 만들기

구조체는 서로 다른 타입의 데이터를 하나의 논리적 단위로 묶는 사용자 정의 타입입니다. 이전에 배운 type 키워드를 사용하여 정의합니다.

type Person struct {
    Name string
    Age  int
}

이 코드는 Person이라는 새로운 데이터 타입을 정의합니다. 이 타입은 Name(문자열)과 Age(정수) 두 개의 **필드(Field)**를 가집니다.

1-1. 구조체의 Zero Value

구조체 변수를 선언만 하고 초기화하지 않으면, 각 필드는 해당 타입의 Zero Value로 자동 초기화됩니다.

var p Person
fmt.Println(p) // 출력: { 0} — Name은 "" (빈 문자열), Age는 0

이는 Go 언어의 메모리 안전성을 보장하는 중요한 특징입니다. 초기화되지 않은 변수를 사용해도 Undefined Behavior가 발생하지 않습니다.


2. 구조체 초기화: 4가지 방법

구조체를 사용하려면 먼저 초기화해야 합니다. Go 언어는 상황에 맞는 다양한 초기화 방법을 제공합니다.

2-1. 기본 초기화 (Zero Value)

var p Person
// p.Name = "", p.Age = 0

모든 필드가 Zero Value로 초기화됩니다.

2-2. 필드를 명시한 초기화

p := Person{Name: "Alice", Age: 25}

가장 권장되는 방식입니다. 필드 이름을 명시하므로 가독성이 높고, 필드 순서가 바뀌어도 영향을 받지 않습니다.

// 순서를 바꿔도 동일한 결과
p := Person{Age: 25, Name: "Alice"}

2-3. 필드를 생략한 초기화

p := Person{"Alice", 25}

필드 이름을 생략하고 정의 순서대로 값을 나열합니다. 짧아 보이지만 실무에서는 권장되지 않습니다. 구조체 정의가 변경되면 모든 초기화 코드를 수정해야 하기 때문입니다.

2-4. new 키워드를 사용한 초기화

p := new(Person)
// p는 *Person 타입 (포인터)
// p.Name = "", p.Age = 0

new(Person)은 Zero Value로 초기화된 Person 구조체의 포인터를 반환합니다. 포인터가 필요한 경우에 사용합니다.

💡 포인터 vs 값

new를 사용하면 포인터를 얻지만, 리터럴 초기화에 &를 붙여도 동일한 효과를 얻을 수 있습니다.

p := &Person{Name: "Alice", Age: 25}
// p는 *Person 타입

3. 구조체 필드 접근: Dot 연산자와 자동 역참조

구조체의 필드는 . (dot) 연산자로 접근합니다.

p := Person{Name: "Alice", Age: 25}
fmt.Println(p.Name) // 출력: Alice

p.Age = 26
fmt.Println(p.Age)  // 출력: 26

3-1. 포인터에 대한 자동 역참조

Go 언어의 강력한 편의 기능 중 하나는 포인터에 대해서도 . 연산자를 사용할 수 있다는 점입니다.

p := new(Person)
p.Name = "Bob"  // (*p).Name = "Bob"와 동일
p.Age = 27

fmt.Println(p) // 출력: &{Bob 27}

C 언어였다면 p->Name 또는 (*p).Name처럼 명시적으로 역참조해야 하지만, Go 언어는 컴파일러가 자동으로 처리합니다.


4. 메서드(Method): 구조체에 동작 부여하기

Go 언어에서는 구조체에 메서드를 정의하여 해당 타입의 “행위(Behavior)“를 구현합니다. 메서드는 **리시버(Receiver)**를 가진 함수입니다.

type Person struct {
    Name string
    Age  int
}

// Person 타입의 메서드
func (p Person) Greet() string {
    return "안녕하세요, 제 이름은 " + p.Name + "입니다."
}

func main() {
    alice := Person{Name: "Alice", Age: 25}
    fmt.Println(alice.Greet()) // 출력: 안녕하세요, 제 이름은 Alice입니다.
}

(p Person) 부분이 **리시버(Receiver)**입니다. 이는 Greet 메서드가 Person 타입에 속한다는 것을 의미합니다.

4-1. 값 리시버(Value Receiver)

위 예시의 (p Person)처럼 구조체를 값으로 받는 리시버입니다. 메서드 내부에서 필드를 수정해도 원본에 영향을 주지 않습니다.

func (p Person) HaveBirthday() {
    p.Age++ // 복사본의 Age만 증가
}

func main() {
    alice := Person{Name: "Alice", Age: 25}
    alice.HaveBirthday()
    fmt.Println(alice.Age) // 출력: 25 (변경되지 않음)
}

4-2. 포인터 리시버(Pointer Receiver)

리시버를 포인터로 받으면 원본 구조체를 직접 수정할 수 있습니다.

func (p *Person) HaveBirthday() {
    p.Age++ // 원본의 Age를 증가
}

func main() {
    alice := Person{Name: "Alice", Age: 25}
    alice.HaveBirthday()
    fmt.Println(alice.Age) // 출력: 26 (변경됨)
}

🛠️ 실무 Best Practice

  1. 구조체 크기가 큰 경우: 포인터 리시버를 사용하여 복사 비용을 줄입니다.
  2. 필드를 수정해야 하는 경우: 반드시 포인터 리시버를 사용합니다.
  3. 일관성 유지: 한 타입의 모든 메서드는 가급적 같은 종류의 리시버를 사용합니다.

5. 임베딩(Embedding): 구조체 합성 패턴

Go 언어에는 상속(Inheritance)이 없지만, **임베딩(Embedding)**을 통해 구조체를 합성(Composition)할 수 있습니다.

type Address struct {
    City    string
    Country string
}

type Employee struct {
    Name    string
    Age     int
    Address // 필드 이름 없이 타입만 명시 (임베딩)
}

func main() {
    emp := Employee{
        Name: "Alice",
        Age:  30,
        Address: Address{
            City:    "Seoul",
            Country: "Korea",
        },
    }

    // 임베딩된 필드에 직접 접근 가능
    fmt.Println(emp.City)    // 출력: Seoul
    fmt.Println(emp.Country) // 출력: Korea
}

AddressEmployee 안에 이름 없이 포함되어 있습니다. 이를 익명 필드(Anonymous Field) 또는 임베딩이라고 합니다.

5-1. 메서드 승격(Method Promotion)

임베딩된 타입의 메서드도 외부 타입에서 직접 호출할 수 있습니다.

type Address struct {
    City    string
    Country string
}

func (a Address) FullAddress() string {
    return a.City + ", " + a.Country
}

type Employee struct {
    Name    string
    Address
}

func main() {
    emp := Employee{
        Name:    "Alice",
        Address: Address{City: "Seoul", Country: "Korea"},
    }

    // Address의 메서드를 Employee에서 직접 호출
    fmt.Println(emp.FullAddress()) // 출력: Seoul, Korea
}

이는 Go 언어의 구조체 합성(Composition over Inheritance) 철학을 구현하는 핵심 메커니즘입니다.


6. 구조체 태그(Struct Tags): 메타데이터 정의

구조체 필드 뒤에 백틱(`)으로 감싼 문자열을 추가하여 메타데이터를 정의할 수 있습니다. 이를 **구조체 태그(Struct Tag)**라고 합니다.

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

6-1. JSON 직렬화/역직렬화

구조체 태그는 주로 encoding/json 패키지와 함께 사용되어 JSON 필드 이름을 지정합니다.

package main

import (
    "encoding/json"
    "fmt"
)

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {
    // 구조체 → JSON
    p := Person{Name: "Alice", Age: 25}
    jsonData, _ := json.Marshal(p)
    fmt.Println(string(jsonData)) // 출력: {"name":"Alice","age":25}

    // JSON → 구조체
    jsonStr := `{"name":"Bob","age":30}`
    var p2 Person
    json.Unmarshal([]byte(jsonStr), &p2)
    fmt.Println(p2) // 출력: {Bob 30}
}

6-2. 고급 태그 옵션

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name,omitempty"`     // 값이 Zero Value면 JSON에서 생략
    Password string `json:"-"`                  // JSON 변환에서 제외
    Email    string `json:"email_address"`      // 다른 이름으로 매핑
}
태그 옵션의미
omitemptyZero Value일 경우 JSON에서 필드 생략
-JSON 변환에서 완전히 제외
"이름"JSON 필드 이름을 구조체 필드 이름과 다르게 지정

🛠️ 실무 팁

API 응답 구조체에서 omitempty를 사용하면 불필요한 필드 전송을 줄여 네트워크 비용을 절감할 수 있습니다. 특히 선택적 필드가 많은 경우 유용합니다.


7. 구조체 비교 가능성(Comparability)

구조체는 모든 필드가 비교 가능한 타입일 경우에만 ==!= 연산자로 비교할 수 있습니다.

type Point struct {
    X, Y int
}

p1 := Point{X: 1, Y: 2}
p2 := Point{X: 1, Y: 2}

fmt.Println(p1 == p2) // 출력: true

7-1. 비교 불가능한 경우

슬라이스, 맵, 함수 타입 필드를 포함하면 비교 불가능합니다.

type Container struct {
    Items []int // 슬라이스는 비교 불가
}

c1 := Container{Items: []int{1, 2}}
c2 := Container{Items: []int{1, 2}}

// fmt.Println(c1 == c2) // ❌ 컴파일 에러: invalid operation

이 경우 reflect.DeepEqual을 사용하거나, 직접 비교 로직을 구현해야 합니다.


8. 실무 패턴과 Best Practices

8-1. 생성자 함수 패턴

Go 언어에는 생성자(Constructor)가 없지만, 관례적으로 New 접두사를 가진 함수를 정의합니다.

type Config struct {
    Host string
    Port int
}

// 생성자 함수
func NewConfig(host string, port int) *Config {
    return &Config{
        Host: host,
        Port: port,
    }
}

func main() {
    cfg := NewConfig("localhost", 8080)
    fmt.Println(cfg)
}

8-2. 옵션 패턴 (Functional Options)

많은 선택적 파라미터가 있을 때 사용하는 고급 패턴입니다.

type Server struct {
    Host    string
    Port    int
    Timeout int
}

type Option func(*Server)

func WithTimeout(t int) Option {
    return func(s *Server) {
        s.Timeout = t
    }
}

func NewServer(host string, port int, opts ...Option) *Server {
    s := &Server{
        Host: host,
        Port: port,
        Timeout: 30, // 기본값
    }

    for _, opt := range opts {
        opt(s)
    }

    return s
}

func main() {
    srv := NewServer("localhost", 8080, WithTimeout(60))
    fmt.Println(srv) // 출력: &{localhost 8080 60}
}

8-3. 빈 구조체(Empty Struct)의 활용

struct{}메모리를 전혀 차지하지 않는 특수한 타입입니다. 주로 시그널링 용도로 사용됩니다.

done := make(chan struct{}) // 메모리 효율적인 채널

go func() {
    // 작업 수행...
    done <- struct{}{} // 완료 신호
}()

<-done // 완료 대기

마치며: struct는 Go 언어 설계의 핵심

Go 언어에서 구조체는 단순한 “데이터 묶음”을 넘어, 타입 시스템의 중심이자 객체지향 설계의 기반입니다. 클래스가 없는 대신, 구조체와 메서드, 인터페이스, 임베딩을 조합하여 더 명확하고 유연한 설계가 가능합니다.

오늘 배운 핵심을 요약하면:

  1. 구조체는 type 키워드로 정의하며, 필드들은 각 타입의 Zero Value로 자동 초기화된다.
  2. 메서드는 리시버를 통해 구조체에 연결되며, 값 리시버와 포인터 리시버의 차이를 이해해야 한다.
  3. 임베딩은 상속 없이 구조체를 합성하는 Go만의 강력한 패턴이다.
  4. 구조체 태그는 JSON 변환 등 메타데이터 정의에 필수적이다.
  5. 생성자 함수, 옵션 패턴 등 실무 관용구를 익혀 견고한 코드를 작성한다.

다음 글에서는 Go 언어의 **인터페이스(Interface)**를 다루며, 구조체와 메서드를 넘어 **다형성(Polymorphism)**과 **추상화(Abstraction)**를 구현하는 방법을 알아보겠습니다. Go가 어떻게 덕 타이핑(Duck Typing)과 정적 타입 시스템을 결합하는지 확인해보세요!