본문 바로가기

Work/책 정리

[리뷰] Head First Go

어느덧 Go언어를 사용한지 2년이 되었다. Java 언어로 개발을 해오다가 처음 Go 언어를 접했을 때 어떤 부분은 C와 비슷하기도 하고 Java와 비슷한 부분도 있어서 혼란스러운 부분이 많았었다. 특히 포인터를 활용하는 부분에 있어서는 예전에 C++을 했던 기억으로 내가 느끼기에는 Syntax가 애매모호한 부분들이 있었다. Head First Go 책에도 포함된 내용이긴 하지만 내가 느꼈던 혼란은 Go 언어 자체적으로 편의를 위해 제공되는 것들이 꽤 있었기 때문이었다.

 

넘나 명쾌한 포인터 설명

 

예를 들어 구조체 포인터인 경우 정상적으로 포인터를 참조하려면 (*pointer).value 와 같이 참조를 해야할 것 같은데 pointer.value로도 참조가 가능하고, 생성하지 않아 nil 값인 슬라이스를 내장 함수에 전달할 경우 값이 채워진 슬라이스로 반환된다. 이러한 부분들은 번거로움을 줄이기 위해 Go 언어에서 자체적으로 제공되는 기능인 것이었다.

지금은 Go 언어에 많이 익숙해져 이전과 달리 생산성이 많이 증가한 것을 느끼는 중이다. 현재 개발 중인 업무 특성상 크로스 플랫폼을 지원해야하고, 최대한 가볍고 종속성이 없는 어플리케이션을 배포해야하는 데, 이 요구사항에 딱 들어맞는 것이 Go 언어라고 생각한다.

 

내 책장의 Head First 책들

 

언어 자체를 학습하는데도 그리 오랜 시간이 걸리진 않지만 사실 대충 알고 개발에 들어가면 실수하는 부분들이 많이 존재한다. 처음 Go 언어를 접했을 때 Head First Go 책이 있었다면 굉장히 큰 도움이 되었을 것 같다. 관심 있는 분야에 Head First 책이 있다면 일단 사놓고 보는 스타일인데 그 이유는 Head First 책들은 모두 이해하기 쉽기 때문이다. 이해하기 쉬운 적절한 예제 뿐만 아니라 글을 읽다보면 숙련된 사수가 1:1 코칭을 해주는 듯한 느낌을 받게 한다. 어떤 분야든 Head First는 입문서로는 최고인 것 같다.

 

 

2년 동안 Go 언어를 활용해보면서 나름 많이 익숙해졌다고 생각을 하고 있었는데, 사실 개발을 하다보면 가장 기본적인 부분에서 막히는 경우가 상당하다. 이 책을 읽다보니 머릿속에 흩어져있던 지식들이 정리가 되는 느낌이 들었다. 알고 있었지만 너무 익숙해져서 놓쳤던 부분들과 전혀 몰랐었던 내용도 있었다. 익숙하게 사용하고 있지만 놓치기 쉬운 부분들은 따로 정리를 해보았다. (아래 더보기 클릭)

더보기

패키지

  • 의미가 명확한 경우에는 축약어를 사용한다.
    • ex) fmt
  • 가능하면 한 단어만 사용하는게 좋지만 불가능할 경우에는 밑줄로 분리하지 않고 사용한다.
    • ex) strconv
  • 임포트 된 패키지의 이름이 로컬 변수명과 충돌하는 경우에는 해당 변수명을 사용하지 않아야 한다.

슬라이스

슬라이스 생성

  • 아래와 같이 명시적으로 크기를 지정하지 않은 경우는 모두 슬라이스로 생성한다.

    var arr [5]string
  • 슬라이스로 선언되는 경우 예는 다음과 같다.

    var arr []string // 슬라이스 변수 선언
    make([]string, 6) // 슬라이스 생성
    []string{"do", "re", "mi", "fa"} // 슬라이스 리터럴
    • 기본적으로 슬라이스는 변수 선언만 하면 안되고 make 함수로 생성을 해줘야한다.
      • 선언만하고 append로 데이터를 추가하는 경우 내부적으로 생성까지 해준다.
    • 슬라이스 리터럴을 사용하는 경우 make를 하지 않아도 생성해준다.

