‘Go 언어 프로그래밍’, 구조체에 생성자를 둘 수 있나? — 상

골든래빗
골든래빗
Published in
18 min readJul 27, 2023

--

Go 언어에서는 구조체의 생성자 메서드를 지원하지 않습니다. 그래서 구조체 생성 시 초기화 로직을 명시할 수가 없습니다.

일반적으로 패키지를 만들 때 외부에 공개되는 구조체 인스턴스를 생성하는 생성 함수를 만들기도 하지만 꼭 그 생성 함수를 이용해야지만 구조체를 만들 수 있는 건 아닙니다.

예를 들어 bufio 패키지의 Scanner는 Scanner 객체를 생성하는 NewScanner( )라는 생성 함수를 제공하지만 그냥 Scanner{}를 해도 객체를 만들 수 있습니다.

type Scanner
func NewScanner(r io.Reader) *Scanner
var scanner1 = bufio.NewScanner(os.Stdin) // ❶ 표준 입력을 이용한 스캐너 생성
var scanner2 = &bufio.Scanner{} // ❷ 동작하지 않는 스캐너 생성

❶ bufio.NewScanner( ) 함수를 이용해서 표준 입력을 이용한 스캐너 객체를 생성합니다. 이렇게 생성하면 내부 구조체 필드가 알맞는 값으로 초기화되어서 스캐너를 이용할 수 있습니다.

❷ bufio.NewScanner( ) 함수를 이용하지 않고 그냥 구조체를 생성합니다. 이렇게 생성하면 필드가 각 타입의 기본값으로 초기화되어서 제대로 동작하지 않는 스캐너가 생성됩니다.

이렇게 패키지 외부로 공개되는 구조체의 경우 별도의 생성 함수를 제공하더라도 패키지 이용자에게 꼭 생성 함수를 이용하도록 문법적으로 강제할 방법은 없습니다. 그게 해당 객체를 올바르게 생성하는 방법이 아니라도 말이죠.

해결책으로 구조체를 외부로 공개하지 않고 인터페이스만 공개하는 방법이 있습니다. 그러면 생성 함수를 이용하지 않고는 객체 인스턴스를 생성하지 못하도록 강제할 수 있습니다.

내부 구조체를 감추고 인터페이스를 공개함으로써 생성 함수를 강제하는 패키지 예제를 살펴봅시다.

package bankaccount

type Account interface { // ❶ 공개되는 인터페이스
Withdraw(money int) int
Deposit(money int)
Balance() int
}

func NewAccount() Account { // ❷ 계좌 생성 함수 - 인터페이스 반환
return &innerAccount{ balance: 1000 }
}

type innerAccount struct { // ❸ 공개되지 않는 구조체 balance int
balance int
}

func (a *innerAccount) Withdraw(money int) int {
a.balance -= money
return a.balance
}

func (a *innerAccount) Deposit(money int) {
a.balance += money
}

func (a *innerAccount) Balance() int {
return a.blanace
}

❶ 외부로 공개되는 Account 인터페이스를 정의합니다.

❷ 역시 외부로 공개되는 NewAccount() 함수를 통해 Account 인터페이스 인스턴스를 반환합니다. 중요한 점은 구체화된 구조체가 아닌 인터페이스로 반환한다는 점입니다. 그래서 실제 인스턴스 타입은 innerAccount로 생성하지만 외부로 공개되는 구조체가 아니기 때문에 필드에 접근할 수 없고 인터페이스 메서드로만 사용할 수 있습니다.

❸ 실제 계좌 정보를 나타내는 구조체는 외부로 공개하지 않습니다. 이를 통해서 패키지 이용자로 하여금 계좌 인스턴스를 만들 때 NewAccount( ) 함수 이용을 강제할 수 있습니다.

실제 이 패키지를 사용하는 예제를 보겠습니다.

package main

import (
"fmt"
"github.com/tuckersGo/musthaveGo/exB1/bankaccount"
)

func main() {
account := bankaccount.NewAccount() // ❶ 계좌 생성
account.Deposit(1000)
fmt.Println(account.Balance())
}
2000

❶ bankaccount 내부의 innerAccount 구조체는 외부로 공개되는 구조체가 아니기 때문에 계좌 인스턴스를 생성하려면 NewAccount( ) 함수를 이용할 수밖에 없습니다.

