1 맵 소개

Go 언어에서 맵(map)은 서로 다른 타입의 키-값 쌍 컬렉션을 저장할 수 있는 특별한 데이터 유형입니다. 이는 Python의 딕셔너리나 Java의 HashMap과 유사합니다. Go에서 맵은 해시 테이블을 사용하여 구현되는 내장 유형으로, 빠른 데이터 조회, 업데이트 및 삭제 기능을 제공합니다.

특징

  • 참조 유형: 맵은 참조 유형이며, 생성 후에 실제로 기본 데이터 구조에 대한 포인터를 얻습니다.
  • 동적 확장: 슬라이스와 유사하게, 맵의 공간은 정적이 아니며 데이터가 증가함에 따라 동적으로 확장됩니다.
  • 키의 고유성: 맵 내 각 키는 고유하며, 동일한 키를 사용하여 값 저장 시 새 값이 기존 값을 덮어씁니다.
  • 순서 없는 컬렉션: 맵 내 요소는 순서가 없으므로 맵이 탐색될 때마다 키-값 쌍의 순서가 다를 수 있습니다.

사용 사례

  • 통계: 키의 고유성을 활용하여 중복되지 않는 요소를 빠르게 계산합니다.
  • 캐싱: 키-값 쌍 메커니즘은 캐싱을 구현하기에 적합합니다.
  • 데이터베이스 연결 풀: 데이터베이스 연결과 같은 리소스 집합을 관리하여 리소스를 여러 클라이언트에서 공유하고 액세스할 수 있도록 합니다.
  • 구성 항목 저장: 구성 파일에서 매개변수를 저장하는 데 사용됩니다.

2 맵 생성

2.1 make 함수를 사용하여 생성

맵을 만드는 가장 일반적인 방법은 make 함수를 사용하는 것인데, 이는 다음과 같은 구문을 가집니다:

make(map[키_유형]값_유형)

여기서 키_유형은 키의 유형이고, 값_유형은 값의 유형입니다. 다음은 구체적인 사용 예입니다:

// 문자열 키 유형과 정수 값 유형을 가지는 맵 생성
m := make(map[string]int)

이 예에서 우리는 문자열 키와 정수 값으로 구성된 빈 맵을 생성했습니다.

2.2 리터럴 구문을 사용하여 생성

make을 사용하는 것 외에도 리터럴 구문을 사용하여 맵을 생성 및 초기화할 수 있습니다. 이는 동시에 일련의 키-값 쌍을 선언하는 방식으로 이루어집니다:

m := map[string]int{
    "사과": 5,
    "배": 6,
    "바나나": 3,
}

이는 맵을 생성하는 동시에 세 개의 키-값 쌍을 설정합니다.

2.3 맵 초기화 고려사항

맵을 사용할 때 주의해야 할 점으로, 초기화되지 않은 맵의 제로 값은 nil이며, 이 시점에서 직접 키-값 쌍을 저장할 수 없습니다. 왜냐하면 실행 시 패닉을 유발할 수 있기 때문에 먼저 make를 사용하여 초기화해야 합니다:

var m map[string]int
if m == nil {
    m = make(map[string]int)
}
// 이제 m을 안전하게 사용할 수 있습니다

또한, 맵 내 키의 존재 여부를 확인하기 위한 특별한 구문이 있습니다:

value, ok := m["key"]
if !ok {
    // "key"는 맵 안에 없습니다
}

여기서 value는 주어진 키에 연관된 값이며, ok는 해당 키가 맵 안에 존재하면 true이고 존재하지 않으면 false인 부울 값입니다.

3 맵 접근 및 수정

3.1 요소 접근

Go 언어에서 맵의 키에 해당하는 값에 접근하려면 해당 키를 지정합니다. 키가 맵 안에 존재하면 해당 값이 반환되며, 키가 존재하지 않으면 값 유형의 제로 값이 반환됩니다. 예를 들어, 정수를 저장하는 맵에서 키가 존재하지 않는 경우 0이 반환됩니다.

func main() {
    // 맵 정의
    scores := map[string]int{
        "Alice": 92,
        "Bob": 85,
    }

    // 존재하는 키에 접근
    aliceScore := scores["Alice"]
    fmt.Println("Alice의 점수:", aliceScore) // 결과: Alice의 점수: 92

    // 존재하지 않는 키에 접근
    missingScore := scores["Charlie"]
    fmt.Println("Charlie의 점수:", missingScore) // 결과: Charlie의 점수: 0
}

