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

이 글의 핵심

원본만 고쳤다고 생각했는데 다른 함수에서 쓰던 슬라이스까지 바뀌는 버그는 C++ 개발자가 Go에서 가장 먼저 겪는 함정 가운데 하나입니다. 이 글은 시리즈 두 번째 편으로 nil 포인터, 배열과 슬라이스의 차이, 슬라이싱이 메모리를 공유하는 방식, 해시 테이블인 map을 C++ 감각으로 비교하며 설명합니다.

시리즈 안내

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

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

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


나 Go 처음 배울 때 얘기부터 할게

실제로 C++만 하다가 Go 문서 처음 펼쳤을 때는 “이거 언어 맞아?” 수준이었다. 헤더도 없으며, :=만 써도 변수가 생기고, 에러는 왜 계속 if err != nil이냐 같은 것도 있었고. 그런데 실제로 슬라이스에서 같은 배열을 두 슬라이스가 공유하는 걸 모르고 한 번 난리 난 적이 있다. 원본만 고친 줄 알았는데 옆 함수에서 쓰던 뷰까지 같이 바뀌어서, 그때 “아, Go는 편한 대가가 있구나”를 몸으로 깨달았다. 이 글은 그때 내가 정리했으면 덜 당황했을 것들 위주로 갈 것입니다. 교과서처럼 완벽하게 다 안 적는다. 실전에서 자주 박는 지점만 짚는다.

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

C++에서는 포인터 연산(p++, p + offset)으로 메모리를 자유롭게 탐색할 수 있지만, 그만큼 세그폴트도 같이 온다. Go는 포인터는 있지만 포인터 연산은 없다. 나는 이게 “표현력을 죽인다”기보다 팀 코드에서 살아남기 쉬운 쪽이라고 본다. 여기서는 포인터·슬라이스·맵을 C++ 감각으로만 붙잡고 가면 된다.

이번에 집중할 것: 포인터 제한, 값/포인터 전달, 슬라이스 len·cap·공유, 맵의 ok 패턴.


실무에서의 체감 (주관적)

Go를 “도입하면 무조건 빨라진다”라고 말하고 싶진 않다. 다만 망가지기 어려운 단순함은 체감된다. GC 덕에 delete 체질을 안 가져가도 되고, 단일 바이너리로 굴리기 좋다. 반대로 말하면, C++에서 익숙한 미세한 메모리 튜닝의 맛은 기대하지 말라. 그 대신 리뷰할 때 “이 포인터 지금 누가 소유하지?” 같은 밤샘은 줄어든다.


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

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    // ✅ 인덱스 접근

내 생각엔 “편의가 아니라 사고방지”에 가깝다. 오프셋 놀이를 못 하게 막아서 대신 인덱스와 슬라이스 문법으로만 가게 만든 것입니다.

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
}

내 기준: 작은 타입은 그냥 값으로 넘기고, 바꿀 거면 포인터. “큰 구조체면 무조건 포인터” 같은 말도 많이 나오는데, 팀 컨벤션에 맞추는 게 제일 크다. 일관성이 안 그러면 리뷰가 지옥입니다.

C++ 개발자가 가장 놀라는 차이는 지역 변수의 주소를 반환해도 된다는 점입니다. func newUser() *User { u := User{Name: "a"}; return &u }는 C++이라면 댕글링 포인터지만, Go에서는 정상적인 코드입니다. 컴파일러의 탈출 분석(escape analysis)이 u가 함수 밖으로 빠져나간다는 것을 알아채고 스택 대신 힙에 할당하며, 이후 수명은 GC가 관리합니다. go build -gcflags=-m을 붙이면 moved to heap: u 같은 메시지로 어떤 변수가 힙으로 갔는지 확인할 수 있습니다. 대신 이 편리함에는 비용이 있어서, 작은 구조체를 무심코 포인터로만 주고받으면 힙 할당과 GC 부담이 늘어납니다. 구조체 포인터로 필드에 접근할 때는 (*p).Name 대신 p.Name처럼 자동 역참조가 되므로, C++의 -> 같은 별도 연산자도 필요 없습니다.

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 or nil pointer dereference

C++의 널 역참조는 미정의 동작이라 운이 나쁘면 크래시 없이 엉뚱한 메모리를 건드리지만, Go의 nil 역참조는 항상 패닉으로 멈추고 스택 트레이스에 정확한 줄을 남깁니다. 패닉은 recover로 잡을 수 있지만, 일반적인 코드에서는 잡지 않고 원인을 고치는 것이 원칙입니다. 실무에서 가장 흔한 nil 패닉은 포인터 자체보다 구조체 안의 포인터·맵 필드를 초기화하지 않은 경우입니다. type Server struct { cache map[string]int }를 Server{}로 만들고 s.cache["k"] = 1을 하면 아래 map 절의 nil map 패닉이 나므로, 생성자 함수(NewServer())에서 필드를 초기화하는 관례가 여기서 나옵니다.


