Go · 기본
작고 명확한 Go 프로그램
구조체와 메서드 - 값 리시버와 포인터 리시버
구조체 리터럴, 임베딩, 메서드 리시버 선택 기준, 생성 함수 관례
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 슬라이스와 맵으로 값을 여러 개 모으는 방법을 익혔다. 이번 장에서는 서로 관련 있는 값을 하나의 이름 아래 묶는 구조체(struct)와, 그 묶음에 동작을 붙이는 메서드(method)를 다룬다. 자전거 대여소 프로그램에서 "자전거", "대여소", "정산 장부"가 각각 하나의 구조체가 된다. Go에는 클래스가 없다. 구조체와 메서드, 그리고 다음 장의 인터페이스가 그 자리를 채운다.
- 구조체를 정의하고 필드 이름을 붙인 리터럴로 값을 만든다.
- 값 리시버와 포인터 리시버가 어떻게 다르게 동작하는지 설명하고, 둘 중 하나를 고르는 기준을 적용한다.
- 구조체 임베딩(embedding)으로 다른 구조체의 필드와 메서드를 끌어다 쓴다.
NewXxx형태의 생성 함수를 언제, 어떻게 만드는지 안다.
문제 상황
대여소 프로그램을 만든다고 하자. 자전거 번호, 대여 여부, 대여소 이름, 대여소 주소를 각각 변수로 들고 다니면 함수 인자가 금방 늘어난다. rent(stationName, district, street, bikeID, bikes) 같은 시그니처가 생기고, 인자 순서를 바꿔 넘겨도 컴파일러는 문자열끼리라서 잡아 주지 않는다.
더 곤란한 경우는 상태를 바꾸는 함수다. 정산 장부에 요금을 더하는 함수를 만들었는데 호출한 뒤에도 합계가 0이다. 원인은 대부분 값이 복사되었다는 데 있다. 이 장은 그 복사가 언제 일어나고 어떻게 피하는지를 중심에 둔다.
구조체와 리터럴
구조체는 이름 붙은 필드(field)를 모아 놓은 타입이다. 대여소의 주소와 자전거를 이렇게 정의한다.
type Address struct {
District string
Street string
}
type Bike struct {
ID string
Rented bool
}
값을 만드는 식을 리터럴(literal)이라 한다. 필드 이름을 적어서 만드는 방식을 기본으로 삼는다.
a := Address{District: "마포구", Street: "월드컵로 12"}
b := Bike{ID: "B-01"}
p := &Bike{ID: "B-02"}
Bike{ID: "B-01"}에서 적지 않은 Rented는 제로값인 false가 된다. 제로값은 앞에서 다룬 대로 타입마다 정해진 초깃값이다. 세 번째 줄의 &는 값이 놓인 자리의 주소를 얻는 연산자로, p는 Bike 자체가 아니라 Bike를 가리키는 포인터(pointer) 타입 *Bike다. 필드는 p.ID처럼 점으로 읽는다. 포인터여도 점 하나로 접근할 수 있다.
필드 이름 없이 Address{"마포구", "월드컵로 12"}처럼 순서로만 적는 방식도 문법상 가능하다. 하지만 필드가 추가되거나 순서가 바뀌면 컴파일은 되면서 값이 엉뚱한 필드로 들어갈 수 있다. 필드가 하나뿐인 아주 작은 타입이 아니면 이름을 적는다.
구조체 값은 대입하거나 함수에 넘길 때 통째로 복사된다. 복사본을 고쳐도 원본은 변하지 않는다. 앞으로 나올 모든 내용이 이 사실에서 출발한다.
메서드와 리시버
메서드 선언
메서드는 특정 타입에 붙는 함수다. func 다음에 괄호로 리시버(receiver)를 적는다. 리시버는 메서드가 호출될 때 "누구에게 호출했는가"를 받는 매개변수다.
func (b Bike) Status() string {
if b.Rented {
return "대여 중"
}
return "대기"
}
이 메서드는 bike.Status()로 호출한다. 리시버 변수 이름은 타입 이름의 첫 글자 한두 자를 소문자로 쓰는 것이 관례다. self나 this는 쓰지 않는다.
값 리시버와 포인터 리시버
리시버를 (b Bike)로 쓰면 값 리시버, (b *Bike)로 쓰면 포인터 리시버다. 값 리시버는 호출할 때 값을 복사해서 메서드에 넘긴다. 포인터 리시버는 원본의 주소를 넘기므로 메서드 안에서 원본을 바꿀 수 있다.
호출하는 쪽에서는 차이가 잘 보이지 않는다. ledger.Record(r)에서 ledger가 변수이면 Go가 알아서 (&ledger).Record(r)로 바꿔 준다. 그래서 선언 쪽을 보아야 복사인지 아닌지 알 수 있다.
어느 쪽을 고를까
| 상황 | 선택 | 이유 | 예 |
|---|---|---|---|
| 메서드가 필드를 바꾼다 | 포인터 | 복사본을 바꾸면 결과가 사라진다 | Station.Rent |
| 값이 커서 복사 비용이 부담된다 | 포인터 | 주소 하나만 넘긴다 | 필드가 많은 Station |
| 내부에 잠금 같은 복사하면 안 되는 값이 있다 | 포인터 | 복사하면 의미가 깨진다 | 뒤 장의 동기화 도구 |
| 작고 읽기만 하며 값처럼 쓰인다 | 값 | 안전하고 호출이 단순하다 | Ride.Fare, Bike.Status |
한 타입의 메서드는 한 종류로 통일한다. 하나라도 필드를 바꿔서 포인터 리시버가 필요하면 나머지 메서드도 포인터로 맞춘다. 읽기 전용 메서드까지 포인터로 써도 손해가 없고, 두 종류가 섞여 있으면 어떤 메서드가 원본을 건드리는지 읽는 사람이 매번 확인해야 한다. 이 통일 규칙이 다음 장에서 한 번 더 중요해진다.
슬라이스 필드는 조금 다르게 보인다. 구조체를 복사하면 슬라이스 헤더만 복사되어 같은 배열을 가리킨다. 그래서 복사본에서 요소를 고치면 원본에도 보이지만, append로 길이를 늘린 결과는 원본에 반영되지 않는다. 이런 헷갈림을 피하려는 것이 통일 규칙의 목적이다.
임베딩과 생성 함수
임베딩
구조체 안에 필드 이름 없이 타입만 적으면 그 타입이 임베딩된다. 대여소는 주소를 갖고 있으므로 이렇게 쓸 수 있다.
type Station struct {
Address
Name string
bikes []Bike
}
이제 Address의 필드와 메서드가 Station에서 바로 보인다. st.District는 st.Address.District의 줄임 표기이고, st.Label()은 Address에 정의한 메서드를 호출한다. 이를 승격(promotion)이라 부른다.
임베딩은 상속이 아니다. Station은 Address가 아니라 Address를 가진 타입이다. Address를 받는 함수에 st를 그대로 넘기면 컴파일 오류가 나고, show(st.Address)처럼 안에 든 값을 꺼내 넘겨야 한다.
바깥 타입이 같은 이름의 메서드를 직접 정의하면 바깥 것이 우선한다. 안쪽 것은 st.Address.Label()로 여전히 부를 수 있다. 이름이 가려지는 규칙은 단순하지만, 의도하지 않은 가림이 생기지 않도록 임베딩은 "이 타입이 저 타입의 모든 것을 그대로 노출해도 좋다"고 판단될 때만 쓴다. 조금이라도 망설여지면 Location Address처럼 이름 있는 필드로 두는 편이 낫다.
생성 함수 관례
Go에는 생성자 문법이 없다. 대신 New로 시작하는 평범한 함수를 만든다. 타입이 Station이면 NewStation이다. 초기화가 필요한 타입은 이 함수를 통해 만들게 하고, 보통 포인터를 돌려준다.
func NewStation(name string, addr Address, ids ...string) *Station {
s := &Station{Address: addr, Name: name}
for _, id := range ids {
s.bikes = append(s.bikes, Bike{ID: id})
}
return s
}
ids ...string은 문자열을 개수 제한 없이 받는 가변 인자이며, 함수 안에서는 []string이다. 생성 함수가 필요한 경우는 맵을 make로 만들어야 하거나, 필드 사이의 일관성을 확인해야 하거나, 기본값을 채워야 할 때다. 반대로 제로값 그대로 쓸 수 있게 설계된 타입은 생성 함수가 필요 없다. 이 장의 Ledger가 그런 예로, 합계와 건수가 모두 0에서 시작하므로 var ledger Ledger만으로 바로 쓸 수 있다.
| 방식 | 쓰는 때 | 예 |
|---|---|---|
| 제로값 그대로 사용 | 0, 빈 문자열이 유효한 시작 상태일 때 | var ledger Ledger |
| 리터럴 직접 작성 | 필드가 적고 초기화 규칙이 없을 때 | Bike{ID: "B-01"} |
| NewXxx 생성 함수 | 맵 생성, 검증, 기본값이 필요할 때 | NewStation(...) |
완성 코드
아래 프로그램은 대여소를 만들고, 자전거를 빌리고 반납하고, 요금을 정산한다. 파일 이름은 main.go다.
package main
import "fmt"
// Address 는 대여소의 위치다.
type Address struct {
District string
Street string
}
// Label 은 주소를 한 줄로 만든다.
func (a Address) Label() string {
return a.District + " " + a.Street
}
// Bike 는 자전거 한 대다.
type Bike struct {
ID string
Rented bool
}
// Status 는 자전거 상태를 글로 돌려준다.
func (b Bike) Status() string {
if b.Rented {
return "대여 중"
}
return "대기"
}
// Station 은 자전거를 보관하는 대여소다.
type Station struct {
Address
Name string
bikes []Bike
}
// NewStation 은 자전거 번호를 받아 대여소를 만든다.
func NewStation(name string, addr Address, ids ...string) *Station {
s := &Station{Address: addr, Name: name}
for _, id := range ids {
s.bikes = append(s.bikes, Bike{ID: id})
}
return s
}
// setRented 는 번호가 id 이고 상태가 want 와 다른 자전거를 want 로 바꾼다.
func (s *Station) setRented(id string, want bool) bool {
for i := range s.bikes {
b := &s.bikes[i]
if b.ID == id && b.Rented != want {
b.Rented = want
return true
}
}
return false
}
// Rent 는 자전거를 빌린다. 성공하면 true 다.
func (s *Station) Rent(id string) bool {
return s.setRented(id, true)
}
// Return 은 자전거를 반납한다. 성공하면 true 다.
func (s *Station) Return(id string) bool {
return s.setRented(id, false)
}
// Available 은 빌릴 수 있는 자전거 수다.
func (s *Station) Available() int {
n := 0
for _, b := range s.bikes {
if !b.Rented {
n++
}
}
return n
}
// Summary 는 대여소 현황을 한 줄로 만든다.
func (s *Station) Summary() string {
return fmt.Sprintf("%s (%s): 대기 %d / 전체 %d",
s.Name, s.Label(), s.Available(), len(s.bikes))
}
// Ride 는 한 번의 이용 기록이다.
type Ride struct {
BikeID string
Minutes int
}
// Fare 는 이용 요금이다. 30분까지 1000원, 이후 10분마다 500원(올림)이다.
func (r Ride) Fare() int {
const (
baseFare = 1000
baseMinutes = 30
unitFare = 500
unitMinutes = 10
)
if r.Minutes <= baseMinutes {
return baseFare
}
extra := r.Minutes - baseMinutes
units := (extra + unitMinutes - 1) / unitMinutes
return baseFare + units*unitFare
}
// Ledger 는 정산 장부다. 제로값 그대로 쓸 수 있다.
type Ledger struct {
total int
count int
}
// Record 는 이용 기록의 요금을 장부에 더한다.
func (l *Ledger) Record(r Ride) {
l.total += r.Fare()
l.count++
}
// Report 는 장부 요약을 만든다.
func (l *Ledger) Report() string {
return fmt.Sprintf("정산: %d건 %d원", l.count, l.total)
}
func main() {
st := NewStation("월드컵공원점", Address{District: "마포구", Street: "월드컵로 12"}, "B-01", "B-02", "B-03")
fmt.Println(st.Summary())
fmt.Println("B-02 대여:", st.Rent("B-02"))
fmt.Println("B-02 재대여:", st.Rent("B-02"))
fmt.Println(st.Summary())
for _, b := range st.bikes {
fmt.Println(b.ID, b.Status())
}
copied := st.bikes[0]
copied.Rented = true
fmt.Println("복사본:", copied.Status(), "/ 원본:", st.bikes[0].Status())
var ledger Ledger
rides := []Ride{
{BikeID: "B-02", Minutes: 42},
{BikeID: "B-01", Minutes: 25},
{BikeID: "B-03", Minutes: 71},
}
for _, r := range rides {
fmt.Printf("%s %d분 %d원\n", r.BikeID, r.Minutes, r.Fare())
ledger.Record(r)
}
fmt.Println("B-02 반납:", st.Return("B-02"))
fmt.Println("B-02 재반납:", st.Return("B-02"))
fmt.Println(st.Summary())
fmt.Println(ledger.Report())
}
줄별 해설
Address와 Label: 주소는 읽기만 하는 작은 값이므로 값 리시버를 썼다. 메서드는 두 문자열을 공백으로 이어 돌려준다.
Bike와 Status: 이것도 읽기 전용이라 값 리시버다. Bike는 필드가 둘뿐이어서 복사 비용도 신경 쓸 수준이 아니다.
Station: Address를 임베딩했고, 소문자로 시작하는 bikes는 패키지 밖에서 접근할 수 없다. 이름의 첫 글자가 대문자이면 공개, 소문자이면 비공개라는 규칙은 패키지를 나누는 장에서 다시 본다. 지금은 자전거 목록을 바깥에서 마음대로 바꾸지 못하게 하려는 뜻이라고만 알아 두면 된다.
NewStation: 주소와 이름을 채운 Station을 만들고 &로 포인터를 얻는다. 가변 인자로 받은 번호마다 Bike를 하나씩 append한다. 자전거의 Rented는 적지 않았으므로 모두 false다.
setRented: 인덱스로 슬라이스를 돌며 &s.bikes[i]로 요소의 주소를 얻는다. for _, b := range로 돌면 b가 복사본이라 고쳐도 목록에 반영되지 않기 때문이다. 조건 b.Rented != want 덕분에 이미 빌린 자전거를 다시 빌리려 하면 false가 나온다. 이 함수를 Rent와 Return이 공유해서 같은 반복문을 두 번 쓰지 않았다.
Available과 Summary: 읽기만 하지만 Station의 나머지 메서드와 맞추려고 포인터 리시버로 통일했다. Summary 안의 s.Label()은 임베딩된 Address의 메서드다.
Ride.Fare: 요금 규칙을 상수로 이름 붙였다. 30분을 넘으면 초과 시간을 10분 단위로 올림한다. (extra + unitMinutes - 1) / unitMinutes는 정수 나눗셈으로 올림을 얻는 흔한 방법이다. 42분이면 초과 12분이고 (12+9)/10이 2가 되어 2000원이다.
Ledger: 생성 함수가 없다. Record가 포인터 리시버라서 l.total과 l.count의 변화가 호출한 쪽 변수에 남는다.
main: 대여소를 만들어 현황을 찍고, 같은 자전거를 두 번 빌려 두 번째는 실패하는 것을 확인한다. 이어서 copied := st.bikes[0]이 복사본임을 보인다. 복사본의 Rented를 바꿔도 원본은 "대기"로 남는다. 마지막에는 세 건의 이용을 장부에 기록하고 반납한 뒤 현황과 정산을 찍는다.
실행 결과
$ go run main.go
월드컵공원점 (마포구 월드컵로 12): 대기 3 / 전체 3
B-02 대여: true
B-02 재대여: false
월드컵공원점 (마포구 월드컵로 12): 대기 2 / 전체 3
B-01 대기
B-02 대여 중
B-03 대기
복사본: 대여 중 / 원본: 대기
B-02 42분 2000원
B-01 25분 1000원
B-03 71분 3500원
B-02 반납: true
B-02 재반납: false
월드컵공원점 (마포구 월드컵로 12): 대기 3 / 전체 3
정산: 3건 6500원
실무에서 자주 틀리는 것
값 리시버로 상태를 바꾸려 한다
틀린 코드는 컴파일도 되고 오류도 없다. 그래서 더 찾기 어렵다.
type Ledger struct{ total int }
func (l Ledger) Add(won int) { l.total += won }
func main() {
var l Ledger
l.Add(1000)
fmt.Println(l.total) // 0
}
Add는 복사본의 total을 늘리고 끝난다. 포인터 리시버로 고친다.
func (l *Ledger) Add(won int) { l.total += won }
한 타입에 리시버 종류를 섞는다
func (s Station) Available() int { /* ... */ }
func (s *Station) Rent(id string) bool { /* ... */ }
Available은 호출할 때마다 Station 전체를 복사한다. 게다가 이 타입의 메서드가 어느 쪽인지 외워야 한다. 하나라도 포인터가 필요하면 모두 포인터로 맞춘다.
func (s *Station) Available() int { /* ... */ }
func (s *Station) Rent(id string) bool { /* ... */ }
맵 필드를 초기화하지 않는다
type Registry struct {
renters map[string]string
}
func (r *Registry) Add(bikeID, name string) {
r.renters[bikeID] = name
}
func main() {
var r Registry
r.Add("B-01", "김민")
}
맵의 제로값은 nil이고, nil 맵에 값을 넣으면 실행 중에 panic: assignment to entry in nil map으로 멈춘다. 이런 타입에는 생성 함수를 만든다.
func NewRegistry() *Registry {
return &Registry{renters: make(map[string]string)}
}
range 변수를 고친다
for _, b := range st.bikes {
b.Rented = false // 복사본만 바뀐다
}
반복 변수 b는 요소의 복사본이다. 원본을 바꾸려면 인덱스로 접근한다.
for i := range st.bikes {
st.bikes[i].Rented = false
}
한눈에 보기
| 주제 | 문법 | 핵심 |
|---|---|---|
| 구조체 정의 | type Bike struct { ... } | 필드를 이름 붙여 묶는다 |
| 리터럴 | Bike{ID: "B-01"} | 필드 이름을 적고, 빠진 필드는 제로값 |
| 값 리시버 | func (b Bike) M() | 복사본에서 동작한다 |
| 포인터 리시버 | func (s *Station) M() | 원본을 바꿀 수 있다 |
| 임베딩 | struct 안에 타입만 적는다 | 필드와 메서드가 승격되며 상속이 아니다 |
| 생성 함수 | func NewStation(...) *Station | 초기화가 필요할 때 쓰고, 제로값이 유효하면 생략한다 |
연습 문제
Bike에"B-01(대기)"형태의 문자열을 돌려주는Label메서드를 추가하라. 어떤 리시버를 쓸지도 고르고 이유를 적어라.Ledger를 처음 상태로 되돌리는Reset메서드를 추가하라.Station에Label() string메서드를 직접 정의하면st.Label()은 어느 메서드를 호출하는가.Address의Label을 부르려면 어떻게 쓰는가.NewStation이 이름으로 빈 문자열을 받으면"이름 없음"으로 채우도록 고쳐라.
정답과 해설
1번. 읽기만 하는 작은 타입이므로 값 리시버가 알맞다.
func (b Bike) Label() string {
return b.ID + "(" + b.Status() + ")"
}
2번. 필드를 바꾸므로 포인터 리시버다. 리시버가 가리키는 값 전체를 새 제로값으로 덮어쓴다.
func (l *Ledger) Reset() {
*l = Ledger{}
}
*l은 포인터가 가리키는 값 자체를 뜻한다.
3번. 바깥 타입인 Station의 Label이 호출된다. 임베딩된 쪽은 st.Address.Label()로 부른다. 같은 이름이 여러 깊이에 있으면 얕은 쪽이 이긴다.
4번.
func NewStation(name string, addr Address, ids ...string) *Station {
if name == "" {
name = "이름 없음"
}
s := &Station{Address: addr, Name: name}
for _, id := range ids {
s.bikes = append(s.bikes, Bike{ID: id})
}
return s
}
매개변수 name도 함수 안에서는 복사본 지역 변수라 자유롭게 바꿀 수 있다. 기본값을 채우는 일은 생성 함수가 있어야 한 곳에서 처리된다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.