Go (3) - 배열, 구조체, 포인터, 문자열, 패키지

배열

 

배열(array)은 같은 타입의 데이터들로 이루어진 타입입니다. 배열을 이루는 각 값은 요소(element)라고 하고 요소를 가리키는 위치값을 인덱스(index)라고 합니다. 배열은 같은 타입의 여러 데이터를 하나의 변수로 효과적으로 사용할 수 있도록 해줍니다.

배열의 인덱스는 0부터 시작합니다.

 

배열 선언 및 초기화

var nums [5]int		// 1

days := [3]string{"monday", "tuesday", "wednesday"}		// 2

var temps [5]float64 = [5]float64{24.3, 26.7}			// 3

var s = [5]int{1:10, 3:30}			// 4

x := [...]int{10, 20, 30}			// 5

 

  1. int 타입 요소를 5개 갖는 배열 nums를 할당 후 별도의 초기값을 지정하지 않아 int 타입의 기본값인 0으로 초기화됩니다.
  2. string 타입 요소를 3개 갖는 배열 days를 할당 후 각 요소값을 초기화했습니다.
  3. float64 타입 요소를 5개 갖는 배열 temps를 할당 후 첫 번째, 두 번째 요소를 초기화하고 나머지는 float64 타입의 기본값인 0.0으로 초기화됩니다.
  4. int 타입 요소를 5개 갖는 배열 s를 할당 후 인덱스가 1인 요소값을 10으로, 인덱스가 3인 요소값을 30으로 초기화하고 나머지는 int 타입의 기본값인 0으로 초기화됩니다.
  5. ... 를 사용해 배열 요소 개수를 생략하면 배열 요소 개수는 초기화되는 요소 개수와 같습니다.
배열 선언 시 개수는 항상 상수로 써야 합니다.

 

컴퓨터는 배열의 시작 주소에 '인덱스 + 타입 크기'를 더해서 배열의 요소를 찾아갑니다.

 

배열 요소 접근

 

배열 요소에 접근할 때에는 배열 변수에 대괄호 [ ]를 쓰고 그 사이에 접근하고자 하는 요소의 인덱스를 적습니다.

nums := [...]int{10, 20, 30, 40, 50}

nums[2] = 300

 

for 반복문에서 range 키워드를 이용하면 배열 요소를 순회할 수 있습니다.

var arr [t]float64 = [5]float64{24.0, 25.1, 26.2, 27.3}

for i, v := range arr {
    // i는 인덱스, v는 원소값
}
위 코드의 for문에서 사용된 문법은 앞서 배웠던 기본적인 for문의 문법이 아닌 별도의 range 순회 문법으로 배열뿐 아니라 문자열, 슬라이스, 맵, 채널 등에도 사용할 수 있습니다.

 

만약 range를 사용할 때 인덱스를 사용하지 않는다면 _(밑줄)을 사용해서 첫 번째 값을 반드시 무효화시켜야 합니다.

 

다중 배열

 

다중 배열은 중첩된 배열을 말합니다. 그 중에서 이중 배열은 X, Y 좌표계의 위치 데이터들을 위해 주로 사용되어서 이차원 배열이라고 부르고, 삼중 배열은 삼차원 공간상의 좌표 데이터들을 위해 주로 사용되어서 삼차원 배열이라고 부르기도 합니다.

이중 배열을 초기화할 때는 { } 도 이중으로 사용해야 합니다.

var b = [3][5]int32{
    { 1, 2, 3, 4, 5},
    { 2, 3, 4, 5, 6},
    { 3, 4, 5, 6, 7},
}

var b = [3][5]int32{
    { 1, 2, 3, 4, 5},
    { 2, 3, 4, 5, 6},
    { 3, 4, 5, 6, 7} }
배열 초기화 시 닫는 중괄호가 배열의 마지막 항목과 같은 줄에 있지 않은 경우 쉼표를 찍어야 합니다.

 

 

구조체

 

배열이 같은 타입의 값들을 변수 하나로 묶어줬던 것과 달리 구조체는 다른 타입의 값들을 변수 하나로 묶어주는 기능입니다.

type 타입명 struct {
    필드명 타입
    ...
    필드명 타입
}

 

  • type 키워드는 새로운 사용자 정의 타입을 선언할 때 사용
  • struct 키워드는 구조체 타입을 정의할 때 사용
  • { } 안에 안에 구조체가 가질 필드의 이름과 타입을 선언
