본문 바로가기

Backend/Golang

Go 언어 - [5장] (구조체)

※ 디스커버리 Go 언어 라는 책 내용을 전반적으로 다루지만 책 리뷰보다는 좀 더 간단하게 정리해보겠습니다.

 

5. 구조체

 

구조체는 필드들을 묵어놓은 것으로 다른 언어들의 구조체, 클래스, 레코드 등과 비슷한 역할을 하며 구조체를 이용하면 더 복잡한 자료형을 정의할 수 있습니다. 인터페이스는 구현은 없고 메서드의 형태만 있는 메서드의 집합입니다. 이번 장에서는 구조체와 인터페이스 이 두가지에 대해 알아보도록 하겠습니다.

 

5.1.1 구조체 사용법

 

구조체 자료형으로 된 변수 하나를 선언해보겠습니다.

 

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

// 2
type Task struct {
  title String
  done bool
  due *time.Time
}
var myTask = Task{"laundry", false, nil}
var myTask2 = Task{title: "laundry"}
var myTask3 = Task{title: "laundry", done: true}
var myTask4 = Task{
  title: "laundry",
  done: true,
}

 

1번과 2번 방법 둘은 동일합니다. myTask2 변수처럼 원하는 필드에만 값을 넣을 경우 값이 없는 필드는 기본값으로 설정됩니다.

myTask3 변수와 같이 여러 필드의 값을 정할 땐 쉼표로 구분합니다. 하지만 이렇게 줄이 길어질 경우, myTask4 처럼 사용하는 것이 go 에 가장 맞습니다.

 

5.1.2 const 와 iota

 

개발자들은 자주 요구 사항이 변경되고 기능이 확장되는 일을 피하기 어려운데, 확장성을 위해선 bool 형을 쓸 곳에 enum 형을 쓰면 좋습니다. 위에서 선언한 Task 자료형은 done 필드가 bool 형으로 되어있는데 enum 형으로 변경한다면 완료, 미완료 외의 추가적인 상태도 넣을 수 있어서 enum 형으로 변경해보도록 하겠습니다.

 

type status int

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

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

 

Go 에선 enum 이 따로 없기 때문에 위처럼 상수로 정의해서 사용합니다. 이렇게 정의한 상수를 묶어서 iota 를 사용해보도록 하겠습니다.

 

// 1
const {
  UNKNOWN status = 0
  TODO status = 1
  DONE status = 2
}

// 2
const {
  UNKNOWN status = iota
  TODO status = iota
  DONE status = iota
}

// 3
const {
  UNKNOWN status = iota
  TODO
  DONE
}

 

1, 2, 3 모두 동일한 방법이며, 상수에 0, 1, 2 값을 순서대로 붙이지않고 iota 를 위처럼 사용할 수 있습니다. 3번처럼 iota 를 한번만 써줘도 동일하게 적용됩니다.

 

5.1.3 테이블 기반 테스트

 

다른 프로그래밍 언어를 이용해 assertion 을 이용한 유닛 테스트를 해왔다면 다른 자료형도 비교해 테스트해주는것과 비교해 제네릭을 지원하지 않는 Go 언어에선 그 부분이 어렵고, 여러 사례를 테스트하고 싶을 때 문제가 더욱 커집니다. 이럴 때 구조체와 배열을 이용해 테이블 기반 테스트를 할 수 있습니다.

 

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 커뮤니티 관습 역시 if문을 이용하거나 테이블 기반 테스트를 하는 것을 권장하고 있습니다.

 

5.1.4 구조체 내장

 

구조체는 여러 자료형의 필드들을 가질 수 있다는 점이 가장 중요합니다. 구조체를 재사용하기 위해 Go 언어에서 지원되는 포함 관계를 활용해 보도록 하겠습니다.

 

type Deadline time.Time // Deadline 자료형 선언

// Deadline 자료형에 OverDue 메서드 정의
func (d *Deadline) OverDue() bool {
  return d != nil && time.Time(*d).Before(time.Now())
}

type Task struct {
  Title string
  Status status
  Deadline *Deadline
}

// Task 의 OverDue 메서드는 Deadline 에 위임
func (t Task) OverDue() bool {
  return t.Deadline.OverDue()
}

 

Task 구조체가 Deadline 자료형의 필드를 가지고 있어서 t.Deadline.OverDue() 처럼 메서드를 이용할 수 있었습니다. 하지만 메서드마다 모두 같은 이름의 메서드를 호출하는 코드를 작성하는 것은 너무 귀찮은 일이며, 이를 덜어주는 것이 내장 기능입니다.

 

