Skip to content
ioob.dev
Go back

Go 입문 6편 — 구조체와 메서드

· 4분 읽기

Table of contents

struct 선언

Go에는 클래스가 없다. 대신 구조체(struct)로 데이터를 묶고, 거기에 메서드를 붙이는 방식으로 객체지향을 구현한다.

package main

import "fmt"

type User struct {
    Name  string
    Email string
    Age   int
}

func main() {
    // 필드 이름 지정
    u1 := User{
        Name:  "Alice",
        Email: "alice@example.com",
        Age:   30,
    }

    // 필드 순서대로 (비권장)
    u2 := User{"Bob", "bob@example.com", 25}

    fmt.Println(u1)       // {Alice alice@example.com 30}
    fmt.Println(u2.Name)  // Bob
}

필드 이름을 명시하는 방식을 쓰는 게 좋다. 나중에 필드가 추가되면 순서 기반 초기화는 깨지기 때문이다.

초기화하지 않은 필드는 zero value가 들어간다. 이건 1편에서 다룬 Go의 철학과 일맥상통한다.

package main

import "fmt"

type Config struct {
    Host    string
    Port    int
    Debug   bool
}

func main() {
    c := Config{Host: "localhost"}
    fmt.Println(c)  // {localhost 0 false}
    // Port는 0, Debug는 false
}

메서드 — 함수에 리시버를 붙이다

Go에서 메서드는 특정 타입에 붙는 함수다. 함수 이름 앞에 리시버(receiver)를 선언한다.

package main

import "fmt"

type Rectangle struct {
    Width  float64
    Height float64
}

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

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

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    fmt.Println("넓이:", rect.Area())       // 넓이: 50
    fmt.Println("둘레:", rect.Perimeter())  // 둘레: 30
}

func (r Rectangle)에서 r이 리시버다. Java의 this와 비슷한 역할을 하되, 이름을 직접 지정하는 것이다. 관례적으로 타입 이름의 첫 글자를 소문자로 쓴다. Rectangle이면 r, Useru 같은 식이다.

포인터 리시버 vs 값 리시버

위 예제에서 (r Rectangle)값 리시버다. 메서드가 호출될 때 구조체가 복사된다. 원본을 수정하고 싶으면 포인터 리시버를 써야 한다.

package main

import "fmt"

type Counter struct {
    Value int
}

// 값 리시버 — 원본을 바꿀 수 없다
func (c Counter) Display() {
    fmt.Println("현재:", c.Value)
}

// 포인터 리시버 — 원본을 바꾼다
func (c *Counter) Increment() {
    c.Value++
}

func main() {
    counter := Counter{Value: 0}
    counter.Increment()
    counter.Increment()
    counter.Display()  // 현재: 2
}

Increment의 리시버가 *Counter인 점에 주목하자. c.Value++는 원본 구조체의 Value를 직접 바꾼다. 값 리시버였다면 복사본이 바뀌고 원본은 그대로였을 것이다.

언제 뭘 쓸지 기준은 명확하다.

하나의 타입에서 포인터 리시버와 값 리시버를 섞어 쓰는 건 권장되지 않는다. 일관성을 위해 한쪽으로 통일하는 게 관례인데, 대부분의 경우 포인터 리시버로 통일한다. 구조체가 커질 가능성을 미리 대비하는 셈이다.

임베딩 — 상속 없는 재사용

Go에는 상속이 없다. 대신 임베딩(embedding)으로 타입을 조합한다.

package main

import "fmt"

type Address struct {
    City    string
    ZipCode string
}

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

type Employee struct {
    Name string
    Address  // 임베딩 — 필드 이름 없이 타입만 적는다
}

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

    // 임베딩된 필드에 직접 접근 가능
    fmt.Println(emp.City)           // Seoul
    fmt.Println(emp.FullAddress())  // Seoul 06234

    // 물론 이렇게도 된다
    fmt.Println(emp.Address.City)   // Seoul
}

EmployeeAddress를 이름 없이 넣으면, Address의 필드와 메서드가 마치 Employee의 것처럼 접근 가능해진다. 상속이 아니라 위임(delegation)에 가깝다.