타입명과 구조체의 필드명은 변수명과 동일하게 패키지 외부에서 접근 가능한지 여부에 따라 대문자, 소문자로 작성합니다.
package main

type House struct {
    Address string
    Size int
    Price float64
    Type string
}

func main() {
    var houd House
    house.Address = "서울시 강서구 ..."
    houst.Size = 28
    house.Pirce = 9.8
    house.type = "아파트"
}

 

구조체 필드 초기화

 

1. 초기값 생략

초기값을 생략하면 모든 필드가 기본값으로 초기화됩니다.

var house House

 

2. 모든 필드 초기화

모든 필드값을 중괄호 사이에 넣어서 초기화합니다.

var house House = House{ "서울시 강동구", 28, 9.80, "아파트" }

var house House = House{ 
    "서울시 강동구", 
    28, 
    9.80, 
    "아파트", 
}
여러 줄에 걸쳐서 초기화하는 경우 제일 마지막 값 뒤에 꼭 쉼표를 달아주어야 합니다.

 

3. 일부 필드만 초기화

일부 필드값만 초기화할 때는 필드명을 지정해서 초기화합니다. 초기화되지 않은 필드들은 기본값이 할당됩니다.

var house House = House{ Size: 28, Type: "아파트" }

var house House = House{ 
    Size: 28, 
    Type: "아파트",
}

 

구조체를 포함하는 구조체

 

구조체의 필드로 다른 구조체를 포함할 수 있습니다.

 

1. 내장 타입처럼 포함하는 방식

package main

type User struct {
    Name string
    ID string
    Age int
}

type VIPUser struct {
    UserInfo User
    VIPLevel int
    Price int
}

func main() {
    user := User{ "홍길동", "hong", 25 }

    vip := VIPUser {
        User{ "이길동", "lee", 30 },
        3,
        250,
    }
    
    vip.UserInfo.Age = 31
}

 

2. Embedded Field 방식

위 예시에서처럼 vip에서 Name이나 Age와 같이 UserInfo 안에 속한 필드를 접근하려면 두 단계를 걸쳐 접근해야 합니다. 그러나 구조체에서 다른 구조체를 필드로 포함할 때 필드명을 생략하면 .을 한 번만 찍어 접근할 수 있습니다.

package main

type User struct {
    Name string
    ID string
    Age int
}

type VIPUser struct {
    User		// 필드명 생략
    VIPLevel int
    Price int
}

func main() {
    user := User{ "홍길동", "hong", 25 }

    vip := VIPUser {
        User{ "이길동", "lee", 30 },
        3,
        250,
    }
    
    vip.Age = 31
}

 

구조체 크기

 

구조체 변수가 선언되면 컴퓨터는 구조체 필드를 모두 담을 수 있는 메모리 공간을 할당합니다.

package main

import (
    "fmt"
    "unsafe"
)

type User1 struct {
    Age int
    Score float64
}

type User2 struct {
    Age int32
    Score float64
}

func main() {
    var user1 User1
    var user2 User2

    fmt.Println(unsafe.Sizeof(user1))
    fmt.Println(unsafe.Sizeof(user2))
}

 

User1 구조체의 user1 변수가 선언되면 컴퓨터는 Age와 Score 필드를 연속되게 담을 수 있는 메모리 공간을 찾아 할당합니다. Age는 8바이트, Score도 8바이트이므로 총 16바이트 크기가 필요합니다. 즉, 구조체 변수 user1의 크기는 16바이트가 됩니다.

 

반면, User2 구조체의 user2 변수가 선언되면 컴퓨터는 4바이트 + 8바이트 = 12바이트 크기의 메모리 공간을 할당할 것 같지만, 실제로는 16바이트라고 출력됩니다. 이는 바로 메모리 정렬(Memory Alignment) 때문입니다.

 

◎ 메모리 정렬

메모리 정렬이란 컴퓨터가 데이터에 효과적으로 접근하고자 메모리를 일정 크기 간격으로 정렬하는 것을 말합니다. 실제 연산에 사용되는 데이터가 저장되는 곳이 레지스터인데, 이 레지스터의 크기가 4바이트인 컴퓨터를 32비트 컴퓨터라고 부르고, 레지스터의 크기가 8바이트인 컴퓨터를 64비트 컴퓨터라고 부릅니다. 레지스터 크기가 8바이트라는 얘기는 한 번 연산에 8바이트 크기를 연산할 수 있다는 의미입니다. 따라서 데이터가 레지스터 크기와 동일한 크기로 정렬되어 있으면 더욱 효율적으로 데이터를 읽어 올 수 있습니다.

 