배열: 고정 크기의 값 타입

// C++: 배열
int arr[5] = {1, 2, 3, 4, 5};
int size = sizeof(arr) / sizeof(arr[0]);  // 5
void process(int* arr, int size) {
    // ...
}
// Go: 배열 (크기가 타입의 일부)
var arr [5]int = [5]int{1, 2, 3, 4, 5}
arr := [...]int{1, 2, 3, 4, 5}
length := len(arr)  // 5
func process(arr [5]int) {
    // 배열 전체가 복사됨
}

[5]int랑 [10]int는 다른 타입이다. 함수에 넘기면 통째로 복사된다. 따라서 실무 코드에서 배열을 직접 만지는 빈도는 실제로 낮다. 슬라이스가 주인공입니다.


슬라이스: Go의 동적 배열

Slice는 말 그대로 많이 쓴다. C++의 std::vector랑 비슷하다고만 생각하면 초반엔 편한데, 내부 배열을 공유한다는 점만큼은 vector 감각 그대로 두면 큰코다.

// 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 (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
    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 생성·슬라이싱

package main
import "fmt"
func main() {
    var s1 []int
    fmt.Println(s1 == nil)  // true
    s2 := []int{1, 2, 3}
    s3 := make([]int, 5)
    s4 := make([]int, 5, 10)
    arr := [5]int{1, 2, 3, 4, 5}
    s5 := arr[1:4]  // 원본 배열 공유
    fmt.Println(s1, s2, s3, s4, s5)
}
// C++: 부분은 보통 복사본을 만든다
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> sub(vec.begin() + 1, vec.begin() + 4);
// Go: 슬라이싱은 원본 공유
slice := []int{1, 2, 3, 4, 5}
sub := slice[1:4]
sub[0] = 100        // slice[1]도 같이 바뀜
copied := make([]int, len(sub))
copy(copied, sub)

append는 cap 넘으면 새 배열 잡고 옮긴다. 용량이 대략 두 배씩 늘기 때문에 루프에서 append를 백만 번 해도 재할당은 수십 번 수준이고 전체 비용은 선형이라 “망할” 정도는 아니지만, 중간에 버려지는 배열이 GC 부담이 된다. 크기 대략 알 거면 make([]T, 0, n) 한 방이 정신 건강에 이롭다.

공유 버그가 실제로 어떻게 터지는지는 아래 두 줄이 가장 잘 보여 줍니다. a := make([]int, 3, 10)에서 b := append(a, 1), c := append(a, 2)를 하면, a의 cap에 여유가 있으므로 두 append가 같은 배열의 4번째 칸에 차례로 쓰고, 결국 b[3]도 2가 됩니다. C++의 std::vector는 값 의미론이라 auto b = a; b.push_back(1);이 a에 영향을 주지 않지만, Go의 슬라이스 대입은 헤더(포인터·len·cap)만 복사하므로 이런 일이 생깁니다. 제가 처음 겪은 버그도 정확히 이 모양이었는데, 공통 접두사 슬라이스에 서로 다른 값을 append해 여러 경로를 만드는 코드(백트래킹, 경로 탐색)에서 결과가 전부 마지막 값으로 덮여 있었습니다. 이런 코드는 append 전에 path = append([]int(nil), path...)로 복사하거나, path[:len(path):len(path)]로 cap을 잘라 두어야 합니다.

// capacity 초과 시 재할당되는 흐름만 기억하면 됨
slice := make([]int, 0, 3)
slice = append(slice, 1, 2, 3)
slice = append(slice, 4) // 여기서 cap 늘어남(구현에 따라 다름)

Map: 해시 테이블

C++ unordered_map 감각으이면 되는데, Go는 없는 키를 읽으면 제로 값이고 C++처럼 조용히 삽입되진 않는다. 그리고 순서 보장 없다. 테스트에서 map 출력 순서에 의존하면 피 본다.

m := make(map[string]int)
m["a"] = 1
if v, ok := m["a"]; ok {
    _ = v
}
delete(m, "a")

v, ok := m[k] 패턴은 익숙해지면 손이 간다. 나는 “그냥 m[k]만 읽어도 되지?” 하다가 타입이 int면 0이 정말 값인지 없는 건지 구분이 안 될 때가 있어서, 의심스러우면 무조건 ok 쪽이 마음 편하다.

