생각하는 Go 언어 프로그래밍 : 구조체에 생성자를 둘 수 있나?

《[Must Have] Tucker의 Go 언어 프로그래밍》 중
골든래빗 출판사
공봉식 지음


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( ) 함수는 실제 내부 연결 구조체를 몰라도 되도록 인터페이스를 공개하고 공개된 인터페이스 객체를 반환하도록 되어 있습니다.
B.3 포인터를 사용해도 복사가 일어나나?
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바이트만큼 복사됩니다.
“대입 연산자는 항상 우변의 값을 좌변의 메모리 공간에 복사하고 그 크기는 타입의 크기와 같습니다.”
B.3.1 배열과 슬라이스의 복사
배열과 슬라이스의 복사를 살펴봅시다.
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개 필드를 갖는 구조체로 표현됩니다(18장 ‘슬라이스’ 참조).
type SliceHeader struct {
    Data uintptr  // 실제 배열을 가리키는 포인터 
    Len  int  // 요소 개수
    Cap  int  // 실제 배열의 길이
}
구조체에 대입 연산자를 사용하면 구조체의 모든 필드가 복사됩니다. 즉 Data, Len, Cap 필드가 복사됩니다. 메모리 주소를 나타내는 Data는 64비트 컴퓨터에서 64비트(8바이트)이고 int 타입 역시 8바이트이므로 총 24바이트가 복사됩니다. 즉 슬라이스는 가리키는 배열 크기가 얼마인지 상관없이 대입 연산 시 항상 24바이트가 복사됩니다.
B.3.2 함수 호출 시 인수값 전달
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 언어에서는 항상 복사로 값이 전달되고 복사되는 양은 타입 크기와 같다는 점을 명심하세요.
이런 점은 슬라이스 외 맵과 채널도 마찬가지입니다. 맵과 채널 또한 내부에 실제 데이터를 가리키는 포인터 필드를 가지고 있어서 다른 변수로 대입되어도 전체 데이터가 복사되는 게 아닌 포인터만 복사됩니다.
B.4 값 타입을 쓸 것인가? 포인터를 쓸 것인가?
구조체 객체 인스턴스를 값 타입으로 사용해야 할까요 아니면 포인터로 사용해야 할까요? 먼저 값 타입으로 사용한다는 것과 포인터로 사용한다는 것이 무엇이 다른지 살펴보겠습니다.
// 온도를 나타내는 값 타입
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의 포인터에 포함된 메서드입니다.
그럼 값 타입과 메서드 타입으로 사용될 때 어떤 차이가 있는지 살펴보겠습니다.
B.4.1 성능에는 거의 차이가 없다
복사되는 크기가 다르기는 합니다. 앞서 살펴보았듯이 모든 대입은 복사로 일어나고 복사되는 크기는 타입 크기와 같습니다. 모든 포인터의 크기는 메모리 주소 크기인 8바이트로 고정됩니다. 하지만 값 타입은 모든 필드 크기를 합친 크기가 됩니다. 그래서 포인터로 사용되는 *Student는 복사할 때마다 항상 8바이트씩 복사되지만 값 타입으로 사용되는 Temperature는 Value 필드인 int 크기 8바이트와 string의 내부 필드 크기인 16바이트를 합친 총 24바이트가 복사됩니다. 사실 8바이트와 24바이트면 3배나 차이나서 커보이지만 전체 메모리 공간에 비하면 작고 성능에 미치는 영향도 거의 없습니다. 사실 Go 언어에서는 메모리를 많이 차지하는 슬라이스, 문자열, 맵 등이 모두 내부 포인터를 가지는 형태로 제작되어 있어서 값 복사에 따른 메모리 낭비를 걱정하지 않으셔도 됩니다(하지만 내부 필드로 거대한 배열을 가지고 있으면 얘기가 달라집니다).
그래서 값 타입으로 사용될 때와 포인터로 사용될 때 복사되는 크기 면에서 보면 포인터가 더 효율적이지만 거의 차이가 없다01고 볼 수 있습니다.

