본문 바로가기
Golang/Tucker

[묘공단] 4주차

by 윤원용 2023. 10. 22.

이 글은 골든래빗 《Tucker의 Go 언어 프로그래밍》의 18, 19, 20, 21, 22장 써머리입니다.

오늘은 각 항목마다 주의할 점들과 간단한 예제로 리뷰할 예정이다. 이유는 이전 예제에서도 많이 사용했던 내용들이라 굳이 추가적으로 예제를 만들면서 할 이유가 없다 생각이 된다. 꼬우면 책을 사서 읽으삼!!!

더보기
  • 18 슬라이스
    • 18.1 슬라이스
    • 18.2 슬라이스 동작 원리
    • 18.3 슬라이싱
    • 18.4 유용한 슬라이싱 기능 활용
    • 18.5 슬라이스 정렬
  • 19 메서드
    • 19.1 메서드 선언
    • 19.2 메서드는 왜 필요한가?
    • 19.3 포인터 메서드 vs 값 타입 메서드
  • 20 인터페이스
    • 20.1 인터페이스
    • 20.2 인터페이스 왜 쓰나?
    • 20.3 덕 타이핑
    • 20.4 인터페이스 기능 더 알기
    • 20.5 인터페이스 변환하기
  • 21 함수 고급편
    • 21.1 가변 인수 함수
    • 21.2 defer 지연 실행
    • 21.3 함수 타입 변수
    • 21.4 함수 리터럴
  • 22 자료구조
    • 22.1 리스트
    • 22.2 링
    • 22.3 맵
    • 22.4 맵의 원리

목차를 보면 분명 중요한 게 많긴 하지만 그냥 넘어가도 괜찮은 것들도 많다 ㅎㅎ;;

 

18 슬라이스

Golang에서 내부적으로 지원하는 여러 함수들을 사용해서 슬라이스를 생성하거나 관리할 수 있고 주로 길이가 정해지지 않은 선형적인 데이터를 저장하기 위해 사용된다.  참고로 일반적인 상황에서는 타입은 정해져 있어야 한다.

 

슬라이스 선언 방법을 알아보기 앞서 컴파일 시스템이 있는 모던 프로그래밍 언어에서 지원하는 선형 데이터 관리 자료구조의 통용되는 부분을 이해하고 넘어갈 필요가 있다. 이전에 리뷰했던 배열을 기본적으로 이해하고 있어야 하며, 추가적으로 length(길이)와 capacity(용량)에 대해 이해하고 넘어가야 한다. length는 말 그대로 배열의 길이를 뜻하며 capacity는 배열이 할당받은 메모리 영역을 의미하는데 Golang의 슬라이스에서 주의해야 하는 부분은 length가 3이고 capacity가 10일 경우 배열에서 5번 index로 접근할 시 panic이 발생한다는 점이다. 이유는 배열을 선언할 때 capacity 만큼 할당받은 공간 있지만 그 공간만큼 초기화되지 않았고 length의 3만큼만 초기화 됐다는 이유이다.  쉽게 이야기하면 슬라이스가 할당받은 메모리 공간은 10(capacity)이고 슬라이스가 사용하고 있는 배열의 길이는 3(length)이기 때문에 슬라이스를 통해 5번 index에 접근하는 것은 배열이 할당받은 길이 외로 메모리에 접근하는 것이기 때문에 panic이 발생하는 것이다. 그렇기 때문에 Golang에서는 슬라이스의 length와 capacity 정보를 알 수 있는 함수를 각각 지원하는데 length는 len, capacity는 cap 함수를 통해 슬라이스의 정보를 확인할 수 있다. 혹시 모르는 사람이 있을 까봐 첨언을 하면 내장 api라는 것은 별도의 import 없이 호출할 수 있는 api들을 뜻한다 생각해도 괜찮다. (아니면 덧글로 알려주세요.)

하나만 더 주의할 내용을 추가하면 슬라이스라고 해서 배열과 다르게 동적으로 길이를 변경할 수는 없다. 다만 슬라이스는 배열과 다르게 내장 api인 append 함수를 사용해서 동적으로 확장할 수 있다. 

 

