본문 바로가기
Language/Go

Go - maps

by ocwokocw 2021. 12. 31.

- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/maps

- 개요

array와 slice에서는 데이터를 순차적으로 저장한다. go에서는 key를 기반으로 데이터를 저장할 수 있는 map을 제공한다. dictionary를 만들어 보면서 map에 대해 알아보도록 하자.

- map

package main

import "testing"

func TestSearch(t *testing.T) {
    dictionary := map[string]string{"test": "this is just a test"}

    got := Search(dictionary, "test")
    want := "this is just a test"

    if got != want {
        t.Errorf("got %q want %q given, %q", got, want, "test")
    }
}
 
map을 선언할때에는 map으로 선언한다. 그 뒤에 [] 안에는 key형을 정의해주고 [] 뒤에는 value형을 정의한다. value형은 제한이 없지만 key형에는 제약이 있는데 비교가 가능해야 한다는것이다. key를 비교해야 저장한 데이터인지 신규 데이터인지 알 수 있기 때문에 당연한 얘기라 할 수 있다.
 
위의 코드를 수행하면 Search를 정의하라는 메시지가 나올것이다.
 
package main

func Search(dictionary map[string]string, word string) string {
    return ""
}
.........

func Search(dictionary map[string]string, word string) string {
    return dictionary[word]
}
 
처음에는 Compile만 되는상태로 go test를 수행하여 fail이 잘되는지 확인한 후, 제대로 동작하는 코드를 작성해준다. 이상없이 코드를 작성했다면 test가 통과할것이므로 리팩토링할 사항이 있는지 찾아보자.
 
got과 want 비교 부분은 helper 메소드로 추출해주면 더 의도가 명확해지고 재사용성이 좋아지므로 helper메소드로 추출해보도록 하자.
 
func TestSearch(t *testing.T) {
    dictionary := map[string]string{"test": "this is just a test"}

    got := Search(dictionary, "test")
    want := "this is just a test"

    assertStrings(t, got, want)
}

func assertStrings(t testing.TB, got, want string) {
    t.Helper()

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}
 
Dictionary의 사용성을 높이기 위해 map에 기반한 새로운 형을 정의하고 Search 메소드도 정의해보자. 이 요구사항을 만족했다고 가정한 후 test코드를 먼저 작성해보면 아래와 같다.
 
func TestSearch(t *testing.T) {
    dictionary := Dictionary{"test": "this is just a test"}

    got := dictionary.Search("test")
    want := "this is just a test"

    assertStrings(t, got, want)
}
 
이제 Dictionary와 Search메소드를 만들어보자.
 
type Dictionary map[string]string

func (d Dictionary) Search(word string) string {
    return d[word]
}
 
map에 기반한 새로운 Dictionary형을 정의하였다. 또한 이렇게 새로운 형을 정의하면서 해당 형을 receiver로 하여 메소드도 정의할 수 있게 되었다.
 
사전의 기본적인 검색기능을 구현하는게 어려운건 아니다. 다만 한 가지 생각해볼점이 있는데 찾으려고 하는 word가 없으면 어떻게 되느냐는 점이다.
 
해당 word가 없으면 빈값이 반환될것이다. 이런 상황에서도 프로그램은 계속 동작하며, 대세에 큰 영향은 없지만 Search 메소드가 word가 없다는 정보를 반환하도록 하는게 더 좋은 접근방법이 될 수 있다. 또 이렇게 하면 해당 word가 없는 상태인지, word는 있는데 정의가 되지 않은 상태인지를 명확히 알 수 있다.
 
func TestSearch(t *testing.T) {
    dictionary := Dictionary{"test": "this is just a test"}

    t.Run("known word", func(t *testing.T) {
        got, _ := dictionary.Search("test")
        want := "this is just a test"

        assertStrings(t, got, want)
    })

    t.Run("unknown word", func(t *testing.T) {
        _, err := dictionary.Search("unknown")
        want := "could not find the word you were looking for"

        if err == nil {
            t.Fatal("expected to get an error.")
        }

        assertStrings(t, err.Error(), want)
    })
}
 
Go에서는 보통 이런경우에 2번째 인자로 error를 반환한다. error타입은 .Error() 메소드를 호출하면 문자열로 변환이 가능하여 assertStrings를 이용할 수 있게해준다. 또 if 문으로 nil 검사를 선제적으로 해주어서 err가 nil인 경우 assertStrings 호출시 panic에 빠지지 않도록 할 수 있다.
 