예를 들어, 64비트 컴퓨터에서 int64 데이터의 시작 주소가 100번지일 경우 100은 8의 배수가 아니기 때문에 레지스터 크기에 맞게 정렬되어 있지 않습니다. 이럴 경우 데이터를 메모리에서 읽어올 때 성능을 손해 보기 때문에 처음부터 프로그램 언어에서 데이터를 만들 때 8의 배수인 메모리 주소에 데이터를 할당합니다. 이 경우 100번지가 아닌 8의 배수인 104번지에 할당됩니다.

 

앞의 예시를 다시 살펴보겠습니다.

Age는 4바이트, Score는 8바이트입니다. User2 구조체 변수 user2의 시작 주소가 96번지라면 Age의 시작 주소 역시 96번지가 됩니다. Age는 4바이트 공간을 차지하기 때문에 바로 이어서 Score 변수를 할당하면 Score 변수의 시작 주소는 100번지가 됩니다. 이 경우 메모리 정렬이 되어있지 않기 때문에 성능을 손해 보기 때문에 4바이트만큼 추가로 띄워서 104번지에 Score 변수를 할당합니다. 이렇게 메모리 정렬을 위해서 필드 사이에 공간을 띄우는 것을 메모리 패딩(Memory Padding)이라고 합니다.

참고로 메모리 패딩 시 4바이트 변수의 시작 주소는 4의 배수로 맞추고, 2바이트 변수의 시작 주소는 2의 배수로 맞춥니다.

 

구조체 필드 배치

 

위에서 배운 메모리 패딩을 고려한 효율적인 구조체 필드 배치 방법에 대해 알아보겠습니다.

type Char1 struct {
    A int8	// 1바이트
    B int
    C int8	// 1바이트
    D int
    E int8	// 1바이트
}

type Char2 struct {
    A int8	// 1바이트
    C int8	// 1바이트
    E int8	// 1바이트
    B int
    D int
}

 

8바이트보다 작은 필드는 크기(단위)를 고려해서 몰아서 배치하면 메모리 낭비를 줄일 수 있습니다.
그러나 메모리 용량이 충분한 데스크톱 애플리케이션이라면 패딩으로 인한 메모리 낭비를 크게 걱정하지 않아도 됩니다.

 

구조체 복사

 

구조체를 대입 연산자를 사용하여 변수에 복사하면 구조체의 크기만큼 메모리가 할당되면서 구조체의 모든 필드가 복사됩니다.

이때, 두 변수는 서로 다른 인스턴스를 바라보게 됩니다.

package main

type User struct {
    Age int
    Score float64
}

func main() {
    user1 := User{ 20, 3.5 }
    user2 := user1
    user2.Age = 30
}

 

 

결합도와 응집도

 

결합도(Coupling)는 모듈간 상호 의존 관계를 형성해서 서로 강하게 결합되어 있는 정도를 나타내는 용어로 의존성이라고 말하기도 합니다. 응집도(Cohesion)는 모듈의 완성도를 말하는 것으로 모듈 내부의 모든 기능이 단일 목적에 충실하게 모여 있는지를 나타내는 용어입니다.

 

프로그래밍 역사는 객체 지향 설계 원칙(SOLID)에 따라 객체 간 결합도는 낮추고 응집도는 높이는 방향으로 흘러왔습니다. 지금까지 배운 함수, 배열, 구조체는 모두 응집도를 높이는 역할을 합니다.

 

 

포인터

 

포인터는 메모리 주소를 값으로 갖는 타입입니다. 예를 들어 int 타입 변수 a가 있을 때 a는 메모리에 저장되어 있고 속성으로 메모리 주소를 갖고 있습니다. 변수 a의 주소가 0x0100번지라고 했을 때 메모리 주소값 또한 숫자값이기 때문에 다른 변수의 값으로 사용될 수 있습니다. 이렇게 메모리 주소값을 변숫값으로 가진 변수를 포인터 변수라고 합니다.

 

포인터 변수는 가리키는 데이터 타입 앞에 *을 붙여서 선언합니다. 포인터 변수를 초기화하기 않으면 기본값은 nil 입니다.

