13장. 구조체
11장과 12장에서 다룬 슬라이스와 맵은 같은 타입의 값을 줄 세우거나 키-값으로 짝지어 보관하는 도구였다.
그런데 실제 프로그램에서는 서로 다른 타입의 값들이 한 덩어리로 묶여 다녀야 하는 경우가 많다.
- 사용자 한 명 — 이름(string), 나이(int), 활성(bool)
- 상품 하나 — 코드(string), 가격(int), 재고(int)
- 좌표 한 점 — X(int), Y(int)
이런 “한 덩어리짜리 데이터” 를 표현하는 도구가 구조체 (struct) 다. 다른 언어의 record, dataclass, 또는 필드만 있는 객체와 비슷하다.
목표:
- 구조체를 정의하고 만드는 여러 방법 익히기
- 필드에 접근하고 수정하기
- 중첩 구조체와 익명 구조체 다루기
- 구조체 비교가 가능한 조건 이해하기
13.1 구조체란
구조체는 서로 다른 타입의 필드들을 하나의 이름 아래 묶는 사용자 정의 타입이다.
Person
├── Name string
└── Age int
이렇게 묶어 두면 “사용자 정보” 라는 한 단위로 다룰 수 있다. 함수 인자로 넘기거나, 슬라이스에 담거나, 맵의 값으로 쓰는 일이 쉬워진다.
type Person struct {
Name string
Age int
}
alice := Person{Name: "Alice", Age: 30}
Person 이라는 새 타입이 생겼고,
그 타입의 값 하나가 alice 다.
13.2 구조체 정의
구조체는 type 키워드와 struct 키워드를 함께 써서 정의한다.
type Person struct {
Name string
Age int
}
읽는 법.
type Person ...— “Person 이라는 새 타입을 만든다”struct { ... }— “그 타입은 구조체이고, 안에는 다음 필드들이 있다”
필드 작성 규칙
필드 한 줄에 하나씩 쓰는 것이 표준이다. 이름이 먼저, 타입이 뒤에 온다 (변수 선언과 같은 순서).
type Book struct {
Title string
Author string
Pages int
InStock bool
}
같은 타입의 필드 여러 개를 한 줄에 묶을 수도 있다.
type Point struct {
X, Y int
}
X 와 Y 둘 다 int 다.
한 줄짜리 정의
세미콜론으로 구분해서 한 줄에 쓰는 것도 문법적으론 가능하다.
type Person struct { Name string; Age int }
하지만 실무에서는 거의 쓰지 않는다. gofmt 가 여러 줄로 풀어 주기도 한다.
13.3 구조체 초기화
값을 채워 새 구조체를 만드는 방법은 크게 세 가지다.
1. 필드 이름 지정 (권장)
alice := Person{
Name: "Alice",
Age: 30,
}
각 필드의 이름과 값을 명시한다.
장점.
- 어떤 값이 어디로 들어가는지 한눈에 보인다
- 나중에 필드 순서가 바뀌어도 문제 없다
- 일부 필드만 채워도 된다 (나머지는 제로값)
대부분의 코드에서 이 방식을 쓴다.
2. 순서대로 (비권장)
이름을 빼고 값만 순서대로 나열할 수 있다.
alice := Person{"Alice", 30}
짧지만 함정이 있다. 필드를 하나 추가하거나 순서를 바꾸면 모든 호출 지점이 깨진다.
// 나중에 Email 필드가 추가되면
type Person struct {
Name string
Age int
Email string
}
alice := Person{"Alice", 30} // 컴파일 에러
좌표처럼 필드가 영원히 두 개일 게 분명한 작은 구조체에서만 조심해서 쓴다.
3. 일부 필드만 (나머지는 제로값)
이름 지정 방식에서는 일부만 채워도 된다.
p := Person{Name: "Alice"}
fmt.Println(p)
// {Alice 0}
Age 를 안 적었기 때문에 int 의 제로값인 0 이 들어간다.
| 필드 타입 | 제로값 |
|---|---|
int, float64 | 0 |
string | "" |
bool | false |
| 슬라이스, 맵, 포인터 | nil |
4장의 제로값 개념이 여기서도 그대로 적용된다.
4. 빈 구조체
값을 하나도 안 채우고 만들 수도 있다.
var p Person // 모든 필드가 제로값
q := Person{} // 같은 결과
이 둘은 똑같이 동작한다.
13.4 필드 접근
구조체 안의 필드는 점 . 으로 가져온다.
alice := Person{Name: "Alice", Age: 30}
fmt.Println(alice.Name) // Alice
fmt.Println(alice.Age) // 30
대입
같은 문법으로 값을 바꿀 수도 있다.
alice.Age = 31
fmt.Println(alice.Age) // 31
Println 으로 통째로 출력
구조체 전체를 그대로 출력하면 중괄호 안에 값이 나열된다.
fmt.Println(alice)
// {Alice 31}
필드 이름까지 같이 보고 싶다면 %+v 를 쓴다.
fmt.Printf("%+v\n", alice)
// {Name:Alice Age:31}
7장의 포맷 동사가 여기서 다시 유용해진다.
13.5 중첩 구조체
구조체의 필드 타입은 또 다른 구조체일 수도 있다. 이를 중첩 구조체라고 부른다.
type Person struct {
Name string
Age int
}
type Company struct {
Name string
Owner Person
}
Company 안에 Person 이 통째로 들어 있다.
초기화
리터럴을 중첩해서 쓴다.
acme := Company{
Name: "Acme",
Owner: Person{
Name: "Alice",
Age: 30,
},
}
접근
점을 이어서 깊이 들어간다.
fmt.Println(acme.Owner.Name) // Alice
대입도 같은 방식이다.
acme.Owner.Age = 31
fmt.Println(acme.Owner.Age) // 31
읽다 보면 자연스러워진다. “acme 의 Owner 의 Name” 처럼 문장 그대로 읽으면 된다.
한 번 더 중첩
깊이를 늘려도 같은 방식이다.
type Address struct {
City string
Country string
}
type Person struct {
Name string
Address Address
}
p := Person{
Name: "Alice",
Address: Address{
City: "Seoul",
Country: "KR",
},
}
fmt.Println(p.Address.City) // Seoul
Go 에는 “임베딩 (embedding)” 이라는 또 다른 중첩 방식이 있다. 필드 이름을 생략하고 타입만 적는 형태인데, 메서드와 인터페이스 개념이 필요해 15~16장에서 다룬다. 지금은 평범한 필드로서의 중첩만 알면 충분하다.
13.6 익명 구조체
한 번만 쓸 구조체라면 굳이 type 으로 이름을 붙일 필요가 없다.
정의와 사용을 한 줄에 합칠 수 있다.
point := struct {
X, Y int
}{
X: 1,
Y: 2,
}
fmt.Println(point.X, point.Y) // 1 2
읽는 법.
struct { X, Y int }— 타입 정의 (이름 없음){X: 1, Y: 2}— 그 타입의 값 하나
언제 쓰는가
- 함수 안에서만 잠깐 쓸 작은 데이터 묶음
- 테스트 코드에서 케이스마다 다른 모양의 데이터를 표현할 때
- JSON 응답을 임시로 받을 때 (30장 미리보기)
tests := []struct {
input int
expected int
}{
{1, 2},
{2, 4},
{3, 6},
}
for _, tc := range tests {
if double(tc.input) != tc.expected {
fmt.Println("실패:", tc.input)
}
}
테이블 기반 테스트라고 부르는 흔한 패턴이다. 32장에서 본격적으로 다시 만난다.
13.7 구조체 비교
같은 타입의 구조체 두 개를 == 또는 != 로 비교할 수 있을까?
답은 “필드들이 비교 가능하면 된다” 이다.
비교 가능한 경우
모든 필드가 비교 가능한 타입이라면 구조체 자체도 그대로 비교할 수 있다.
type Point struct {
X, Y int
}
a := Point{1, 2}
b := Point{1, 2}
c := Point{3, 4}
fmt.Println(a == b) // true
fmt.Println(a == c) // false
모든 필드 값이 같아야 같다고 판정된다.
비교 불가능한 경우
필드 중에 슬라이스, 맵, 함수처럼 비교가 불가능한 타입이 하나라도 있으면
구조체 전체도 == 로 비교할 수 없다.
type Bag struct {
Items []string
}
a := Bag{Items: []string{"a"}}
b := Bag{Items: []string{"a"}}
fmt.Println(a == b) // 컴파일 에러
// invalid operation: a == b
// (struct containing []string cannot be compared)
이유는 11장과 12장에서 본 그대로다.
슬라이스나 맵은 그 자체가 == 를 지원하지 않는다.
그래서 그걸 품은 구조체도 자동으로 비교 불가 상태가 된다.
맵의 키로 쓸 수 있는가
12장의 “키는 비교 가능한 타입이어야 한다” 와 연결된다.
| 구조체 | 비교 가능? | 맵 키로 쓸 수 있나? |
|---|---|---|
Point{X, Y int} | 예 | 예 |
Person{Name string, Age int} | 예 | 예 |
Bag{Items []string} | 아니오 | 아니오 |
비교 가능한 구조체는 맵의 키로도 쓸 수 있다는 뜻이다. 좌표를 키로 두는 맵 같은 패턴이 그 덕에 깔끔하게 표현된다.
grid := map[Point]string{
{0, 0}: "원점",
{1, 1}: "대각선 한 칸",
}
13.8 정리
이 장에서 살펴본 내용:
- 구조체는 서로 다른 타입의 필드를 한 덩어리로 묶는다
type 이름 struct { ... }형태로 정의한다- 초기화는 필드 이름을 명시하는 방식을 쓰는 게 좋다
- 일부 필드만 채우면 나머지는 제로값
- 필드 접근과 수정은 점
.으로 한다 - 구조체 안에 구조체를 둘 수 있다 (중첩)
- 이름 없는 익명 구조체를 즉석에서 만들 수도 있다
- 비교 가능한 필드만 있다면 구조체 전체를
==로 비교 가능하다- 비교 가능한 구조체는 맵 키로도 쓸 수 있다
슬라이스, 맵, 구조체. 이제 일상적인 데이터 모델을 표현하는 세 가지 도구가 모두 모였다.
하지만 한 가지 답답한 점이 곧 보일 것이다. 구조체를 함수에 넘기면 통째로 복사된다. 큰 구조체를 매번 복사하는 건 비효율적이고, 함수 안에서 값을 바꿔도 호출한 쪽에 반영되지 않는다.
이 문제를 풀기 위해 다음 장에서는 포인터 를 본다. “값을 직접 넘기지 말고 위치만 알려 주는” 방식이다.