이와 같은 방법으로 패키지 외부에서 특정 함수를 사용해서 구조체를 생성하도록 강제할 수는 있지만 일반적으로 많이 사용되는 방법은 아닙니다. 구조체를 생성할 때 특정 로직을 강제해야 할 때만 사용하기 바랍니다.

Go 기본 패키지 안에 들어있는 예를 살펴보겠습니다. 아래는 net 패키지의 일부입니다.

type Conn interface
func Dial(network, address string) (Conn, error)

net 패키지는 네트워크를 다루는 기본 패키지로 연결Connection을 나타내는 Conn 인터페이스와 연결을 맺는 함수인 Dial()을 제공합니다.

net 패키지 이용자는 Dial() 함수를 통해서 네트워크를 연결하고 연결을 나타내는 Conn 인스턴스를 이용해서 데이터를 주고받을 수 있습니다.

Dial( ) 함수는 실제 내부 연결 구조체를 몰라도 되도록 인터페이스를 공개하고 공개된 인터페이스 객체를 반환하도록 되어 있습니다.

포인터를 사용해도 복사가 일어나나?

Go 언어에서 변수 간 값의 전달은 타입에 상관없이 항상 복사로 일어납니다.
따라서 대입 연산자=는 우변의 값을 좌변 변수(메모리 공간)에 복사합니다.

a=b   // b값을 a의 메모리 공간에 복사합니다.

예를 들어보겠습니다.

type Student struct {
name string
age int
}

var s Student
var p *Student
p = &s // ❶ s의 메모리 주소를 대입
p.name = "bbb"

❶ Student 객체 s의 주소를 Student 포인터 p에 대입합니다. 그럼 p는 s를 가리키게 되고, p의 name을 변경하면 s의 name도 변경됩니다.

이때 p가 s 객체를 가리키니까 1 대입 연산자는 다르게 동작하지 않나 생각할 수 있지만, 대입 연산자도 똑같이 우변의 값을 좌변의 메모리 공간에 복사합니다.

이때 값은 &s 즉 s의 메모리 주소이고 이것 또한 숫잣값로 표현됩니다. 그래서 s의 메모리 주소를 나타내는 숫잣값을 p의 메모리 공간에 복사하게 됩니다.

이렇게 우변의 값이 좌변의 변수가 가리키는 메모리 공간에 복사되는 것은 변수 타입과 상관없이 동일하게 일어납니다.

그럼 얼마큼 복사할 것이냐 하는 문제가 발생합니다. 바로 타입 크기만큼 복사합니다.

p의 타입인 *Student는 Student 객체의 메모리 주소를 갖는 타입으로 메모리 주소 크기는 64비트 컴퓨터에 서는 64비트(8바이트)가 됩니다. 그래서 8바이트만큼 복사됩니다.

“대입 연산자는 항상 우변의 값을 좌변의 메모리 공간에 복사하고 그 크기는 타입의 크기와 같습니다.”

배열과 슬라이스의 복사

배열과 슬라이스의 복사를 살펴봅시다.

package main

import "fmt"

func main() {
var array [5]int = [5]int{1, 2, 3, 4, 5}
var b [5]int
b = array // ❶

var c []int
// c = array는 안 됩니다.
c = array[:] // ❷

b[0] = 1000
c[3] = 500

fmt.Println("array:", array)
fmt.Println("b:", b)
fmt.Println("c:", c)
}
array: [1 2 3 500 5]
b: [1000 2 3 4 5]
c: [1 2 3 500 5]

array의 타입은 [5]int입니다. 이것은 int 요소가 5개인 배열입니다.

❶ 같은 타입인 b에 대입 연산자를 이용해서 값을 복사합니다. 그럼 복사되는 크기는 얼마큼일까요?

앞서 살펴보았듯이 타입 크기만큼 복사됩니다. [5]int 타입의 크기는 int 타입 크기 5를 곱한 만큼, 즉 int 타입이 64비트 컴퓨터에서 8바이트이기 때문에 총 40바이트가 복사됩니다. 그래서 b는 array 배열의 복사본을 가지게 됩니다.

❷ c는 []int 타입입니다. 이것은 배열이 아니고 슬라이스 타입입니다 그래서 c = array로 바로 대입할 수 없습니다. 양변의 타입이 서로 같지 않기 때문입니다.