또한 포인터 변수 앞에 *를 붙이면 그 포인터 변수가 가리키는 메모리 공간에 접근할 수 있습니다. 

var a int
var p1 *int
var p2 *int

p1 = &a
p2 = &a

*p1 = 20

var b boolean = (p1 == p2)

 

비교 연산자를 사용해서 포인터 변수를 비교할 수도 있습니다.

 

포인터 사용 이유

 

변수에 대입하거나 함수에 인수를 전달할 때는 항상 값을 복사하기 때문에 큰 메모리 공간을 복사할 때에는 성능 문제가 발생할 수 있습니다.

package main

type Data struct {
    value int 
    data [200]int
}

func ChangeData(arg Data) {
    arg.value = 999
    arg.data[100] = 999
}

func main() {
    var data Data

    ChangeData(data)
}

 

위 코드에서는 ChangeData() 함수를 호출할 때 Data 타입의 구조체 변수인 data가 매개변수 arg로 복사됩니다.


package main

type Data struct {
    value int 
    data [200]int
}

func ChangeData(arg *Data) {
    arg.value = 999
    arg.data[100] = 999
}

func main() {
    var data Data

    ChangeData(&data)
}

 

그러나 ChageData() 함수의 매개변수에 포인터 변수를 받도록 수정하면 구조체 크기만큼의 복사가 일어나는 것이 아닌 메모리 주소의 크기인 8바이트만 복사됩니다. 이처럼 포인터를 이용하면 더효율적으로 데이터를 조작할 수 있습니다.

arg는 포인터 변수이기 때문에 함수 내에서 *arg.value 라고 표현해야 하지만 Go 언어에서는 arg.value 라고 사용해도 동작합니다.

 

포인터 변수 초기화 

 

포인터 변수를 초기화하는 방법에는 3가지가 있습니다.

 

1. 주소 연산자 사용

var data Data
var p *Data = &data

 

2. 구조체 리터럴 사용

별도의 Data 변수를 선언하지 않고 한 번에 포인터 변수를 선언할 수도 있습니다.

var p1 *Data = &Data{}

var p2 *Data = &Data{ value: 3 }
이때 구조체 리터럴에서 초기화되지 않은 필드는 자동으로 기본값으로 초기화됩니다.

 

3. new() 함수 사용

new() 내장 함수를 사용하면 더 간단하게 표현 가능합니다.

var p = new(Data)
new() 함수로 초기화할 때는 원하는 값으로 초기화할 수 없습니다. (기본값 초기화)

 

인스턴스

 

인스턴스란 메모리에 할당된 데이터의 실체를 말합니다. 인스턴스를 만들고 그 메모리 주소를 포인터 변수에 할당하기 때문에 포인터 변수가 아무리 많아도 인스턴스가 추가로 생성되는 것은 아닙니다.

메모리는 무한한 자원이 아니기 때문에 만약 메모리에 데이터가 할당만 되고 사라지지 않는다면 프로그램은 금세 메모리가 고갈되어 프로그램이 비정상 종료될 것입니다. 그래서 쓸모없는 데이터를 메모리에서 해제하는 기능이 필요한데, Go 언어는 가비지 컬렉터(Garbage Collector)라는 메모리 청소부 기능을 제공합니다. 이 가비지 컬렉터가 일정 간격으로 메모리에서 쓸모없어진 데이터를 청소합니다. 

func TestFunc() {
    u := &User{}
    u.Age = 30
    fmt.Println(u)
}

 

위 코드와 같이 함수 안에서 선언된 변수는 함수가 종료되면 내부 지역 변수인 u는 사라져 User 인스턴스는 쓸모가 없게 됩니다. 그러면 가비지 컬렉터가 이 인스턴스를 청소하게 됩니다. 하지만 세상에 공짜는 없다고 가비지 컬렉터는 메모리 영역을 모두 검사해서 쓸모없는 데이터를 지워주는 데 성능을 많이 씁니다. 즉, 가비지 컬렉터를 사용하면 메모리 관리에서 이득을 보지만 성능에서 손해가 발생하는 것입니다.

 

Stack 메모리와 Heap 메모리

 

