Go 언어 구조체(Struct) 완벽 정복: 기본부터 메모리 최적화, 활용 팁까지
Go 언어에서 struct
는 데이터를 정의하고 캡슐화하는 데 사용되는 복합 타입인데요.
서로 다른 타입의 필드들을 하나로 묶을 수 있게 해줍니다. struct
는 다른 언어의 클래스와 유사한 사용자 정의 데이터 타입으로 볼 수 있지만, 상속은 지원하지 않습니다.
메서드는 특정 타입(주로 struct
)과 연관된 함수로, 해당 타입의 인스턴스를 사용하여 호출할 수 있습니다.
1. Struct 정의 및 초기화 알아보기
Struct 정의하기
struct
는 type
과 struct
키워드를 사용하여 정의하는데요.
간단한 struct
정의 예시는 다음과 같습니다.
type User struct {
Username string
Email string
SignInCount int
IsActive bool
}
Struct 초기화하기
struct
는 다양한 방식으로 초기화할 수 있습니다.
필드 이름을 사용하여 초기화하기
user1 := User{
Username: "alice",
Email: "alice@example.com",
SignInCount: 1,
IsActive: true,
}
기본값으로 초기화하기
일부 필드를 지정하지 않으면 해당 타입의 제로 값으로 초기화됩니다.
user2 := User{
Username: "bob",
}
이 예시에서 Email
은 빈 문자열("")로, SignInCount
는 0으로, IsActive
는 false
로 초기화됩니다.
포인터를 사용하여 초기화하기
struct
는 포인터를 사용하여 초기화할 수도 있습니다.
user3 := &User{
Username: "charlie",
Email: "charlie@example.com",
}
2. Struct의 메서드 및 동작 방식 살펴보기
Go에서 struct
는 데이터 저장뿐만 아니라 메서드를 정의할 수도 있는데요.
이를 통해 struct
는 데이터와 관련된 동작을 캡슐화할 수 있습니다.
아래에서는 struct
메서드 및 동작 방식에 대해 자세히 설명합니다.
Struct를 위한 메서드 정의하기
메서드는 리시버(receiver)를 사용하여 정의하는데요.
리시버는 메서드의 첫 번째 매개변수로, 메서드가 속한 타입을 지정합니다.
리시버는 값 리시버 또는 포인터 리시버가 될 수 있습니다.
Value Receiver (값 리시버)
값 리시버는 메서드가 호출될 때 struct
의 복사본을 생성하므로, 필드를 수정해도 원본 struct
에는 영향을 미치지 않습니다.
type User struct {
Username string
Email string
}
func (u User) PrintInfo() {
fmt.Printf("사용자 이름: %s, 이메일: %s\n", u.Username, u.Email)
}
Pointer Receiver (포인터 리시버)
포인터 리시버는 메서드가 원본 struct
필드를 직접 수정할 수 있도록 합니다.
func (u *User) UpdateEmail(newEmail string) {
u.Email = newEmail
}
Method Sets (메서드 집합)
Go에서 struct
의 모든 메서드는 해당 struct
의 메서드 집합을 구성하는데요.
값 리시버에 대한 메서드 집합에는 값 리시버를 사용하는 모든 메서드가 포함되는 반면, 포인터 리시버에 대한 메서드 집합에는 포인터 리시버와 값 리시버를 사용하는 모든 메서드가 포함됩니다.
Interfaces와 Struct 메서드
struct
메서드는 다형성을 달성하기 위해 인터페이스와 함께 자주 사용되는데요.
인터페이스를 정의할 때 struct
가 구현해야 하는 메서드를 지정합니다.
type UserInfo interface {
PrintInfo()
}
// User는 UserInfo 인터페이스를 구현합니다.
func (u User) PrintInfo() {
fmt.Printf("사용자 이름: %s, 이메일: %s\n", u.Username, u.Email)
}
func ShowInfo(ui UserInfo) {
ui.PrintInfo()
}
3. Struct에서의 메모리 정렬 자세히 알아보기
Go에서 struct
의 메모리 정렬은 접근 효율성을 향상시키도록 설계되었는데요.
다양한 데이터 타입은 특정 정렬 요구 사항을 가지며, 컴파일러는 이러한 요구 사항을 충족하기 위해 struct
필드 사이에 패딩 바이트를 삽입할 수 있습니다.
메모리 정렬이란 무엇일까요?
메모리 정렬은 메모리 상의 데이터가 특정 값의 배수가 되는 주소에 위치해야 함을 의미하는데요.
데이터 타입의 크기가 정렬 요구 사항을 결정합니다. 예를 들어, int32
는 4바이트 정렬이 필요하고 int64
는 8바이트 정렬이 필요합니다.
메모리 정렬이 왜 필요할까요?
효율적인 메모리 접근은 CPU 성능에 매우 중요한데요.
변수가 제대로 정렬되지 않으면 CPU가 데이터를 읽거나 쓰기 위해 여러 번의 메모리 접근이 필요할 수 있으며, 이는 성능 저하로 이어집니다.
데이터를 정렬함으로써 컴파일러는 효율적인 메모리 접근을 보장합니다.
Struct 메모리 정렬 규칙
- 필드 정렬: 각 필드의 주소는 해당 타입의 정렬 요구 사항을 충족해야 합니다. 컴파일러는 적절한 정렬을 보장하기 위해 필드 사이에 패딩 바이트를 삽입할 수 있습니다.
- Struct 정렬:
struct
의 크기는 해당 필드 중 가장 큰 정렬 요구 사항의 배수여야 합니다.
예시:
package main
import (
"fmt"
"unsafe"
)
type Example struct {
a int8 // 1 바이트
b int32 // 4 바이트
c int8 // 1 바이트
}
func main() {
fmt.Println(unsafe.Sizeof(Example{}))
}
출력: 12
분석:
a
는int8
로 1바이트를 차지하며 1바이트 경계에 정렬됩니다.b
는int32
로 4바이트 정렬이 필요한데요. 컴파일러는b
의 주소를 4의 배수로 맞추기 위해a
와b
사이에 3바이트의 패딩을 삽입합니다.c
는int8
로 1바이트가 필요하지만,struct
의 전체 크기는 가장 큰 정렬 요구 사항인 4의 배수여야 합니다. 컴파일러는 끝에 3바이트의 패딩을 추가합니다.
메모리 정렬 최적화하기
패딩을 최소화하고 메모리 사용량을 줄이기 위해 struct
필드의 순서를 재배치할 수 있습니다.
type Optimized struct {
b int32 // 4 바이트
a int8 // 1 바이트
c int8 // 1 바이트
}
출력: 8
이 최적화된 버전에서는 b
가 먼저 배치되어 4바이트 경계에 정렬됩니다.
a
와 c
는 연속적으로 배치되어 전체 크기가 8바이트가 되므로, 최적화되지 않은 버전보다 더 간결합니다.
요약
- Go의
struct
필드는 잠재적인 패딩 바이트와 함께 정렬 요구 사항에 따라 메모리가 할당됩니다. - 필드 순서를 조정하면 패딩을 최소화하고 메모리 사용량을 최적화할 수 있습니다.
unsafe.Sizeof
를 사용하여struct
의 실제 메모리 크기를 확인할 수 있습니다.
4. 중첩 Struct 및 합성 알아보기
Go에서 중첩 struct
와 합성은 코드 재사용 및 복잡한 데이터 구성을 위한 강력한 도구인데요.
중첩 struct
를 사용하면 한 struct
가 다른 struct
를 필드로 포함할 수 있어 복잡한 데이터 모델 생성이 가능합니다.
반면에 합성은 다른 struct
를 포함하여 새로운 struct
를 생성함으로써 코드 재사용을 용이하게 합니다.
중첩 Struct
중첩 struct
는 하나의 struct
가 다른 struct
를 필드로 포함할 수 있게 하는데요.
이는 데이터 구조를 더욱 유연하고 체계적으로 만듭니다. 다음은 중첩 struct
의 예시입니다.
package main
import "fmt"
// Address struct 정의
type Address struct {
City string
Country string
}
// Address struct를 포함하는 User struct 정의
type User struct {
Username string
Email string
Address Address // 중첩 struct
}
func main() {
// 중첩 struct 초기화
user := User{
Username: "alice",
Email: "alice@example.com",
Address: Address{
City: "New York",
Country: "USA",
},
}
// 중첩 struct의 필드 접근
fmt.Printf("사용자: %s, 이메일: %s, 도시: %s, 국가: %s\n", user.Username, user.Email, user.Address.City, user.Address.Country)
}
Struct 합성
합성은 여러 struct
를 새로운 struct
로 결합하여 코드 재사용을 가능하게 하는데요.
합성에서 struct
는 여러 다른 struct
를 필드로 포함할 수 있습니다.
이는 더 복잡한 모델을 구축하고 공통 필드나 메서드를 공유하는 데 도움이 됩니다.
다음은 struct
합성의 예시입니다.
package main
import "fmt"
// Address struct 정의
type Address struct {
City string
Country string
}
// Profile struct 정의
type Profile struct {
Age int
Bio string
}
// Address와 Profile을 합성하는 User struct 정의
type User struct {
Username string
Email string
Address Address // Address struct 합성
Profile Profile // Profile struct 합성
}
func main() {
// 합성된 struct 초기화
user := User{
Username: "bob",
Email: "bob@example.com",
Address: Address{
City: "New York",
Country: "USA",
},
Profile: Profile{
Age: 25,
Bio: "소프트웨어 개발자입니다.",
},
}
// 합성된 struct의 필드 접근
fmt.Printf("사용자: %s, 이메일: %s, 도시: %s, 나이: %d, 소개: %s\n", user.Username, user.Email, user.Address.City, user.Profile.Age, user.Profile.Bio)
}
중첩 Struct와 합성의 차이점
- 중첩 Struct:
struct
들을 결합하는 데 사용되며, 한struct
의 필드 타입이 다른struct
인 경우입니다. 이 접근 방식은 계층적 관계를 갖는 데이터 모델을 설명하는 데 자주 사용됩니다. - 합성:
struct
가 여러 다른struct
의 필드를 포함할 수 있도록 합니다. 이 방법은 코드 재사용을 달성하는 데 사용되며,struct
가 더 복잡한 동작과 속성을 가질 수 있도록 합니다.
요약
중첩 struct
와 합성은 Go에서 복잡한 데이터 구조를 구성하고 관리하는 데 도움이 되는 강력한 기능인데요.
데이터 모델을 설계할 때 중첩 struct
와 합성을 적절하게 사용하면 코드를 더 명확하고 유지 관리하기 쉽게 만들 수 있습니다.
5. 빈 Struct 알아보기
Go에서 빈 struct
는 필드가 없는 struct
를 의미합니다.
크기 및 메모리 주소
빈 struct
는 0바이트의 메모리를 차지하는데요.
그러나 서로 다른 상황에서는 메모리 주소가 같을 수도 있고 다를 수도 있습니다.
메모리 탈출(escape)이 발생하는 경우 주소는 같아지며, runtime.zerobase
를 가리킵니다.
// empty_struct.go
type Empty struct{}
//go:linkname zerobase runtime.zerobase
var zerobase uintptr // go:linkname 지시어를 사용하여 zerobase를 runtime.zerobase에 연결
func main() {
a := Empty{}
b := struct{}{}
fmt.Println(unsafe.Sizeof(a) == 0) // true
fmt.Println(unsafe.Sizeof(b) == 0) // true
fmt.Printf("%p\n", &a) // 0x590d00 (실행 시 다를 수 있음)
fmt.Printf("%p\n", &b) // 0x590d00 (실행 시 다를 수 있음)
fmt.Printf("%p\n", &zerobase) // 0x590d00 (실행 시 다를 수 있음)
c := new(Empty)
d := new(Empty) // c와 d가 탈출하도록 강제
fmt.Sprint(c, d)
println(c) // 0x590d00 (실행 시 다를 수 있음)
println(d) // 0x590d00 (실행 시 다를 수 있음)
fmt.Println(c == d) // true
e := new(Empty)
f := new(Empty)
println(e) // 0xc00008ef47 (실행 시 다를 수 있음)
println(f) // 0xc00008ef47 (실행 시 다를 수 있음, 때로는 다름)
fmt.Println(e == f) // false (때로는 true일 수 있음, Go 버전에 따라 동작이 다를 수 있음)
}
출력에서 변수 a
, b
, 그리고 zerobase
는 모두 전역 변수 runtime.zerobase
(runtime/malloc.go
)를 가리키며 동일한 주소를 공유합니다.
탈출 시나리오에 관해서는:
- 변수
c
와d
는 힙으로 탈출합니다. 이들의 주소는 같으며 비교 결과도true
입니다. - 변수
e
와f
는 (대부분의 경우) 서로 다른 주소를 가지며 비교 결과는false
입니다. 참고: Go의 메모리 관리 방식에 따라 때때로 같은 주소를 가질 수도 있습니다.
이러한 동작은 Go에서 의도된 것인데요.
빈 struct
변수가 탈출하지 않으면 포인터는 같지 않습니다. 탈출 후에는 포인터가 같아집니다.
빈 Struct를 포함할 때의 공간 계산
빈 struct
자체는 공간을 차지하지 않지만, 다른 struct
에 포함될 때는 위치에 따라 공간을 차지할 수 있습니다.
struct
의 유일한 필드일 경우, 해당struct
는 공간을 차지하지 않습니다.- 첫 번째 또는 중간 필드일 경우, 공간을 차지하지 않습니다.
- 마지막 필드일 경우, 이전 필드와 동일한 크기의 공간을 차지할 수도 있습니다 (패딩 규칙에 따라).
type s1 struct {
a struct{}
}
type s2 struct {
_ struct{}
}
type s3 struct {
a struct{}
b byte
}
type s4 struct {
a struct{}
b int64
}
type s5 struct {
a byte
b struct{}
c int64
}
type s6 struct {
a byte
b struct{}
}
type s7 struct {
a int64
b struct{}
}
type s8 struct {
a struct{}
b struct{}
}
func main() {
fmt.Println(unsafe.Sizeof(s1{})) // 0
fmt.Println(unsafe.Sizeof(s2{})) // 0
fmt.Println(unsafe.Sizeof(s3{})) // 1
fmt.Println(unsafe.Sizeof(s4{})) // 8
fmt.Println(unsafe.Sizeof(s5{})) // 16
fmt.Println(unsafe.Sizeof(s6{})) // 2 (Go 버전에 따라 1일 수도 있음)
fmt.Println(unsafe.Sizeof(s7{})) // 16
fmt.Println(unsafe.Sizeof(s8{})) // 0
}
빈 struct
가 배열이나 슬라이스의 요소일 때:
var a [10]int
fmt.Println(unsafe.Sizeof(a)) // 80 (int가 8바이트인 64비트 시스템 기준)
var b [10]struct{}
fmt.Println(unsafe.Sizeof(b)) // 0
var c = make([]struct{}, 10)
fmt.Println(unsafe.Sizeof(c)) // 24 (슬라이스 헤더의 크기)
활용 사례
빈 struct
의 제로 크기 속성 덕분에 추가적인 메모리 오버헤드 없이 다양한 목적으로 사용할 수 있습니다.
키가 지정되지 않은 Struct 초기화 방지
type MustKeyedStruct struct {
Name string
Age int
_ struct{} // 빈 struct 필드 추가
}
func main() {
person := MustKeyedStruct{Name: "hello", Age: 10} // 정상 작동
fmt.Println(person)
// person2 := MustKeyedStruct{"hello", 10} // 컴파일 오류: MustKeyedStruct에 값이 너무 적습니다.
// fmt.Println(person2)
}
Set 자료 구조 구현
package main
import (
"fmt"
)
type Set struct {
items map[interface{}]emptyItem
}
type emptyItem struct{}
var itemExists = emptyItem{}
func NewSet() *Set {
return &Set{items: make(map[interface{}]emptyItem)}
}
func (set *Set) Add(item interface{}) {
set.items[item] = itemExists
}
func (set *Set) Remove(item interface{}) {
delete(set.items, item)
}
func (set *Set) Contains(item interface{}) bool {
_, contains := set.items[item]
return contains
}
func (set *Set) Size() int {
return len(set.items)
}
func main() {
set := NewSet()
set.Add("hello")
set.Add("world")
fmt.Println(set.Contains("hello")) // true
fmt.Println(set.Contains("Hello")) // false
fmt.Println(set.Size()) // 2
}
채널을 통한 신호 전송
때로는 채널을 통해 전송되는 데이터의 내용이 중요하지 않고 단지 신호 역할만 할 때가 있는데요.
예를 들어, 세마포어 구현에서 빈 struct
를 사용할 수 있습니다.
var empty = struct{}{}
type Semaphore chan struct{}
// n개의 리소스를 확보합니다.
func (s Semaphore) P(n int) {
for i := 0; i < n; i++ {
s <- empty
}
}
// n개의 리소스를 해제합니다.
func (s Semaphore) V(n int) {
for i := 0; i < n; i++ {
<-s
}
}
// 잠금을 획득합니다. (하나의 리소스 확보)
func (s Semaphore) Lock() {
s.P(1)
}
// 잠금을 해제합니다. (하나의 리소스 해제)
func (s Semaphore) Unlock() {
s.V(1)
}
// N개의 동시 접근을 허용하는 새로운 세마포어를 생성합니다.
func NewSemaphore(N int) Semaphore {
return make(Semaphore, N)
}
'Go' 카테고리의 다른 글
Go (고) 언어 동시성의 비밀, 고루틴 스케줄링 (0) | 2025.03.22 |
---|---|
Go의 Structs와 Interfaces, 객체지향 프로그래밍을 넘어서 (0) | 2025.03.19 |
Gin 프레임워크 깊이 파헤치기: Golang의 선도적인 웹 프레임워크 (0) | 2025.03.15 |
Go 언어에서 다양한 형태의 for 루프를 제공하는 이유: 간결함 속에 담긴 힘 (1) | 2025.02.15 |
Go 언어로 블록체인 직접 만들기: 개념부터 구현까지 쉽게 파헤치기 (0) | 2025.02.15 |