그래서 c = array[:] 이렇게 array 전체를 슬라이싱한 값으로 대입해야 합니다. 그럼 ❷ 이때 얼마큼 복사되는지 살펴보겠습니다 슬라이스는 다음과 같이 총 3개 필드를 갖는 구조체로 표현됩니다.

type SliceHeader struct {
Data uintptr // 실제 배열을 가리키는 포인터
Len int // 요소 개수
Cap int // 실제 배열의 길이
}

구조체에 대입 연산자를 사용하면 구조체의 모든 필드가 복사됩니다. 즉 Data, Len, Cap 필드가 복사됩니다.

메모리 주소를 나타내는 Data는 64비트 컴퓨터에서 64비트(8바이트)이고 int 타입 역시 8바이트이므로 총 24바이트가 복사됩니다.

즉 슬라이스는 가리키는 배열 크기가 얼마인지 상관없이 대입 연산 시 항상 24바이트가 복사됩니다.

함수 호출 시 인수값 전달

Go 언어는 함수 호출 시 인수값도 항상 복사로 전달됩니다.

함수 호출 시 인수값 전달을 살펴봅시다.

package main

import "fmt"

func CallbyCopy(n int, b [5]int, s []int) {
n = 3000
b[0] = 1000
s[3] = 500
}

func main() {
var array [5]int = [5]int{1, 2, 3, 4, 5}
var c []int
c = array[:]
CallbyCopy(100, array, c) // ❶

fmt.Println("array:", array)
fmt.Println("c:", c)
}
array: [1 2 3 500 5]
c: [1 2 3 500 5]

CallbyCopy() 함수 호출 시 인수가 총 3개 전달됩니다. Go 언어에서 인수 전달은 항상 복 사로 일어납니다.

그래서 함수 내부 변수 n에 100의 값이 복사되고 [5]int 타입인 변수 b에는 array값 즉 [5]int 타입 크기만큼인 40바이트가 복사됩니다. s는 슬라이스 타입이므로 내부 필드인 Data, Len, Cap 값이 복사되어 24바이트가 복사됩니다.

CallbyCopy( ) 함수 내부 변수인 b는 array의 복사본이라서 배열의 각 요소값은 같지만 서로 다른 배열을 나타냅니다.

그래서 b의 첫 번째 요소값을 변경해도 array의 첫 번째 요소값은 변경되지 않습니다. 반면 main() 함수의 c는 array의 전체를 슬라이싱한 값이고 s 또한 같은 배열을 가리키기 때문에 s의 네 번째 요소값을 변경하면 main( ) 함수의 c도 변경되고 array도 변경됩니다.

모두 같은 메모리 공간을 가리키고 있기 때문입니다. Go 언어에서는 항상 복사로 값이 전달되고 복사되는 양은 타입 크기와 같다는 점을 명심하세요.

이런 점은 슬라이스 외 맵과 채널도 마찬가지입니다. 맵과 채널 또한 내부에 실제 데이터를 가리키는 포인터 필드를 가지고 있어서 다른 변수로 대입되어도 전체 데이터가 복사되는 게 아닌 포인터만 복사됩니다.

값 타입을 쓸 것인가? 포인터를 쓸 것인가?

구조체 객체 인스턴스를 값 타입으로 사용해야 할까요 아니면 포인터로 사용해야 할까요?
먼저 값 타입으로 사용한다는 것과 포인터로 사용한다는 것이 무엇이 다른지 살펴보겠습니다.

// 온도를 나타내는 값 타입
type Temperature struct { // ❶ 값 타입
Value int
Type string
}

// 온도 객체 생성
func NewTemperature(v int, t string) Temperature { // ❷ 값 타입 생성
return Temperature{ Value: v, Type: t }
}

// 온도 증가
func (t Temperature) Add(v int) Temperature { // ❸ 값 타입 메서드
return Temperature{ Value: t.Value + v, Type: t.Type }
}

// 학생을 나타내는 포인터
type Student struct { // ❹ 포인터
Age int
Name string
}

// 새로운 학생 생성
func NewStudent(age int, name string) *Student { // ❺ 포인터 생성
return &Student{ Age: age, Name: name }
}

// 나이 증가
func (s *Student) AddAge(a int) { // ❻ 포인터 메서드
s.Age += a
}

Temperature와 Student 선언만 보면 별반 차이가 없습니다. 하지만 메서드들까지 자세히 보면 Temperature는 값 타입으로 사용되는 구조체이고 Student는 포인터로 사용되는 구조체입니다.