대부분의 프로그래밍 언어는 메모리를 할당할 때 stack 메모리 영역 또는 heap 메모리 영역을 사용합니다. 이론상 stack 메모리 영역이 heap 영역보다 훨씬 효율적이기 때문에 stack 메모리 영역에서 메모리를 할당하는 게 좋지만, stack 메모리는 함수 내부에서만 사용 가능한 영역입니다. 함수가 종료되면 stack 메모리를 정리하기 때문에 Go 언어에서는 함수 밖에서도 사용되어야 하는 변수는 탈출 검사(excape analysis)를 해서 자동으로 stack 영역이 아닌 heap 영역에 메모리를 할당합니다. 또 Go 언어에서 stack 메모리는 계속 증가되는 동적 메모리 풀이기 때문에 메모리 효율성이 좋고, 스택 메모리가 고갈되는 문제도 발생하지 않습니다.

Java는 탈출 검사가 아닌 변수가 어떤 타입인지에 따라 메모리 위치가 달라집니다.
Java의 stack 영역은 고정으로 동적 확장이 되지 않습니다.

 

package main

import "fmt"

type User struct {
    Name string
    Age int
}

func NewUser(name string, age int) *User {
    var u = User{name, age}
    return &u		// 탈출 분석
}

func main() {
    userPointer := NewUser("AAA", 23)

    fmt.Println(userPointer)
}

 

위 코드에서 Go 언어는 NewUser() 함수 내 지역 변수 u가 함수 외부로 공개되는 것을 분석해서 자동으로 heap 메모리에 할당하게 됩니다.

 

 

문자열

 

문자열은 말 그대로 문자 집합입니다. 문자열의 타입명은 string으로 큰따옴표("")나 백틱(``)으로 묶어서 표시합니다. 백틱으로 문자열을 묶으면 문자열 안의 특수 문자가 일반 문자처럼 처리됩니다.

func main() {
    str1 := "Hello\tWorld\n"	// Hello	World

    str2 := `Hello\tWorld\n`	// Hello\tWorld\n
}

 

UTF-8 문자코드

 

Go는 UTF-8 문자코드를 표준 문자코드로 사용합니다. UTF-8은 다국어 문자를 지원하고 문자열 크기를 절약할 목적으로 Go 언어 창시자인 롭 파이크와 켄 톰슨이 고안한 문자코드입니다. UTF-16이 한 문자에 2바이트를 고정 사용하는 것과 달리 UTF-8은 자주 사용되는 영문자, 숫자, 일부 특수 무자를 1바이트로 표현하고 그 외 다른 문자들은 2~3바이트로 표현합니다.

 

Go는 문자 하나를 표현하는 데 rune 타입을 사용합니다. UTF-8은 한 글자가 1~3바이트 크기이기 때문에 UTF-8 문자값을 가지려면 3바이트가 필요한데, Go 언어의 기본 타입에서 3바이트 정수 타입은 제공되지 않기 때문에 rune 타입은 4바이트 정수 타입인 int32를 사용합니다.

rune 타입과 int32는 이름만 다를 뿐 같은 타입입니다.

 

타입 변환

 

string 타입은 []rune, []byte과 상호 타입 변환이 가능합니다. 문자열도 결국 메모리에 1바이트 단위로 저장되기 때문에 바이트 배열로도 변환 가능합니다. byte[]로의 변환은 추후 io.Writer 인터페이스를 사용할 때 알아보겠습니다.

package main

import "fmt"

func main() {
    str := "hello World"

    runes := []rune(str)
    // []rune{ 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100 }

    fmt.Println(str)		// Hello World
    fmt.Printlb(runes)		// Hello World
    
    fmt.Println(len(str))	// 문자열의 크기: 1(byte) * 11 = 11
    fmt.Println(len(runes))	// 문자열의 길이: 8
}

 

string 타입의 변수에 len() 함수를 사용하면 문자열이 차지하는 메모리의 크기인 문자열의 크기를 알 수 있습니다.

[]rune 타입의 변수에 len() 함수를 사용하면 배열의 요소 개수인 문자열의 길이를 알 수 있습니다.

 

문자열 순회

 

문자열을 순회하는 방법은 크게 3가지입니다.

1. 인덱스를 사용한 바이트 단위 순회

package main

import "fmt"

func main() {
    str := "Hello 월드!"

    for i := 0; i < len(str); i++ {
        fmt.Printf("%T, %d, %c\n", str[i], str[i], str[i])
    }
}
string 타입의 변수를 [ ] 를 사용하여 인덱스로 접근하면 해당 인덱스에 해당하는 바이트를 반환합니다.

 