선언 및 주의점

슬라이스를 만들기 위해서는 크게 세 가지 방법이 있다.

1. 배열과 비슷하게 선언하는 방법

slice1는 아무것도 없지만 가변적인 크기를 갖는 슬라이스를 생성한 것이다. 배열과 다른 점으로는 대괄호([]) 안에 배열의 길이를 명시하지 않았다는 점이다.

slice2는 선언과 동시에 초기화하는 방식으로 슬라이스를 생성한 것이다. 이때 슬라이스의 length와 capacity는 4이다.

slice3은 slice2와 방식은 같으나 초기화하는 방법이 조금 다른데 0: 12는 0번째 index에 12로 초기화한다는 의미고 4: 52 또한 마찬가지로 4번째 index에 52로 초기화한다는 의미이다. 그리고 중간중간 건너뛴 index들의 값은 당연히 슬라이스를 선언할 때 명시한 자료형의 기본 값으로 초기화된다. 

위 이미지는 앞서 설명한 방식으로 선언된 슬라이스들을 출력한 결과인데 배열과 다르게 출력할 때도 대괄호([]) 사이에 배열의 길이가 없다.

 

2. 내장 api인 make를 사용하여 만들기

사실 실무에서 1번 방법을 사용해 slice를 만드는 것을 본 적이 없는 것 같다.  주로 2번이나 다음으로 설명할 3번을 많이 사용하는데 2번과 3번의 장단점은 슬라이스에 초기화 요소들을 넣을 수 있는가? capacity를 정할 수 있는가? 이 정도의 차이만 있지 거의 비슷하다 생각해도 괜찮다. make 함수를 사용해서 얻을 수 이점은 length와 capacity를 명시할 수 있다는 점이다. make 함수의 사용방법을 알아보면 첫 번째 파라미터는 슬라이스가 사용할 자료형이고 두 번째 파라미터는 length 여기 까지는 필수고 세 번째 파라미터인 capacity는 선택이다. make 함수는 슬라이스뿐만 아니라 뒤에 리뷰할 map이나 channel과 같이 Golang에서 지원하는 자료구조를 만들 때 사용되는 함수이니 기억해 두면 좋다.
위 이미지의 내용을 출력해 보면

위와 같이 출력되는데 두 번째 줄을 보면 len이 5지만 cap10이다. 하지만 초기화된 요소의 개수를 보면 len과 같은 5인 것을 확인할 수 있다. 

위와 같은 코드가 있다고 했을 때 과연 panic이 발생하지 않을까?라는 의문의 해답이 바로 3번째 방법을 가장 많이 사용하는 이유이다. 이 코드는 slice2 변수에 할당한 10(cap) 만큼을 전부 사용할 수 없고 make 함수의 파라미터로 넣은 5(len) 만큼만 사용할 수 있다. 코드의 실행 결과를 출력해 보면

위와 같기 때문에 이런 코드는 cap함수가 아닌 len 함수를 써야 하다는 점을 꼭!! 꼭!!! 이해하자

3. 내장 api인 append를 사용하여 만들기

어떻게 보면 1번 방법에다 append를 사용한 것과 똑같다. append 함수는 사실 배열을 확장하는 것에 이점이 있다.

 

위 코드의 내용은 A~Z까지 slice2에 추가하면서 slice2의 정보를 출력하는 코드이다. 출력 내용을 확인해 보면 주의할 점이 있는데

출력 내용을 보면 가장 중요하게 봐야 하는 내용은 cap이 변경될 때 pointer가 변경된다는 점이다. 코드의 목적에 따라 다를 수 있지만 주로 append 함수를 사용할 땐 첫 파라미터로 넣은 슬라이스에 대입하는 식으로 해야 pointer가 변경돼도 이러한 내용을 고민하지 않고 코딩할 수 있다.

 

