구조체
이 챕터에서 다루는 것
구조체(struct)는 Go에서 타입을 만드는 주된 수단이다. 클래스가 없으므로 데이터를 묶는 일은 전부 구조체가 한다. 문법은 30초면 끝나지만, 비교 가능성과 메모리 배치는 따로 짚어야 한다. 둘 다 나중에 조용히 발목을 잡는 주제다.
문제 — 클래스가 없는 언어에서 타입을 만드는 법
Java나 Python에서 왔다면 "클래스가 없다"는 말이 당황스러울 수 있다. Go에는 상속도, 생성자도, 접근 제어자도 없다. 있는 것은 이것뿐이다.
- 구조체 — 필드를 묶는다.
- 메서드 — 임의의 타입에 함수를 붙인다. (Part 4)
- 인터페이스 — 메서드 집합으로 동작을 서술한다. (Part 4)
이 챕터는 첫 번째만 다룬다. 메서드가 없는 구조체는 그냥 "이름 붙은 필드 묶음"이고, 그 소박함이 오히려 메모리 동작을 투명하게 만든다. 구조체 값이 차지하는 공간은 필드들이 차지하는 공간(과 패딩)이 전부다. 숨은 헤더도, 가상 함수 테이블도 없다.
정의와 리터럴
package main
import "fmt"
type Point struct {
X, Y int
}
type Segment struct {
From, To Point
Label string
}
func main() {
// 필드명을 지정하는 리터럴. 필드가 늘어나도 깨지지 않는다.
p := Point{X: 1, Y: 2}
// 위치 지정 리터럴은 필드 순서에 묶인다. 작은 타입에만 쓴다.
q := Point{3, 4}
fmt.Println(p, q)
// 제로값 구조체는 var로 바로 얻는다.
var origin Point
fmt.Println("origin:", origin, "제로값인가:", origin == Point{})
// 중첩 구조체
s := Segment{
From: p,
To: q,
Label: "대각선",
}
fmt.Println(s)
fmt.Println("s.From.X =", s.From.X)
// 비교 가능한 구조체는 == 로 비교되고 맵 키가 될 수 있다.
grid := map[Point]string{
{X: 0, Y: 0}: "원점",
{X: 1, Y: 2}: "p",
}
fmt.Println("grid[p] =", grid[p])
fmt.Println("Point{1,2} == p:", Point{X: 1, Y: 2} == p)
// 익명 구조체: 이 자리에서만 쓸 타입
stats := struct {
Total int
Name string
}{Total: 42, Name: "임시"}
fmt.Println("익명:", stats)
// %+v는 필드명을, %#v는 Go 문법 그대로를 보여 준다.
fmt.Printf("%v\n%+v\n%#v\n", s, s, s)
}
go run ./04-struct-basics
{1 2} {3 4}
origin: {0 0} 제로값인가: true
{{1 2} {3 4} 대각선}
s.From.X = 1
grid[p] = p
Point{1,2} == p: true
익명: {42 임시}
{{1 2} {3 4} 대각선}
{From:{X:1 Y:2} To:{X:3 Y:4} Label:대각선}
main.Segment{From:main.Point{X:1, Y:2}, To:main.Point{X:3, Y:4}, Label:"대각선"}
필드명을 지정하는 리터럴을 쓴다
Point{X: 1, Y: 2} // 이쪽
Point{1, 2} // 이쪽은 예외적으로만
위치 지정 리터럴은 필드를 하나 추가하는 순간 전부 컴파일 에러가 되고, 더 나쁘게는
필드 순서를 바꾸면 컴파일은 되는데 값이 뒤바뀐다. Point{X, Y}처럼 순서가 자명하고
영원히 안 바뀔 타입에만 쓴다.
go vet의 composites 분석기는 다른 패키지의 구조체를 위치 지정으로 초기화하는
것을 잡아 준다. 같은 패키지 안은 잡아 주지 않으니 습관으로 지켜야 한다.
grid 리터럴의 {X: 0, Y: 0}처럼 타입 이름을 생략할 수 있다는 점도 눈여겨보자.
맵이나 슬라이스 리터럴 안에서 요소 타입이 이미 정해져 있으면 Point{...}를 {...}로
줄일 수 있다.
제로값
var origin Point는 모든 필드가 제로값인 구조체다. Point{}도 같다. Go에 생성자가
없어도 되는 이유가 여기 있다 — 제로값이 곧 쓸 만한 값이 되도록 타입을 설계하면
초기화 절차가 필요 없다. sync.Mutex, bytes.Buffer, strings.Builder가 그 예다.
물론 항상 되는 것은 아니다. 맵 필드가 있으면 제로값이 위험하고
(3-3), 그럴 때 생성자 함수 newT()를 둔다.
필드 접근과 중첩
s.From.X처럼 점으로 파고든다. 포인터여도 마찬가지다 — Go에는 C의 ->가 없고,
p.X가 p가 포인터든 값이든 동작한다. 3-5에서 다시 본다.
출력 형식
| 형식 | 결과 |
|---|---|
%v | {{1 2} {3 4} 대각선} |
%+v | {From:{X:1 Y:2} To:{X:3 Y:4} Label:대각선} |
%#v | main.Segment{From:main.Point{X:1, Y:2}, ...} |
디버깅에는 %+v가 기본이다. 필드명이 없으면 어느 값이 어느 필드인지 알 수 없다.
비교 가능성
구조체는 모든 필드가 비교 가능하면 비교 가능하다. 비교는 필드별로 이뤄진다.
| 비교 가능 | 비교 불가 |
|---|---|
숫자, 문자열, bool | 슬라이스 |
| 포인터, 채널 | 맵 |
| 비교 가능한 요소의 배열 | 함수 |
| 비교 가능한 필드만 가진 구조체 | 위의 것을 필드로 가진 구조체 |
type Bag struct {
Items []string
}
Bag{} == Bag{} // 컴파일 에러:
// invalid operation: Bag{} == Bag{} (struct containing []string cannot be compared)
이게 왜 중요한가. 비교 가능한 타입만 맵 키가 될 수 있기 때문이다. map[Point]string은
되고 map[Bag]string은 안 된다. 나중에 어떤 타입을 맵 키로 쓰고 싶어졌는데 그 안에
슬라이스 필드가 하나 있어서 못 쓰는 상황이 실제로 온다.
내용 비교가 필요하면 reflect.DeepEqual을 쓸 수 있지만 느리고 런타임 검사다.
Go 1.21 이후로는 슬라이스에 slices.Equal, 맵에 maps.Equal이 있으니 필드별로
직접 쓰는 편이 낫다.
:::warning 비교 가능해도 ==가 원하는 답을 준다는 보장은 없다
포인터 필드가 있으면 ==는 가리키는 대상이 같은지를 본다. 내용이 같은 두 객체를
가리키는 서로 다른 포인터는 !=다. 부동소수점 필드가 있으면 NaN이 자기 자신과
다르다. 구조체를 맵 키로 쓸 때는 이 두 가지를 먼저 확인하자.
:::
익명 구조체
이름 붙일 가치가 없는 일회용 타입에 쓴다.
stats := struct {
Total int
Name string
}{Total: 42, Name: "임시"}
실전에서 자주 보이는 곳은 두 군데다.
테이블 주도 테스트의 케이스 목록 — Part 8에서 본격적으로 다루지만, 형태는 이미 3-7의 실습에서 쓴다.
for _, tc := range []struct {
name string
in int
want int
}{
{"영", 0, 0},
{"양수", 3, 9},
} {
// ...
}
JSON 응답 껍데기 — 한 번만 쓰고 버릴 구조를 파일 상단에 이름 붙여 늘어놓지 않아도 된다. Part 9에서 다룬다.
빈 구조체 struct{}
struct{}는 필드가 하나도 없는 타입이고, 크기가 0바이트다.
package main
import (
"fmt"
"slices"
"unsafe"
)
func main() {
fmt.Println("struct{} 크기:", unsafe.Sizeof(struct{}{}), "바이트")
fmt.Println("bool 크기: ", unsafe.Sizeof(false), "바이트")
// 집합(set): 값에 의미가 없으니 struct{}를 쓴다.
seen := map[string]struct{}{}
for _, w := range []string{"go", "rust", "go", "zig", "rust"} {
seen[w] = struct{}{}
}
fmt.Println("고유 개수:", len(seen))
if _, ok := seen["go"]; ok {
fmt.Println("go 있음")
}
words := make([]string, 0, len(seen))
for w := range seen {
words = append(words, w)
}
slices.Sort(words)
fmt.Println("정렬:", words)
// map[string]bool과 비교하면 값 하나당 1바이트를 아낀다.
// 대신 "false로 넣어 둔 키"라는 애매한 상태가 사라진다.
seenBool := map[string]bool{"go": true, "rust": false}
fmt.Println("bool 맵 조회:", seenBool["rust"], "실제 존재 여부는 comma-ok로만 안다")
// 큰 배열도 요소가 struct{}면 0바이트다.
var huge [1000]struct{}
fmt.Println("[1000]struct{} 크기:", unsafe.Sizeof(huge), "바이트")
}
go run ./04-empty-struct
struct{} 크기: 0 바이트
bool 크기: 1 바이트
고유 개수: 3
go 있음
정렬: [go rust zig]
bool 맵 조회: false 실제 존재 여부는 comma-ok로만 안다
[1000]struct{} 크기: 0 바이트
[1000]struct{}도 0바이트다. 요소가 0바이트면 몇 개를 모아도 0이다.
쓰임 두 가지.
-
집합(set) — Go에는 집합 타입이 없다.
map[T]struct{}가 관용구다.map[T]bool보다 값당 1바이트를 아끼고, 더 중요하게는false로 들어 있는 키라는 애매한 상태가 아예 생기지 않는다. 위 출력의seenBool["rust"]가 그 애매함이다 —false가 나왔지만 키는 존재한다.단점은
seen[w] = struct{}{}가seen[w] = true보다 읽기 나쁘다는 것이다. 가독성을 택해map[T]bool을 쓰는 코드도 많고, 그것도 틀리지 않다. -
신호용 채널 —
chan struct{}. 값에 의미가 없고 "일이 일어났다"만 전달할 때 쓴다. Part 7의 주요 관용구다.
필드 정렬과 패딩
CPU는 8바이트 값을 8의 배수 주소에서 읽는 것을 좋아한다(혹은 그것만 할 수 있다). 컴파일러는 각 필드가 자기 크기의 배수 위치에 오도록 빈 바이트를 끼워 넣는다. 이것이 패딩(padding)이다.
package main
import (
"fmt"
"unsafe"
)
// Bad는 필드 순서 때문에 빈틈(패딩)이 생긴다.
type Bad struct {
A bool // 1바이트 + 7바이트 패딩
B int64 // 8바이트
C bool // 1바이트 + 7바이트 패딩
}
// Good은 같은 필드를 크기 내림차순으로 놓았다.
type Good struct {
B int64 // 8바이트
A bool // 1바이트
C bool // 1바이트 + 6바이트 패딩
}
func main() {
fmt.Println("Bad 크기:", unsafe.Sizeof(Bad{}), "정렬:", unsafe.Alignof(Bad{}))
fmt.Println("Good 크기:", unsafe.Sizeof(Good{}), "정렬:", unsafe.Alignof(Good{}))
fmt.Println()
fmt.Println("Bad 오프셋 A/B/C:",
unsafe.Offsetof(Bad{}.A), unsafe.Offsetof(Bad{}.B), unsafe.Offsetof(Bad{}.C))
fmt.Println("Good 오프셋 B/A/C:",
unsafe.Offsetof(Good{}.B), unsafe.Offsetof(Good{}.A), unsafe.Offsetof(Good{}.C))
fmt.Println()
fmt.Println("string 크기:", unsafe.Sizeof(""))
fmt.Println("슬라이스 크기:", unsafe.Sizeof([]int(nil)))
fmt.Println("맵 크기: ", unsafe.Sizeof(map[string]int(nil)))
fmt.Println("포인터 크기:", unsafe.Sizeof((*int)(nil)))
}
go run ./04-padding
Bad 크기: 24 정렬: 8
Good 크기: 16 정렬: 8
Bad 오프셋 A/B/C: 0 8 16
Good 오프셋 B/A/C: 0 8 9
string 크기: 16
슬라이스 크기: 24
맵 크기: 8
포인터 크기: 8
(64비트 환경 기준이다. 32비트에서는 포인터가 4바이트라 값들이 달라진다.)
같은 세 필드인데 24바이트와 16바이트다. Bad는 A 뒤에 7바이트, C 뒤에 7바이트를
버렸다. Good은 int64를 앞에 두고 bool 둘을 붙여 놔서 마지막 6바이트만 버렸다.
오프셋 출력이 이를 그대로 보여 준다 — Bad의 B는 8번지, C는 16번지에서 시작한다.
규칙은 큰 필드부터 내림차순으로 놓는다 하나면 충분하다.
:::tip 언제 신경 쓰고 언제 무시할 것인가 필드 순서로 아끼는 것은 인스턴스당 몇 바이트다. 수백만 개를 메모리에 들고 있는 구조체라면 의미가 있고(24 → 16은 33% 절감이다), 그 외에는 가독성이 이긴다. 관련 있는 필드끼리 묶어 두는 편이 훨씬 낫다.
정말 필요할 때만 fieldalignment 분석기
(go run golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest ./...)
로 확인하면 된다. 이 분석기는 go vet의 기본 구성에 포함돼 있지 않다.
:::
아래 네 줄도 기억해 둘 만하다. 문자열은 16바이트(포인터+길이), 슬라이스는 24바이트(포인터+길이+용량), 맵과 포인터는 8바이트(포인터 하나)다. 3-1에서 말한 "슬라이스 헤더는 24바이트"가 여기서 실측된다. 맵 값이 8바이트라는 것은 맵 값이 런타임 해시 테이블을 가리키는 포인터 하나라는 뜻이고, 그래서 맵을 복사해도 같은 테이블을 본다.
:::note unsafe를 썼지만 안전하다
unsafe.Sizeof, Alignof, Offsetof는 컴파일 시점에 상수로 계산된다. 실제로
안전하지 않은 메모리 접근을 하지 않고, go vet도 문제 삼지 않는다. unsafe.Pointer로
타입을 우회하는 것과는 전혀 다른 이야기다.
:::
흔히 하는 실수
1. 위치 지정 리터럴을 쓴다
필드를 추가하거나 순서를 바꾸면 터진다. 특히 순서를 바꿨을 때 컴파일이 되면서 값이 뒤바뀌는 경우가 최악이다.
2. 슬라이스나 맵 필드를 넣고 ==를 기대한다
컴파일 에러로 잡히니 그나마 낫다. 하지만 "나중에 맵 키로 쓰려고 했는데 못 쓰는" 상황은 설계를 되돌려야 해서 비싸다. 맵 키 후보 타입은 처음부터 비교 가능하게 설계한다.
3. 맵 필드가 있는 구조체를 리터럴로만 만든다
3-3의 주제다. 생성자 함수를 둔다.
4. 큰 구조체를 값으로 계속 복사한다
2-5에서 예고한 내용이다. 함수 인자, range의
값 변수, 맵 조회, 슬라이스 요소 대입 — 전부 복사다. 판단 기준은 3-5에서.
5. 필드 정렬을 과도하게 신경 쓴다
3번 실수보다 이쪽이 더 흔하다. 프로파일링으로 문제가 확인되기 전에는 읽기 좋은 순서를 택하자.
6. 내보낼 필드와 아닐 필드를 구분하지 않는다
대문자로 시작하는 필드만 패키지 밖에서 보인다. json.Marshal도 소문자 필드는
무시한다 — Part 9에서 이것 때문에 "왜 빈 객체가 나오지?"를 겪게 된다. 지금
기억해 둘 것은 대소문자가 곧 접근 제어라는 사실이다.
정리
- 구조체는 필드 묶음이다. 숨은 헤더가 없어서 크기가 필드 크기의 합(+패딩)이다.
- 필드명을 지정하는 리터럴을 쓴다. 위치 지정은
Point{X, Y}급 타입에만. - 리터럴 안에서 요소 타입이 자명하면 타입 이름을 생략할 수 있다.
- 제로값이 쓸 만하도록 설계한다. 맵 필드가 있으면 생성자 함수를 둔다.
- 모든 필드가 비교 가능하면 구조체도 비교 가능하고, 그때만 맵 키가 된다.
- 익명 구조체는 테이블 주도 테스트와 일회용 JSON 껍데기에 쓴다.
struct{}는 0바이트다. 집합map[T]struct{}와 신호 채널chan struct{}에 쓴다.- 필드는 큰 것부터 놓으면 패딩이 준다. 다만 대개는 가독성이 우선이다.
- 64비트에서 string 16, 슬라이스 24, 맵/포인터 8바이트.
연습문제
-
필드가
bool,int64,bool,int32,string인 구조체를 정의하고unsafe.Sizeof로 크기를 재 보자. 그다음 순서를 바꿔 가장 작은 크기를 찾아보자. 최소 크기가 얼마이고, 왜 그보다 더 줄일 수 없는가? -
map[Point]string은 되는데map[Bag]string은 안 된다.Bag을 맵 키로 쓸 수 있게 만드는 방법을 두 가지 생각해 보자. 힌트: 하나는 타입을 바꾸는 것이고, 하나는 키를 바꾸는 것이다. -
map[string]struct{}로 집합을 만들고 합집합·교집합·차집합 함수를 써 보자.map[string]bool로 같은 것을 썼을 때와 비교해서, 어느 쪽이 실수하기 쉬운지 판단해 보자.