본문으로 건너뛰기

구조체

이 챕터에서 다루는 것

구조체(struct)는 Go에서 타입을 만드는 주된 수단이다. 클래스가 없으므로 데이터를 묶는 일은 전부 구조체가 한다. 문법은 30초면 끝나지만, 비교 가능성메모리 배치는 따로 짚어야 한다. 둘 다 나중에 조용히 발목을 잡는 주제다.

문제 — 클래스가 없는 언어에서 타입을 만드는 법

Java나 Python에서 왔다면 "클래스가 없다"는 말이 당황스러울 수 있다. Go에는 상속도, 생성자도, 접근 제어자도 없다. 있는 것은 이것뿐이다.

  • 구조체 — 필드를 묶는다.
  • 메서드 — 임의의 타입에 함수를 붙인다. (Part 4)
  • 인터페이스 — 메서드 집합으로 동작을 서술한다. (Part 4)

이 챕터는 첫 번째만 다룬다. 메서드가 없는 구조체는 그냥 "이름 붙은 필드 묶음"이고, 그 소박함이 오히려 메모리 동작을 투명하게 만든다. 구조체 값이 차지하는 공간은 필드들이 차지하는 공간(과 패딩)이 전부다. 숨은 헤더도, 가상 함수 테이블도 없다.

정의와 리터럴

examples/03-composite-types/04-struct-basics/main.go
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 vetcomposites 분석기는 다른 패키지의 구조체를 위치 지정으로 초기화하는 것을 잡아 준다. 같은 패키지 안은 잡아 주지 않으니 습관으로 지켜야 한다.

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.Xp가 포인터든 값이든 동작한다. 3-5에서 다시 본다.

출력 형식

형식결과
%v{{1 2} {3 4} 대각선}
%+v{From:{X:1 Y:2} To:{X:3 Y:4} Label:대각선}
%#vmain.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바이트다.

examples/03-composite-types/04-empty-struct/main.go
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이다.

쓰임 두 가지.

  1. 집합(set) — Go에는 집합 타입이 없다. map[T]struct{}가 관용구다. map[T]bool보다 값당 1바이트를 아끼고, 더 중요하게는 false로 들어 있는 키라는 애매한 상태가 아예 생기지 않는다. 위 출력의 seenBool["rust"]가 그 애매함이다 — false가 나왔지만 키는 존재한다.

    단점은 seen[w] = struct{}{}seen[w] = true보다 읽기 나쁘다는 것이다. 가독성을 택해 map[T]bool을 쓰는 코드도 많고, 그것도 틀리지 않다.

  2. 신호용 채널chan struct{}. 값에 의미가 없고 "일이 일어났다"만 전달할 때 쓴다. Part 7의 주요 관용구다.

필드 정렬과 패딩

CPU는 8바이트 값을 8의 배수 주소에서 읽는 것을 좋아한다(혹은 그것만 할 수 있다). 컴파일러는 각 필드가 자기 크기의 배수 위치에 오도록 빈 바이트를 끼워 넣는다. 이것이 패딩(padding)이다.

examples/03-composite-types/04-padding/main.go
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바이트다. BadA 뒤에 7바이트, C 뒤에 7바이트를 버렸다. Goodint64를 앞에 두고 bool 둘을 붙여 놔서 마지막 6바이트만 버렸다. 오프셋 출력이 이를 그대로 보여 준다 — BadB는 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바이트.

연습문제

  1. 필드가 bool, int64, bool, int32, string인 구조체를 정의하고 unsafe.Sizeof로 크기를 재 보자. 그다음 순서를 바꿔 가장 작은 크기를 찾아보자. 최소 크기가 얼마이고, 왜 그보다 더 줄일 수 없는가?

  2. map[Point]string은 되는데 map[Bag]string은 안 된다. Bag을 맵 키로 쓸 수 있게 만드는 방법을 두 가지 생각해 보자. 힌트: 하나는 타입을 바꾸는 것이고, 하나는 키를 바꾸는 것이다.

  3. map[string]struct{}로 집합을 만들고 합집합·교집합·차집합 함수를 써 보자. map[string]bool로 같은 것을 썼을 때와 비교해서, 어느 쪽이 실수하기 쉬운지 판단해 보자.