type Task struct {
  Title string
  Status status
  *Deadline
}

 

이전의 Task 와 같지만 Deadline 에 필드 이름을 생략했고, 이렇게 하면 Task 에 대해 의미없는 OverDue 메서드를 작성할 필요가 없습니다. Task 가 내장하고 있는 *Deadline 자료형은 자료형의 이름과 같은 Deadline 이라는 필드를 갖게 되고 정의되어 있는 메서드도 바로 호출할 수 있게 됩니다.

구조체를 내장하게 되면 내장된 구조체에 들어있는 필드들도 바로 접근이 가능합니다. 따라서 구조체 내장을 이용하면 여러 구조체에 있는 필드들이 모두 합쳐진 구조체 같은 것을 만들 수 있습니다.

 

5.2 직렬화와 역직렬화

 

직렬화 (Serialization) 란 객체의 상태를 보관이나 전송 가능한 상태로 변환하는 것입니다. 구조체 만이 아니라 숫자, 문자열, 배열, 맵 역시 직렬화가 가능합니다. 직렬화의 반대로 보관되거나 전송받은 것을 다시 객체로 복원하는 것을 역직렬화 (Deserialization) 라 합니다.

 

보조기억장치에 저장 및 불러오기 / 네트워크를 통한 메시지 전송 / RPC 등의 방법을 사용할 때 직렬화가 필요합니다.

 

5.2.1 JSON

 

- JSON 직렬화 및 역직렬화

 

t := Task {
  "Laundry",
  DONE,
  NewDeadline(time.Date(2015, time.August, 16, 15, 43, 0, 0, time.UTC)),
}
b, err := json.Marshal(t) // 직렬화

c := []byte`{"Title": "Buy Milk", "Status": 2, "Deadline": "2015-08-16T15:43:00Z"}`)
t2 := Task{}
err := json.Unmarshal(b, &t2) // 역직렬화

 

JSON 패키지는 대문자로 시작하는 필드들만 JSON 으로 직렬화합니다. 직렬화 하고 싶지 않은 필드는 소문자로 만들면 됩니다. 역직렬화 시에는 포인터를 이용해야 수정된 것이 반영되므로 &t2 를 사용했습니다.

 

- JSON 태그

 

기본 직렬화 필드 중 필드 이름을 변경하거나, 표시하지 않고 싶을때 구조체 필드에 json 태그를 붙일 수 있습니다. 이 태그를 JSON 라이브러리가 읽고 처리해줍니다.

 

type MyStruct struct {
  Title string `json:"title""` // "title" 을 필드로 사용
  Internal string `json:"-"` // Internal 필드 나타나지 않고 무시
  Value int64 `json:",omitempty"` // Value 가 0 인 경우 표시하지 않음
  ID int64 `json:",string"` // 64비트 정수형이지만 JSON 에는 문자열로 나타남
}

 

ID 필드의 경우 그대로 두어도 괜찮지만, 정수값은 53비트를 넘어서면 정확도가 떨어지기 때문에 64비트 정수를 주고받을 때엔 `json:,string` 을 해주는 습관을 기르는 것도 좋습니다.

 

- JSON 직렬화 사용자 정의

 

int 타입을 가진 필드의 경우 숫자로 직렬화 되고 역직렬화 되지만 받는 쪽에선 문자열로 받아야 할 경우가 있을 때, 커스텀 코드를 만들어 주면 됩니다.

 

func (s status) MarshalJSON() ([]byte, error) {
  switch s {
  case UNKNOWN:
    return []byte(`"UNKNOWN"`), nil
  case TODO:
    return []byte(`"TODO"`), nil
  case DONE:
    return []byte(`"DONE"`), nil
  default:
    return nil, errors.New("status.MarshalJSON: unknown value")
  }
}

func (s *status) UnmarshalJSON(data []byte) error {
  switch string(data) {
  case `"UNKNOWN"`:
    *s = UNKNOWN
  case `"TODO"`:
    *s = TODO
  case `"DONE"`:
    *s = DONE
  default:
    return errors.New("status.UnmarshalJSON: unknown value")
  }
  return nil
}

 

이렇게 추가하면 가능은 하지만 굉장히 반복적인 작업에 상태값이 하나 추가되면 두 군데를 동시에 수정해야 합니다.

