고 기초 #6 구조체와 메서드

6 분 소요

#5 컬렉션에서 데이터를 묶는 도구를 봤습니다. 이번엔 — 자기 의미를 가진 타입을 만드는 차례입니다. struct와 메서드.

Struct — 사용자 정의 타입 #

struct 기본
type User struct {
	ID   string
	Name string
	Age  int
}

func main() {
	u := User{
		ID:   "u1",
		Name: "커티스",
		Age:  30,
	}
	fmt.Println(u)         // {u1 커티스 30}
	fmt.Println(u.Name)    // 커티스
}

type 이름 struct { 필드들 }로 정의. 필드마다 타입을 적습니다.

만드는 법 — 여러 가지 #

struct 인스턴스
// 필드 이름 명시 (권장)
u1 := User{ID: "u1", Name: "커티스", Age: 30}

// 일부 필드만 (나머지는 zero value)
u2 := User{Name: "앨리스"}

// 위치 기반 (권장 안 함 — 필드 순서 바꾸면 깨짐)
u3 := User{"u3", "밥", 35}

// zero value
var u4 User    // {  0}

// new로 포인터
u5 := new(User)   // *User, 모든 필드 zero

필드 이름을 명시하는 형태가 표준 입니다. 위치 기반은 필드를 추가/재정렬할 때 깨지기 쉽습니다.

필드 접근 — 점 표기 #

필드 접근
u := User{Name: "커티스", Age: 30}
fmt.Println(u.Name)
u.Age = 31

포인터를 통해서도 같은 점 표기를 써요 — Go가 자동으로 역참조합니다.

포인터로 접근
p := &u
fmt.Println(p.Name)   // OK — (*p).Name이지만 자동 역참조
p.Age = 32             // OK

메서드 — 타입에 묶인 함수 #

메서드 기본
type User struct {
	Name string
	Age  int
}

func (u User) Greet() {
	fmt.Printf("안녕, %s (%d)\n", u.Name, u.Age)
}

func main() {
	u := User{Name: "커티스", Age: 30}
	u.Greet()    // 안녕, 커티스 (30)
}

func (u User) Greet()(u User) 부분이 리시버(receiver). 이 함수는 User 타입에 묶인 메서드입니다.

리시버 이름은 보통 타입의 첫 글자 소문자를 씁니다(u for User). this/self가 아니라 매번 명시적으로 이름을 적는 게 Go 컨벤션입니다.

값 리시버 vs 포인터 리시버 #

여기가 처음에 가장 헷갈리는 부분입니다.

값 리시버 — 복사본을 받음 #

값 리시버
func (u User) IncrementAge() {
	u.Age++             // 복사본의 Age가 증가 — 원본 안 바뀜
}

u := User{Age: 30}
u.IncrementAge()
fmt.Println(u.Age)   // 30 (변화 없음)

값 리시버는 호출 시점에 struct가 통째로 복사됩니다. 메서드 안에서 변경해도 원본은 그대로.

포인터 리시버 — 원본을 가리킴 #

포인터 리시버
func (u *User) IncrementAge() {
	u.Age++             // 원본의 Age 증가
}

u := User{Age: 30}
u.IncrementAge()
fmt.Println(u.Age)   // 31

*User 리시버는 포인터를 받아 — 메서드 안의 변경이 원본에 반영됩니다.

어떤 걸 쓰나? #

가이드 라인:

  1. 값을 변경해야 하면 → 포인터 리시버
  2. struct가 크면 → 포인터 리시버 (복사 비용 회피)
  3. 위 두 경우가 아니면 → 값 리시버

다만 실무에서 가장 자주 쓰는 가이드는 — 한 타입 안에서 일관성 유지입니다. 한 메서드는 포인터 리시버, 다른 메서드는 값 리시버 식으로 섞으면 어색하니, 보통 그 타입의 모든 메서드를 한 종류로 통일합니다.

Go의 자동 역참조/포인터 변환 #

호출하는 쪽에서는 두 가지를 거의 신경 안 씁니다.

자동 변환
u := User{Age: 30}
p := &u

u.PointerMethod()     // 자동으로 (&u).PointerMethod()
p.ValueMethod()       // 자동으로 (*p).ValueMethod()

호출자가 값이든 포인터든, Go 컴파일러가 알아서 변환해 줍니다. 다만 변경이 필요하면 호출자가 포인터를 가지고 있어야 합니다(슬라이스의 원소처럼 일시적인 위치라면 포인터로 안 잡힐 수 있음).

생성자 — New... 컨벤션 #

Go에는 클래스나 명시적 constructor가 없습니다. 대신 생성자 함수를 컨벤션으로 만듭니다.

New 함수 패턴
type User struct {
	ID   string
	Name string
	Age  int
}

func NewUser(name string, age int) *User {
	return &User{
		ID:   generateID(),
		Name: name,
		Age:  age,
	}
}

func main() {
	u := NewUser("커티스", 30)
	fmt.Println(u.Name)
}

NewUser, NewClient처럼 **New타입이름**이 표준 이름입니다. 보통 포인터를 반환합니다.

임베딩 — 합성으로 재사용 #

Go에는 클래스 상속이 없지만 — **임베딩(embedding)**으로 비슷한 효과를 낼 수 있습니다.

임베딩
type Animal struct {
	Name string
}

func (a Animal) Speak() {
	fmt.Println(a.Name, "가(이) 소리를 냅니다")
}

type Dog struct {
	Animal           // 임베딩 — 필드 이름 없이 타입만
	Breed string
}

func main() {
	d := Dog{
		Animal: Animal{Name: "보리"},
		Breed:  "시바",
	}
	d.Speak()        // Animal의 메서드를 직접 사용 가능
	fmt.Println(d.Name)   // Dog에 직접 Name 접근
}