슬라이싱 같은 경우는 본인은 실무에서 사용해 본 적이 없지만 간단하게 설명만 하면 슬라이스에서 index를 기준으로 범위를 지정해 잘라내는 문법을 뜻하며, slice[startIndex: endIndex] 처럼 변수에 대괄호를 붙이고 startIndex부터 endIndex -1 까지 잘라야 할 때 사용된다. 이전 배열에서 소개한 표현 방법으로 표현하면 [startIndex: endIndex) 이다.

위 코드를 보면 slice1의 1부터 2 - 1까지 요소를 잘라서 slice2를 만들고 출력하는 코드인데 출력 내용을 보면 포인터가 다르다는 것을 알 수 있다. 이게 실수하기 좋은 내용이 있는데 slice2의 0번째 index에 값을 변경하면 slice1에도 영향을 준다는 점이다.

출력 내용을 보면 앞의 이미지에 보이는 slice1의 원소가 "A", "B", "C"가 아닌 "A", "Z", "C"가 된다는 점이 주의해야 할 부분이고 포인터에 대해 개념이 부족하다면 이 부분을 이해할 수 없다. 때문에 본인이 생각하기엔 이러한 기능을 사용해서 짧게 코딩하기보단 반복문과 조건문을 사용하더라도 이해하기 쉬운 코드가 유지보수에 좋다는 부분에서 슬라이싱을 사용할 땐 다른 방법을 생각해봐야 한다고 생각한다. 사용 후 문제가 생겨 디버깅할 생각 하면 어질어질하다....

 

정렬은 큰 의미 없어 pass (정렬은 본인이 생각하기에 실무에서 orm을 사용하더라도 쓸 일이 거의 없고 보통 RDBMS인 경우는 query로 해결하거나 redis 같은 경우는 sorted set를 사용해서 해결하면 그만이기 때문에 알고리즘 문제 풀 것이 아니면 굳이 몰라도 괜찮다 생각한다.)

 

19 메서드

책에서는 메서드를 소개하면서 결합도(coupling), 응집도(cohesion)에 대한 내용도 소개하고 있기 때문에 매우 좋고 oop(Object Oriented Programming) 또 한 설명해서 좋다. 하지만 이번에는 책과 다르게 본인의 생각으로 리뷰할 예정이다.

틀릴 수 있으니 책을 사서 공부하는 걸 추천한다. ㅋㅋ struct에 대해 궁금하면 이전에 리뷰했던 내용을 봐주는 것도 좋을 듯

본인은 struct를 사용할 때 메서드(method)는 중요하다 생각하고 struct의 method가 있으면 instance, method가 없으면 vo(value object)처럼 생각하고 사용한다. instance는 heap memory 영역에 할당된 메모리를 뜻하는 거라 vo도 똑같지만 vo 같은 경우는 field들로만 구성됐다면 isntance는 field와 method를 갖고 있다. 즉 상태와 행동을 갖고 있다고 이해하면 좋을 것 같다. vo와 dto도 구별하면 다르지만 이번 주제와 벗어나니 넘어가고 Golang에서는 struct에 method를 선언하려면 receiver가 필요하다. receiver는 struct를 value와 pointer 두 종류를 할당받는 임의의 변수명을 명시하는 것이다.  이제 코드로 보면

package main

import (
        "fmt"
)

type MethodStruct struct {
        msg string
}

func (ms *MethodStruct) PointReceiverPrint() {
        fmt.Println("pointer: \t", ms.msg)
}


func (ms MethodStruct) ValueReceiverPrint() {
        fmt.Println("value: \t\t", ms.msg)

}

func main() {
        pms := new(MethodStruct)
        pms.msg = "hello world!!!"
        fmt.Println("===pointer struct pms===")
        pms.PointReceiverPrint()
        pms.ValueReceiverPrint()

        vms := MethodStruct{
                msg: "hello world!!!",
        }
        fmt.Println("====value struct vms===")
        vms.PointReceiverPrint()
        vms.ValueReceiverPrint()
}

위 코드에서 (ms *MethodStruct)와 (ms MethodStruct) 이 부분이 리시버(receiver)다. 이제 main함수를 실행하여 결과를 보면 