현재 Search에서 key에 대한 정의만 반환하고 있으므로 go test를 실행하면 반환개수가 맞지 않다는 에러를 출력할것이다.
 
func (d Dictionary) Search(word string) (string, error) {
    return d[word], nil
}

...........

func (d Dictionary) Search(word string) (string, error) {
    definition, ok := d[word]
    if !ok {
        return "", errors.New("could not find the word you were looking for")
    }

    return definition, nil
}
 
Compile만 통과하도록 코드를 작성하여 assert에서 fail이 제대로 동작하는지 확인해준 후 test가 pass하도록 코드를 수정해준다. map에서 2번째 인자까지 반환받으면 정의뿐만 아니라 해당 key의 존재여부를 알 수 있다. 위의 코드에서는 ok로 key가 map에 존재하는지 여부를 반환받았다.
 
test를 pass하도록 수정했으니 이제 리팩토링할 부분이 있는지 살펴보자. errors.New 에서 문자열은 Search 에서 1번, Test에서 1번 총 2번쓰인다. 만약 Search에서 해당 문자열을 변경하면 Test가 실패하므로 일종의 magic number가 된다. 따라서 해당 Error를 변수로 추출하도록 하자.
 
var ErrNotFound = errors.New("could not find the word you were looking for")

func (d Dictionary) Search(word string) (string, error) {
    definition, ok := d[word]
    if !ok {
        return "", ErrNotFound
    }

    return definition, nil
}
 
ErrNotFound 라는 error변수로 추출하였으므로 test에서도 error문자열을 assert하지 않고 Error형으로 비교할 수 있다.
 
t.Run("unknown word", func(t *testing.T) {
    _, got := dictionary.Search("unknown")

    assertError(t, got, ErrNotFound)
})

func assertError(t testing.TB, got, want error) {
    t.Helper()

    if got != want {
        t.Errorf("got error %q want %q", got, want)
    }
}
 
위와 같이 error의 문구를 변경해도 test가 실패하지 않도록 더 견고한 코드가 되었다.
 
Search기능을 구현했으니 Add기능을 구현해보자. Add기능이 제대로 동작하는지 확인하기 위해서 이미 구현된 Search 기능을 활용하여 Test코드를 작성한다.
 
func TestAdd(t *testing.T) {
    dictionary := Dictionary{}
    dictionary.Add("test", "this is just a test")

    want := "this is just a test"
    got, err := dictionary.Search("test")
    if err != nil {
        t.Fatal("should find added word:", err)
    }

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}
func (d Dictionary) Add(word, definition string) {
}
.........
func (d Dictionary) Add(word, definition string) {
    d[word] = definition
}
 
위와 같이 Compile만 되도록 작성하여 Fail을 확인한 후 Test가 통과하도록 기능을 구현해준다.

- map의 pointer와 복사

map은 함수나 메소드로 넘길 때 포인터형(ex - &myMap)으로 주소를 넘겨주지 않아도 수정할 수 있다. 함수나 메소드로 넘길 때 데이터를 포함한 자료구조가 아닌 포인터를 복사한다.
 
map의 문제는 nil 값이 될 수 있다는것이다. map에서 읽을때에는 비어있는 것처럼 동작하지만, map에 쓰려고 할 때에는 runtime panic을 발생시킨다. 그래서 일반적으로 사용할 때에는 아래와 같이 비어있는 map을 선언하면 안된다.
 
var m map[string]string
 
빈값을 할당하는 방식으로 초기화하거나({}) make를 사용해주는것이 좋다.
 
var dictionary = map[string]string{}

// OR

var dictionary = make(map[string]string)
 
map을 이렇게 초기화하는 습관을 들여놓으면 runtime panic에 관해서 걱정할 일은 없다.
 
func TestAdd(t *testing.T) {
    dictionary := Dictionary{}
    word := "test"
    definition := "this is just a test"

    dictionary.Add(word, definition)

    assertDefinition(t, dictionary, word, definition)
}

func assertDefinition(t testing.TB, dictionary Dictionary, word, definition string) {
    t.Helper()

    got, err := dictionary.Search(word)
    if err != nil {
        t.Fatal("should find added word:", err)
    }

    if definition != got {
        t.Errorf("got %q want %q", got, definition)
    }
}
 
