[Go] 구조체, 직렬화, 인터페이스

Jiwoo Kim·2021년 11월 6일
0

Go

목록 보기
7/11
post-thumbnail

구조체

필드들의 모음 혹은 묶음을 구조체라고 한다. 배열은 같은 자료형만 묶을 수 있는 반면, 구조체는 서로 다른 자료형의 자료들도 묶을 수 있다.

type Task struct {
    title	string
    done	bool
    due		*time.Time
}

구조체 type 선언은 위와 같이 할 수 있다.

var myTask Task
var myTask = Task{"laundry", true, nil}
var myTask = Task{
    title: "laundry",
    done: true,		// 뒤에 쉼표를 붙여야 구문 분석 시 오류가 안 난다.
}

선언된 구조체 type 변수는 위와 같이 생성할 수 있다. 필요한 필드만 넣어서 변수를 생성할 수 있고, 입력값이 없는 필드는 자동으로 기본값이 할당된다.

var task = struct {
    title 	string
    done 	bool
    due 	*time.Time
}{"laundry", false, nil}

선언과 동시에 변수를 하나 생성할 수도 있다.

const

Go는 enum을 지원하지 않는다. 대신 const를 활용해 자료형을 정의하고 그 안에 상수들을 묶어 놓으면 enum 스타일을 구현할 수 있다.

type status int

const (
    UNKNOWN	status = 0
    TODO	status = 1
    DONE	status = 2
)

iota

const (
    UNKNOWN	status = iota
    TODO
    DONE
)

iota를 사용하면 순서대로 0, 1, 2,...가 붙는다.
단순하게 숫자를 붙이는 것 외에도 아래와 같이 사용할 수도 있다.

type ByteSize float64

const (
    _	         = iota // ignore first value	
    KB	ByteSize = 1 << (10 * iota)
    MB
    GB
    // ...
)

더 단순하게 개선하면 아래와 같이도 할 수 있다.

const (
    KB	ByteSize = 1 << (10 * (1 + iota))
    MB
    GB
    // ...
)

테이블 기반 테스트

Go는 제네릭도 지원하지 않으며, assertion 함수도 지원하지 않는다. 따라서 여러 사례를 테스트하고 싶을 때는 구조체와 배열을 이용해 테이블 기반 테스트를 해야 한다. 테이블 기반 테스트란, 구조체의 배열에 테스트하고자 하는 사례들을 나열해두고 반복문으로 검사하는 방식이다.

func TestFib(t *testing.T) {
    cases := []struct {
    	in, want int
    }{
    	{0, 0},
        {5, 5},
        {6, 8},
    }
    for _, c := range cases {
    	got := seq.Fib(c.in)
        if got != c.want {
        	t.Errorf("Fib(%d) == %d, want %d", c.in, got, c.want)
        }
    }
}

구조체 내장

Go는 다른 언어에 비해 구조체 자체가 갖는 의미가 크지는 않다. Go에서 구조체를 특별하게 만드는 것은 여러 자료형의 필드들을 가질 수 있다는 점이다. 이러한 구조체를 재사용함으로써 포함 관계를 더 쉽게 사용할 수 있다.

특히 Go는 구조체를 내장할 수 있다. 필드 이름 없이 다른 자료형의 이름만 넣으면 해당 자료형이 구조체에 내장된다.
아래와 같이 Deadline이 정의되어 있을 때, 아래 Task 구조체는 포인터를 필드로 가짐으로써 기능을 내장할 수 있다.

type Deadline time.Time

func (d Deadline) OverDue() bool {
    return time.Time(d).Before(time.Now())
}
type Task struct {
    Title	string
    Status	status
    *Deadline		// task.OverDue()와 같이 호출 가능
}

내장 구조체 필드 이름을 생략하면, *Deadline에 정의되어 있는 메서드를 바로 호출할 수 있고, 필드에도 바로 접근할 수 있는 상태가 된다.

하지만 이에 따른 몇 가지 문제점(직렬화/역직렬화 등)이 발생할 수 있다는 점을 유의해야 한다. 또한, 상속과는 달리 내부 필드에 내장 필드들을 실제로 가지고 있으면서 편의를 제공한다는 점도 명심해야 한다.

직렬화

직렬화란, 객체의 상태를 보관이나 전송 가능한 형태로 변환하는 것을 말한다. 보조 기억장치에 저장 기능을 구현하거나, 네트워크를 통해 메시지를 전송하거나, 다른 기계에게 RPC를 통해 메시지를 전송할 때 모두 직렬화/역직렬화가 이루어진다.

JSON

b, err := json.Marshal(t)	// 직렬화
err := json.Unmarshal(b, &t)	// 역직렬화