* 01_ 성능과 메모리에 민감한 프로젝트에는 이렇게 복사로 발생되는 비용까지 계산해야 합니다.
그럼 왜 값 타입과 포인터를 구분하는 게 중요할까?
B.4.2 객체 성격에 맞춰라
객체가 사람도 아니고 무슨 성격이 다르다는 것인지 언뜻 와닿지 않을 겁니다. Temperature와 Student 객체를 잘 살펴보겠습니다. Temperature는 말 그대로 온도값을 나타냅니다. 중요한 점은 객체의 상태가 변할 때 서로 다른 객체인가 아닌가가 중요합니다. 예를 들어 10도를 나타내는 Temperature가 있을 때 여기에 5도를 더해서 15도를 나타내는 Temperature를 생성한다고 보겠습니다. 그럼 10도의 Temperature 객체와 15도의 Temperature를 같은 객체로 볼 것인가 여부가 값 타입으로 사용하는 게 맞는지 포인터로 사용되는 게 맞는지를 결정합니다. 10도와 15도는 엄연히 다르기 때문에 서로 다른 객체가 되는 게 맞을 겁니다. 반면 Student는 다릅니다. 16세인 어떤 학생이 한 살 나이를 더 먹었다고 해서 다른 학생으로 변하지는 않습니다. 즉 내부 상태가 바뀌어도 여전히 객체가 유지되기 때문에 Student는 포인터가 더 어울리게 됩니다.
다른 예제를 보겠습니다. 시각을 나타내는 데 사용되는 time.Time 객체를 살펴보겠습니다.
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 언어에서는 아무런 의미가 없습니다. 다만 프로그래머가 타입을 만들고 메서드를 정의할 때 이 타입을 값 타입으로 사용할지 포인터로 사용할 것인지 정할 뿐입니다. 하지만 값 타입과 포인터는 성격이 다릅니다. 따라서 객체를 정의할 때 둘을 섞어 쓰기보다는 값 타입이나 포인터 중 하나만 사용하는 게 좋습니다.
B.5 구체화된 객체와 관계하라고?
《Tucker의 Go 언어 프로그래밍》 27.6절 ‘의존 관계 역전 원칙’에서 구체화된 객체와 관계를 맺지 말고 추상화된 객체와 관계를 맺으라고 설명했습니다. 이번 절에는 그 반대로 구체화된 객체와 관계하라는 얘기를 해보겠습니다.
객체지향 설계가 보편화되지 않았던 시절에 모든 관계를 구체화된 객체들과 맺다 보니 산탄총 수술 문제를 비롯한 많은 문제가 발생했습니다. 오랫동안 서비스된 한 게임은 객체 간 결합도가 너무 높아 계속 억지로 끼워 맞추면서 업데이트하다 보니 ‘나룻배 위에 항공모함을 얹는다’는 말을 듣기도 했습니다.
오늘날에는 객체지향 설계가 보편화되고 좋은 디자인 패턴들도 사용됩니다. 그래서 앞서 거론한 게임과 같은 문제가 많이 해결됐습니다. 오히려 모든 관계를 추상화하다 보니 객체 간 관계가 감춰져서 코드 동작 구조를 파악하기 어려워지는 문제가 생겨났습니다. 또 추상화된 설계를 강조하다 보니 설계에 너무 많은 정성을 쏟게 되어 프로젝트 일정과 비용이 늘어나는 결과를 만들었습니다. 이렇게 필요없는 오버스펙으로 프로젝트 시간과 비용을 증가시키는 것을 벽에 금 페인트를 칠 한 것과 같다고 해서 금칠gold painting이라고 말합니다.
스타트업 프로젝트가 늘어나면서 이제는 프로그래밍 품질도 중요하지만 생산성이 더 중요해졌습니다. 빠르게 만들고 빠르게 시장의 검증을 받는 것이 중요해졌습니다. 그래서 “망할거면 빨리 망하자”라는 신조가 생겨났습니다. 빠르게 제작하고 시장에서 어느 정도 성공을 거두면 그때부터 계속 유지보수하면서 지속적으로 개선되는 프로그래밍이 중요한 시대가 됐습니다.
Go 언어는 덕 타이핑을 지원하기 때문에 구체화된 객체로 빠르게 제작하고 유지보수가 필요할 때마다 기존 객체를 수정하지 않고 인터페이스만 추가해서 의존 관계를 역전시킬 수 있습니다. 즉 높은 생산성과 지속적 개선에 너무 잘맞는 프로그래밍 언어입니다. 적어도 Go 프로그래밍에서는 구체화된 객체와 관계 맺기를 두려워하지 말라고 말할 수 있습니다.
아래와 같이 아이템 구매 기능을 담당하는 모듈이 있다고 해보겠습니다.
package marketplace