"Charlie" 키가 존재하지 않더라도 에러가 발생하지 않고, 대신에 정수 제로 값인 0이 반환됩니다.

3.2 키 존재 여부 확인

가끔 우리는 맵 내에 키가 존재하는지 여부만을 간단히 확인하고 해당 값에는 신경 쓰지 않는 경우가 있습니다. 이 경우에는 맵 접근의 두 번째 반환값을 사용할 수 있습니다. 이 불리언 반환값은 해당 키가 맵 내에 존재하는지 여부를 알려줍니다.

func main() {
    scores := map[string]int{
        "Alice": 92,
        "Bob": 85,
    }

    // 키 "Bob"의 존재 여부 확인
    score, exists := scores["Bob"]
    if exists {
        fmt.Println("Bob의 점수:", score)
    } else {
        fmt.Println("Bob의 점수를 찾을 수 없습니다.")
    }

    // 키 "Charlie"의 존재 여부 확인
    _, exists = scores["Charlie"]
    if exists {
        fmt.Println("Charlie의 점수를 찾을 수 있습니다.")
    } else {
        fmt.Println("Charlie의 점수를 찾을 수 없습니다.")
    }
}

이 예시에서는 불리언 값을 확인하는 if 문을 사용하여 키의 존재 여부를 판단합니다.

3.3 요소 추가 및 업데이트

맵에 새로운 요소를 추가하거나 기존 요소를 업데이트할 때에는 동일한 구문을 사용합니다. 만약 키가 이미 존재한다면 기존 값은 새 값으로 대체됩니다. 키가 존재하지 않는다면 새로운 키-값 쌍이 추가됩니다.

func main() {
    // 빈 맵 정의
    scores := make(map[string]int)

    // 요소 추가
    scores["Alice"] = 92
    scores["Bob"] = 85

    // 요소 업데이트
    scores["Alice"] = 96  // 기존 키 업데이트

    // 맵 출력
    fmt.Println(scores)   // 출력: map[Alice:96 Bob:85]
}

추가 및 업데이트 작업은 간결하며 간단한 할당으로 수행될 수 있습니다.

3.4 요소 삭제

맵에서 요소를 제거할 때는 내장 함수인 delete를 사용합니다. 다음 예시는 삭제 작업을 보여줍니다.

func main() {
    scores := map[string]int{
        "Alice": 92,
        "Bob": 85,
        "Charlie": 78,
    }

    // 요소 삭제
    delete(scores, "Charlie")

    // Charlie가 삭제되었는지 맵 출력으로 확인
    fmt.Println(scores)  // 출력: map[Alice:92 Bob:85]
}

delete 함수는 맵 자체를 첫 번째 매개변수로, 삭제할 키를 두 번째 매개변수로 받습니다. 키가 맵 내에 존재하지 않는 경우 delete 함수는 효과가 없으며 오류를 발생시키지 않습니다.

4. 맵 순회

Go 언어에서는 for range 문을 사용하여 맵 데이터 구조를 순회하고 컨테이너 내의 각 키-값 쌍에 액세스할 수 있습니다. 이러한 유형의 반복 순회 작업은 맵 데이터 구조에서 지원하는 기본적인 작업입니다.

4.1 for range를 사용하여 맵 순회

for range 문을 바로 맵에 사용하여 맵 내의 각 키-값 쌍을 검색할 수 있습니다. 아래는 맵을 순회하는 기본적인 예시입니다:

package main

import "fmt"

func main() {
    myMap := map[string]int{"Alice": 23, "Bob": 25, "Charlie": 28}

    for key, value := range myMap {
        fmt.Printf("키: %s, 값: %d\n", key, value)
    }
}

이 예시에서 key 변수에는 현재 순회의 키가 할당되고, value 변수에는 해당 키에 연결된 값이 할당됩니다.

4.2 순회 순서에 대한 고려사항

맵을 순회할 때 순회 순서는 매번 동일하지 않을 수 있으며 맵의 내용이 변경되지 않았더라도 동일한 순서를 보장할 수 없습니다. 이는 Go에서 맵을 순회하는 과정이 특정한 순회 순서에 의존하지 않도록 무작위로 설계되어 있기 때문에 발생합니다. 따라서 코드의 안정성을 향상시키기 위해 프로그램이 특정한 순회 순서에 의존하는 것을 방지합니다.