위와 같이 json 라이브러리의 Marshal() 함수로 JSON 형태로 직렬화할 수 있다. t의 public 필드, 즉 대문자로 시작하는 필드만 직렬화되고 소문자로 시작하는 필드는 무시된다.

JSON 태그

type MyStruct struct {
    Title	string	`json:"title"`		// JSON key 이름을 title로 변경한다.
    Internal	string	`json:"-"`		// JSON에 담지 않는다.
    Value	int64	`json:",omitempty"`	// 0인 경우 JSON에 담지 않는다.
    ID		int64	`json:",string"`		// int64를 string으로 형변환해서 담는다.
}

+) int64 자료형을 자바스크립트가 읽을 때 문제가 발생한다. 자바스크립트는 숫자형이 8바이트 실수형이기 때문이다. 따라서 위와 같이 string으로 형변환해서 넘겨주는 것이 안전하다.

JSON 직렬화 사용자 정의

커스텀 코드를 통해 직렬화/역직렬화 방식을 별도로 정의할 수 있다.

func (s status) MarshalJSON() ([]byte, error) {
    switch s {
    	case UNKNOWN:
        	return []byte(`"UNKNOWN"`), nil
        case TODO:
        	return []byte(`"TODO"`), nil
        // ...
    }
}

func (s status) UnmarshalJSON(data []byte) error {
    switch string(data) {
    	case `"UNKNOWN"`:	// 넘어오는 데이터가 따옴표까지 포함된 문자열이기 때문에
        	*s = UNKNOWN	// `로 한 번 더 둘러싼 것
        case `"TODO"`:
            *s = TODO
        // ...
    }
}

이렇게 하나하나 코드를 작성하는 건 번거롭다. go generate라는 도구를 활용해서 더 간단하게 커스터마이즈 할 수 있다.

구조체가 아닌 자료형 직렬화

JSON 라이브러리를 활용해 구조체 이외에도 배열이나 맵 등을 직렬화할 수 있다.

func Example_mapMarshalJSON() {
    b, _ := json.Marshal(map[string]string{
    	"name": "John",
        "age": 16,
    })
    fmt.Println(string(b))
    // Output:
    // {"age": "16", "name": "John"}	// JSON은 기본적으로 key를 오름차순 정렬한다.
}

JSON에 이용하는 맵은 key가 string이어야 한다. value에 아무 자료형을 담으려면 아래와 같이 해야 한다.