import "item"

type Marketplace struct {
}

func NewMarketplace() *Marketplace {
     ...
}

func (m *Marketplace) PurchaseItem() *item.Item {
     ...
}
이 모듈을 사용할 때 굳이 인터페이스를 만들지 않고 Marketplace 객체를 바로 사용해서 빠르게 제작합니다.
package main
 import (
     "item"
     "marketplace"
 )

 func main() {
      mp := marketplace.NewMarketplace()
      mp.PurchaseItem()
      ...
}
추후에 Marketplace 기능이 확장되거나 다른 업체 서비스로 변경되어서 Marketplace를 아래 패키지로 교체해야 한다고 해보겠습니다.
package marketplaceV2

import "item"

type MarketplaceV2 struct {
}

func NewMarketplace() *MarketplaceV2 {
     ...
}

func (m *MarketplaceV2) PurchaseItem() *item.Item {
     ...
}
이제 인터페이스를 만들어서 의존 관계를 역전할 때가 됐습니다. Go 언어는 덕 타이핑을 지원하기 때문에 Markerplace나 MarkerplaceV2를 수정할 필요없이 패키지 이용자 쪽에서 인터페이스를 정의해서 사용할 수 있습니다.
package main

import (
    "item"
    "marketplaceV2"
)

type ItemPurchaser {  // 인터페이스 정의
    PurchaseItem() *item.Item
}

func main() {
    var purchaser ItemPurchaser
    purchaser = marketplaceV2.NewMarketplace() // ❶
    purchaser.PurchaseItem()
    ...
}
모듈 제공자가 아니라 모듈을 이용하는 쪽에서 인터페이스를 정의해서 사용할 수 있습니다. ❶ Markerplace와 MarkerplaceV2 모두 ItemPurchaser 인터페이스를 구현하고 있기 때문에 어느 쪽 객체로도 만들어서 사용할 수 있습니다. 만약 MarketplaceV2의 인터페이스가 기존 Marketplace와 다르더라도 어댑터 패턴02을 적용해서 맞춰줄 수 있습니다.

* 02_ adapter pattern. 서로 다른 인터페이스를 맞춰주는 프로그래밍 패턴입니다.
위와 같이 Go 언어에서는 구체화된 객체를 바로 사용해서 빠르게 제작한 다음에 나중에 필요할 때 인터페이스를 통한 의존성 역전을 통해 지속적인 개선을 할 수 있습니다.
가비지 컬렉터는 불필요한 메모리를 청소해서 재사용할 수 있게 해줍니다. Go 언어는 가비지 컬렉터를 지원합니다. C/C++ 언어는 가비지 컬렉터를 지원하지 않기 때문에 메모리를 개발자가 직접 관리해야 합니다. 이로 인해 메모리를 할당하고 지우지 않아서 메모리가 부족해지는 문제, 이미 지운 메모리를 다시 지우는 문제, 이미 지운 메모리를 다른 객체가 가리키고 있어서 엉뚱한 데이터를 가져오는 문제 등이 발생합니다.
C/C++ 이후에 가비지 컬렉터를 지원하는 많은 언어가 등장해서 프로그래머를 메모리 관리로부터 해방시켜주었습니다. 하지만 가비지 컬렉터에 장점만 있는 것은 아닙니다. 가장 큰 문제는 필요 없어진 인스턴스를 찾고 메모리를 정리하는 데 CPU를 사용해 성능 저하, 프로그램 멈춤, 사용 메모리 상승 등의 문제가 발생한다는 겁니다.
가비지 컬렉터 알고리즘을 간단히 살펴보고 Go 언어에서 제공하는 가비지 컬렉터에 대해서 알아보겠습니다.
B.6.1 가비지 컬렉터 알고리즘
가비지 컬렉터에서 사용하는 알고리즘을 살펴보겠습니다.
표시하고 지우기
표시하고 지우기mark and sweep는 단순한 알고리즘입니다. 모든 메모리 블록을 검사해서 사용하고 있으면 1, 아니면 0으로 표시한 뒤, 0으로 표시된 모든 메모리 블록을 삭제하는 방식입니다. 구현하기 편하다는 장점이 있지만, 모든 메모리 블록을 전수 검사해야 하기 때문에 CPU 성능이 많이 필요하고, 검사하는 도중 메모리 상태가 변하면 안 되기 때문에 프로그램을 멈추고 검사해야 한다는 단점이 있습니다.
삼색 표시
삼색 표시tri-color mark and sweep는 메모리 블록에 색깔을 칠하는 방식입니다(실제로 색을 칠하는 게 아니라 0, 1, 2로 표시합니다). 표시하고 지우기 방식에서 한 발 더 발전한 방식으로 볼 수 있습니다. 회색은 아직 검사하지 않는 메모리를 나타내고 흰색은 아무도 사용하지 않는 블록, 검은색은 이미 검사가 끝낸 블록을 나타냅니다.
방법은 단순합니다. 아무거나 회색 블록을 찾아서 검은색으로 바꾸고, 그 메모리 블록에서 참조 중인 다른 모든 블록을 회색으로 바꿉니다. 이걸 모든 회색 블록이 없어질 때까지 반복합니다.
그러고 난 뒤 흰색 블록을 삭제하면 됩니다.
예를 들어보겠습니다.
type Student struct {
    name string
    age int
    group *Group
}