Dog 안의 Animal이 임베딩 — 필드 이름 없이 타입만 적었습니다. 이러면:

  • Animal의 메서드(Speak)를 Dog에서 직접 부를 수 있음
  • Animal의 필드(Name)를 d.Name으로 직접 접근

상속이 아니라 **승격(promotion)**입니다 — 외부에서 보면 마치 Dog가 직접 그 메서드/필드를 가진 것처럼 동작합니다.

메서드 오버라이드 #

같은 이름의 메서드를 Dog에 정의하면 — Dog의 것이 승격된 메서드를 가립니다.

오버라이드
func (d Dog) Speak() {
	fmt.Println(d.Name, "가(이) 짖습니다 (멍멍)")
}

d := Dog{Animal: Animal{Name: "보리"}, Breed: "시바"}
d.Speak()    // 보리 가(이) 짖습니다 (멍멍)

// Animal의 메서드를 명시적으로 부르려면
d.Animal.Speak()

임베딩된 타입의 메서드는 d.Animal.Speak()처럼 직접 호출할 수도 있습니다. JS의 super.method와 비슷한 개념입니다.

임베딩 vs 상속 — 차이 #

전통적인 OOP의 상속과는 다음이 다릅니다.

  • Dog는 Animal을 가진 것 이지 Animal 인 것은 아님
  • 다중 임베딩 가능 (type X struct { A; B })
  • “is-a” 관계가 아니라 “has-a” + 메서드 승격

Go의 디자인 철학 중 하나가 — 상속보다 합성입니다. 깊은 클래스 계층을 만들 수 없게 일부러 막혀 있습니다.

Struct 비교 #

같은 타입의 두 struct는 모든 필드가 같으면 ==가 true.

struct 비교
u1 := User{Name: "커티스", Age: 30}
u2 := User{Name: "커티스", Age: 30}
u3 := User{Name: "앨리스", Age: 25}

u1 == u2   // true
u1 == u3   // false

다만 — 슬라이스/맵/함수를 필드로 가진 struct는 비교 불가 합니다(컴파일 에러). 이런 경우에는 reflect.DeepEqual 또는 자기만의 Equal 메서드.

무명 struct (anonymous struct) #

이름 없이 즉석에서 만드는 struct.

무명 struct
config := struct {
	Host string
	Port int
}{
	Host: "localhost",
	Port: 8080,
}

fmt.Println(config.Host, config.Port)

테스트 케이스나 임시 데이터 모음 용도에 어울립니다. 반복적으로 쓸 데이터라면 type으로 이름을 주는 게 좋습니다.

Tag — 메타데이터 #

struct 필드에 태그(메타데이터)를 붙일 수 있습니다. JSON 직렬화나 DB 매핑에서 핵심 도구입니다.

JSON 태그
type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Age  int    `json:"age,omitempty"`
}

encoding/json 패키지가 이 태그를 읽어 — Go 필드명(ID)과 JSON 키(id)를 매핑합니다. omitempty는 zero value 면 결과에서 빼라는 뜻.

중급 #7 표준 라이브러리 투어실전 #3 JSON에서 자세히 다룹니다.

자주 쓰는 패턴들 #

1) Builder 패턴 — 메서드 체이닝 #

builder 패턴
type Query struct {
	table  string
	limit  int
	where  []string
}

func NewQuery() *Query {
	return &Query{}
}

func (q *Query) Table(t string) *Query {
	q.table = t
	return q
}

func (q *Query) Where(w string) *Query {
	q.where = append(q.where, w)
	return q
}

func (q *Query) Limit(n int) *Query {
	q.limit = n
	return q
}

// 사용
q := NewQuery().
	Table("users").
	Where("active = true").
	Limit(10)

각 메서드가 자기 자신을 반환해서 체이닝이 가능. 라이브러리 API에서 자주 등장하는 모양입니다.

2) 옵션 패턴 — 함수형 옵션 #

생성자에 많은 옵션이 필요할 때.

functional options
type Server struct {
	host    string
	port    int
	timeout time.Duration
}

type Option func(*Server)

func WithHost(host string) Option {
	return func(s *Server) { s.host = host }
}

func WithPort(port int) Option {
	return func(s *Server) { s.port = port }
}

func WithTimeout(d time.Duration) Option {
	return func(s *Server) { s.timeout = d }
}

func NewServer(opts ...Option) *Server {
	s := &Server{
		host:    "localhost",
		port:    8080,
		timeout: 30 * time.Second,
	}
	for _, opt := range opts {
		opt(s)
	}
	return s
}

// 사용
srv := NewServer(
	WithPort(3000),
	WithTimeout(60 * time.Second),
)

옵션 함수들로 설정을 모아 받는 패턴. Go 라이브러리에서 표준에 가깝게 쓰입니다.

마무리 #

이번 글에서 정리한 내용:

  • type X struct { ... }으로 사용자 정의 타입
  • 인스턴스 — 필드 이름 명시가 권장
  • 메서드 — func (r 타입) Method() 형태
  • 값 리시버 vs 포인터 리시버 — 변경 필요/큰 struct → 포인터
  • 한 타입의 메서드는 한 종류로 통일하는 게 자연스러움
  • New... 컨벤션으로 생성자 흉내
  • 임베딩으로 합성 + 메서드 승격 — 상속 아님
  • 같은 타입 struct는 ==가능 (필드가 비교 가능할 때)
  • 태그로 메타데이터 — JSON/DB 매핑에 사용
  • builder 패턴, functional options 패턴

다음 글(#7 패키지와 모듈)에서는 시리즈 마지막으로 — 코드를 여러 파일/패키지로 나누고, go mod로 외부 의존성을 다루는 방법을 정리합니다.

X