위와 같이 출력된다. 결과만 보면 receiver를 pointer를 사용하나 value로 사용하다 별 차이 없는 것처럼 보일 수 있지만 field를 수정하는 method를 사용할 땐 명확하게 차이가 발생한다.

func (ms *MethodStruct) PointerReceiverSetMsg(msg string) {
        ms.msg = msg
}

func (ms MethodStruct) ValueReceiverSetMsg(msg string) {
        ms.msg = msg
}

위 코드를 추가 후 사용한 결과를 출력하면

위 결과를 보면 pointer변수인 pms변수가 PointerReceiverSetMsg 함수를 사용한 후 출력한 결과를 보면 "hello world!!!"에서 "bye world!!!"로 변경된 것을 확인할 수 있지만 value변수인 vms변수가 ValueReceiverSetMsg 함수를 사용한 후 출력한 결과는 변경되지 않은 것을 확인할 수 있다. 이제 pms변수가 ValueReceiverSetMsg 함수를 사용하고 vms변수가 PointerReceiverSetMsg 함수를 사용하면 어떻게 될까?

위 결과를 보면 딱 감이 잡힐 것 같은데 변수가 pointer인지 value인지는 중요하지 않고 method의 receiver가 pointer인지 value인지가 중요하다는 점이다. method를 통해 field를 수정할 때에는 receiver가 pointer인 경우 같은 memory를 참조하지만 value인 경우는 복사가 이뤄지기 때문에 다른 memory를 참조하기 때문에 변수의 형태가 아닌 receiver의 형태가 중요하다는 것을 알 수 있다.

 

20 인터페이스

인터페이스를 설명하기 시작하면 끝도 없다. 그러므로 실무에서 자주 사용하는 예제 코드를 설명하는 방식으로 리뷰할 예정이다. 꼬우면 책 사서 보자. 생각해 보니 덕 타이핑을 이해한 후 활용하면 할 수 있는 것이 많아 중요한데... pass;;

 

1. interface를 활용해 아무 struct나 marshalling과 unmarshalling 하기 (json 사용하면 꼭 알아두기)

예제를 보기 앞서 간단히 설명하면 json 형태의 string을 []byte 형태로 변경(casting)한 후 A struct와 B struct를 생성하며 []byte 값을 struct의 field에 맞게 대입하거나 struct의 정보를 []byte 형태로 만드는 코드이다.

package main

import (
        "fmt"
        "encoding/json"
)

type (
        A struct {
                Name string `json:"name"`
                Age int64 `json:"age"`
        }

        B struct {
                name string `json:"name"`
                age int64 `json:"age"`
        }
)
func main() {

        data := []byte("{\"name\": \"수빈\", \"age\": 100}")
        a := new(A)

        fmt.Printf("a Unmarshalling before\n%#+v\n", a)
        Unmarshalling(data, a)
        fmt.Printf("a Unmarshalling after\n%#+v\n", a)
        data = Marshalling(a)
        fmt.Printf("a Marshalling result: %s\n", string(data))

        b := new(B)
        fmt.Printf("a Marshalling data b Unmarshalling before\n%#+v\n", b)
        Unmarshalling(data, b)
        fmt.Printf("a Marshalling data b Unmarshalling after\n%#+v\n", b)
}

func Marshalling(obj interface{}) []byte {
        data, err := json.Marshal(obj)
        if err != nil {
                panic(err)
        }
        return data
}

func Unmarshalling(data []byte, obj interface{}) {
        if err := json.Unmarshal(data, obj); err != nil {
                panic(err)
        }
}

위 코드는 간단한데 Marshalling과 Unmarshalling 함수에서 사용하는 interface{}에 주목해야 한다. Golang은 강타입 언어인 것은 잘 알겠지만 interface{}를 사용하면 꽤 편하게 코딩할 수 있다. A, B struct는 분명 다르지만 interface{}로 덮어 씌울 수 있다. (쉽게 설명하기 위해 덮어 씌운다 했지만 casting이다.) 

 

2. interface를 활용해 여러 struct를 하나로 묶기(다형성 아닌 다형성, 상속 아닌 상속)