var student Student
위와 같이 Student 인스턴스를 담은 변수 student가 있다고 해보겠습니다. student 객체 안에 Group 메모리를 가리키는 포인터를 포함하고 있습니다.
student 인스턴스가 있는 메모리 블록을 검은색으로 바꿀 때 student 객체의 필드인 group이 가리키고 있는 group 메모리 블록을 회색으로 바꿔서 group 메모리를 다음에 검사할 수 있도록 합니다.
이 방식은 프로그램 실행 중에도 검사할 수 있어서 프로그램 멈춤 현상을 줄일 수 있습니다. 단점으로는 모든 메모리 블록을 검사하기 때문에 속도가 느리다는 점과 메모리 상태가 계속 변화하기 때문에 언제 메모리를 삭제할지 정하기 힘들다는 점입니다. 또, 속도가 느리기 때문에 만약 메모리를 삭제하는 속도보다 할당되는 속도가 더 빠르면 메모리가 지속적으로 증가되어서 프로그램을 완전 멈추고 전체 검사를 해야 하는 경우가 생기기도 합니다.
객체 위치 이동
객체 위치 이동moving object은 삭제할 메모리를 표시한 뒤 한쪽으로 몰아서 한꺼번에 삭제하는 방식입니다.
위와 같이 삭제할 메모리를 흰색으로 표시한 뒤 한쪽으로 메모리 블록을 이동한 뒤에 한꺼번에 삭제합니다. 메모리를 몰아서 지우기 때문에 메모리 단편화03가 생기지 않는 장점이 있습니다.