❶ Temperature는 값 타입으로 사용되는 구조체인데, 그 이유는 우선 새로운 Temperature 객체를 생성하는 ❷ NewTemperature( ) 함수 반환 타입이 Temperature의 포인터가 아닌 값 타입이고 온도를 더해서 새로운 Temperature를 만드는 ❸ Add( ) 메서드가 Temperature 값 타입에 포함된 메서드이고 반환값도 값 타입이기 때문입니다.

반면 ❹ Student 구조체는 포인터로 사용됩니다. 마찬가지로 새로운 학생을 생성하는 ❺ NewStudent() 함수의 반환 타입이 Student의 포인터이고 학생 나이를 증가시키는 AddAge( ) 메서드가 Student의 포인터에 포함된 메서드입니다.

그럼 값 타입과 메서드 타입으로 사용될 때 어떤 차이가 있는지 살펴보겠습니다.

성능에는 거의 차이가 없다

복사되는 크기가 다르기는 합니다. 앞서 살펴보았듯이 모든 대입은 복사로 일어나고 복사되는 크기는 타입 크기와 같습니다. 모든 포인터의 크기는 메모리 주소 크기인 8바이트로 고정됩니다.

하지만 값 타입은 모든 필드 크기를 합친 크기가 됩니다. 그래서 포인터로 사용되는 *Student는 복사할 때마다 항상 8바이트씩 복사되지만 값 타입으로 사용되는 Temperature는 Value 필드인 int 크기 8바이트와 string의 내부 필드 크기인 16바이트를 합친 총 24바이트가 복사됩니다.

사실 8바이트와 24바이트면 3배나 차이나서 커보이지만 전체 메모리 공간에 비하면 작고 성능에 미치는 영향도 거의 없습니다.

사실 Go 언어에서는 메모리를 많이 차지하는 슬라이스, 문자열, 맵 등이 모두 내부 포인터를 가지는 형태로 제작되어 있어서 값 복사에 따른 메모리 낭비를 걱정하지 않으셔도 됩니다.
(하지만 내부 필드로 거대한 배열을 가지고 있으면 얘기가 달라집니다)

그래서 값 타입으로 사용될 때와 포인터로 사용될 때 복사되는 크기 면에서 보면 포인터가 더 효율적이지만 거의 차이가 없다_01고 볼 수 있습니다.

* 01_성능과 메모리에 민감한 프로젝트에는 이렇게 복사로 발생되는 비용까지 계산해야 합니다.

그럼 왜 값 타입과 포인터를 구분하는 게 중요할까?

객체 성격에 맞춰라

객체가 사람도 아니고 무슨 성격이 다르다는 것인지 언뜻 와닿지 않을 겁니다. Temperature와 Student 객체를 잘 살펴보겠습니다.

Temperature는 말 그대로 온도값을 나타냅니다. 중요한 점은 객체의 상태가 변할 때 서로 다른 객체인가 아닌가가 중요합니다.

예를 들어 10도를 나타내는 Temperature가 있을 때 여기에 5도를 더해서 15도를 나타내는 Temperature를 생성한다고 보겠습니다. 그럼 10도의 Temperature 객체와 15도의 Temperature를 같은 객체로 볼 것인가 여부가 값 타입으로 사용하는 게 맞는지 포인터로 사용되는 게 맞는지를 결정합니다. 10도와 15도는 엄연히 다르기 때문에 서로 다른 객체가 되는 게 맞을 겁니다.

반면 Student는 다릅니다. 16세인 어떤 학생이 한 살 나이를 더 먹었다고 해서 다른 학생으로 변하지는 않습니다. 즉 내부 상태가 바뀌어도 여전히 객체가 유지되기 때문에 Student는 포인터가 더 어울리게 됩니다.

다른 예제를 보겠습니다.

type Time
func Now() Time // ❶ 현재 시각을 나타내는 Time 반환
func (t Time) Add(d Duration) Time
func (t Time) AddDate(years int, months int, days int) Time
func (t Time) After(u Time) bool

위는 Time 객체가 가지고 있는 메서드 목록입니다.
❶ Now() 함수는 현재 시각을 나타내는 Time 객체를 값 타입으로 반환합니다. 또 모든 메서드가 값 타입에 포함되고 있고, 값 타입을 반환합니다.