이후에 보게 될 go generate* 라는 도구를 이용하면 이런 반복적인 코드를 자동으로 생성할 수 있습니다.

 

- 구조체가 아닌 자료형 처리

 

배열을 직렬화 및 역직렬화 하는데 JSON 라이브러리를 쓸 수도 있습니다. 구조체 이외에도 맵을 이용하여 자바스크립트의 오브젝트를 처리할 수 있습니다. 

 

func Example_mapMarshalJSON() {
  b, _ := json.Marshal(map[string]string{
    "Name": "John",
    "Age": 16,
  })
  fmt.Println(string(b)) // -> {"Age": "16", "Name": "John"}
}

 

JSON 에 이용하는 맵은 키가 문자열형이어야 합니다. 위의 예제에선 값도 문자열형으로 나타났습니다. 아무 자료형을 담으려면 interface{} 을 사용하면 됩니다.

 

func Example_mapMarshalJSON() {
  b, _ := json.Marshal(map[string]interface{}{
    "Name": "John",
    "Age": 16,
  })
  fmt.Println(string(b)) // -> {"Age": 16, "Name": "John"}
}

 

- JSON 필드 조작하기

 

생각보다 실무에서 JSON 라이브러리가 잘 적용되지 않는 사례가 있는데, JSON 의 구조에 따라 구조체의 구조가 제한되어 버린다는 점입니다. 구조체 내장을 이용해 이 부분을 해결해 보도록 하겠습니다. 구조체 내장을 이용하면 원래 구조체를 고치지 않고, 원하는 필드들만 제외하거나 추가하여 직렬화할 수 있습니다.

 

type Fields struct {
  VisibleField string
  InvisibleField string
}

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

 

빼고 직렬화하고 싶은 필드 InvisibleField 를 함께 넣은 후 `json:",omitempty"` 를 태그로 넣어주고, 이 필드를 초기화하지 않으면 빈 필드가 되어 사라집니다. 그 결과 Additional 은 추가된 형태로 직렬화가 되었습니다.

 

5.2.2 Gob

 

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

사용시 JSON 과 다른 점은 gob.NewEncoder, gob.NewDecoder 로 인코더와 디코더를 생성해서 각각 io.Writer, io.Reader 를 넘긴다는 점입니다. Gob 패키지문서를 통해 더 자세한 내용을 참고 바라겠습니다.

 

5.3 인터페이스

 

인터페이스는 메서드들의 묶음입니다. 인터페이스 이름을 붙일 때는 주로 인터페이스의 메서드 이름에 er 을 붙입니다. io.Reader 는 Read 메서드를 갖고 있는 인터페이스입니다. 문자열로 표현할 수 있는 것들은 fmt.Stringer 가 됩니다.

 

5.3.1 인터페이스의 정의

 

인터페이스는 구조체와 매우 유사한 구조를 띠고 있습니다.

 

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

 

두 메서드를 정의하고 있는 자료형은 이 인터페이스로 사용할 수 있으며 인터페이스 역시 이름을 붙여줄 수 있습니다.

 

type Loader interface {
  Load(filename string) error
}

 

구조체의 내장과 비슷한 형식으로 여러 인터페이스를 합칠 수 있습니다.

 

type ReadWriter {
  io.Reader
  io.Writer
}

 

5.3.2 커스텀 프린터

 

이름 붙인 자료형은 Print 계열의 함수들을 이용해 출력할 때 나타나는 형식을 정의할 수 있습니다. Print 계열 함수들이 문자열이 아닌 자료들을 출력할 때 미리 정해진 방식을 이용하는데 이것을 설정해주고 싶으면 String() 함수를 정의해주면 됩니다.

 

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

func ExampleTask_String() {
  fmt.Println(Task{"Laundry", DONE, nil}) // -> [v] Laundry <nil>
}

 

5.3.3 정렬과 힙

 

정렬은 자료들을 어떤 순서에 따라 늘어놓는 것이며, 순환 구조가 생기면 안됩니다. Go 의 sort.Sort 에서 이용하는 방법은 비교 정렬이자 불안정 정렬 (unstable sort) 입니다. 임의의 자료형에 대해 두 자료의 순서를 비교하는 함수는 제네릭을 지원하지 않는 언어에서는 구현하기 까다롭습니다.

 

- 정렬 인터페이스의 구현

 

