[Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다

[Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다

이 글의 핵심

Go의 포인터·슬라이스·맵을 C++ 포인터·vector·map과 비교합니다. len·cap·append와 키 존재 확인(ok)까지 실전 패턴으로 정리합니다. 시리즈 Day 3~4입니다.

시리즈 안내

📚 Go 2주 완성 시리즈 #02 | 전체 목차 보기

이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼Day 3~4 내용입니다.

이전: #01 기본 문법 ← | → 다음: #03 객체지향


들어가며: 안전한 포인터의 세계

C++에서는 포인터 연산(p++, p + offset)으로 메모리를 자유롭게 탐색할 수 있지만, 동시에 세그멘테이션 폴트의 공포도 함께했습니다. Go는 포인터는 있지만 포인터 연산은 없습니다. 안전성을 택한 것입니다. 이 글에서는 Go의 포인터와 핵심 자료구조인 Slice, Map을 C++과 비교하며 배웁니다.

이 글에서 배울 내용:

  • Go 포인터의 제한과 안전성
  • Call by Value vs Call by Reference
  • Slice의 Length와 Capacity 이해
  • Map의 활용과 주의사항

실무에서의 체감

C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 툴체인이 단순해 보인다는 점입니다. 프로덕션에서는 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다.

자주 언급되는 장점:

  • 개발 속도: 팀·도메인에 따라 다르지만, 네트워크·CLI 코드를 빠르게 완성하기 쉬운 편입니다.
  • 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
  • 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.

목차

  1. 포인터: 연산은 없지만 역참조는 있다
  2. 배열: 고정 크기의 값 타입
  3. 슬라이스: Go의 동적 배열
  4. Map: 해시 테이블
  5. 실습 과제

1. 포인터: 연산은 없지만 역참조는 있다

C++ vs Go: 포인터 기본

// C++: 포인터 연산 가능
int x = 10;
int* p = &x;

*p = 20;        // 역참조
p++;            // 포인터 연산 (다음 int 위치)
*(p + 5) = 30;  // 오프셋 접근

int arr[10];
int* ptr = arr;
ptr[5] = 100;   // 배열 인덱싱 = 포인터 연산
// Go: 포인터 연산 불가
x := 10
p := &x

*p = 20         // ✅ 역참조 가능
// p++          // ❌ 컴파일 에러: 포인터 연산 불가
// *(p + 5)     // ❌ 컴파일 에러

// 배열 접근은 인덱스로
arr := [10]int{}
arr[5] = 100    // ✅ 인덱스 접근

핵심 차이점:

  • Go는 포인터 연산을 허용하지 않습니다 (안전성)
  • *(역참조)와 &(주소 연산)만 가능
  • 배열 접근은 인덱스 문법만 사용

C++ vs Go: 함수 인자 전달

// C++: 값, 포인터, 참조
void byValue(int x) {
    x = 100;  // 원본 변경 안 됨
}

void byPointer(int* p) {
    *p = 100;  // 원본 변경됨
}

void byReference(int& r) {
    r = 100;  // 원본 변경됨
}

int main() {
    int x = 10;
    byValue(x);        // x는 여전히 10
    byPointer(&x);     // x는 100
    byReference(x);    // x는 100
}
// Go: 값 또는 포인터 (참조 없음)
func byValue(x int) {
    x = 100  // 원본 변경 안 됨
}

func byPointer(p *int) {
    *p = 100  // 원본 변경됨
}

func main() {
    x := 10
    byValue(x)      // x는 여전히 10
    byPointer(&x)   // x는 100
}

포인터 사용 가이드:

  • 작은 타입 (int, bool 등): 값 전달
  • 큰 구조체 (64바이트 이상): 포인터 전달
  • 수정 필요: 포인터 전달
  • 읽기 전용: 값 또는 포인터 (일관성 고려)

nil 포인터

// C++: nullptr
int* p = nullptr;
if (p == nullptr) {
    std::cout << "null pointer\n";
}

// 역참조 시 세그멘테이션 폴트
// *p = 10;  // 크래시!
// Go: nil
var p *int
if p == nil {
    fmt.Println("nil pointer")
}

// 역참조 시 패닉
// *p = 10  // panic: runtime error: invalid memory address

2. 배열: 고정 크기의 값 타입

C++ vs Go: 배열 선언

// C++: 배열
int arr[5] = {1, 2, 3, 4, 5};
int size = sizeof(arr) / sizeof(arr[0]);  // 5

// 함수에 전달 시 포인터로 decay
void process(int* arr, int size) {
    // ...
}
// Go: 배열 (크기가 타입의 일부)
var arr [5]int = [5]int{1, 2, 3, 4, 5}
// 또는
arr := [5]int{1, 2, 3, 4, 5}
// 또는 (크기 자동 추론)
arr := [...]int{1, 2, 3, 4, 5}

// 길이 확인
length := len(arr)  // 5

// 함수에 전달 시 값 복사 (포인터 decay 없음)
func process(arr [5]int) {
    // 배열 전체가 복사됨
}

핵심 차이점:

  • Go 배열은 크기가 타입의 일부입니다: [5]int[10]int는 다른 타입
  • 함수에 전달 시 전체 복사됩니다 (C++의 포인터 decay 없음)
  • 실무에서는 배열보다 슬라이스를 주로 사용

3. 슬라이스: Go의 동적 배열

Slice는 Go에서 가장 많이 사용하는 자료구조입니다. C++의 std::vector와 유사하지만 더 강력합니다.

C++ vs Go: 동적 배열

// C++: std::vector
#include <vector>
#include <iostream>

int main() {
    std::vector<int> vec;
    
    vec.push_back(1);
    vec.push_back(2);
    vec.push_back(3);
    
    std::cout << "Size: " << vec.size() << "\n";
    std::cout << "Capacity: " << vec.capacity() << "\n";
    
    // 예약
    vec.reserve(100);
    
    // 범위 기반 for
    for (const auto& v : vec) {
        std::cout << v << " ";
    }
}
// Go: Slice
package main

import "fmt"

func main() {
    var slice []int  // nil 슬라이스
    
    slice = append(slice, 1)
    slice = append(slice, 2)
    slice = append(slice, 3)
    
    fmt.Println("Length:", len(slice))
    fmt.Println("Capacity:", cap(slice))
    
    // 용량 미리 확보
    slice2 := make([]int, 0, 100)  // len=0, cap=100
    
    // range로 순회
    for i, v := range slice {
        fmt.Println(i, v)
    }
}

Slice의 내부 구조

// Slice는 내부적으로 3개 필드를 가진 구조체
type slice struct {
    ptr *[...]T  // 배열 포인터
    len int      // 현재 길이
    cap int      // 용량
}
graph LR
    A[Slice] --> B[ptr: 배열 포인터]
    A --> C[len: 길이]
    A --> D[cap: 용량]
    B --> E[실제 배열 메모리]

Slice 생성 방법

// Go: 다양한 Slice 생성 방법
package main

import "fmt"

func main() {
    // 1. nil 슬라이스
    var s1 []int
    fmt.Println(s1 == nil)  // true
    
    // 2. 리터럴
    s2 := []int{1, 2, 3}
    
    // 3. make (길이와 용량 지정)
    s3 := make([]int, 5)      // len=5, cap=5, 모두 0으로 초기화
    s4 := make([]int, 5, 10)  // len=5, cap=10
    
    // 4. 슬라이싱
    arr := [5]int{1, 2, 3, 4, 5}
    s5 := arr[1:4]  // [2, 3, 4], 원본 배열 공유
    
    fmt.Println(s1, s2, s3, s4, s5)
}

Slice 슬라이싱

// C++: 부분 배열 (수동 복사)
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> sub(vec.begin() + 1, vec.begin() + 4);  // [2, 3, 4]
// Go: 슬라이싱 (원본 공유)
slice := []int{1, 2, 3, 4, 5}

sub := slice[1:4]   // [2, 3, 4], 인덱스 1~3
sub[0] = 100        // slice[1]도 100으로 변경됨!

// 슬라이싱 문법
// slice[low:high]      // low부터 high-1까지
// slice[low:]          // low부터 끝까지
// slice[:high]         // 처음부터 high-1까지
// slice[:]             // 전체 (복사 아님, 같은 배열 참조)

// 복사가 필요하면 명시적으로
copied := make([]int, len(sub))
copy(copied, sub)

주의사항: 슬라이싱은 원본 배열을 공유합니다. 수정하면 원본도 변경됩니다.

append와 재할당

// Go: append의 동작 원리
package main

import "fmt"

func main() {
    slice := make([]int, 0, 3)  // len=0, cap=3
    fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
    
    slice = append(slice, 1)  // len=1, cap=3
    slice = append(slice, 2)  // len=2, cap=3
    slice = append(slice, 3)  // len=3, cap=3
    fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
    
    slice = append(slice, 4)  // len=4, cap=6 (재할당!)
    fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
    
    // capacity 초과 시 자동으로 2배 확장 (대략)
}

성능 팁: 최종 크기를 알면 make([]T, 0, capacity)로 미리 확보하세요.

// ❌ 비효율적: 여러 번 재할당
slice := []int{}
for i := 0; i < 10000; i++ {
    slice = append(slice, i)
}

// ✅ 효율적: 용량 미리 확보
slice := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    slice = append(slice, i)
}

4. Map: 해시 테이블

C++ vs Go: Map 사용

// C++: std::map (정렬) / std::unordered_map (해시)
#include <unordered_map>
#include <iostream>

int main() {
    std::unordered_map<std::string, int> m;
    
    // 삽입
    m["apple"] = 100;
    m["banana"] = 200;
    m.insert({"cherry", 300});
    
    // 조회
    if (m.find("apple") != m.end()) {
        std::cout << "Found: " << m["apple"] << "\n";
    }
    
    // 존재하지 않는 키 접근 시 0 삽입됨
    int x = m["nonexistent"];  // m에 "nonexistent": 0 추가
    
    // 삭제
    m.erase("apple");
    
    // 순회
    for (const auto& [key, value] : m) {
        std::cout << key << ": " << value << "\n";
    }
}
// Go: map (해시 테이블)
package main

import "fmt"

func main() {
    // 생성
    m := make(map[string]int)
    
    // 삽입
    m["apple"] = 100
    m["banana"] = 200
    m["cherry"] = 300
    
    // 조회 (두 번째 반환값으로 존재 여부 확인)
    if v, ok := m["apple"]; ok {
        fmt.Println("Found:", v)
    }
    
    // 존재하지 않는 키 접근 시 제로 값 반환 (맵에 추가 안 됨)
    x := m["nonexistent"]  // 0 반환, m은 변경 안 됨
    
    // 삭제
    delete(m, "apple")
    
    // 순회 (순서 보장 안 됨)
    for key, value := range m {
        fmt.Println(key, ":", value)
    }
}

핵심 차이점:

  • Go map은 순서를 보장하지 않습니다 (매 실행마다 순서 다를 수 있음)
  • 존재하지 않는 키 접근 시 제로 값 반환 (C++처럼 삽입 안 됨)
  • 두 번째 반환값(ok)으로 키 존재 여부 확인

Map 생성 방법

// Go: Map 생성 방법
package main

func main() {
    // 1. make로 생성
    m1 := make(map[string]int)
    
    // 2. 리터럴로 생성
    m2 := map[string]int{
        "apple":  100,
        "banana": 200,
    }
    
    // 3. nil map (읽기만 가능, 쓰기 시 패닉)
    var m3 map[string]int
    // m3["key"] = 1  // ❌ panic: assignment to entry in nil map
    v := m3["key"]    // ✅ 0 반환 (읽기는 가능)
    
    // 4. 용량 힌트 (성능 최적화)
    m4 := make(map[string]int, 100)  // 100개 정도 들어갈 것으로 예상
}

Map 활용 패턴

// Go: Map 활용 예시
package main

import "fmt"

// 단어 빈도 계산
func wordCount(words []string) map[string]int {
    counts := make(map[string]int)
    for _, word := range words {
        counts[word]++  // 존재하지 않으면 0에서 시작
    }
    return counts
}

// Set 구현 (map[T]bool)
func uniqueElements(numbers []int) []int {
    seen := make(map[int]bool)
    result := []int{}
    
    for _, num := range numbers {
        if !seen[num] {
            seen[num] = true
            result = append(result, num)
        }
    }
    return result
}

func main() {
    words := []string{"go", "is", "go", "is", "simple"}
    counts := wordCount(words)
    fmt.Println(counts)  // map[go:2 is:2 simple:1]
    
    nums := []int{1, 2, 2, 3, 3, 3, 4}
    unique := uniqueElements(nums)
    fmt.Println(unique)  // [1 2 3 4]
}

5. 실습 과제

과제 1: 슬라이스 역순 정렬

// Go: 슬라이스 역순으로 뒤집기
package main

import "fmt"

func reverse(slice []int) {
    for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 {
        slice[i], slice[j] = slice[j], slice[i]  // 스왑
    }
}

func main() {
    nums := []int{1, 2, 3, 4, 5}
    reverse(nums)
    fmt.Println(nums)  // [5 4 3 2 1]
}

C++ 버전과 비교:

// C++: std::reverse
#include <algorithm>
#include <vector>

std::vector<int> vec = {1, 2, 3, 4, 5};
std::reverse(vec.begin(), vec.end());

과제 2: 중복 제거

// Go: 슬라이스에서 중복 제거
package main

import "fmt"

func removeDuplicates(slice []int) []int {
    seen := make(map[int]bool)
    result := []int{}
    
    for _, v := range slice {
        if !seen[v] {
            seen[v] = true
            result = append(result, v)
        }
    }
    return result
}

func main() {
    nums := []int{1, 2, 2, 3, 3, 3, 4, 5, 5}
    unique := removeDuplicates(nums)
    fmt.Println(unique)  // [1 2 3 4 5]
}

과제 3: 두 슬라이스 병합

// Go: 슬라이스 병합
package main

import "fmt"

func merge(s1, s2 []int) []int {
    result := make([]int, 0, len(s1)+len(s2))
    result = append(result, s1...)  // ... 연산자로 언팩
    result = append(result, s2...)
    return result
}

func main() {
    a := []int{1, 2, 3}
    b := []int{4, 5, 6}
    merged := merge(a, b)
    fmt.Println(merged)  // [1 2 3 4 5 6]
}

과제 4: Map으로 그룹화

// Go: 점수별 학생 그룹화
package main

import "fmt"

type Student struct {
    Name  string
    Score int
}

func groupByScore(students []Student) map[int][]string {
    groups := make(map[int][]string)
    
    for _, s := range students {
        groups[s.Score] = append(groups[s.Score], s.Name)
    }
    
    return groups
}

func main() {
    students := []Student{
        {"Alice", 90},
        {"Bob", 85},
        {"Charlie", 90},
        {"David", 85},
    }
    
    groups := groupByScore(students)
    fmt.Println(groups)
    // map[85:[Bob David] 90:[Alice Charlie]]
}

정리: Day 3~4 학습 체크리스트

완료해야 할 항목

  • Go 포인터는 연산 불가, 역참조만 가능
  • 함수 인자: 값 전달 vs 포인터 전달
  • 배열은 고정 크기, 실무에서는 슬라이스 사용
  • Slice의 Length와 Capacity 차이 이해
  • append의 재할당 메커니즘 이해
  • 슬라이싱은 원본 배열 공유 (주의!)
  • Map 생성과 조회 (ok 패턴)
  • 실습 과제 4개 완료

C++에서 Go로 전환 포인트

C++Go비고
int* p; p++p := &x (연산 불가)안전성 우선
std::vector<T>[]T (Slice)더 간결한 문법
vec.size()len(slice)내장 함수
vec.capacity()cap(slice)내장 함수
std::unordered_mapmap[K]V내장 타입
m.find(k) != m.end()v, ok := m[k]더 간결

다음 단계 예고

Day 3~4에서는 포인터와 자료구조를 배웠습니다. 다음 글에서는 클래스 없는 객체지향을 다룹니다. 상속 대신 합성, 메서드와 리시버의 개념을 배웁니다.


📚 시리즈 네비게이션

이전 글목차다음 글
← #01 기본 문법📑 전체 목차#03 객체지향 →

Go 2주 완성 시리즈: 커리큘럼#01 기본 문법#02 자료구조#03 객체지향#04 인터페이스#05 에러 처리#06 고루틴·채널#07 테스팅#08 REST API#09 context·우아한 종료


한 줄 요약: Go의 포인터는 안전하고, Slice는 강력하며, Map은 간결합니다. C++의 복잡함 없이 동일한 작업을 더 쉽게 할 수 있습니다.

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법 - C++ 개발자의 첫인상
  • C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
  • C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴

이 글에서 다루는 키워드 (관련 검색어)

Go 슬라이스, Go map, Go 포인터, append cap len, C++ vector 비교, Golang 자료구조, Go 2주 완성, 메모리 GC 등으로 검색하시면 이 글이 도움이 됩니다.

실전 팁

실무에서 바로 적용할 수 있는 팁입니다.

디버깅 팁

  • 문제가 발생하면 먼저 컴파일러 경고를 확인하세요
  • 간단한 테스트 케이스로 문제를 재현하세요

성능 팁

  • 프로파일링 없이 최적화하지 마세요
  • 측정 가능한 지표를 먼저 설정하세요

코드 리뷰 팁

  • 코드 리뷰에서 자주 지적받는 부분을 미리 체크하세요
  • 팀의 코딩 컨벤션을 따르세요

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ std::vector와 Go Slice의 결정적 차이를 이해하고, 포인터 연산 없는 안전한 포인터 사용법을 배웁니다. Capacity와 Length, Map 활용까지 실전 예제로 학습합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


관련 글

  • C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
  • C++ 기술 면접 질문 30선 |
  • C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
  • C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
  • C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]