위 코드에서 Printf() 를 사용하여 타입(%T), 값(%d), 문자값(%c) 을 출력해보면 타입은 uint8 이고 값과 문자값은 1byte인 영문자와 느낌표(!)만 제대로 출력이 되고, 3byte인 한글은 깨져서 나옵니다.

 

2. []rune으로 타입 변환 후 한 글자씩 순회

package main

import "fmt"

func main() {
    str := "hello 월드!"
    arr := []rune(str)

    for i := 0; i < len(arr); i++ {
        fmt.Printf("%T, %d, %c\n", str[i], str[i], str[i])
    }
}

 

위 코드에서 Printf() 를 사용하여 타입(%T), 값(%d), 문자값(%c) 을 출력해보면 타입은 int32 이고 값과 문자값도 모두 올바르게 출력됩니다.

[]rune으로 변환 후 순회 시 한 글자씩 순회할 수 있지만 []rune으로 변환되는 과정에서 별도의 배열을 할당하므로 불필요한 메모리를 사용하게 됩니다.

 

3. range 키워드를 이용해 한 글자씩 순회 ✅

package main

import "fmt"

func main() {
    str := "hello 월드!"
    for _, v := range str {
        fmt.Printf("%T, %d, %c\n", v, v, v)
    }
}

 

[]rune 순회와 동일하게 한 글자씩 순회하여 int32 타입으로 모두 올바르게 출력되지만, 추가 메모리 할당 없이 순회할 수 있어서 불필요한 메모리 낭비를 없앨 수 있습니다.

 

문자열 연산

 

1. 문자열 합치기

func main() {
    str1 := "Hello"
    str2 := "World"

    fmt.Println(str1 + " " + str2)
}

 

2. 문자열 비교하기

func main() {
    str1 := "Hello"
    str2 := "Hell"
    str3 := "Hello"
    str3 := "Hella"

    fmt.Println(str1 != str2)
    fmt.Println(str1 == str3)
    fmt.Println(str1 >= str4)
}
문자열의 대소 비교 시 문자열의 길이와 상관없이 앞글자부터 같은 위치에 있는 글자끼리 유니코드 값을 비교합니다.

 

문자열 구조 (심화)

 

Go 언어에서는 어떻게 문자열을 처리하는지 string 타입의 구조를 더 자세히 알아보겠습니다.

string 타입은 Go 언어에서 제공하는 내장 타입으로 그 내부 구현은 감춰져 있습니다. 하지만 reflect 패키지 안의 StringHeader 구조체를 통해서 내부 구현을 엿볼 수 있습니다.

type StringHeader struct {
    Data uintptr
    Len int
}

 

엄밀히 말하면 string은 구조체가 아니지만, 구조체처럼 내부에 2개의 필드를 갖고있습니다. 첫 번째 필드 Data는 uintptr 타입으로 문자열의 데이터가 있는 메모리 주소를 나타내는 일종의 포인터입니다. 두 번째 필드 Len은 int 타입으로 문자열의 길이를 나타냅니다.

func main() {
    str1 := "안녕하세요"
    str2 := str1
}

 

string은 구조체와 비슷한 내부 구조를 가진 값 타입으로 위와 같이 변수에 복사하면 구조체의 크기만큼 메모리가 할당되고 모든 필드값이 복사됩니다. 즉, 실제 문자열 데이터의 메모리 주소인 Data 필드값과 그 문자열의 길이인 Len 필드값을 동일하게 가지는 새로운 인스턴스가 생성됩니다. 그러나 두 인스턴스는 동일한 문자열 주소를 필드로 갖고 있기 때문에 출력시 동일한 문자열을 보여줍니다. 

 

값 타입과 참조 타입

Go 언어에서는 값 타입 변수와 참조 타입 변수를 구분합니다.

앞서 배운 int, bool, string, array, struct 와 같은 기본 타입들이 값 타입에 해당되고, 그외에 타입들은 참조 타입에 해당합니다.

 

값 타입 변수는 실제 값 자체를 갖고 있는 변수를 의미하며, 참조 타입 변수는 실제 값을 갖고 있는 것이 아닌 실제 값의 메모리 주소를 가리키는 포인터를 갖고 있습니다. 그렇기 때문에 변수에 대입(복사)될 때 값 타입 변수는 실제 데이터의 크기만큼 메모리가 할당되며 복사되고, 참조 타입 변수는 실제 데이터의 크기와는 상관 없이 포인터를 필드로 갖는 내부 구조체의 크기만큼 메모리가 할당되며 복사됩니다.