* 03_ memory fragmentation. 메모리 할당과 해제를 반복하다 보면 마치 구멍이 뚫린 것처럼 메모리가 중간 중간 비게 되어서 메모리 효율성이 떨어지는 문제를 말합니다.
반면 메모리 위치 이동이 쉽지 않다는 단점이 있습니다. 객체들은 서로 연관 관계를 가지고 있기 때문에 메모리 블록을 이동시키려면 모든 연관된 블록에 대한 조작을 멈춘 다음에 해야 합니다. 즉 여러 객체에 읽기/쓰기를 제한하고 난 다음에 옮겨야 하기 때문에 CPU 성능이 많이 필요하고 또 프로그래밍 언어 레벨에서 메모리 이동이 쉬운 구조를 가지고 있어야 합니다. Go 언어는 포인 터를 직접 사용하는 방식의 언어이기 때문에 위치 이동이 어렵습니다.
세대 단위 수집
컴퓨터 공학자들은 다년간의 경험을 통해서 ‘대부분 객체는 할당된 뒤 얼마되지 않아서 삭제된다’는 점을 발견했습니다. 즉 대부분 객체의 수명이 짧다는 겁니다. 그래서 전체 메모리를 검사하는 게 아니라 할당된 지 얼마 안 된 메모리 블록을 먼저 검사하는 방식이 세대 단위 수집generational garbage collection 방식입니다.
방금 할당된 메모리를 1세대 목록에 집어넣습니다. 1세대 가비지 컬렉터가 돌면 1세대 목록을 검사해서 불필요한 메모리를 삭제합니다. 그리고 살아남은 블록은 2세대 목록으로 옮깁니다. 이런 식으로 3세대, 4세대까지 늘어날 수 있습니다.
1세대 가비지 컬렉터를 자주 돌리고 세대가 깊어질수록 가비지 컬렉터를 돌리는 간격을 늘립니다. 이렇게 하면 세대를 분리했기 때문에 각 가비지 컬렉터 수행 시간이 짧아져서 더 효율적으로 가비지 컬렉팅을 할 수 있습니다.
단점은 구현이 복잡해지는 점과 역시 메모리 블록을 세대별로 이동해야 하는 문제가 발생합니다.
이상 가비지 컬렉팅 방식04에 대해서 알아봤습니다. 제가 좋아하는 프로그래밍 격언 중에 《맨먼스 미신》(인사이트, 2015)에서 나온 “늑대인간을 한 방에 죽일 은총알은 없다”는 말이 있습니다. 그 얘기는 적어도 컴퓨터 공학에서 복잡한 문제를 단번에 해결할 수 있는 해결 방법은 존재하지 않는다는 뜻입니다. 가비지 컬렉팅 방식 역시 모두 장점과 단점을 가지고 있습니다. 또 각각 알고리즘의 성격이 달라서 얻는 것이 있으면 잃는 것이 있습니다. 그래서 어떤 방식만 좋고 나머지는 나쁘다고 할 수 없습니다. 이제 Go 언어에서 사용되는 가비지 컬렉팅 방식에 대해서 살펴보겠습니다.

* 04_ 가비지 컬렉팅은 매우 복잡하고 계속 발전하고 있습니다. 자세한 이야기는 《THE GARBAGE COLLECTION HANDBAOOK》을 참조하기 바랍니다.
B.6.2 Go 언어 가비지 컬렉터
Go 언어 가비지 컬렉터는 계속 발전되고 있고 매우 빠른 성능을 자랑하고 있습니다. Go 언어 1.16 버전은 동시성 삼색 표시 수집concurrent tri-color mark and sweep garbage collection 방식을 사용합니다. 여러 고루틴에서 병렬로 삼색 검사를 한다는 뜻입니다. 즉 멈춤 시간을 매우 짧게 유지하면서 가비지 컬렉팅을 할 수 있습니다. Go 언어 가비지 컬렉팅은 세대 단위 수집 방식을 사용하지 않습니다. 그래서 메모리 이동에 따른 비용이 발생하지 않습니다. 이 결과 Go 언어 가비지 컬렉터는 한 번 돌때 1ms 미만의 프로그램 멈춤만으로 가비지 컬렉팅을 할 수 있습니다. Go 언어 가비지 컬렉터의 장단점을 살펴보겠습니다.
  • 장점
    1. 매우 짧은 멈춤 시간(1ms 이하)
  • 단점
    1. 추가 힙 메모리 필요
    2. 실행 성능이 저하될 수 있음
삼색 표시 방식이 멈춤 시간을 짧게 유지할 수 있지만, 메모리 할당 속도가 빠르면 프로그램을 멈추고 전체 검사를 해야 할 수도 있기 때문에 Go 언어에서 메모리 할당을 빈번하게 하는 것은 좋지 않습니다.05 이 점에 유의해 프로그래밍하기 바랍니다.