oop 패러다임을 지원하는 프로그래밍 언어에서 자주 사용하는 예제이지만 Golang에서도 자주 사용하긴 하는 것 같다.

간단하게 설명하고 예제를 보면 Animal interface를 생성 후 Animal에 공통으로 사용하는 method를 정의한 후 Animal을 field로 갖는 Dog와 Cat를 생성한 후 Animal에서 공통으로 사용할 method들을 override한 후 호출하는 코드이다.

package main

type (
        Animal interface {
                Eat()
                Sleep()
                Bark()

        }
        Dog struct {
                Animal
        }

        Cat struct {
                Animal
        }
)
func main() {
        dog := new(Dog)
        cat := new(Cat)

        dog.Eat()
        cat.Bark()
}

그전에 위와 같은 코드가 있다면 build가 성공적으로 될까?

너무 잘되지만 실행하면 당연히 panic이 발생한다. 즉 build 과정에서 panic을 알 수 없기 때문에 runtime에 발생한다는 점을 주의해야 한다!!!!

다시 예제를 마저 구현해 보면

package main

import (
        "fmt"
)

type (
        Animal interface {
                Eat(food string)
                Sleep()
                Bark()

        }
        Dog struct {
                Animal
        }

        Cat struct {
                Animal
        }
)

func (d *Dog) Eat(food string) {
        fmt.Printf("개가 %s을(를) 먹어요.\n", food)
}

func (d *Dog) Sleep() {
        fmt.Println("개가 쿨쿨 자고 있어요.")
}

func (d *Dog) Bark() {
        fmt.Println("개가 왈왈 소리로 짖고 있어요.")
}

func (c *Cat) Eat(food string) {
        fmt.Printf("고양이가 %s을(를) 먹어요.\n", food)
}

func (d *Cat) Sleep() {
        fmt.Println("고양이가 골골 소리를 내며 자고 있어요.")
}

func (d *Cat) Bark() {
        fmt.Println("고양이가 야옹 소리로 짖고 있어요.")
}

func main() {
        var animals [2]Animal

        animals[0] = new(Dog)
        animals[1] = new(Cat)

        for _, animal := range animals {
                animal.Eat("사료")
                animal.Bark()
                animal.Sleep()
        }
}

위 코드를 실행해 보면

이런 결과를 얻을 수 있다. 아주 간단한 코드지만 응용 방법이 무궁무진하기 때문에 꼭!!! 자신만의 코드를 만들어 보자~

 

3. 마지막으로 interface 타입 알아내기!!!

이건 사실 generic이 나온 후 부터 딱히 필요 없는 내용이긴 한데 reflect를 사용해 응용할 때 필요한 부분이라 간단한 예제로 보겠다. 2번의 예제에서 조금만 수정한 후 확인해보자.

const RAN = 10

func main() {
        var animals [RAN]Animal

        seed := time.Now().UnixNano()
        randSource := rand.NewSource(seed)
        rand := rand.New(randSource)
        for i := 0; i < RAN; i++ {
                if rand.Intn(RAN) % 2 == 0 {
                        animals[i] = new(Cat)
                        continue
                }
                animals[i] = new(Dog)
        }

        for i, animal := range animals {
                fmt.Printf("index: %d, animalType: %s\n", i, animalTypePrint(animal))
        }
}

func animalTypePrint(animal Animal) string {
        msg := "????"
        switch animal.(type) {
                case *Dog:
                        msg = "Dog"
                case *Cat:
                        msg = "Cat"
        }

        return msg
}

 코드를 간단하게 설명하면 짝수는 Cat을 홀수면 Dog를 Animals에 추가한 후 Animals를 순회하면서 요소의 타입을 출력하는 코드다.

'Golang > Tucker' 카테고리의 다른 글

[묘공단] 5주차 (2)  (0) 2023.10.29
[묘공단] 5주차 (1)  (1) 2023.10.29
[묘공단] 3주차 (2)  (1) 2023.10.16
[묘공단] 3주차 (1)  (2) 2023.10.15
[묘공단] 2주차 (2)  (1) 2023.10.09