Go 는 제네릭을 지원하지 않지만 인터페이스를 지원하기 때문에 다양한 형태의 정렬을 수행할 수 있습니다. sort 패키지를 보면 sort.Interface 라는 인터페이스를 정의하고 있고 이것에 따르기만 하면 정렬을 할 수 있습니다.

문자열을 대소문자 구분 없이 정렬해 보겠습니다.

 

type CaseInsensitive []string

func (c CaseInsensitive) Len() int {
  return len(c)
}

func (c CaseInsensitive) Less(i, j int) bool {
  return strings.ToLower(c[i]) < strings.ToLower(c[j]) ||
    (strings.ToLower(c[i]) == strings.ToLower(c[j]) && c[i] < c[j])
}

func (c CaseInsensitive) Swap(i, j int) {
  c[i], c[j] = c[j], c[i]
}

func ExampleCaseInsensitive_sort() {
  apple := CaseInsensitive([]string{
    "iPhone", "iPad", "MacBook", "AppStore",
  })
  sort.Sort(apple)
  fmt.Println(apple) // -> [AppStore iPad iPhone MacBook]
}

 

- 정렬 알고리즘

 

일반적으로 싱글 스레드에서 비교 정렬 알고리즘은 빠른 정렬이 아주 좋습니다. 다만 빠른 정렬은 최악의 경우 O(n^2) 가 될 수 있기에 이를 극복하고자 랜덤 피벗을 뽑기도 하고 몇 개의 피벗을 골라서 이용하기도 합니다. 7개 이하의 값들에 대해선 삽입 정렬이 가장 효율적입니다. Go 에서의 sort.Sort 는 기본적으로 빠른 정렬을 이용합니다. 빠른 정렬 최악의 경우를 피하기 위해 피벗 3개를 골라서 가운데 값을 고르는 중위법을 이용합니다. 그렇지만 너무 깊이 빠른 정렬에 빠지면 힙 정렬을 이용하고, 7개 이하의 자료에 대해선 삽입 정렬을 이용합니다.

 

- 힙

 

힙은 자료 중에 가장 작은 값을 O(log N) 의 시간 복잡도로 꺼낼 수 있는 자료구조입니다.

heap.Interface 는 sort.Interface 를 내장하고 있습니다. 따라서 5개의 메서드가 구현되어야 합니다. 대소문자 구분없이 꺼낼 수 있는 힙을 구현하려면 정렬 인터페이스에 추가로 두 메서드 구현을 아래와 같이 해야합니다.

 

func (c *CaseInsensitive) Push(x interface{}) {
  *c = append(*c, x.(string))
}

func (c *CaseInsensitive) Pop() interface{} {
  len := c.Len()
  last := (*c)[len - 1]
  *c = (*c)[:len - 1]
  return last
}

func ExampleCaseInsensitive_heap() {
  apple := CaseInsensitive([]string{
    "iPhone", "iPad", "MacBook", "AppStore",
  })
  heap.Init(&apple)
  for apple.Len() > 0 {
    fmt.Println(heap.Pop(&apple))
  }
  // Output:
  // Appstore
  // iPad
  // iPhone
  // MacBook
}

 

5.3.4 외부 의존성 줄이기

 

func Save(f *os.File) {
  ...
}

func Save(w io.Writer) {
  ...
}

 

첫번째 함수를 테스트 하기 위해선 파일을 넘겨줘야 합니다. 그보단 두번째 함수처럼 io.Writer 를 받으면 실제 구현에서는 파일을 넘기면 되고, 테스트에선 bytes.Buffer 와 같은 것을 넘겨 테스트해볼 수 있습니다.

가능하면 만들어져 있는 인터페이스를 받아서 동작하게 코드를 작성하면 유연하게 테스트할 수 있습니다. 파일시스템에 접근하는 경우 파일시스템 인터페이스를 만들어서 이용하는 것이 유연성을 높이는 데 도움이 됩니다. 이렇듯 인터페이스를 잘 활용하면 외부 의존성을 줄이는 데 많은 도움이 됩니다.

 

5.3.5 빈 인터페이스와 형 단언

 