이 구분이 중요하다. 상속에서는 “Employee는 Address이다”라는 관계가 성립하지만, 임베딩에서는 “Employee가 Address를 가지고 있다”가 맞다. Go는 “is-a” 대신 “has-a” 관계를 선호하며, 이것이 코드 재사용에 더 유연하다는 입장이다.

여러 타입을 동시에 임베딩할 수도 있다.

type Logger struct{}
func (l Logger) Log(msg string) { fmt.Println("[LOG]", msg) }

type Validator struct{}
func (v Validator) Validate(s string) bool { return len(s) > 0 }

type Service struct {
    Logger
    Validator
}

ServiceLogValidate 메서드를 모두 사용할 수 있다. 필요한 기능을 조합해서 새로운 타입을 만드는 이 패턴은 Go 프로그래밍의 핵심 스타일이다.

생성자 패턴 — NewXxx

Go에는 생성자 문법이 없다. 대신 New로 시작하는 팩토리 함수를 만드는 것이 관례다.

package main

import (
    "errors"
    "fmt"
)

type Server struct {
    host string
    port int
}

func NewServer(host string, port int) (*Server, error) {
    if host == "" {
        return nil, errors.New("host는 비어있을 수 없다")
    }
    if port < 1 || port > 65535 {
        return nil, errors.New("port는 1~65535 범위여야 한다")
    }
    return &Server{host: host, port: port}, nil
}

func (s *Server) Address() string {
    return fmt.Sprintf("%s:%d", s.host, s.port)
}

func main() {
    srv, err := NewServer("localhost", 8080)
    if err != nil {
        fmt.Println("생성 실패:", err)
        return
    }
    fmt.Println(srv.Address())  // localhost:8080
}

NewServer가 포인터 *Server를 반환하는 점에 주목하자. 검증 로직을 넣을 수 있고, 에러도 반환할 수 있다. Java의 생성자보다 유연한 이유다.

필드가 소문자(host, port)로 시작하면 패키지 외부에서 직접 접근할 수 없다. Go에서 대문자로 시작하면 공개(exported), 소문자면 비공개(unexported)다. 생성자 함수를 통해서만 값을 설정하도록 강제할 수 있으니, 캡슐화를 자연스럽게 구현하는 셈이다.

구조체 태그

구조체 필드에 메타데이터를 붙일 수 있다. 이걸 태그라고 한다.

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age,omitempty"`
}

func main() {
    u := User{
        Name:  "Alice",
        Email: "alice@example.com",
    }

    data, _ := json.Marshal(u)
    fmt.Println(string(data))
    // {"name":"Alice","email":"alice@example.com"}
    // Age가 0이라 omitempty에 의해 생략됨
}

태그는 백틱(`)으로 감싸며, key:"value" 형식이다. encoding/json, encoding/xml, ORM 라이브러리 등이 이 태그를 읽어서 동작을 결정한다.

자주 쓰이는 태그 패턴을 몇 가지 정리하면 이렇다.

type Product struct {
    ID    int    `json:"id" db:"product_id"`
    Name  string `json:"name" validate:"required"`
    Price int    `json:"price" validate:"min=0"`
    Note  string `json:"-"`  // JSON 직렬화에서 제외
}

json:"-"는 해당 필드를 직렬화에서 완전히 제외한다. 내부용 필드를 외부에 노출하고 싶지 않을 때 쓴다. db 태그는 데이터베이스 컬럼 이름과 매핑할 때, validate 태그는 검증 라이브러리에서 사용한다.

태그는 리플렉션으로 읽히기 때문에 런타임에 동작한다. 오타가 있어도 컴파일 에러가 나지 않으니, go vet 같은 도구로 검사하는 습관을 들이면 좋다.


여기까지가 Go 입문 시리즈다. 변수와 타입에서 시작해서 구조체와 메서드까지, Go의 기본 문법을 한 바퀴 돌았다. 이 정도면 간단한 Go 프로그램을 읽고 쓸 수 있는 기초 체력이 갖춰진 셈이다.

Go는 문법이 적은 대신 관용적 패턴이 중요한 언어다. if err != nil, 네이밍 컨벤션, 인터페이스 활용 같은 것들은 코드를 많이 읽으면서 자연스럽게 체화된다. 공식 문서의 Effective Go가 그 출발점으로 좋다.


Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 7편 — 인터페이스
Next Post
Go 입문 5편 — 배열, 슬라이스, 맵