예를 들어, 아래 코드를 연속으로 두 번 실행하면 각각 다른 출력이 나올 수 있습니다:

package main

import "fmt"

func main() {
    myMap := map[string]int{"Alice": 23, "Bob": 25, "Charlie": 28}

    fmt.Println("첫 번째 순회:")
    for key, value := range myMap {
        fmt.Printf("키: %s, 값: %d\n", key, value)
    }

    fmt.Println("\n두 번째 순회:")
    for key, value := range myMap {
        fmt.Printf("키: %s, 값: %d\n", key, value)
    }
}

지도에 관한 5가지 고급 주제

이제 지도와 관련된 여러 고급 주제에 대해 자세히 살펴보겠습니다. 이를 통해 지도를 더 잘 이해하고 활용할 수 있을 것입니다.

5.1 지도의 메모리 및 성능 특성

Go 언어에서 지도는 매우 유연하고 강력한 데이터 유형입니다만, 동적 성격으로 인해 메모리 사용량과 성능 측면에서 특정한 특성을 갖습니다. 예를 들어, 지도의 크기는 동적으로 커질 수 있으며, 저장된 요소의 수가 현재 용량을 초과하면 지도는 자동으로 성장하는 수요를 수용하기 위해 더 큰 저장 공간을 재할당합니다.

이러한 동적 성장은 특히 큰 지도나 성능에 민감한 응용 프로그램에서 성능 문제로 이어질 수 있습니다. 성능을 최적화하기 위해 지도를 생성할 때 합리적인 초기 용량을 지정할 수 있습니다. 예를 들어:

myMap := make(map[string]int, 100)

이렇게 하면 실행 중에 지도의 동적 확장 비용을 줄일 수 있습니다.

5.2 참조 유형의 특성

지도는 참조 유형이며, 즉 새 변수에 지도를 할당하면 새 변수가 원래 지도와 동일한 데이터 구조를 참조한다는 것을 의미합니다. 이는 새 변수를 통해 지도를 변경하면 이러한 변경 사항이 원래의 지도 변수에도 반영된다는 것을 의미합니다.

다음은 예시입니다:

package main

import "fmt"

func main() {
    originalMap := map[string]int{"Alice": 23, "Bob": 25}
    newMap := originalMap

    newMap["Charlie"] = 28

    fmt.Println(originalMap) // 출력 결과에는 새로 추가된 "Charlie": 28 key-value 쌍이 표시될 것입니다.
}

함수 호출 시 지도를 매개변수로 전달할 때도 참조 유형 동작을 주의해야 합니다. 이 시점에서 전달되는 것은 지도에 대한 참조이며, 사본이 아닙니다.

5.3 동시성 안전성과 sync.Map

Go 언어에서 지도를 다중 스레드 환경에서 사용할 때는 동시성 안전 문제에 특별한 주의가 필요합니다. 동시적 시나리오에서 제대로된 동기화가 이루어지지 않으면 Go의 지도 유형이 경합 조건을 일으킬 수 있습니다.

Go 표준 라이브러리는 동시적 환경을 위해 설계된 안전한 지도인 sync.Map 유형을 제공합니다. 이 유형은 Map에서 동작하는 기본적인 Load, Store, LoadOrStore, Delete 및 Range와 같은 메서드를 제공합니다.

아래는 sync.Map 사용 예시입니다:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mySyncMap sync.Map

    // 키-값 쌍 저장
    mySyncMap.Store("Alice", 23)
    mySyncMap.Store("Bob", 25)

    // 키-값 쌍 검색 및 출력
    if value, ok := mySyncMap.Load("Alice"); ok {
        fmt.Printf("키: Alice, 값: %d\n", value)
    }

    // sync.Map을 반복하는 Range 메서드 사용
    mySyncMap.Range(func(key, value interface{}) bool {
        fmt.Printf("키: %v, 값: %v\n", key, value)
        return true // 반복 계속
    })
}

일반 지도 대신 sync.Map을 사용하는 것은 동시적 환경에서 지도를 수정할 때 경합 조건 문제를 회피하여 스레드 안전성을 보장할 수 있습니다.