Go

Go 언어 구조체(Struct) 완벽 정복: 기본부터 메모리 최적화, 활용 팁까지

드리프트2 2025. 3. 15. 20:35

Go 언어 구조체(Struct) 완벽 정복: 기본부터 메모리 최적화, 활용 팁까지

Go 언어에서 struct는 데이터를 정의하고 캡슐화하는 데 사용되는 복합 타입인데요.

 

서로 다른 타입의 필드들을 하나로 묶을 수 있게 해줍니다. struct는 다른 언어의 클래스와 유사한 사용자 정의 데이터 타입으로 볼 수 있지만, 상속은 지원하지 않습니다.

 

메서드는 특정 타입(주로 struct)과 연관된 함수로, 해당 타입의 인스턴스를 사용하여 호출할 수 있습니다.

 

1. Struct 정의 및 초기화 알아보기

Struct 정의하기

structtypestruct 키워드를 사용하여 정의하는데요.

 

간단한 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으로, IsActivefalse로 초기화됩니다.

 

포인터를 사용하여 초기화하기

 

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

 

분석:

  • aint8로 1바이트를 차지하며 1바이트 경계에 정렬됩니다.
  • bint32로 4바이트 정렬이 필요한데요. 컴파일러는 b의 주소를 4의 배수로 맞추기 위해 ab 사이에 3바이트의 패딩을 삽입합니다.
  • cint8로 1바이트가 필요하지만, struct의 전체 크기는 가장 큰 정렬 요구 사항인 4의 배수여야 합니다. 컴파일러는 끝에 3바이트의 패딩을 추가합니다.

메모리 정렬 최적화하기

패딩을 최소화하고 메모리 사용량을 줄이기 위해 struct 필드의 순서를 재배치할 수 있습니다.

type Optimized struct {
        b int32 // 4 바이트
        a int8  // 1 바이트
        c int8  // 1 바이트
}

 

출력: 8

이 최적화된 버전에서는 b가 먼저 배치되어 4바이트 경계에 정렬됩니다.

 

ac는 연속적으로 배치되어 전체 크기가 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)를 가리키며 동일한 주소를 공유합니다.

 

탈출 시나리오에 관해서는:

  • 변수 cd는 힙으로 탈출합니다. 이들의 주소는 같으며 비교 결과도 true입니다.
  • 변수 ef는 (대부분의 경우) 서로 다른 주소를 가지며 비교 결과는 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)
}