빈 인터페이스는 아무 자료형이나 취급할 수 있으며 꽤 유용히 사용될 수 있습니다. interface{} 타입을 원래의 자료형으로 변환하려면 다른 문법을 사용해야 합니다. 형변환을 할 때 자료형이 맞는지 실행 시간에 검사가 일어나야 하기에, 형 단언 (type assertion) 이라고 합니다.

 

 func ExampleCaseInsensivie_heapString() {
   apple := CaseInsensitive([]string{
     "iPhone", "iPad", "MacBook", "AppStore",
   })
   heap.Init(&apple)
   for apple.Len() > 0 {
     popped := heap.Pop(&apple)
     s := popped.(string)
     fmt.Println(s)
   }
   // Output
   // AppStore
   // iPad
   // iPhone
   // MacBook
 }

 

heap.Pop 을 보면 interface{} 형을 반환하고 있습니다. 따라서 popped 는 interface{} 형인데, 이것에 .(string) 을 붙여서 필히 문자열형 이라고 형 단언을 한 것입니다. 따라서 for 문안에 있는 s 는 문자열형입니다. 만일 실행시간에 이것이 단언한 형이 아니면 패닉을 발생시키게 됩니다.

형 단언은 빈 인터페이스에서만 쓸 수 있는 것이 아니라, 인터페이스를 실제 자료형으로 받을 때 마찬가지로 형 단언을 사용합니다.

 

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

 

r 이 실제로 os.File 인 경우에 f 를 해당 자료형으로 이용할 수 있습니다. 그러나 자료형이 맞지 않으면 패닉이 발생하기에 위처럼 두번째 값을 써서 검사할 수 있습니다. os.File 인 경우 ok 가 true 가 되고 아닌 경우 ok 가 false 가 되면서 패닉이 발생하지 않습니다.

 

5.3.6 인터페이스 변환 스위치

 

인터페이스를 이용해 해당 메서드들을 구현하여 사용하는 것 외에 포괄적으로 인터페이스를 받아서 특정 자료형 혹은 좀 더 좁은 범위의 인터페이스를 구현할 때 경우에 따라서 구현을 달리 하고 싶을 경우 자료형 스위치 (type switch) 를 이용하면 됩니다.

문자열 슬라이스를 받아서 구분자를 사이에 두고 각 문자열을 연결하는 strings.Join 함수를 확장 구현해보도록 하겠습니다.

 

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)
}

func ExampleJoin() {
  t := task.Task{
    Title: "Laundry",
    Status: task.DONE,
    Deadline: nil,
  }
  fmt.Println(Join(",", 1, "two", 3, t)) // -> 1,two,3,[v] Laundry <nil>
}

 

switch 문에서 a[i] 를 type 으로 형단언 해 x 에 할당한 모양새입니다. 이렇게 작성하면 case 에서 자료형의 이름을 지정해 각각의 경우 다르게 구현할 수 있습니다. 문자열일때는 그냥 t 에 복사, 정수형일때는 strconv.Itoa 를 호출해 문자열로 바꾸어서 넣어주고 그 외에 fmt.Stringer 인터페이스를 구현하면 String 메서드를 호출해 넣어줍니다.

 

5.4 Summary

 

  • 필드들의 모음 혹은 묶음을 구조체라고 함. 구조체는 struct { ... } 형태로 각 필드의 이름과 자료형을 나열
  • const 를 이용해 정수 상수 정의를 통하여 enum 스타일 구현 가능. iota 를 이용하면 이어지는 숫자 정의 편리
  • 구조체를 이용하면 테이블 기반 테스트를 작성하기 편리함
  • 구조체 내에 필드 이름 없이 다른 자료형의 이름을 넣으면 해당 자료형이 구조체에 내장되고 구조체에 자료형과 이름이 동일한 필드가 생김
  • 직렬화를 통해 자료를 파일에 저장하거나 네트워크로 보낼 수 있고, 역직렬화를 통해 복원 가능
  • JSON 형식으로 직렬화할 때 자바스크립트에서 사용되는 것을 염두에 둔다면 64비트 정수형 자료는 `json:",string"` 태그를 붙임
  • Gob 형식으로 직렬화 및 역직렬화 가능
  • 인터페이스는 메서드들의 묶음으로 이 메서드들이 모두 정의되어 있는 명명된 자료형은 이 인터페이스를 구현하는 것
  • 인터페이스를 이용해 새로운 자료형에 대한 정렬 알고리즘과 힙 자료구조 사용 가능
  • 인터페이스를 이용해 외부 의존성을 줄이고 유연한 구현 가능
  • 형 단언과 인터페이스 자료형 스위치를 이용하면 인터페이스를 구현하는 자료형에 따라 다른 구현 가능

 

 

출처 : https://book.naver.com/bookdb/book_detail.nhn?bid=10337667