고 기초 #6 구조체와 메서드
#5 컬렉션에서 데이터를 묶는 도구를 봤습니다. 이번엔 — 자기 의미를 가진 타입을 만드는 차례입니다. 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 { 필드들 }로 정의. 필드마다 타입을 적습니다.
만드는 법 — 여러 가지 #
// 필드 이름 명시 (권장)
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 리시버는 포인터를 받아 — 메서드 안의 변경이 원본에 반영됩니다.
어떤 걸 쓰나? #
가이드 라인:
- 값을 변경해야 하면 → 포인터 리시버
- struct가 크면 → 포인터 리시버 (복사 비용 회피)
- 위 두 경우가 아니면 → 값 리시버
다만 실무에서 가장 자주 쓰는 가이드는 — 한 타입 안에서 일관성 유지입니다. 한 메서드는 포인터 리시버, 다른 메서드는 값 리시버 식으로 섞으면 어색하니, 보통 그 타입의 모든 메서드를 한 종류로 통일합니다.
Go의 자동 역참조/포인터 변환 #
호출하는 쪽에서는 두 가지를 거의 신경 안 씁니다.
u := User{Age: 30}
p := &u
u.PointerMethod() // 자동으로 (&u).PointerMethod()
p.ValueMethod() // 자동으로 (*p).ValueMethod()호출자가 값이든 포인터든, Go 컴파일러가 알아서 변환해 줍니다. 다만 변경이 필요하면 호출자가 포인터를 가지고 있어야 합니다(슬라이스의 원소처럼 일시적인 위치라면 포인터로 안 잡힐 수 있음).
생성자 — New... 컨벤션
#
Go에는 클래스나 명시적 constructor가 없습니다. 대신 생성자 함수를 컨벤션으로 만듭니다.
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.
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.
config := struct {
Host string
Port int
}{
Host: "localhost",
Port: 8080,
}
fmt.Println(config.Host, config.Port)테스트 케이스나 임시 데이터 모음 용도에 어울립니다. 반복적으로 쓸 데이터라면 type으로 이름을 주는 게 좋습니다.
Tag — 메타데이터 #
struct 필드에 태그(메타데이터)를 붙일 수 있습니다. JSON 직렬화나 DB 매핑에서 핵심 도구입니다.
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 패턴 — 메서드 체이닝 #
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) 옵션 패턴 — 함수형 옵션 #
생성자에 많은 옵션이 필요할 때.
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로 외부 의존성을 다루는 방법을 정리합니다.