* 05_ Go뿐 아니라 가비지 컬렉터가 있는 모든 언어에서 빈번한 할당은 좋지 않습니다.
B.6.3 쓰레기를 줄이는 방법
쓰레기 분리수거보다 중요한 건 쓰레기 자체를 줄이는 겁니다. 마찬가지로 가비지 컬렉터가 아무리 빨라도 가비지 자체를 줄이는 게 더 효율적입니다. 메모리 쓰레기를 줄이는 방법을 살펴보겠습니다.
1. 불필요한 메모리 할당을 없앤다
쓰레기 대부분이 일회용품이듯 메모리 역시 잠깐 쓰고 버려지는 경우가 대부분입니다. 이러한 임시 메모리 할당을 줄인다면 프로그램 성능을 크게 증가시킬 수 있습니다. 그럼 불필요한 메모리 할당이 언제 일어나는지 살펴보겠습니다.
슬라이스 크기 증가
슬라이스가 append( )에 의해서 크기가 증가될 때 2배 크기에 해당하는 배열을 할당해서 사용합니다. 이때 불필요한 메모리 할당이 발생할 수 있습니다. 아래 예제를 보겠습니다.
package main

import "fmt"

func main() {
    var slice []int // ❶ 0개 cap을 갖는 슬라이스
    
    slice, allocCnt := append1000times(slice)
    fmt.Println("allocCnt:", allocCnt, "cap:", cap(slice))

    var slice2 = make([]int, 0, 1000) // ❷ 1000개 cap을 갖는 슬라이스
    slice, allocCnt = append1000times(slice2)
    fmt.Println("allocCnt:", allocCnt, "cap:", cap(slice))
}

func append1000times(slice []int) ([]int, int) {
    var lastCap int = cap(slice)
    var allocCnt int = 0
    for i := 0; i < 1000; i++ {
        slice = append(slice, i)
        if lastCap != cap(slice) { // ❸ capacity가 바뀔 때 allocCnt 증가
            allocCnt++
            lastCap = cap(slice)
        }
    }
    return slice, allocCnt
 }
allocCnt: 11 cap: 1024
allocCnt: 0 cap: 1000
❶ capacity 0개를 갖는 슬라이스를 만들어서 요소를 1000번 추가하는 함수인 append1000times() 함수를 호출합니다. ❷ capacity 1000개를 갖는 슬라이스를 만들어서 append1000times( ) 함수를 호출합니다. ❸ append( )를 하고 난 뒤 capacity값이 달라졌다 는 얘기는 새로운 배열이 할당됐다는 겁니다.
출력 결과를 보면 capacity 0개를 갖는 슬라이스에 요소를 1000번 추가하면 총 11번 메모리 할당이 일어나는 반면, 이미 capacity가 1000개인 슬라이스에 요소를 1000번 추가하면 메모리 할당이 한 번도 안 일어납니다.
이렇게 발생한 10번의 메모리 할당은 모두 바로 버려지는 메모리 쓰레기입니다. 이런 불필요한 메모리 할당만 없애도 많은 쓰레기가 줄어들 수 있습니다. 그래서 요소 개수가 예상되는 슬라이스를 만들 때는 예상 개수만큼 초기에 할당해 불필요한 메모리 할당을 줄일 수 있습니다.
string 합산
문자열 연산은 프로그래밍에서 빈번하게 발생합니다. 문자열은 불변이기 때문에 문자열 조작은 항상 새로운 메모리 할당을 유발합니다. 문자열을 추가할 때 15장에서 살펴보았듯이 string 합 연산보다는 strings.Builder를 이용하는 게 좋습니다. 또 strings 패키지는 다양한 문자열 조작 기능을 제공하고 있으니 새로 만드는 것보다 strings 패키지를 이용하는 걸 추천합니다.
2. 재활용
메모리 쓰레기 역시 재활용할 수 있습니다. 자주 할당되는 객체를 객체 풀pool에 넣었다가 다시 꺼내 쓰면 됩니다. 이것을 플라이웨이트flyweight 패턴 방식이라고 합니다. 예제를 살펴보겠습니다.
package main

import "fmt"

func main() {
    fac := NewFlyweightFactory(1000) // ❶ 객체 공장
    for i := 0; i < 1000; i++ {
        obj := fac.Create() // ❷ 1000번 만들고 버립니다. 
        obj.Somedata = "Somedata"
        fac.Dispose(obj)
    }
    fmt.Println("AllocCnt:", fac.AllocCnt)
}

type FlyweightFactory struct {
    pool     []*Flyweight
    AllocCnt int
}