string은 특이하게 실제 값을 바라보는 포인터를 필드로 갖고 있어 참조 타입처럼 동작하지만, 그 실제 값이 불변이기 때문에 값 타입처럼 안전하게 다룰 수 있는 타입입니다.

 

문자열은 불변 (심화)

 

문자열은 불변(immutable)이기 때문에 string 타입이 가리키는 문자열의 일부만 변경할 수 없습니다. 만약 아래와 같이 일부만 바꾸려고 하면 컴파일 에러가 발생합니다.

var str string = "Hello World"

str = "How are you"
str[2] = "a"		// compile error
str += "?"

 

또한 불변이기 때문에 string 타입의 변수에 새로운 값을 대입하거나 문자열을 더하기 연산으로 처리하는 경우에도 원본 문자열을 변경하는 것이 아닌 새로운 문자열을 메모리에 할당하고, 그 새로운 문자열을 변수에 할당합니다.

package main

import "strings"

func main() {
    var str string = "Hello World"

    var builder strings.Builder
    builder.WriteString(str)
    builder.WriteString("?")
    
    result := builder.String()
}

 

따라서 string의 합 연산을 빈번하게 하면 메모리가 낭비되는데, 이 경우 strings 패키지의 Builder를 이용하면 메모리 낭비를 줄일 수 있습니다. 그러나 이 역시도 기존 string 문자열에 추가하는 방법이 아닌 기존의 문자열을 builder로 새로 작성하고 이어 붙이는 것으로 문자열의 빈번한 합 연산 시에 유용한 것입니다.

 

 

패키지

 

패키지(package)란 Go 언어에서 코드를 묶는 가장 큰 단위입니다.

◎ main 패키지 (실행 파일용)

main 패키지는 특별한 패키지로 프로그램 시작점인 main() 함수를 포함한 패키지입니다. 프로그램이 실행되면 운영체제는 프로그램을 메모리로 로드하고 프로그램 시작점부터 한 줄씩 코드를 실행합니다.

일반적으로 "디렉터리명 = 패키지명"으로 맞추는 것이 관례이나 반드시 main() 함수가 있는 main.go 파일은 패키지를 main으로 선언해야 합니다.

 

◎ 그외 패키지 (라이브러리용)

한 프로그램은 main 패키지 외에 다수의 다른 패키지를 포함할 수 있습니다. 표준 입출력은 fmt 패키지를, 암호화 기능은 crypto 패키지를, 네트워크 기능은 net 패키지를 import 해 사용하면 됩니다.

 

패키지 사용하기

 

1. import 하기

패키지를 import 예약어로 가져오면 해당 패키지에서 외부로 노출하는 함수, 구조체, 변수, 상수 등을 사용할 수 있습니다.

import "fmt"

import (
    "fmt"
    "net"
)

 

소괄호로 패키지들을 묶어 여러 패키지를 import 할 수 있습니다.

 

2. 패키지 멤버에 접근하기

패키지를 가져오면 해당 패키지명을 쓰고 점(.) 연산자를 사용해 패키지에서 제공하는 함수, 구조체 등에 접근할 수 있습니다.

fmt.Println("Hello World")

 

3. 경로가 있는 패키지 사용하기

package main

import (
    "fmt"
    "math/rand"
)

func main() {
	fmt.Println(rand.Int())
}

 

math/rand 패키지를 import 한 후 메서드에 접근할 때 마지막 폴더명인 rand 만 사용합니다.

 

4. 겹치는 패키지명에 별칭 부여하기

package main

import (
    "text/template"
    htemplate "html/template"	// 별칭 부여
)

 

5. 사용하지 않는 패키지 포함하기

import (
    "database/sql"
    _ "github.com/mattn/go-sqlite3"
)

 

import 한 패키지를 직접 사용하지 않고 다른 패키지에 사용하겠다는 부가효과를 얻고자 하는 경우 앞에 _(밑줄)을 붙여줘 컴파일 에러를 방지했습니다.

해당 패키지의 초기화 함수(init)만 실행되길 원하는 경우에 사용합니다.

 

패키지명과 패키지 외부 공개

 