내부 배열

  • 슬라이스는 배열을 기반으로 구현되어 있다.

    • 모든 슬라이스는 내부 배열(underlying array)를 기반으로 구현되어 있있다.
      • 내부 배열은 슬라이스의 데이터가 실제로 저장되는 공간이다.
      • 슬라이스는 단지 이 배열 원소의 일부 또는 전체에 대한 추상화된 뷰 일뿐
  • make 함수 또는 슬라이스 리터럴로 슬라이스를 생성하면 내부 배열이 자동으로 생성된다.

    • 슬라이스를 거치지 않고는 직접 내부 배열에 접근할 수 없다.
  • 배열을 생성하고 슬라이스 연산자를 사용하면 해당 배열을 기반으로 하는 슬라이스로 만들 수도 있다.

    mySlice := myArray[1:3]
  • 내부 배열의 슬라이스를 사용하면 슬라이스를 통해 노출되는 내부 배열 원소의 일부만 볼 수 있다.

    array1 := [5]string{"a", "b", "c", "d", "e"}
    slice1 := array1[0:3]
    fmt.Println(slice1)
    // 결과 : ["a", "b", "c"]
    • 슬라이싱을 해도 원본에 해당하는 내부 배열은 그대로 유지한다. 즉, "d", "e"가 제거되지 않는다.
    • 슬라이스는 내부 배열의 뷰어라고 생각해도 무방하다.
    • 내부 배열의 값이 변경되면 슬라이스의 값도 변경된다.
      • 왜냐하면 슬라이스는 내부 배열의 주소만 참조하기 때문에
      • 내부 배열을 먼저 만들고 슬라이스로 변환해서 사용하면 내부 배열의 값 변경 시 같은 내부배열로 만들어진 모든 슬라이스들의 값이 변경되는 효과가 되기 때문에 일반적으로는 배열로 슬라이스를 만드는 것 보다는 make나 슬라이스 리터럴로 슬라이스를 생성하여 내부 배열 자체를 조작하는 일을 만들지 않는 것이 좋다.

슬라이싱

  • 슬라이스 연산자를 사용하는 것을 슬라이싱이라고 한다.

    myArray[1:3]
    • 슬라이싱을 할 때 주의할 점은 두번째 인덱스를 포함하지 않고 해당 인덱스의 바로 앞에서 끝난다는 것
    • i:j의 범위는 i ~ (j-1)

append 함수 사용 방법

  • 일반적으로 append 사용 시 아래와 같이 append 함수 호출 결과를 기존 슬라이스에 대입한다.

    slice := []string{"a", "b"}
    slice = append(slice, "c")
  • 기존 슬라이스에 함수 결과를 대입하는 이유는 append에서 반환된 슬라이스의 동작방식으로 인한 잠재적인 일관성 문제를 피하기 위함이다.

    • 슬라이스의 내부 배열은 크기를 변경할 수 없기 때문에 배열에 원소를 추가할 공간이 부족해지면 모든 원소를 더 큰 사이즈의 배열에 복사한 다음 슬라이스가 새로운 배열을 가리키도록 변경한다.
    • 이 모든 과정은 append 함수 내부에서 이루어지기 때문에 함수에서 반환된 슬라이스가 전달된 슬라이스와 동일한 내부 배열을 사용하는지, 크기가 변경된 다른 내부 배열을 사용하는지 알 수 없다.
    • 따라서 원본 슬라이스와 append 함수에서 반환된 슬라이스를 모두 사용하는 경우에는 예상치 못한 문제가 발생할 수 있다.
      • 기존 슬라이스를 유지한 채로 append한 값에 참조하려고 하면 index out of range panic이 발생할 수 있다.
    슬라이스와 제로 값
    • 슬라이스를 생성하고, 아무 값도 할당하지 않은 Element에 접근하면 제로 값이 반환된다.

      floatSlice := make([]float64, 10)
      boolSlice := make([]bool, 10) fmt.Println(floatSlice[9], boolSlice[5])
      // 결과 : 0 false
    • 슬라이스 변수만 선언한 경우 nil 값을 갖게 된다.

      var intSlice []int
      fmt.Printf("%#v", intSlice)
      // 결과 : []int(nil) var intSlice []int

      fmt.Printf("%v", intSlice)
      // 결과 : []
      • 아래 결과를 보면 슬라이스 변수가 nil이어도 빈 슬라이스가 반환된다.
    • Go의 내장 함수들은 nil 슬라이스 값을 마치 빈 슬라이스인 것처럼 처리하도록 작성되어 있다.

      • 예를 들어 len 함수를 사용하는 경우에도 nil 슬라이스를 인자로 전달하면 0값을 반환한다.

        fmt.Println(len(intSlice)) # 결과 : 0
    • append 함수도 마찬가지로 nil 슬라이스를 빈 슬라이스처럼 처리한다.

      • append 함수에 빈 슬라이스를 전달하면 슬라이스에 값을 추가한 다음 하나의 원소를 갖는 슬라이스를 반환한다.
      • 즉, append 함수에 nil 슬라이스를 전달하면 실제로는 값이 추가될 슬라이스가 없더라도 하나의 원소가 추가된 슬라이스를 반환받게 된다.
      • 슬라이스 변수가 빈 슬라이스인지 nil 슬라이스인지 신경쓸 필요가 없다.
    가변 인자 함수
    • 가변 인자 함수에 전달되는 인자는 슬라이스 타입만 가능하다. (배열은 불가능)

      func severalInts(numbers ...int) {
        fmt.Println(numbers)
      }
      • 가변 인자인 numbers는 실제로 슬라이스이다.

        func main() {
          numbers := []int{1,2,3,4,5}
          fmt.Printf("type : %v, address : %p\\n", reflect.TypeOf(numbers), &numbers[0])
          severalInts(numbers...)
        }

        func severalInts(numbers ...int) {
          fmt.Printf("type : %v, address : %p\\n", reflect.TypeOf(numbers), &numbers[0])
          fmt.Println(numbers)
        }
        // 결과 type : []int, address : 0xc0000a0060 type : []int, address : 0xc0000a0060
    • 가변인자에 아무것도 넘기지 않거나 nil을 전달하더라도 빈 슬라이스로 처리된다.