func Example_mapMarshalJSON() {
    b, _ := json.Marshal(map[string]interface(){	// 어떠한 JSON 오브젝트든 다 담을 수 있다.
    	// ...
    }
    fmt.Println(string(b))
    // Output: {"age": 16, "name": "John"}	// value에 int 타입의 16이 담긴다.
}

JSON 필드 조작하기

JSON의 구조에 구조체의 구조가 제한되는 것을 방지하기 위해, 구조체 내장을 적용할 수 있다.

type Fields struct {
    VisibleField	string	`json:"visibleField"`
    InvisibleField	string	`json:"invisibleField"`
}

func ExampleOmitFields() {
    f := &Fields{"a", "b"}
    b, _ := json.Marshal(struct {
    	*Fields
        InvisibleField	string	`json:"invisibleField,omitempty"`
        Additional	string	`json:"additional,omitempty"`
    }{Fields: f, Additional: "c"})
    fmt.Println(string(b))
    // Output: {"VisibleField":"a", "Additional":"c"}
}

이렇게 직렬화에서 제외하고 싶은 필드를 직렬화 대상 구조체에 내장하고, omitempty 태그를 걸어 주면, 이 필드를 초기화하지 않으면 빈 필드가 되어서 사라진다. 또한, 원래 구조체와 지우는 구조체 모두 같은 이름으로 해주어야 한다.

Gob

Gob은 언어에서 기본으로 제공하는 또 다른 직렬화 방식이다. Go 언어에서만 읽고 쓸 수 있는 형태지만, 더 효율적인 변환이 가능하다. 따라서, 주고받는 코드가 모두 Go로 되어 있는 경우 Gob 이용을 고려해볼 수 있다.

인터페이스

인터페이스는 메서드의 집합이다. 구현은 없고 메서드의 형태만 있다. 인터페이스의 이름은 주로 메서드 이름에 er을 붙인다.

정의

interface {
    Method1()
    Method2(i int) error
}

인터페이스의 메서드를 정의하고 있는 자료형은 이 인터페이스를 구현한 것이다.

type Loader interface {
    Load(filename string) error
}

인터페이스도 이름을 붙여줄 수 있다.

type ReadWriter {
    io.Reader
    io.Writer
}

구조체의 내장과 비슷한 형식으로, 여러 인터페이스를 합칠 수 있다. 이 인터페이스들의 모든 메서드를 구현한 명명 자료형은 모두 ReadWriter가 된다.

커스텀 프린터

문자열이 아닌 자료형을 Print 하기 위해서는 fmt.Stringer 인터페이스의 func String() string 함수를 정의해주면 된다.

func (t Task) String() string {
    check := "v"
    if t.Status != DONE {
    	check = " "
    }
    return fmt.Sprintf("[%s] %s %s", check, t.Title, t.Deadline)
}

이렇게 Task를 정의해주면, 모든 Stringer를 받는 아래의 Print 함수가 Task를 출력할 수 있다.

func PrintStringer(data fmt.Stringer) {
    fmt.Print(data.String())
}

정렬과 힙

정렬 인터페이스를 구현하면 새로운 명명 자료형에 대한 정렬을 구현할 수 있다.

Go는 sort.Sort를 통해, 비교 정렬이자 불안정 정렬(unstable sort)를 제공한다. 두 자료를 비교해서 어느 자료가 더 먼저 와야 하는지 결과를 돌려주는 부분만 작성하면 나머지 부분은 이미 나와 있는 정렬 알고리즘을 이용하여 정렬해준다.

정렬 인터페이스 구현

Go는 제네릭을 지원하지 않지만, 인터페이스를 지원하기 때문에 다양한 형태의 정렬을 수행할 수 있다.

sort 패키지에는 sort.Interface 인터페이스가 정의되어 있어서, 이것만 따라 구현하면 정렬을 할 수 있다.

ijint로 고정하고, 이를 바탕으로 인터페이스를 구현하도록 정의되어 있다.

정렬 알고리즘

일반적인 싱글 스레드에서의 비교 정렬 알고리즘의 효율은 퀵 정렬(Quick sort)가 가장 좋다. soft.Sort는 기본적으로 퀵 정렬을 사용하며, 7개 이하의 값에 대해서는 삽입 정렬(Insertion sort)를 사용한다. 또한, 퀵 정렬이 너무 깊이 빠지게 되면 힙 정렬(Heap sort)을 이용한다.

힙 알고리즘을 이용하기 위한 인터페이스는 아래와 같다.

type Interface interface {
    sort.Interface
    Push(x interface{})
    Pop() interface{}
}

sort.Interface가 내장되어 있기 때문에 총 5개의 메서드를 구현해야 한다.

외부 의존성 줄이기

인터페이스를 통해 외부 의존성을 줄이고 유연성을 확장할 수 있다.

func Save(f *os.File) {}

이 함수대로라면 테스트를 할 때도 파일을 넘겨야 한다. 하지만 아래처럼 인터페이스를 인자로 받으면 테스트 등에서 유연하게 활용할 수 있다.

func Save(w io.Writer) {}

또한, 기존의 라이브러리를 추상화하여 필요한 연산만 인터페이스에 정의해 사용하면 더 잘 활용할 수 있다. 자바의 인터페이스 활용과 똑같다.

빈 인터페이스와 형 단언

빈 인터페이스 interface{}는 아무 자료형이나 취급할 수 있다는 뜻이다.

이러한 빈 인터페이스 타입을 다른 자료형(인터페이스나 구조체)으로 변환하려면, 형 단언(type assertion)을 해야 한다. 형 단언을 하면 형변환 시 자료형이 맞는지 런타임 시점에 검사를 한다.

var r io.Reader = NewReader()
f := r.(os.File)

위의 예시는 ros.File 형으로 단언해 f를 해당 자료형으로 이용한다.
하지만 형 단언 시 타입이 맞지 않으면 패닉을 발생시키므로, 아래처럼 안전하게 사용하는 것이 좋다.

var r io.Reader = NewReader()
f, ok := r.(os.File)

자료형 스위치(type switch)

자료형 스위치를 활용하면 포괄적으로 인터페이스를 받아서 특정 자료형일 때, 혹은 더 좁은 범위의 인터페이스일 때 구현을 다르게 할 수 있다.

자료형 스위치는 switch 스위치변수 := 타겟변수.(type) {...} 형태의 구문으로 구현된다. case문 안에서 스위치변수에는 해당 자료형의 이 할당된다.

func Join(sep string, a ...interface{}) string {
    if len(a) == 0 {
    	return ""
    }
    t := make([]string, len(a))
    for i := range a {
    	switch x := a[i].(type) {
        case string:
        	t[i] = x
        case int:
        	t[i] = strconv.Itoa(x)
        case fmt.Stringer:
        	t[i] = x.String()
        }
    }
    return strings.Join(t, sep)
}

0개의 댓글