func (fac *FlyweightFactory) Create() *Flyweight {
    var obj *Flyweight
    if len(fac.pool) > 0 { // ❸ 재활용
        obj, fac.pool = fac.pool[len(fac.pool)-1], fac.pool[:len(fac.pool)-1]
        obj.Reuse()
    } else {
        obj = &Flyweight{}  // ❹ 새로 만듭니다.
        fac.AllocCnt++
    }
return obj 
}

func (fac *FlyweightFactory) Dispose(obj *Flyweight) { // ❺ 반환
    obj.Dispose()
    fac.pool = append(fac.pool, obj)
}

func NewFlyweightFactory(initSize int) *FlyweightFactory {
    return &FlyweightFactory{pool: make([]*Flyweight, 0, initSize)}
}

type Flyweight struct {
    Somedata   string
    isDisposed bool
}
func (f *Flyweight) Reuse() {
    f.isDisposed = false
}

func (f *Flyweight) Dispose() {
    f.isDisposed = true
}

func (f *Flwweight) IsDisposed() bool {
    return f.isDisposed
}
AllocCnt: 1
❶ Flyweight 객체를 만드는 FlyweightFactory 인스턴스를 만듭니다. Flyweight 객체가 필요할 때 직접 생성하는 게 아니라 FlyweightFactory 인스턴스의 Create() 메서드를 사용합니다. ❷ Create( ) 메서드로 1000번 만들고 Dispose( ) 메서드로 바로 버립니다.
❸ FlyweightFactory의 Create( ) 메서드는 pool을 확인해서 객체가 있으면 새로 생성하지 않고 pool 슬라이스의 맨 마지막에서 빼서 반환합니다. ❹ 만약 없으면 객체를 새로 생성해서 반환합니다. ❺ Dispose( ) 메서드를 통해 필요 없는 객체를 반환합니다. 반환된 객체는 pool에 추가됩니다.
Flyweight 객체를 총 1000번 공장에서 만들어서 사용했지만 출력 결과를 보면 새로 생성된 객체는 1개뿐입니다. 쓰고 반환하고 다시 빼서 썼기 때문에 객체가 추가로 생성되지 않았습 니다.
이렇게 짧게 사용되는 객체 할당을 줄일 수 있지만 플라이웨이트 패턴에는 두 가지 주의점이 있습니다.
  1. 메모리가 상승하기만 하지 줄어들지 않습니다. 객체가 삭제되는 게 아니라 풀에 반환되기 때문에 한 번 생성된 객체는 사라지지 않습니다. 그래서 프로그램 전체에서 동시에 사용되는 최 대 개수만큼 메모리가 유지되는 문제가 있습니다.
  2. 이미 반환된 객체를 참조할 수 있습니다. 반환된 객체는 삭제된 객체라고 볼 수 있습니다. 만약 다른 객체에서 이미 반환된 객체를 참조하면 이미 삭제된 객체를 참조하는 댕글링 문제가 발생합니다. 그래서 객체를 사용하기 전에 이미 반환된 객체인지를 꼭 확인해야 합니다. 위 예제에서는 IsDisposed( ) 메서드를 이용해 확인할 수 있습니다.