그래서 Time 객체는 값 타입으로 사용되는 객체입니다. 2021년 1월1일 00시 00분을 나타내는 Time 객체에 한 달을 더해서 2021년 2월1일 00시 00분을 나타내는 Time 객체를 만들었을 때 서로 다른 객체가 됩니다.

그래서 시각을 나타내는 Time은 값 타입으로 만들어져 있습니다.

그에 반해 포인터로 사용되는 같은 time 패키지의 Timer 객체를 살펴보겠습니다.

type Timer
func AfterFunc(d Duration, f func()) *Timer
func NewTimer(d Duration) *Timer
func (t *Timer) Reset(d Duration) bool
func (t *Timer) Stop() bool

Timer 객체는 일정 시간 이후 함수를 호출하거나 채널을 통해서 알림을 주는 객체입니다. Timer 객체를 반환하는 NewTimer( )나 AfterFunc( ) 함수는 *Timer, 즉 포인터 객체입니다. 또 메서드들 또한 *Timer 타입에 포함되어 있습니다.

30초 이후에 알림을 주는 Timer 객체를 생성한 뒤 이 타이머를 멈추거나 남은 시간을 지연시켰다고 해서 이 Timer 객체가 다른 객체로 변하지는 않습니다. 즉 내부 상태가 변해도 다른 객체로 바뀌지 않기 때문에 Timer 객체는 포인터로 만들어져 있습니다.

사실 Go 언어에서는 어떤 타입이 값 타입인지 포인터인지 강제하고 있지 않습니다. 또 그 둘을 섞어서 사용해도 문법적으로 아무 문제가 없습니다. 말하자면 문법적으로만 보면 값 타입이냐 포인터이냐는 Go 언어에서는 아무런 의미가 없습니다.

다만 프로그래머가 타입을 만들고 메서드를 정의할 때 이 타입을 값 타입으로 사용할지 포인터로 사용할 것인지 정할 뿐입니다.

하지만 값 타입과 포인터는 성격이 다릅니다. 따라서 객체를 정의할 때 둘을 섞어 쓰기보다는 값 타입이나 포인터 중 하나만 사용하는 게 좋습니다.

다음 내용을 -하- 편에서 이어집니다.

이 글은 《[Must Have] Tucker의 Go 언어 프로그래밍(세종도서 선정작)》에서 발췌했습니다.
골든래빗 출판사
글 권정민, 만화 주형

Golang 입문부터 고급 기법까지, 재미있는 4가지 프로젝트와 함께

게임 회사 서버 전문가가 알려주는
Go 언어를 내 것으로 만드는 비법

구글이 개발한 Go는 고성능 비동기 프로그래밍에 유용한 언어입니다. 이 책은 Go 언어로 ‘나만의 프로그램’을 만들 수 있게 이끌어줍니다. 프로그래밍 초보자도 쉽고 명확하게 이해할 수 있도록 학습 목표를 일목요연하게 제시하고 핵심 내용을 정리해 보여줍니다. 언어 문법과 예제 작동 순서를 그림을 곁들여 설명하고, 단계별로 프로젝트를 구현하며 프로그래밍을 직접 체험할 수 있게 했습니다.

32만 뷰가 증명하는 GO 언어 명강사를 만나자

Go 언어 1등 유튜버 Tucker가 더 체계적으로 Go 언어를 알려줍니다. 문법만 알려드리는 데 그치지 않습니다. Go 프로그래밍 능력을 길러드리는 것이 목표입니다. Go 언어에 입문해, 커뮤니티와 구글링으로 현업 문제를 해쳐나갈 수 있는 문턱까지 안내해드립니다. 포기하지 않고 예제 하나하나를 타이핑해가며 공부하면 반드시 목표를 달성할 수 있게 구성했습니다.

Tucker의 Go 언어 프로그래밍 바로가기 →

WRITER

공봉식

13년 차 게임 서버 프로그래머로 다양한 장르의 온라인 게임을 개발했습니다. 넥슨과 네오위즈를 거쳐서 현재는 EA 캐나다에서 근무 중입니다. 「Tucker Programming」 유튜브 채널을 운영합니다.

책 내용 중 궁금한 점, 공부하다가 막힌 문제 등 개발 관련 질문이 있으시다면
언제나 열려있는 <Must Have Tucker의 Go 언어 프로그래밍> 저자님의
카카오채널로 질문해주세요!

--

--