구현상으로는 크게 리팩토링할건 없지만 test코드를 리팩토링해주면 좋을것 같다. assert부분을 helper메소드로 만들고 word와 definition을 변수로 만들어주었다. 
 
다만 한 가지 고려하지 못한 부분이 있는데 이미 같은 word 정의가 존재할 때 값을 덮어쓴다는것이다. 물론 다른 프로그래밍언어에서도 map이라는 자료구조를 사용할 때 이미 존재하는 key에 대한 값을 재할당하면 수정하지만 문제는 우리가 정의한 dictionary의 메소드명이 Add라는것이다. Add라는 메소드명에 적합한 동작은 없는 word에 대해서만 정의해주고 이미 존재하는 정의는 수정하지 않는게 더 나아보인다.
 
func TestAdd(t *testing.T) {
    t.Run("new word", func(t *testing.T) {
        dictionary := Dictionary{}
        word := "test"
        definition := "this is just a test"

        err := dictionary.Add(word, definition)

        assertError(t, err, nil)
        assertDefinition(t, dictionary, word, definition)
    })

    t.Run("existing word", func(t *testing.T) {
        word := "test"
        definition := "this is just a test"
        dictionary := Dictionary{word: definition}
        err := dictionary.Add(word, "new test")

        assertError(t, err, ErrWordExists)
        assertDefinition(t, dictionary, word, definition)
    })
}
...
func assertError(t testing.TB, got, want error) {
    t.Helper()
    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}
 
위의 코드에서 Add메소드에 대해 이미 존재하는 word를 추가하려고 할 때, ErrWordExists라는 error를 반환하도록 검사하는 test코드를 추가했다. 또 정상적인 Add case(기존에 정의되어 있지 않은 word를 추가할 때)에 대해서 error가 nil인지 검사하는 코드도 추가해주었다.
 
var (
    ErrNotFound   = errors.New("could not find the word you were looking for")
    ErrWordExists = errors.New("cannot add word because it already exists")
)

func (d Dictionary) Add(word, definition string) error {
    d[word] = definition
    return nil
}
 
Test가 정상적으로 실패할것이다. 이제 통과하도록 코드를 구현해주자.
 
func (d Dictionary) Add(word, definition string) error {
    _, err := d.Search(word)

    switch err {
    case ErrNotFound:
        d[word] = definition
    case nil:
        return ErrWordExists
    default:
        return err
    }

    return nil
}
 
위와 같이 switch를 작성해주면 Search가 반환한 error가 ErrNotFound가 아닐 시 이를 그대로 반환할 수 있다.

- Error wrapping

구현과 관련해서 리팩토링할 사항이 많진 않지만 한 가지 고려해줄만한 점이 있다.
 
const (
    ErrNotFound   = DictionaryErr("could not find the word you were looking for")
    ErrWordExists = DictionaryErr("cannot add word because it already exists")
)

type DictionaryErr string

func (e DictionaryErr) Error() string {
    return string(e)
}
 
error 상수를 만들고 DictionaryErr형을 새롭게 정의하였다. 또한 DictionaryErr 형은 error 인터페이스를 구현하고 있다.

- map delete

위와 같은 과정을 거치면 Update 기능도 어렵지 않게 구현할 수 있다. delete도 별반 다를점은 없지만 golang에서 map의 항목을 지울때에는 built-in 함수를 사용하므로 따로 다루어보았다.
 
func TestDelete(t *testing.T) {
    word := "test"
    dictionary := Dictionary{word: "test definition"}

    dictionary.Delete(word)

    _, err := dictionary.Search(word)
    if err != ErrNotFound {
        t.Errorf("Expected %q to be deleted", word)
    }
}
 
위의 test코드에서는 Delete연산을 하면 Search에서 ErrNotFound error를 반환하는지 검사한다. 
 
func (d Dictionary) Delete(word string) {
    delete(d, word)
}
 
map을 delete할 때에는 위와 같이 built-in 함수를 이용한다.

'Language > Go' 카테고리의 다른 글

Go - mocking  (0) 2022.01.02
Go - dependency injection  (0) 2021.12.31
Go - pointers & errors  (0) 2021.12.26
Go - struct, method, interface  (0) 2021.12.24
Go - array, slice, test coverage  (0) 2021.12.04

댓글