첫 번째 문제는 반환된 지 오래된 객체를 지우는 방식으로 해결할 수 있지만 두 번째 문제는 해결 방법이 없고 프로그래머가 주의할 수밖에 없습니다. 그래서 플라이웨이트 방식은 자주 할당되지만 다른 객체에서 참조되지 않는 이름 그대로 매우 가벼운 객체들에만 사용해야 합니다.
3. 80 대 20 법칙
80 대 20 법칙은 경제학자인 빌프레도 파레토가 한 말이라서 파레토 법칙이라고도 합니다. 이탈리아 부의 80%를 20% 사람이 차지하고 있다는 것을 관찰한 데에서 유래됐습니다.
프로그래밍에서도 80 대 20 법칙을 적용할 수 있습니다. 예를 들면 ‘성능의 80%는 20% 코드에서 사용된다’라든가 ‘메모리의 80%는 20% 객체가 차지한다’ 등으로요. 이 법칙이 정말 80% 메모리를 20% 객체가 차지하는가 여부를 증명하는 것이라기보다는 일부 객체가 대부분의 메모리를 차지한다는 뜻으로 해석하는 게 좋습니다.
우리가 일회용품을 줄여야 하지만 어쩔 수 없는 경우에 써야 하는 것처럼 불필요한 메모리 할당을 모두 찾아서 없애야 한다기보다는 많은 메모리를 사용하는 일부 객체의 불필요한 할당을 줄이는 게 훨씬 효율적입니다. 그럼 어떤 객체에 메모리가 얼마나 할당되는지 알아내는 게 중요하겠군요! 각종 프로파일링 툴을 사용하면 알아낼 수 있습니다.
go test -cpuprofile cpu.prof -memprofile mem.prof –bench .
이 명령으로 벤치마크를 수행해서 사용되는 cpu 사용량 데이터와 메모리 사용량 데이터를 얻어서 분석할 수 있습니다. 또 datadog나 google cloud profiler 등을 통해서 실제 서비스되는 클라우드 머신의 성능을 분석할 수 있습니다.
B.7 Sort 동작 원리
sort.Sort( ) 함수 정의는 다음과 같습니다.
func Sort(data sort.Interface)
sort.Interface 인터페이스를 인수로 받습니다. 그럼 sort.Interface는 뭘까요? sort 패키지의 Interface 인터페이스 정의를 살펴봅시다.
type Interface interface {
    Len() int  // 길이 반환
    Less(i, j int) bool  // i번째와 j번째 비교 
    Swap(i, j int)  // i번째와 j번째 바꿈
}
sort.Sort( ) 함수가 이 인터페이스를 인수로 받기 때문에 어떤 타입이든지 위 세 메서드만 포함하고 있으면 모두 sort.Sort( ) 함수 인수로 사용할 수 있습니다. 즉 우리가 정의한 구조체도 위 세 메서드만 포함하고 있다면 Sort 인수로 사용될 수 있습니다.
하지만 []int 슬라이스는 위 세 메서드를 포함하고 있지 않습니다. 그래서 []int 슬라이스 타입을 Sort() 함수 인수로 사용할 수 없습니다. 그럼 []int 슬라이스를 어떻게 위 세 메서드를 포함한 타입으로 만들 수 있을까요? 위 예제에서는 sort.IntSlice(s)를 사용해서 []int 타입 변수인 s를 sort.Interface를 포함한 타입으로 변환합니다. 그래서 다음 두 줄로 []int 슬라이스를 정렬할 수 있습니다.
s := []int{5, 2, 6, 3, 1, 4}
sort.Sort(sort.IntSlice(s))
그럼 IntSlice()가 어떻게 만들어졌길래 []int 슬라이스를 sort.Interface를 지원하는 타입으로 만들어줄까요?
IntSlice()가 함수 같지만 사실은 별칭 타입입니다. 그래서 IntSlice(s)는 함수 호출이 아니라 s를 IntSlice 타입으로 타입 변환한 겁니다. 이게 어떻게 타입 변환이 됐고 어떻게 sort.Interface 인터페이스를 지원하는지 살펴보죠.
type IntSlice []int // ❶ []int 별칭 타입

func (p IntSlice) Len() int { return len(p) } // ❷ 길이 반환 
func (p IntSlice) Less(i, j int) bool { return p[i] < p[j] } // ❸ 비교 결과 반환 
func (p IntSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } // ❹ 값 바꾸기
생각보다 코드가 간단하죠? ❶ IntSlice 타입은 []int의 별칭 타입입니다. 그래서 []int를 IntSlice로 바로 타입 변환할 수 있습니다. 또 IntSlice는 세 메서드를 가지고 있습니다. 이게 바로 sort.Interface가 요구하는 메서드들입니다. 따라서 sort.Sort( ) 함수의 인수로 사용될 수 있습니다. 각 메서드는 ❷ 길이를 반환하고 ❸ 값을 비교하고 ❹ 두 값을 바꾸는 것으로 되어 있습니다.
이렇게 단순한 세 메서드만 만들어주면 나머지는 모두 sort.Sort( )에서 알아서 해줍니다.

WRITER

공봉식

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

Leave a Reply

©2020 GoldenRabbit. All rights reserved.
서울시 마포구 신촌로2길 19 302호 (우)04051
master@goldenrabbit.co.kr
개인정보처리방침