map에는 C++ 개발자가 걸리기 쉬운 함정이 세 가지 더 있습니다. 첫째, var m map[string]int로 선언만 한 nil map은 읽기는 되지만 쓰기는 패닉(assignment to entry in nil map)입니다. make나 리터럴 map[string]int{}로 초기화해야 합니다. 둘째, map은 동시 접근에 안전하지 않습니다. 여러 고루틴이 한 map에 동시에 쓰면 Go 런타임이 이를 감지해 fatal error: concurrent map writes로 프로세스를 종료하는데, 이는 recover로도 잡히지 않는 치명적 오류입니다. 공유 map은 sync.Mutex/sync.RWMutex로 감싸거나, 키가 주로 늘기만 하는 캐시라면 sync.Map을 씁니다. 셋째, 순회 순서는 구현이 일부러 매번 무작위로 섞기 때문에 “대체로 같은 순서”에 의존한 테스트가 가끔만 실패합니다. 정렬된 출력이 필요하면 키를 슬라이스로 모아 slices.Sort(Go 1.21+)한 뒤 순회해야 합니다.

map의 값은 주소를 얻을 수 없는 위치라서, m["a"].Count++처럼 구조체 값의 필드를 직접 바꾸면 cannot assign to struct field m["a"].Count in map 컴파일 에러가 납니다. 맵이 커지면서 내부 버킷이 재배치될 수 있기 때문입니다. 값을 꺼내 고친 뒤 다시 넣거나(v := m["a"]; v.Count++; m["a"] = v), 처음부터 map[string]*Counter처럼 포인터를 값으로 두면 됩니다.


실습은 이 정도만

과제 네 개 풀어보라는 식으로 늘리는 건 좋아하지 않는다. 역순 뒤집기 하나만 보여주고 나머지는 “슬라이스·맵으로 익숙해질 때까지 직접” 쪽이 낫다고 본다.

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)
}

정리하면

  • 포인터 연산 없음 → 역참조·&만. 의도적으로 C스타일 산책 막음.
  • 배열은 크기가 타입. 실무에선 슬라이스.
  • 슬라이스는 len / cap / 공유 세 마디만 머리에 박아라.
  • 맵은 ok를 기본으로 습관화하는 게 안전하다.

C++에서 오면 대략 이렇게 매핑하면 된다: int* p; p++ 같은 건 없고 p := &x만 쓴다. std::vector 느낌은 []T인데 슬라이싱은 공유다. unordered_map은 map[K]V, 조회는 find 대신 v, ok := m[k]가 덜 지저분하다. 이게 다가 아니지만, Day 3~4에서 삽질 줄이기엔 충분하다.

다음 글

Day 3~4는 여기까지. 다음은 클래스 없는 객체지향—상속 말고 합성 쪽으입니다. → #03 객체지향

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

Go 2주 완성 시리즈: 커리큘럼 · #01 · #02 · #03 · #04 · #05 · #06 · #07 · #08 · #09


포인터는 안전하게 막아놨으며, 슬라이스는 편한 대신 공유를 이해해야 하며, 맵은 ok가 정답에 가깝다. C++보다 덜 화려하지만 밤에 덜 운다.

같이 보면 좋은 글

실전에서 나는 이렇게만 본다

go fmt는 당연하며, 에러 삼키지 말고 if err != nil은 그냥 체질로. 테스트는 go test ./... 돌릴 수 있을 때 돌린다. 벤치는 “느리다”는게 감으로 올 때만.


자주 묻는 질문 (짧게)

Q. 함수에 슬라이스를 넘겨 원소를 바꾸면 호출자에게도 보이는데, append한 원소는 왜 안 보이나요? A. 슬라이스를 넘기면 헤더(포인터·len·cap)가 복사됩니다. 원소 수정은 같은 배열을 건드리므로 보이지만, append로 늘어난 len은 함수 안의 헤더 복사본에만 반영되고, cap을 넘었다면 아예 새 배열이 됩니다. 그래서 Go에서는 s = addItem(s, x)처럼 슬라이스를 반환받는 형태가 관용적입니다.

Q. 지역 변수의 포인터를 반환해도 정말 괜찮나요? A. 괜찮습니다. 컴파일러가 탈출 분석으로 그 변수를 힙에 할당하고 GC가 수명을 관리하므로 C++처럼 댕글링 포인터가 되지 않습니다. go build -gcflags=-m으로 어떤 변수가 힙으로 갔는지 확인할 수 있습니다.


관련 글