Go 언어에서 패키지명은 모든 문자를 소문자로 할 것을 권장하고 있습니다.

변수명, 함수명, 구조체명의 첫 글자가 대문자이면 패키지 외부로 공개됩니다.

 

패키지 초기화

 

패키지를 import 하면 컴파일러는 패키지 내 전역 변수를 초기화합니다.

그런 다음에 패키지에 init() 함수가 있다면 호출해 패키지를 초기화합니다.

init() 함수는 반드시 입력 매개변수가 없고 반환값도 없는 함수여야 합니다.

 

프로젝트 레이아웃

 

기본 패키지 구조는 https://github.com/golang-standards/project-layout/blob/master/README_ko.md 에서 확인할 수 있습니다.

 

Go 모듈과 배포

 

Go 모듈은 Go 패키지들을 모아놓은 Go 프로젝트 단위입니다. 즉 1개 프로젝트 = 1개 모듈입니다.

 

1. 모듈 초기화

일반적으로 실행파일은 /cmd에 라이브러리용 파일은 /pkg에 생성하며, /cmd에 main.go 파일을 생성하면 모듈 초기화(프로젝트 초기화)를 진행합니다. 모듈 초기화 명령어는 아래와 같이 사용하며, 일반적으로 패키지명은 로컬 개발 시에는 모듈명(프로젝트명)을 사용하고 원격 저장소에 배포 시에는 원격 저장소 주소를 적어줍니다. (프로젝트 루트 경로에서 명령어 실행)

go mod init 패키지명

# Local
go mod init myApp

# Github
go mod init github.com/깃허브_이름/레포지토리_이름
모듈 초기화 시 지정한 패키지명은 모듈 식별자로 사용되며, main.go 파일에 표시되는 import 경로에 영향을 미칩니다. 단, 프로그램의 실행에는 영향을 주지 않습니다.

 

모듈 초기화를 하고 나면 루트 경로에 go.mod 파일이 생성되는데, go.mod 파일에는 모듈 이름과 Go 버전, 필요한 외부 패키지 등이 명시되어 있습니다. Go 언어에서는 go build를 통해 실행 파일을 만들 때 go.mod와 외부 저장소 패키지 버전 정보를 담고 있는 go.sum 파일을 통해 외부 패키지와 모듈 내 패키지를 합쳐서 실행 파일을 만들게 됩니다.

go build를 하려면 반드시 Go 모듈 루트 폴더에 go.mod 파일이 있어야 합니다.

 

2. 외부 패키지 import

외부 패키지를 import하여 사용하기 위해서는 반드시 위의 모듈 초기화가 완료되어야 합니다. 외부 라이브러리 의존성은 아래 명령어를 통해 추가할 수 있습니다. -u 옵션은 연관된 라이브러리들도 모두 최신으로 업데이트하라는 의미입니다.

go get -u 외부_라이브러리_주소

 

위 명령어를 수행 후 go.mod 파일을 열어보면 의존성이 추가되면서 "// indirect" 라는 주석이 달려있는 것을 확인할 수 있습니다. 이는 해당 라이브러리가 추가되었으나 직접적으로 사용되지 않았다는 의미입니다.

 

그리고 다운받은 외부 패키지들은 GOPATH/pkg/mod 폴더에 버전별로 저장되어 있습니다. 그래서 이미 한 번 다운받은 패키지들은 다른 모듈에서 사용되더라도 같은 버전이라면 다시 다운로드하지 않고 사용하게 됩니다.

GOPATH는 일반적으로 윈도우에서는 C:\Users\본인계정\go 이고, 맥과 리눅스에서는 ~/go 입니다.

 

3. 의존성 정리

프로그램 개발이 완료되고 나면 배포하기 전에 의존성 정리를 해줘야 합니다. 사용하지 않은 의존성을 제거하고 사용된 의존성은 "// indirect" 라는 주석을 제거해줍니다.

go mod tidy

 

4. 실행 및 빌드

실행(run)과 빌드(build)는 main.go 파일이 있는 (메인)패키지로 이동해서 아래 명령어를 실행합니다.

go run .	// 실행

go build	// 빌드

 

run은 실행 파일을 만들지 않고, build는 해당 패키지명으로 된 실행 파일이 추가됩니다.

실행 파일은 다른 외부 파일 없이 실행 파일 혼자서 독립적으로 실행이 가능합니다.