Map

  • 슬라이스 변수와 마찬가지로 값이 자동으로 생성되지 않는 타입이기 때문에 make 함수를 사용해 맵 값을 직접 생성해줘야 한다.

    • 즉, 맵 변수 자체의 제로값은 nil이다. (슬라이스도 마찬가지)
    • 일반 자료형들은 제로값 할당으로 인해 값을 자동으로 할당할 수 있기 때문에 make를 사용할 필요가 없다.
  • 일단 make로 맵이 생성되면 맵의 키와 값이 어떠한 타입인지 명시적으로 선언이 되기 때문에 존재하지 않는 키를 참조하더라도 제로값으로 인해 안전하게 참조된다.

    • 반면 맵을 make 함수로 생성하지 않는다면 제로값이 nil이기 때문에 키 참조시 panic이 발생한다.
  • 존재하지 않는 키 참조로 인해 제로값이 반환되기 때문에 실제 값인지 키가 존재하지 않아서 제로값이 반환된 것인지 판단이 어려운 문제가 있다. 이 경우 키 참조시 반환되는 bool 타입의 두번째 반환값을 사용한다.

    value, ok := counters["a"]
    if !ok {
      fmt.Println("존재하지 않음")
    }

Struct

  • 여러 타입의 값으로 구성된 값을 의미하고, struct 내 선언되는 변수들을 필드(field)라 한다.

  • 구조체를 의미하는 struct는 go 언어에서 제공되는 타입이지만 struct로 만들어진 타입은 go 언어에서 제공되는 타입이 아니다.

    var subscriber1 struct { name string rate float64 active bool }
    • 변수 선언에 사용된 struct {...} 타입은 golang에서 제공되는 것이 아님

    • int, string과 같은 미리 정의되어 있는 타입을 underlying type(기본 타입)이라하고, struct를 사용한 타입과 같이 정의되지 않은 타입을 defined type(사용자 정의 타입) 이라고 한다.

    • 이렇게 새로운 타입을 정의하려는 경우에는 type 키워드를 사용한다.

      type myType struct [ ... }
  • 구조체도 값이기 때문에 구조체 변수를 함수의 인자로 전달할 경우 복사본을 전달한다. (pass-by-value)

    • 다른 변수들과 마찬가지로 전달한 함수 내에서 변경이 필요한 경우 포인터를 사용한다.

    • 포인터를 사용할 경우 정석대로 할 경우 아래와 같이 참조해야 한다.

      var pointer *myStruct = &value
      fmt.Println((*pointer).myField)
    • 위와 같은 방법은 번거롭기 때문에 도트(.) 연산자는 구조체 값에서 직접 필드에 접근할 수 있는 것처럼 포인터에서도 필드에 대한 접근을 허용한다. 이로인해 * 연산자를 생략할 수 있다.

      var pointer *myStruct = &value
      fmt.Println(pointer.myField)
  • 구조체의 크기가 큰 경우 이 구조체를 pass-by-value로 전달할 경우 모든 필드의 값을 복사하게 된다.

    • 이런 경우에는 구조체를 변경할 필요가 없는 경우라고 해도 포인터로 전달하는 것이 좋다.

구조체 임베딩

  • 구조체 안에 다른 구조체를 필드로 포함(내부구조체)하는 경우 내부 구조체의 값을 참조하기 위해 Depth가 증가하기 때문에 번거롭다.

    subscriber.HomeAddress.Street = "123 Oak St"
    • 이런 경우 내부 구조체의 필드명을 제거하여 익명 필드(anonymous field)를 정의할 수 있다.

    • 익명 필드를 정의하면 필드명으로 참조할 필요 없이 외부 구조체 변수로 참조가 가능하다.

      subscriber.Street = "123 Oak St"
    • 이와 같이 외부 구조체의 익명 필드로 선언된 내부 구조체를 외부 구조체 안에 임베딩(embedded) 되었다고 한다.

  • 내부 구조체를 반드시 임베딩할 필요는 없다.

    • 상황에 따라 임베딩이 아닌 외부 구조체에 필드로 추가하는 것이 더 깔끔한 방법일 수 있다.
    • 상황에 따라 가장 적합한 방법을 사용하는 것이 중요하다.

Tips

  • 변수 값을 Go 코드에서 보이는 그대로 출력

    var intSlice []int fmt.Printf("%#v", intSlice)

 

 

재미있는 예제들이 한가득!

 

책을 읽으면서 번역서라는 느낌이 들지 않을 정도로 문맥을 이해하기 쉬웠고, 예제도 이론적인 설명을 보충하기에 적절하고 지루하지 않았다. 예제를 할 때는 repl 사이트(https://repl.it/)를 사용했는데, 가볍게 따라해볼 때 편리하다. 처음 Go 언어를 접하는 분들에게는 정말 추천하고 싶은 책이다.